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.
- blocklog/__init__.py +78 -0
- blocklog/_global.py +20 -0
- blocklog/_init_fn.py +95 -0
- blocklog/api/approval.py +182 -0
- blocklog/api/compliance.py +162 -0
- blocklog/api/decisions.py +177 -0
- blocklog/api/incidents.py +306 -0
- blocklog/api/replay.py +285 -0
- blocklog/api/traces.py +137 -0
- blocklog/api/verify.py +100 -0
- blocklog/approval.py +119 -0
- blocklog/async_client.py +28 -0
- blocklog/batching/buffer.py +22 -0
- blocklog/client.py +194 -0
- blocklog/compliance.py +95 -0
- blocklog/config.py +17 -0
- blocklog/context/managers.py +15 -0
- blocklog/context/vars.py +13 -0
- blocklog/decorators/__init__.py +4 -0
- blocklog/decorators/agent.py +219 -0
- blocklog/decorators/tool.py +206 -0
- blocklog/incident.py +89 -0
- blocklog/integrations/langchain.py +129 -0
- blocklog/integrations/langgraph.py +3 -0
- blocklog/integrations/openai_agents.py +3 -0
- blocklog/managers/__init__.py +3 -0
- blocklog/managers/decision.py +336 -0
- blocklog/middleware/hooks.py +11 -0
- blocklog/models/events.py +33 -0
- blocklog/models/responses.py +18 -0
- blocklog/replay.py +64 -0
- blocklog/signing/canonical.py +5 -0
- blocklog/signing/ed25519.py +25 -0
- blocklog/transport/auth.py +8 -0
- blocklog/transport/httpx_async.py +39 -0
- blocklog/transport/httpx_sync.py +36 -0
- blocklog/transport/retry.py +26 -0
- blocklog/verify.py +72 -0
- blocklog-0.2.0.dist-info/METADATA +272 -0
- blocklog-0.2.0.dist-info/RECORD +43 -0
- blocklog-0.2.0.dist-info/WHEEL +5 -0
- blocklog-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|