blocklog 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 (43) hide show
  1. blocklog/__init__.py +78 -0
  2. blocklog/_global.py +20 -0
  3. blocklog/_init_fn.py +95 -0
  4. blocklog/api/approval.py +182 -0
  5. blocklog/api/compliance.py +162 -0
  6. blocklog/api/decisions.py +177 -0
  7. blocklog/api/incidents.py +306 -0
  8. blocklog/api/replay.py +285 -0
  9. blocklog/api/traces.py +137 -0
  10. blocklog/api/verify.py +100 -0
  11. blocklog/approval.py +119 -0
  12. blocklog/async_client.py +28 -0
  13. blocklog/batching/buffer.py +22 -0
  14. blocklog/client.py +194 -0
  15. blocklog/compliance.py +95 -0
  16. blocklog/config.py +17 -0
  17. blocklog/context/managers.py +15 -0
  18. blocklog/context/vars.py +13 -0
  19. blocklog/decorators/__init__.py +4 -0
  20. blocklog/decorators/agent.py +219 -0
  21. blocklog/decorators/tool.py +206 -0
  22. blocklog/incident.py +89 -0
  23. blocklog/integrations/langchain.py +129 -0
  24. blocklog/integrations/langgraph.py +3 -0
  25. blocklog/integrations/openai_agents.py +3 -0
  26. blocklog/managers/__init__.py +3 -0
  27. blocklog/managers/decision.py +336 -0
  28. blocklog/middleware/hooks.py +11 -0
  29. blocklog/models/events.py +33 -0
  30. blocklog/models/responses.py +18 -0
  31. blocklog/replay.py +64 -0
  32. blocklog/signing/canonical.py +5 -0
  33. blocklog/signing/ed25519.py +25 -0
  34. blocklog/transport/auth.py +8 -0
  35. blocklog/transport/httpx_async.py +39 -0
  36. blocklog/transport/httpx_sync.py +36 -0
  37. blocklog/transport/retry.py +26 -0
  38. blocklog/verify.py +72 -0
  39. blocklog-0.2.0.dist-info/METADATA +272 -0
  40. blocklog-0.2.0.dist-info/RECORD +43 -0
  41. blocklog-0.2.0.dist-info/WHEEL +5 -0
  42. blocklog-0.2.0.dist-info/licenses/LICENSE +21 -0
  43. blocklog-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,206 @@
1
+ """
2
+ blocklog.decorators.tool
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~
4
+ The ``@tool`` decorator — wraps a function so every call is recorded as a
5
+ ``TOOL_CALL`` event, capturing inputs, outputs, duration, and errors.
6
+
7
+ The decorator inherits the parent ``@agent`` trace context automatically.
8
+
9
+ Usage::
10
+
11
+ import blocklog
12
+
13
+ @blocklog.tool
14
+ def fetch_price(ticker: str) -> float:
15
+ return market_api.get_price(ticker)
16
+
17
+ # With a custom name and schema hint:
18
+ @blocklog.tool(name="fetch-market-price", schema={"ticker": "str"})
19
+ def fetch_price(ticker: str) -> float:
20
+ ...
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import functools
25
+ import inspect
26
+ import logging
27
+ import traceback as _traceback
28
+ from datetime import datetime, timezone
29
+ from typing import Any, Callable, TypeVar
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ F = TypeVar("F", bound=Callable[..., Any])
34
+
35
+
36
+ def tool(
37
+ func: F | None = None,
38
+ *,
39
+ name: str | None = None,
40
+ schema: dict[str, Any] | None = None,
41
+ tags: list[str] | None = None,
42
+ metadata: dict[str, Any] | None = None,
43
+ ) -> F | Callable[[F], F]:
44
+ """Decorator that records a tool call as a Blocklog event.
45
+
46
+ Automatically captures:
47
+ - Function name, call arguments
48
+ - Return value
49
+ - Duration in milliseconds
50
+ - Any exception raised
51
+
52
+ Inherits the trace/session context set by the surrounding ``@agent``.
53
+
54
+ Can be used with or without arguments:
55
+
56
+ .. code-block:: python
57
+
58
+ @blocklog.tool
59
+ def my_tool(x: int) -> int: ...
60
+
61
+ @blocklog.tool(name="my-tool", tags=["external-api"])
62
+ def my_tool(x: int) -> int: ...
63
+
64
+ Parameters
65
+ ----------
66
+ func:
67
+ The function to decorate (when used without arguments).
68
+ name:
69
+ Human-readable tool name. Defaults to ``func.__name__``.
70
+ schema:
71
+ Optional dict describing the input schema (for documentation /
72
+ dashboard display purposes).
73
+ tags:
74
+ Optional string tags.
75
+ metadata:
76
+ Arbitrary extra data stored with each tool call event.
77
+ """
78
+ def decorator(fn: F) -> F:
79
+ tool_name = name or fn.__name__
80
+ tool_meta = {
81
+ "tool_name": tool_name,
82
+ "tags": tags or [],
83
+ "schema": schema or {},
84
+ **(metadata or {}),
85
+ }
86
+
87
+ @functools.wraps(fn)
88
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
89
+ return _run_sync(fn, args, kwargs, tool_name, tool_meta)
90
+
91
+ @functools.wraps(fn)
92
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
93
+ return await _run_async(fn, args, kwargs, tool_name, tool_meta)
94
+
95
+ if inspect.iscoroutinefunction(fn):
96
+ return async_wrapper # type: ignore[return-value]
97
+ return sync_wrapper # type: ignore[return-value]
98
+
99
+ if func is not None:
100
+ return decorator(func)
101
+ return decorator
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Internal helpers
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _run_sync(fn: Callable, args: tuple, kwargs: dict, tool_name: str, meta: dict) -> Any:
109
+ started_at = _now()
110
+ call_args = _safe_args(fn, args, kwargs)
111
+ try:
112
+ result = fn(*args, **kwargs)
113
+ _emit("TOOL_CALL", {
114
+ **meta,
115
+ "inputs": call_args,
116
+ "output": _safe_repr(result),
117
+ "duration_ms": _elapsed_ms(started_at),
118
+ "status": "ok",
119
+ })
120
+ return result
121
+ except BaseException as exc:
122
+ _emit("TOOL_CALL", {
123
+ **meta,
124
+ "inputs": call_args,
125
+ "error_type": type(exc).__name__,
126
+ "error_message": str(exc),
127
+ "traceback": _traceback.format_exc(),
128
+ "duration_ms": _elapsed_ms(started_at),
129
+ "status": "error",
130
+ })
131
+ raise
132
+
133
+
134
+ async def _run_async(fn: Callable, args: tuple, kwargs: dict, tool_name: str, meta: dict) -> Any:
135
+ started_at = _now()
136
+ call_args = _safe_args(fn, args, kwargs)
137
+ try:
138
+ result = await fn(*args, **kwargs)
139
+ _emit("TOOL_CALL", {
140
+ **meta,
141
+ "inputs": call_args,
142
+ "output": _safe_repr(result),
143
+ "duration_ms": _elapsed_ms(started_at),
144
+ "status": "ok",
145
+ })
146
+ return result
147
+ except BaseException as exc:
148
+ _emit("TOOL_CALL", {
149
+ **meta,
150
+ "inputs": call_args,
151
+ "error_type": type(exc).__name__,
152
+ "error_message": str(exc),
153
+ "traceback": _traceback.format_exc(),
154
+ "duration_ms": _elapsed_ms(started_at),
155
+ "status": "error",
156
+ })
157
+ raise
158
+
159
+
160
+ def _emit(event_type: str, payload: dict) -> None:
161
+ try:
162
+ from blocklog._global import get_client
163
+ from blocklog.context.vars import get_context
164
+ ctx = get_context()
165
+ client = get_client()
166
+ client.event(
167
+ event_type,
168
+ payload=payload,
169
+ trace_id=str(ctx.trace_id) if ctx else None,
170
+ session_id=str(ctx.session_id) if ctx else None,
171
+ actor_id=ctx.agent_id if ctx else None,
172
+ actor_type="tool",
173
+ )
174
+ except Exception as exc: # noqa: BLE001
175
+ logger.debug("blocklog: tool emit failed: %s", exc)
176
+
177
+
178
+ def _safe_args(fn: Callable, args: tuple, kwargs: dict) -> dict:
179
+ """Build a safe dict of call arguments, best-effort."""
180
+ try:
181
+ sig = inspect.signature(fn)
182
+ bound = sig.bind(*args, **kwargs)
183
+ bound.apply_defaults()
184
+ return {k: _safe_repr(v) for k, v in bound.arguments.items()}
185
+ except Exception: # noqa: BLE001
186
+ return {"args": str(args), "kwargs": str(kwargs)}
187
+
188
+
189
+ def _safe_repr(value: Any) -> Any:
190
+ """Truncate large values so they don't bloat the event payload."""
191
+ if isinstance(value, (str, int, float, bool, type(None))):
192
+ return value
193
+ try:
194
+ s = repr(value)
195
+ return s if len(s) <= 512 else s[:509] + "..."
196
+ except Exception: # noqa: BLE001
197
+ return "<unserializable>"
198
+
199
+
200
+ def _now() -> str:
201
+ return datetime.now(timezone.utc).isoformat()
202
+
203
+
204
+ def _elapsed_ms(since_iso: str) -> int:
205
+ start = datetime.fromisoformat(since_iso)
206
+ return int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
blocklog/incident.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ blocklog.incident
3
+ ~~~~~~~~~~~~~~~~~
4
+ Module-level namespace for incident management.
5
+
6
+ Usage (Layer 1)::
7
+
8
+ from blocklog import incident
9
+
10
+ inc = incident.create(
11
+ title="Anomalous BUY signal on TSLA",
12
+ trace_id="trace-abc",
13
+ severity="high",
14
+ )
15
+
16
+ inc.assign("alice@fund.com")
17
+ inc.annotate("Reviewing related decisions from the same session")
18
+ inc.resolve(summary="False positive — model weights corrected")
19
+ inc.close()
20
+
21
+ report = inc.report()
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ if TYPE_CHECKING:
28
+ from blocklog.api.incidents import IncidentHandle
29
+
30
+
31
+ def create(
32
+ title: str,
33
+ *,
34
+ trace_id: str | None = None,
35
+ severity: str = "medium",
36
+ description: str | None = None,
37
+ metadata: dict[str, Any] | None = None,
38
+ ) -> "IncidentHandle":
39
+ """Create a new incident.
40
+
41
+ Parameters
42
+ ----------
43
+ title:
44
+ Short title describing the incident.
45
+ trace_id:
46
+ UUID of the trace associated with this incident.
47
+ severity:
48
+ ``"low"``, ``"medium"``, ``"high"``, or ``"critical"``.
49
+ description:
50
+ Longer free-text description.
51
+ metadata:
52
+ Arbitrary extra fields.
53
+
54
+ Returns
55
+ -------
56
+ IncidentHandle
57
+ A live handle with access to the full lifecycle (assign, resolve,
58
+ close, annotate, report, etc.).
59
+
60
+ Examples
61
+ --------
62
+ >>> inc = blocklog.incident.create(
63
+ ... title="Unexpected SELL on AAPL",
64
+ ... trace_id="trace-abc",
65
+ ... severity="high",
66
+ ... )
67
+ >>> inc.assign("alice@fund.com")
68
+ >>> inc.resolve(summary="False positive")
69
+ """
70
+ from blocklog._global import get_client
71
+ return get_client().incidents.create(
72
+ title=title,
73
+ trace_id=trace_id,
74
+ severity=severity,
75
+ description=description,
76
+ metadata=metadata,
77
+ )
78
+
79
+
80
+ def get(incident_id: str) -> "IncidentHandle":
81
+ """Fetch an existing incident by ID."""
82
+ from blocklog._global import get_client
83
+ return get_client().incidents.get(incident_id)
84
+
85
+
86
+ def list_all() -> list["IncidentHandle"]:
87
+ """List all incidents for the authenticated company."""
88
+ from blocklog._global import get_client
89
+ return get_client().incidents.list()
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+
8
+ class BlocklogLangChainCallbackHandler:
9
+ def __init__(self, client, *, source: str = "langchain") -> None:
10
+ self.client = client
11
+ self.source = source
12
+ self._last_run_id = None
13
+
14
+ def on_chain_start(self, serialized: dict[str, Any], inputs: dict[str, Any], *, run_id=None, parent_run_id=None, **kwargs):
15
+ self._last_run_id = run_id
16
+ self.client.event(
17
+ "agent.chain.started",
18
+ {
19
+ "serialized": serialized,
20
+ "inputs": inputs,
21
+ "parent_run_id": str(parent_run_id) if parent_run_id else None,
22
+ },
23
+ source=self.source,
24
+ span_id=str(run_id) if run_id else None,
25
+ causality_type="chain_start",
26
+ agent_metadata=_agent_metadata(
27
+ framework="langchain",
28
+ parent_run_id=parent_run_id,
29
+ extra=kwargs.get("metadata"),
30
+ context={
31
+ "input_keys": sorted(inputs.keys()),
32
+ "context_fetched_at": _utc_now(),
33
+ },
34
+ ),
35
+ )
36
+
37
+ def on_chain_end(self, outputs: dict[str, Any], *, run_id=None, parent_run_id=None, **kwargs):
38
+ self.client.event(
39
+ "agent.chain.completed",
40
+ {"outputs": outputs, "parent_run_id": str(parent_run_id) if parent_run_id else None},
41
+ source=self.source,
42
+ span_id=str(run_id or self._last_run_id) if (run_id or self._last_run_id) else None,
43
+ causality_type="chain_end",
44
+ agent_metadata=_agent_metadata(framework="langchain", parent_run_id=parent_run_id, extra=kwargs.get("metadata")),
45
+ )
46
+
47
+ def on_llm_start(self, serialized: dict[str, Any], prompts: Sequence[str], *, run_id=None, parent_run_id=None, **kwargs):
48
+ self.client.event(
49
+ "agent.model.started",
50
+ {"serialized": serialized, "prompts": list(prompts), "parent_run_id": str(parent_run_id) if parent_run_id else None},
51
+ source=self.source,
52
+ span_id=str(run_id) if run_id else None,
53
+ causality_type="llm_start",
54
+ agent_metadata=_agent_metadata(
55
+ framework="langchain",
56
+ parent_run_id=parent_run_id,
57
+ extra=kwargs.get("metadata"),
58
+ context={
59
+ "prompt_count": len(prompts),
60
+ "context_fetched_at": _utc_now(),
61
+ },
62
+ ),
63
+ )
64
+
65
+ def on_llm_end(self, response: Any, *, run_id=None, parent_run_id=None, **kwargs):
66
+ self.client.event(
67
+ "agent.model.completed",
68
+ {"response": _safe_model_dump(response), "parent_run_id": str(parent_run_id) if parent_run_id else None},
69
+ source=self.source,
70
+ span_id=str(run_id) if run_id else None,
71
+ causality_type="llm_end",
72
+ agent_metadata=_agent_metadata(framework="langchain", parent_run_id=parent_run_id, extra=kwargs.get("metadata")),
73
+ )
74
+
75
+ def on_tool_start(self, serialized: dict[str, Any], input_str: str, *, run_id=None, parent_run_id=None, **kwargs):
76
+ self.client.event(
77
+ "agent.tool.started",
78
+ {"serialized": serialized, "input": input_str, "parent_run_id": str(parent_run_id) if parent_run_id else None},
79
+ source=self.source,
80
+ span_id=str(run_id) if run_id else None,
81
+ causality_type="tool_start",
82
+ agent_metadata=_agent_metadata(
83
+ framework="langchain",
84
+ parent_run_id=parent_run_id,
85
+ extra=kwargs.get("metadata"),
86
+ context={"context_fetched_at": _utc_now()},
87
+ ),
88
+ )
89
+
90
+ def on_tool_end(self, output: Any, *, run_id=None, parent_run_id=None, **kwargs):
91
+ self.client.event(
92
+ "agent.tool.completed",
93
+ {"output": _safe_model_dump(output), "parent_run_id": str(parent_run_id) if parent_run_id else None},
94
+ source=self.source,
95
+ span_id=str(run_id) if run_id else None,
96
+ causality_type="tool_end",
97
+ agent_metadata=_agent_metadata(framework="langchain", parent_run_id=parent_run_id, extra=kwargs.get("metadata")),
98
+ )
99
+
100
+
101
+ def instrument_langchain(client):
102
+ return BlocklogLangChainCallbackHandler(client)
103
+
104
+
105
+ def _safe_model_dump(value: Any) -> Any:
106
+ if hasattr(value, "model_dump"):
107
+ return value.model_dump()
108
+ if hasattr(value, "dict"):
109
+ return value.dict()
110
+ if isinstance(value, (str, int, float, bool, list, dict)) or value is None:
111
+ return value
112
+ return str(value)
113
+
114
+
115
+ def _utc_now() -> str:
116
+ return datetime.now(timezone.utc).isoformat()
117
+
118
+
119
+ def _agent_metadata(*, framework: str, parent_run_id=None, extra: dict[str, Any] | None = None, context: dict[str, Any] | None = None) -> dict[str, Any]:
120
+ metadata = {
121
+ "framework": framework,
122
+ "captured_at": _utc_now(),
123
+ "parent_run_id": str(parent_run_id) if parent_run_id else None,
124
+ }
125
+ if context:
126
+ metadata.update(context)
127
+ if extra:
128
+ metadata.update(extra)
129
+ return metadata
@@ -0,0 +1,3 @@
1
+ def instrument_langgraph(client):
2
+ client.add_hook(lambda payload: payload)
3
+ return client
@@ -0,0 +1,3 @@
1
+ def instrument_openai_agents(client):
2
+ client.add_hook(lambda payload: payload)
3
+ return client
@@ -0,0 +1,3 @@
1
+ from blocklog.managers.decision import decision, DecisionContext
2
+
3
+ __all__ = ["decision", "DecisionContext"]