agenttic-langgraph 1.0.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.
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""agenttic-langgraph — trace a LangGraph agent onto the Agenttic OTel bus.
|
|
2
|
+
|
|
3
|
+
Two lines for the user::
|
|
4
|
+
|
|
5
|
+
from agenttic_langgraph import trace
|
|
6
|
+
graph = trace(compiled_graph, agent_id="support-bot",
|
|
7
|
+
endpoint="https://agenttic.internal")
|
|
8
|
+
|
|
9
|
+
``trace`` returns a transparent wrapper around the compiled graph. On each
|
|
10
|
+
``invoke``/``stream`` it attaches a LangChain **public** callback handler
|
|
11
|
+
(``langchain_core.callbacks.BaseCallbackHandler``) that observes LLM and tool
|
|
12
|
+
events and emits OTel-GenAI spans via :class:`agenttic.ingest.emit.SpanEmitter`.
|
|
13
|
+
|
|
14
|
+
Guarantees (SPEC-7 Step 36, Hard Rules 31–32):
|
|
15
|
+
|
|
16
|
+
* **Behavior-identical** — the callback only observes; it never mutates the
|
|
17
|
+
graph's inputs or outputs. The wrapper delegates every other attribute to the
|
|
18
|
+
wrapped graph, so it is a drop-in.
|
|
19
|
+
* **Public API only** — it hooks ``config["callbacks"]``, the documented
|
|
20
|
+
LangChain/LangGraph extension point. No private modules, no monkey-patching.
|
|
21
|
+
* **Observe by default** — spans are emitted; nothing is blocked. The optional
|
|
22
|
+
``enforce=`` argument routes tool calls through the SPEC-4 gateway at the
|
|
23
|
+
ramp's non-blocking default posture, and fails loudly if no compiled policy
|
|
24
|
+
exists (see :func:`agenttic.enforce.adapter_guard.build_enforce_guard`).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from agenttic.ingest.emit import SpanEmitter
|
|
31
|
+
|
|
32
|
+
try: # public LangChain callback base; optional at import time
|
|
33
|
+
from langchain_core.callbacks import BaseCallbackHandler as _HandlerBase
|
|
34
|
+
HAVE_LANGCHAIN = True
|
|
35
|
+
except Exception: # pragma: no cover - exercised only where langchain is absent
|
|
36
|
+
_HandlerBase = object
|
|
37
|
+
HAVE_LANGCHAIN = False
|
|
38
|
+
|
|
39
|
+
__all__ = ["trace", "AgentticCallbackHandler", "HAVE_LANGCHAIN"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgentticCallbackHandler(_HandlerBase):
|
|
43
|
+
"""A LangChain callback handler that emits OTel-GenAI spans.
|
|
44
|
+
|
|
45
|
+
Purely observational: every method reads the event and records a span; none
|
|
46
|
+
returns or mutates a value the framework would act on."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, emitter: SpanEmitter):
|
|
49
|
+
super().__init__()
|
|
50
|
+
self.emitter = emitter
|
|
51
|
+
self._pending_prompts: list[str] = []
|
|
52
|
+
self._tool_stack: list[tuple[str, Any]] = []
|
|
53
|
+
|
|
54
|
+
# -- LLM ---------------------------------------------------------------
|
|
55
|
+
def on_llm_start(self, serialized, prompts, **kwargs): # noqa: D401
|
|
56
|
+
self._pending_prompts = list(prompts or [])
|
|
57
|
+
|
|
58
|
+
def on_chat_model_start(self, serialized, messages, **kwargs):
|
|
59
|
+
# messages: list[list[BaseMessage]]; flatten to text defensively
|
|
60
|
+
flat = []
|
|
61
|
+
for group in messages or []:
|
|
62
|
+
for m in group or []:
|
|
63
|
+
flat.append(getattr(m, "content", str(m)))
|
|
64
|
+
self._pending_prompts = flat
|
|
65
|
+
|
|
66
|
+
def on_llm_end(self, response, **kwargs):
|
|
67
|
+
completion = _first_generation_text(response)
|
|
68
|
+
usage = _token_usage(response, kwargs)
|
|
69
|
+
model = _model_name(response)
|
|
70
|
+
self.emitter.emit_llm_call(
|
|
71
|
+
system=_system_hint(response) or "langchain",
|
|
72
|
+
model=model,
|
|
73
|
+
prompt="\n".join(str(p) for p in self._pending_prompts) or None,
|
|
74
|
+
completion=completion,
|
|
75
|
+
input_tokens=usage.get("input"),
|
|
76
|
+
output_tokens=usage.get("output"))
|
|
77
|
+
self._pending_prompts = []
|
|
78
|
+
|
|
79
|
+
def on_llm_error(self, error, **kwargs):
|
|
80
|
+
self._pending_prompts = []
|
|
81
|
+
|
|
82
|
+
# -- tools -------------------------------------------------------------
|
|
83
|
+
def on_tool_start(self, serialized, input_str, **kwargs):
|
|
84
|
+
name = (serialized or {}).get("name") or kwargs.get("name") or "tool"
|
|
85
|
+
self._tool_stack.append((name, input_str))
|
|
86
|
+
|
|
87
|
+
def on_tool_end(self, output, **kwargs):
|
|
88
|
+
if self._tool_stack:
|
|
89
|
+
name, args = self._tool_stack.pop()
|
|
90
|
+
else:
|
|
91
|
+
name, args = "tool", None
|
|
92
|
+
self.emitter.emit_tool_call(tool_name=name, arguments=args, result=output)
|
|
93
|
+
|
|
94
|
+
def on_tool_error(self, error, **kwargs):
|
|
95
|
+
if self._tool_stack:
|
|
96
|
+
name, args = self._tool_stack.pop()
|
|
97
|
+
self.emitter.emit_tool_call(tool_name=name, arguments=args,
|
|
98
|
+
result=f"error: {error}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- helpers (defensive extraction; langchain objects vary by version) -----
|
|
102
|
+
|
|
103
|
+
def _first_generation_text(response) -> str | None:
|
|
104
|
+
gens = getattr(response, "generations", None)
|
|
105
|
+
if gens:
|
|
106
|
+
try:
|
|
107
|
+
g = gens[0][0]
|
|
108
|
+
return getattr(g, "text", None) or getattr(
|
|
109
|
+
getattr(g, "message", None), "content", None)
|
|
110
|
+
except (IndexError, TypeError):
|
|
111
|
+
return None
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _token_usage(response, kwargs) -> dict:
|
|
116
|
+
out = getattr(response, "llm_output", None) or {}
|
|
117
|
+
usage = (out.get("token_usage") or out.get("usage") or {}) if isinstance(out, dict) else {}
|
|
118
|
+
return {
|
|
119
|
+
"input": usage.get("prompt_tokens") or usage.get("input_tokens"),
|
|
120
|
+
"output": usage.get("completion_tokens") or usage.get("output_tokens"),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _model_name(response) -> str:
|
|
125
|
+
out = getattr(response, "llm_output", None) or {}
|
|
126
|
+
if isinstance(out, dict):
|
|
127
|
+
return out.get("model_name") or out.get("model") or ""
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _system_hint(response) -> str:
|
|
132
|
+
out = getattr(response, "llm_output", None) or {}
|
|
133
|
+
if isinstance(out, dict):
|
|
134
|
+
return out.get("system") or ""
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _TracedGraph:
|
|
139
|
+
"""Transparent wrapper: delegates everything to the graph, but injects the
|
|
140
|
+
tracing callback on invoke/stream/ainvoke."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, graph, *, agent_id: str, agent_config_hash: str = "",
|
|
143
|
+
endpoint: str | None = None, auth_header: str | None = None,
|
|
144
|
+
sink: list | None = None, enforce_guard=None):
|
|
145
|
+
self._graph = graph
|
|
146
|
+
self._agent_id = agent_id
|
|
147
|
+
self._agent_config_hash = agent_config_hash
|
|
148
|
+
self._endpoint = endpoint
|
|
149
|
+
self._auth_header = auth_header
|
|
150
|
+
self._sink = sink
|
|
151
|
+
self._enforce_guard = enforce_guard
|
|
152
|
+
|
|
153
|
+
def _new_handler(self) -> AgentticCallbackHandler:
|
|
154
|
+
emitter = SpanEmitter(
|
|
155
|
+
self._agent_id, agent_config_hash=self._agent_config_hash,
|
|
156
|
+
endpoint=self._endpoint, auth_header=self._auth_header,
|
|
157
|
+
sink=self._sink, scope_name="agenttic_langgraph")
|
|
158
|
+
return AgentticCallbackHandler(emitter)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _inject(config, handler):
|
|
162
|
+
config = dict(config or {})
|
|
163
|
+
cbs = config.get("callbacks")
|
|
164
|
+
if cbs is None:
|
|
165
|
+
config["callbacks"] = [handler]
|
|
166
|
+
elif isinstance(cbs, list):
|
|
167
|
+
config["callbacks"] = [*cbs, handler]
|
|
168
|
+
else: # a CallbackManager — public add_handler
|
|
169
|
+
try:
|
|
170
|
+
cbs.add_handler(handler)
|
|
171
|
+
except Exception:
|
|
172
|
+
config["callbacks"] = [handler]
|
|
173
|
+
return config
|
|
174
|
+
|
|
175
|
+
def invoke(self, input, config=None, **kwargs):
|
|
176
|
+
handler = self._new_handler()
|
|
177
|
+
if self._enforce_guard is not None:
|
|
178
|
+
self._enforce_guard.begin()
|
|
179
|
+
try:
|
|
180
|
+
return self._graph.invoke(input, config=self._inject(config, handler),
|
|
181
|
+
**kwargs)
|
|
182
|
+
finally:
|
|
183
|
+
handler.emitter.flush()
|
|
184
|
+
if self._enforce_guard is not None:
|
|
185
|
+
self._enforce_guard.end()
|
|
186
|
+
|
|
187
|
+
def stream(self, input, config=None, **kwargs):
|
|
188
|
+
handler = self._new_handler()
|
|
189
|
+
try:
|
|
190
|
+
yield from self._graph.stream(
|
|
191
|
+
input, config=self._inject(config, handler), **kwargs)
|
|
192
|
+
finally:
|
|
193
|
+
handler.emitter.flush()
|
|
194
|
+
|
|
195
|
+
async def ainvoke(self, input, config=None, **kwargs):
|
|
196
|
+
handler = self._new_handler()
|
|
197
|
+
try:
|
|
198
|
+
return await self._graph.ainvoke(
|
|
199
|
+
input, config=self._inject(config, handler), **kwargs)
|
|
200
|
+
finally:
|
|
201
|
+
handler.emitter.flush()
|
|
202
|
+
|
|
203
|
+
def __getattr__(self, item): # transparent delegation
|
|
204
|
+
return getattr(self._graph, item)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def trace(graph, *, agent_id: str = "langgraph-agent", agent_config_hash: str = "",
|
|
208
|
+
endpoint: str | None = None, auth_header: str | None = None,
|
|
209
|
+
sink: list | None = None, enforce: Any = None, reg=None, cfg=None):
|
|
210
|
+
"""Wrap a compiled LangGraph graph so its runs emit OTel-GenAI spans.
|
|
211
|
+
|
|
212
|
+
``enforce`` is off by default (pure observation). When set, tool calls are
|
|
213
|
+
routed through the SPEC-4 gateway at the ramp's non-blocking default posture;
|
|
214
|
+
a missing compiled policy fails loudly (T36.3)."""
|
|
215
|
+
guard = None
|
|
216
|
+
if enforce:
|
|
217
|
+
from agenttic.enforce.adapter_guard import build_enforce_guard
|
|
218
|
+
guard = build_enforce_guard(agent_id, enforce, reg=reg, cfg=cfg)
|
|
219
|
+
return _TracedGraph(graph, agent_id=agent_id,
|
|
220
|
+
agent_config_hash=agent_config_hash, endpoint=endpoint,
|
|
221
|
+
auth_header=auth_header, sink=sink, enforce_guard=guard)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agenttic-langgraph
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Trace a LangGraph agent onto the Agenttic OTel-GenAI bus (public callbacks only).
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: agenttic
|
|
7
|
+
Requires-Dist: langchain-core>=0.2
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# agenttic-langgraph
|
|
13
|
+
|
|
14
|
+
Trace a [LangGraph](https://github.com/langchain-ai/langgraph) agent onto the
|
|
15
|
+
Agenttic OTel-GenAI bus using **public LangChain callbacks only** — no
|
|
16
|
+
monkey-patching, no private internals.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from agenttic_langgraph import trace
|
|
20
|
+
|
|
21
|
+
graph = trace(compiled_graph, agent_id="support-bot",
|
|
22
|
+
endpoint="https://agenttic.internal")
|
|
23
|
+
result = graph.invoke({"messages": [...]}) # behaviour-identical; spans emitted
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or let Agenttic auto-detect the framework for you:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from agenttic import trace # pip install agenttic[langgraph]
|
|
30
|
+
graph = trace(compiled_graph) # dispatches here automatically
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`trace` returns a transparent wrapper: it only observes (Hard Rule 38 —
|
|
34
|
+
behaviour-identical), and the optional `enforce=` argument routes tool calls
|
|
35
|
+
through the Agenttic gateway at the ramp's non-blocking default posture. The span
|
|
36
|
+
builder lives in the `agenttic` distribution; this package is a thin adapter.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
agenttic_langgraph/__init__.py,sha256=ThK5WSlC3Z3fQt2ltrsVJUpiQ0UIfGL7JBo0N-I6drc,8824
|
|
2
|
+
agenttic_langgraph-1.0.0.dist-info/METADATA,sha256=Z6AQxD0viphEeSNsqTrNklF-EJC-nuJrlWGJCq_05R0,1310
|
|
3
|
+
agenttic_langgraph-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
4
|
+
agenttic_langgraph-1.0.0.dist-info/RECORD,,
|