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
blocklog/client.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from blocklog.api.approval import ApprovalClient
|
|
7
|
+
from blocklog.api.compliance import ComplianceClient
|
|
8
|
+
from blocklog.api.decisions import DecisionsClient
|
|
9
|
+
from blocklog.api.incidents import IncidentsClient
|
|
10
|
+
from blocklog.api.replay import ReplayClient
|
|
11
|
+
from blocklog.api.traces import TracesClient
|
|
12
|
+
from blocklog.api.verify import VerifyClient
|
|
13
|
+
from blocklog.batching.buffer import EventBuffer
|
|
14
|
+
from blocklog.config import BlocklogConfig
|
|
15
|
+
from blocklog.context.managers import agent_session
|
|
16
|
+
from blocklog.context.vars import get_context
|
|
17
|
+
from blocklog.integrations.langchain import instrument_langchain
|
|
18
|
+
from blocklog.integrations.langgraph import instrument_langgraph
|
|
19
|
+
from blocklog.integrations.openai_agents import instrument_openai_agents
|
|
20
|
+
from blocklog.middleware.hooks import apply_hooks
|
|
21
|
+
from blocklog.models.events import EventEnvelope
|
|
22
|
+
from blocklog.models.responses import IngestResponse
|
|
23
|
+
from blocklog.signing.ed25519 import hash_sign
|
|
24
|
+
from blocklog.transport.httpx_sync import SyncTransport
|
|
25
|
+
from blocklog.transport.retry import RetryPolicy
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BlocklogClient:
|
|
29
|
+
"""The Blocklog client.
|
|
30
|
+
|
|
31
|
+
Most users should call ``blocklog.init(api_key=...)`` instead of
|
|
32
|
+
instantiating this class directly. The global client is then
|
|
33
|
+
accessible to all module-level helpers (``decision``, ``approval``,
|
|
34
|
+
``incident``, ``replay``, ``verify``, ``compliance``).
|
|
35
|
+
|
|
36
|
+
For advanced control, instantiate this class directly::
|
|
37
|
+
|
|
38
|
+
from blocklog import BlocklogClient, BlocklogConfig
|
|
39
|
+
|
|
40
|
+
client = BlocklogClient(BlocklogConfig(api_key="blk_..."))
|
|
41
|
+
|
|
42
|
+
Attributes
|
|
43
|
+
----------
|
|
44
|
+
decisions : DecisionsClient
|
|
45
|
+
Layer 2 client for AI Decision records.
|
|
46
|
+
incidents : IncidentsClient
|
|
47
|
+
Layer 2 client for the incident lifecycle.
|
|
48
|
+
approval : ApprovalClient
|
|
49
|
+
Layer 2 client for HITL approval workflows.
|
|
50
|
+
replay : ReplayClient
|
|
51
|
+
Layer 2 client for forensic replay sessions.
|
|
52
|
+
compliance : ComplianceClient
|
|
53
|
+
Layer 2 client for compliance report generation.
|
|
54
|
+
verify : VerifyClient
|
|
55
|
+
Layer 2 client for cryptographic verification.
|
|
56
|
+
traces : TracesClient
|
|
57
|
+
Layer 2 client for trace/session queries.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, config: BlocklogConfig) -> None:
|
|
61
|
+
self.config = config
|
|
62
|
+
self.transport = SyncTransport(
|
|
63
|
+
base_url=config.base_url,
|
|
64
|
+
api_key=config.api_key,
|
|
65
|
+
timeout=config.timeout,
|
|
66
|
+
)
|
|
67
|
+
self.retry = RetryPolicy(max_retries=config.max_retries)
|
|
68
|
+
self.buffer = EventBuffer(batch_size=config.batch_size)
|
|
69
|
+
self.hooks: list = []
|
|
70
|
+
|
|
71
|
+
# ── Layer 2 domain sub-clients ────────────────────────────────
|
|
72
|
+
self.decisions = DecisionsClient(self)
|
|
73
|
+
self.incidents = IncidentsClient(self)
|
|
74
|
+
self.approval = ApprovalClient(self)
|
|
75
|
+
self.replay = ReplayClient(self)
|
|
76
|
+
self.compliance = ComplianceClient(self)
|
|
77
|
+
self.verify = VerifyClient(self)
|
|
78
|
+
self.traces = TracesClient(self)
|
|
79
|
+
|
|
80
|
+
# ── Legacy aliases (backward compatibility) ───────────────────
|
|
81
|
+
# These point to the same new clients so old code keeps working.
|
|
82
|
+
self.forensics = self.replay # client.forensics.compare(...) still works
|
|
83
|
+
self.hitl = self.approval # client.hitl.reject(...) still works
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_env(cls) -> "BlocklogClient":
|
|
87
|
+
"""Create a client configured entirely from environment variables."""
|
|
88
|
+
return cls(BlocklogConfig.from_env())
|
|
89
|
+
|
|
90
|
+
def add_hook(self, hook) -> "BlocklogClient":
|
|
91
|
+
"""Register a middleware hook applied to every outbound event payload."""
|
|
92
|
+
self.hooks.append(hook)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def session(self, *, agent_id: str | None = None, source: str = "python-sdk", workflow_id=None):
|
|
96
|
+
"""Open an ``agent_session`` context manager (backward-compatible).
|
|
97
|
+
|
|
98
|
+
Prefer the ``@blocklog.agent`` decorator for new code.
|
|
99
|
+
"""
|
|
100
|
+
return agent_session(agent_id=agent_id, source=source, workflow_id=workflow_id)
|
|
101
|
+
|
|
102
|
+
def instrument_openai_agents(self) -> "BlocklogClient":
|
|
103
|
+
"""Auto-instrument the OpenAI Agents SDK."""
|
|
104
|
+
return instrument_openai_agents(self)
|
|
105
|
+
|
|
106
|
+
def instrument_langchain(self) -> "BlocklogClient":
|
|
107
|
+
"""Auto-instrument LangChain."""
|
|
108
|
+
return instrument_langchain(self)
|
|
109
|
+
|
|
110
|
+
def instrument_langgraph(self) -> "BlocklogClient":
|
|
111
|
+
"""Auto-instrument LangGraph."""
|
|
112
|
+
return instrument_langgraph(self)
|
|
113
|
+
|
|
114
|
+
# ── Low-level event ingest (Layer 3) ─────────────────────────────
|
|
115
|
+
|
|
116
|
+
def event(self, event_type: str, payload: dict[str, Any], **kwargs) -> IngestResponse:
|
|
117
|
+
"""Emit a single event immediately (synchronous)."""
|
|
118
|
+
envelope = self._build_event(event_type=event_type, payload=payload, **kwargs)
|
|
119
|
+
result = self.retry.run(lambda: self.transport.request("POST", "/logs", json=self._serialize(envelope)))
|
|
120
|
+
return IngestResponse.model_validate(result)
|
|
121
|
+
|
|
122
|
+
def enqueue(self, event_type: str, payload: dict[str, Any], **kwargs):
|
|
123
|
+
"""Enqueue an event for batched delivery."""
|
|
124
|
+
envelope = self._build_event(event_type=event_type, payload=payload, **kwargs)
|
|
125
|
+
batch = self.buffer.add(envelope)
|
|
126
|
+
if batch:
|
|
127
|
+
return self.flush(batch=batch)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def flush(self, *, batch=None):
|
|
131
|
+
"""Flush the event buffer, sending all queued events."""
|
|
132
|
+
batch = batch or self.buffer.flush()
|
|
133
|
+
if not batch:
|
|
134
|
+
return {"ingested": 0, "log_ids": []}
|
|
135
|
+
payload = {"logs": [self._serialize(item) for item in batch]}
|
|
136
|
+
return self.retry.run(lambda: self.transport.request("POST", "/logs/batch", json=payload))
|
|
137
|
+
|
|
138
|
+
def _build_event(self, *, event_type: str, payload: dict[str, Any], **kwargs) -> EventEnvelope:
|
|
139
|
+
context = get_context()
|
|
140
|
+
event = EventEnvelope(
|
|
141
|
+
event_type=event_type,
|
|
142
|
+
payload=payload,
|
|
143
|
+
source=kwargs.get("source") or (context.source if context else "python-sdk"),
|
|
144
|
+
trace_id=kwargs.get("trace_id") or (context.trace_id if context else None),
|
|
145
|
+
session_id=kwargs.get("session_id") or (context.session_id if context else None),
|
|
146
|
+
workflow_id=kwargs.get("workflow_id") or (context.workflow_id if context else None),
|
|
147
|
+
actor_id=kwargs.get("actor_id") or (context.agent_id if context else None),
|
|
148
|
+
actor_type=kwargs.get("actor_type", "agent"),
|
|
149
|
+
parent_event_id=kwargs.get("parent_event_id"),
|
|
150
|
+
root_event_id=kwargs.get("root_event_id"),
|
|
151
|
+
span_id=kwargs.get("span_id"),
|
|
152
|
+
attempt_no=kwargs.get("attempt_no", 1),
|
|
153
|
+
causality_type=kwargs.get("causality_type"),
|
|
154
|
+
agent_metadata=kwargs.get("agent_metadata", {}),
|
|
155
|
+
)
|
|
156
|
+
if not event.idempotency_key:
|
|
157
|
+
event.idempotency_key = self._idempotency_key(event)
|
|
158
|
+
return event
|
|
159
|
+
|
|
160
|
+
def _serialize(self, envelope: EventEnvelope) -> dict[str, Any]:
|
|
161
|
+
payload = {
|
|
162
|
+
"event_type": envelope.event_type,
|
|
163
|
+
"timestamp": envelope.timestamp.isoformat(),
|
|
164
|
+
"data": envelope.payload,
|
|
165
|
+
"source": envelope.source,
|
|
166
|
+
"idempotency_key": envelope.idempotency_key,
|
|
167
|
+
"trace_id": str(envelope.trace_id) if envelope.trace_id else None,
|
|
168
|
+
"session_id": str(envelope.session_id) if envelope.session_id else None,
|
|
169
|
+
"workflow_id": str(envelope.workflow_id) if envelope.workflow_id else None,
|
|
170
|
+
"parent_event_id": str(envelope.parent_event_id) if envelope.parent_event_id else None,
|
|
171
|
+
"root_event_id": str(envelope.root_event_id) if envelope.root_event_id else None,
|
|
172
|
+
"span_id": envelope.span_id,
|
|
173
|
+
"attempt_no": envelope.attempt_no,
|
|
174
|
+
"causality_type": envelope.causality_type,
|
|
175
|
+
"schema_version": envelope.schema_version,
|
|
176
|
+
"event_version": envelope.event_version,
|
|
177
|
+
"actor_type": envelope.actor_type,
|
|
178
|
+
"actor_id": envelope.actor_id,
|
|
179
|
+
"agent_metadata": envelope.agent_metadata,
|
|
180
|
+
}
|
|
181
|
+
payload = apply_hooks(payload, self.hooks)
|
|
182
|
+
if self.config.signing_key:
|
|
183
|
+
payload["log_signature"] = hash_sign(payload, private_key=self.config.signing_key)
|
|
184
|
+
return payload
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _idempotency_key(envelope: EventEnvelope) -> str:
|
|
188
|
+
digest = sha256(
|
|
189
|
+
f"{envelope.event_type}:{envelope.source}:{envelope.trace_id}:{envelope.session_id}:{envelope.payload}".encode(
|
|
190
|
+
"utf-8"
|
|
191
|
+
)
|
|
192
|
+
).hexdigest()[:32]
|
|
193
|
+
return f"blk_{digest}"
|
|
194
|
+
|
blocklog/compliance.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.compliance
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Module-level namespace for compliance report generation.
|
|
5
|
+
|
|
6
|
+
Usage (Layer 1)::
|
|
7
|
+
|
|
8
|
+
import blocklog
|
|
9
|
+
|
|
10
|
+
report = blocklog.compliance.generate(
|
|
11
|
+
trace_id="trace-abc",
|
|
12
|
+
framework="SOC2",
|
|
13
|
+
)
|
|
14
|
+
print(report["id"])
|
|
15
|
+
|
|
16
|
+
dashboard = blocklog.compliance.dashboard()
|
|
17
|
+
reports = blocklog.compliance.list()
|
|
18
|
+
share_url = blocklog.compliance.share(report["id"], expires_in=86400)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate(
|
|
26
|
+
trace_id: str | None = None,
|
|
27
|
+
*,
|
|
28
|
+
framework: str | None = None,
|
|
29
|
+
date_from: str | None = None,
|
|
30
|
+
date_to: str | None = None,
|
|
31
|
+
metadata: dict[str, Any] | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Generate a compliance report.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
trace_id:
|
|
38
|
+
Scope the report to a specific trace. Omit for a company-wide
|
|
39
|
+
report.
|
|
40
|
+
framework:
|
|
41
|
+
Compliance framework (``"SOC2"``, ``"GDPR"``, ``"ISO27001"``…).
|
|
42
|
+
date_from:
|
|
43
|
+
ISO-8601 start of the reporting window.
|
|
44
|
+
date_to:
|
|
45
|
+
ISO-8601 end of the reporting window.
|
|
46
|
+
metadata:
|
|
47
|
+
Arbitrary extra data to embed in the report.
|
|
48
|
+
"""
|
|
49
|
+
from blocklog._global import get_client
|
|
50
|
+
return get_client().compliance.generate(
|
|
51
|
+
trace_id=trace_id,
|
|
52
|
+
framework=framework,
|
|
53
|
+
date_from=date_from,
|
|
54
|
+
date_to=date_to,
|
|
55
|
+
metadata=metadata,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get(report_id: str) -> dict[str, Any]:
|
|
60
|
+
"""Fetch a compliance report by ID."""
|
|
61
|
+
from blocklog._global import get_client
|
|
62
|
+
return get_client().compliance.get(report_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list() -> list[dict[str, Any]]: # noqa: A001
|
|
66
|
+
"""List all compliance reports for the company."""
|
|
67
|
+
from blocklog._global import get_client
|
|
68
|
+
return get_client().compliance.list()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dashboard() -> dict[str, Any]:
|
|
72
|
+
"""Return the compliance dashboard summary."""
|
|
73
|
+
from blocklog._global import get_client
|
|
74
|
+
return get_client().compliance.dashboard()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def share(
|
|
78
|
+
report_id: str,
|
|
79
|
+
*,
|
|
80
|
+
expires_in: int | None = None,
|
|
81
|
+
recipient_email: str | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Create a shareable link for a compliance report."""
|
|
84
|
+
from blocklog._global import get_client
|
|
85
|
+
return get_client().compliance.share(
|
|
86
|
+
report_id=report_id,
|
|
87
|
+
expires_in=expires_in,
|
|
88
|
+
recipient_email=recipient_email,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def export(report_id: str, *, download: bool = False) -> dict[str, Any]:
|
|
93
|
+
"""Export a compliance report as JSON."""
|
|
94
|
+
from blocklog._global import get_client
|
|
95
|
+
return get_client().compliance.export(report_id=report_id, download=download)
|
blocklog/config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from os import getenv
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BlocklogConfig(BaseModel):
|
|
7
|
+
base_url: str = Field(default_factory=lambda: getenv("BLOCKLOG_BASE_URL", "http://127.0.0.1:8000/api/v1"))
|
|
8
|
+
api_key: str = Field(default_factory=lambda: getenv("BLOCKLOG_API_KEY", ""))
|
|
9
|
+
signing_key: str = Field(default_factory=lambda: getenv("BLOCKLOG_SDK_SIGNING_KEY", ""))
|
|
10
|
+
timeout: float = Field(default_factory=lambda: float(getenv("BLOCKLOG_TIMEOUT", "10")))
|
|
11
|
+
max_retries: int = Field(default_factory=lambda: int(getenv("BLOCKLOG_MAX_RETRIES", "3")))
|
|
12
|
+
batch_size: int = Field(default_factory=lambda: int(getenv("BLOCKLOG_BATCH_SIZE", "100")))
|
|
13
|
+
flush_interval: float = Field(default_factory=lambda: float(getenv("BLOCKLOG_FLUSH_INTERVAL", "2")))
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_env(cls) -> "BlocklogConfig":
|
|
17
|
+
return cls()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
|
|
3
|
+
from blocklog.models.events import SessionContext
|
|
4
|
+
|
|
5
|
+
from .vars import set_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@contextmanager
|
|
9
|
+
def agent_session(*, agent_id: str | None = None, source: str = "python-sdk", workflow_id=None):
|
|
10
|
+
context = SessionContext(agent_id=agent_id, source=source, workflow_id=workflow_id)
|
|
11
|
+
token = set_context(context)
|
|
12
|
+
try:
|
|
13
|
+
yield context
|
|
14
|
+
finally:
|
|
15
|
+
set_context(None)
|
blocklog/context/vars.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
|
|
3
|
+
from blocklog.models.events import SessionContext
|
|
4
|
+
|
|
5
|
+
_context: ContextVar[SessionContext | None] = ContextVar("blocklog_context", default=None)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_context(context: SessionContext | None):
|
|
9
|
+
return _context.set(context)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_context() -> SessionContext | None:
|
|
13
|
+
return _context.get()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.decorators.agent
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
The ``@agent`` decorator — wraps a function (or class) so that every
|
|
5
|
+
execution is automatically traced, timed, and linked to a Blocklog
|
|
6
|
+
session.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
import blocklog
|
|
11
|
+
blocklog.init(api_key="blk_...")
|
|
12
|
+
|
|
13
|
+
@blocklog.agent
|
|
14
|
+
def market_analyst():
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
# With options:
|
|
18
|
+
@blocklog.agent(name="market-analyst", version="2.1", tags=["prod"])
|
|
19
|
+
def market_analyst():
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
# On a class:
|
|
23
|
+
@blocklog.agent(name="hedge-fund-orchestrator")
|
|
24
|
+
class HedgeFundOrchestrator:
|
|
25
|
+
def run(self):
|
|
26
|
+
...
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import functools
|
|
31
|
+
import inspect
|
|
32
|
+
import logging
|
|
33
|
+
import traceback as _traceback
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import Any, Callable, TypeVar
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def agent(
|
|
43
|
+
func: F | None = None,
|
|
44
|
+
*,
|
|
45
|
+
name: str | None = None,
|
|
46
|
+
version: str = "1.0",
|
|
47
|
+
tags: list[str] | None = None,
|
|
48
|
+
metadata: dict[str, Any] | None = None,
|
|
49
|
+
) -> F | Callable[[F], F]:
|
|
50
|
+
"""Decorator that traces an AI agent function or class.
|
|
51
|
+
|
|
52
|
+
Automatically:
|
|
53
|
+
- Opens a Blocklog ``agent_session`` (sets trace/session context)
|
|
54
|
+
- Emits ``AGENT_START`` event with agent name, version, tags
|
|
55
|
+
- Emits ``AGENT_COMPLETE`` event with duration on clean return
|
|
56
|
+
- Emits ``AGENT_ERROR`` event with traceback on exception
|
|
57
|
+
|
|
58
|
+
Can be used with or without arguments:
|
|
59
|
+
|
|
60
|
+
.. code-block:: python
|
|
61
|
+
|
|
62
|
+
@blocklog.agent
|
|
63
|
+
def my_agent(): ...
|
|
64
|
+
|
|
65
|
+
@blocklog.agent(name="my-agent", version="2.0")
|
|
66
|
+
def my_agent(): ...
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
func:
|
|
71
|
+
The function to decorate (when used without arguments).
|
|
72
|
+
name:
|
|
73
|
+
Human-readable agent name. Defaults to ``func.__name__``.
|
|
74
|
+
version:
|
|
75
|
+
Semver-style version string stored in agent metadata.
|
|
76
|
+
tags:
|
|
77
|
+
Optional list of string tags.
|
|
78
|
+
metadata:
|
|
79
|
+
Arbitrary extra data stored in the agent metadata.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
Callable
|
|
84
|
+
The decorated function (or a decorator if called with arguments).
|
|
85
|
+
"""
|
|
86
|
+
def decorator(fn: F) -> F:
|
|
87
|
+
agent_name = name or fn.__name__
|
|
88
|
+
agent_meta = {
|
|
89
|
+
"agent_name": agent_name,
|
|
90
|
+
"agent_version": version,
|
|
91
|
+
"tags": tags or [],
|
|
92
|
+
**(metadata or {}),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if inspect.isclass(fn):
|
|
96
|
+
return _wrap_class(fn, agent_name, agent_meta) # type: ignore[return-value]
|
|
97
|
+
|
|
98
|
+
@functools.wraps(fn)
|
|
99
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
100
|
+
return _run_sync(fn, args, kwargs, agent_name, agent_meta)
|
|
101
|
+
|
|
102
|
+
@functools.wraps(fn)
|
|
103
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
104
|
+
return await _run_async(fn, args, kwargs, agent_name, agent_meta)
|
|
105
|
+
|
|
106
|
+
if inspect.iscoroutinefunction(fn):
|
|
107
|
+
return async_wrapper # type: ignore[return-value]
|
|
108
|
+
return sync_wrapper # type: ignore[return-value]
|
|
109
|
+
|
|
110
|
+
# Called as @agent (no args)
|
|
111
|
+
if func is not None:
|
|
112
|
+
return decorator(func)
|
|
113
|
+
|
|
114
|
+
# Called as @agent(...) — return the decorator
|
|
115
|
+
return decorator
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Internal helpers
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def _run_sync(fn: Callable, args: tuple, kwargs: dict, agent_name: str, meta: dict) -> Any:
|
|
123
|
+
from blocklog.context.managers import agent_session
|
|
124
|
+
started_at = _now()
|
|
125
|
+
with agent_session(agent_id=agent_name, source=f"agent:{agent_name}") as ctx:
|
|
126
|
+
_emit("AGENT_START", {
|
|
127
|
+
"agent_name": agent_name,
|
|
128
|
+
"started_at": started_at,
|
|
129
|
+
**meta,
|
|
130
|
+
}, ctx)
|
|
131
|
+
try:
|
|
132
|
+
result = fn(*args, **kwargs)
|
|
133
|
+
_emit("AGENT_COMPLETE", {
|
|
134
|
+
"agent_name": agent_name,
|
|
135
|
+
"duration_ms": _elapsed_ms(started_at),
|
|
136
|
+
"status": "ok",
|
|
137
|
+
}, ctx)
|
|
138
|
+
return result
|
|
139
|
+
except BaseException as exc:
|
|
140
|
+
_emit("AGENT_ERROR", {
|
|
141
|
+
"agent_name": agent_name,
|
|
142
|
+
"duration_ms": _elapsed_ms(started_at),
|
|
143
|
+
"error_type": type(exc).__name__,
|
|
144
|
+
"error_message": str(exc),
|
|
145
|
+
"traceback": _traceback.format_exc(),
|
|
146
|
+
}, ctx)
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _run_async(fn: Callable, args: tuple, kwargs: dict, agent_name: str, meta: dict) -> Any:
|
|
151
|
+
from blocklog.context.managers import agent_session
|
|
152
|
+
started_at = _now()
|
|
153
|
+
with agent_session(agent_id=agent_name, source=f"agent:{agent_name}") as ctx:
|
|
154
|
+
_emit("AGENT_START", {
|
|
155
|
+
"agent_name": agent_name,
|
|
156
|
+
"started_at": started_at,
|
|
157
|
+
**meta,
|
|
158
|
+
}, ctx)
|
|
159
|
+
try:
|
|
160
|
+
result = await fn(*args, **kwargs)
|
|
161
|
+
_emit("AGENT_COMPLETE", {
|
|
162
|
+
"agent_name": agent_name,
|
|
163
|
+
"duration_ms": _elapsed_ms(started_at),
|
|
164
|
+
"status": "ok",
|
|
165
|
+
}, ctx)
|
|
166
|
+
return result
|
|
167
|
+
except BaseException as exc:
|
|
168
|
+
_emit("AGENT_ERROR", {
|
|
169
|
+
"agent_name": agent_name,
|
|
170
|
+
"duration_ms": _elapsed_ms(started_at),
|
|
171
|
+
"error_type": type(exc).__name__,
|
|
172
|
+
"error_message": str(exc),
|
|
173
|
+
"traceback": _traceback.format_exc(),
|
|
174
|
+
}, ctx)
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _wrap_class(cls: type, agent_name: str, meta: dict) -> type:
|
|
179
|
+
"""Wrap the ``__init__`` of a class to open an agent session."""
|
|
180
|
+
original_init = cls.__init__
|
|
181
|
+
|
|
182
|
+
@functools.wraps(original_init)
|
|
183
|
+
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
184
|
+
from blocklog.context.managers import agent_session
|
|
185
|
+
from blocklog.context.vars import set_context
|
|
186
|
+
from blocklog.models.events import SessionContext
|
|
187
|
+
|
|
188
|
+
ctx = SessionContext(agent_id=agent_name, source=f"agent:{agent_name}")
|
|
189
|
+
set_context(ctx)
|
|
190
|
+
_emit("AGENT_START", {"agent_name": agent_name, **meta}, ctx)
|
|
191
|
+
original_init(self, *args, **kwargs)
|
|
192
|
+
|
|
193
|
+
cls.__init__ = new_init
|
|
194
|
+
return cls
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _emit(event_type: str, payload: dict, ctx: Any) -> None:
|
|
198
|
+
try:
|
|
199
|
+
from blocklog._global import get_client
|
|
200
|
+
client = get_client()
|
|
201
|
+
client.event(
|
|
202
|
+
event_type,
|
|
203
|
+
payload=payload,
|
|
204
|
+
trace_id=str(ctx.trace_id) if ctx else None,
|
|
205
|
+
session_id=str(ctx.session_id) if ctx else None,
|
|
206
|
+
actor_id=ctx.agent_id if ctx else None,
|
|
207
|
+
actor_type="agent",
|
|
208
|
+
)
|
|
209
|
+
except Exception as exc: # noqa: BLE001
|
|
210
|
+
logger.debug("blocklog: emit %s failed: %s", event_type, exc)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _now() -> str:
|
|
214
|
+
return datetime.now(timezone.utc).isoformat()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _elapsed_ms(since_iso: str) -> int:
|
|
218
|
+
start = datetime.fromisoformat(since_iso)
|
|
219
|
+
return int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|