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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any