necto-sdk 0.1.0__tar.gz
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.
- necto_sdk-0.1.0/.gitignore +33 -0
- necto_sdk-0.1.0/PKG-INFO +5 -0
- necto_sdk-0.1.0/necto_sdk/__init__.py +35 -0
- necto_sdk-0.1.0/necto_sdk/_version.py +3 -0
- necto_sdk-0.1.0/necto_sdk/adapters/__init__.py +20 -0
- necto_sdk-0.1.0/necto_sdk/adapters/langgraph.py +381 -0
- necto_sdk-0.1.0/necto_sdk/adapters/openai_agents.py +222 -0
- necto_sdk-0.1.0/necto_sdk/adapters/otel.py +255 -0
- necto_sdk-0.1.0/necto_sdk/events.py +68 -0
- necto_sdk-0.1.0/necto_sdk/tracer.py +355 -0
- necto_sdk-0.1.0/necto_sdk/transport.py +180 -0
- necto_sdk-0.1.0/pyproject.toml +32 -0
- necto_sdk-0.1.0/tests/test_custom_pipeline.py +62 -0
- necto_sdk-0.1.0/tests/test_events.py +31 -0
- necto_sdk-0.1.0/tests/test_langgraph_adapter.py +145 -0
- necto_sdk-0.1.0/tests/test_langgraph_integration.py +73 -0
- necto_sdk-0.1.0/tests/test_openai_agents_adapter.py +144 -0
- necto_sdk-0.1.0/tests/test_otel_adapter.py +193 -0
- necto_sdk-0.1.0/tests/test_tracer.py +233 -0
- necto_sdk-0.1.0/tests/test_transport.py +182 -0
- necto_sdk-0.1.0/uv.lock +1842 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
venv/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
|
|
14
|
+
# Node
|
|
15
|
+
node_modules/
|
|
16
|
+
web/dist/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
|
|
19
|
+
# Env & secrets
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
22
|
+
!.env.example
|
|
23
|
+
|
|
24
|
+
# Local database / tools (dev stand-ins, not part of the repo)
|
|
25
|
+
pgdata/
|
|
26
|
+
.tools/
|
|
27
|
+
necto-tools/
|
|
28
|
+
|
|
29
|
+
# OS / editor
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
.idea/
|
|
33
|
+
.vscode/
|
necto_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""necto-sdk — instrument multi-agent AI apps for necto.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
from necto_sdk import Necto
|
|
6
|
+
|
|
7
|
+
necto = Necto(endpoint="http://127.0.0.1:8000")
|
|
8
|
+
|
|
9
|
+
with necto.run(name="demo") as run:
|
|
10
|
+
run.handoff("router", "researcher", reason="needs sources")
|
|
11
|
+
|
|
12
|
+
Events are buffered and shipped in the background. The SDK never raises into
|
|
13
|
+
your application: if the necto server is down, events are dropped and logged.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .events import agent, event, external, model, ref, state, tool
|
|
18
|
+
from .tracer import Necto, Run
|
|
19
|
+
from .transport import HttpTransport, NullTransport, RecordingTransport
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Necto",
|
|
23
|
+
"Run",
|
|
24
|
+
"HttpTransport",
|
|
25
|
+
"NullTransport",
|
|
26
|
+
"RecordingTransport",
|
|
27
|
+
"agent",
|
|
28
|
+
"event",
|
|
29
|
+
"external",
|
|
30
|
+
"model",
|
|
31
|
+
"ref",
|
|
32
|
+
"state",
|
|
33
|
+
"tool",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Framework integrations.
|
|
2
|
+
|
|
3
|
+
Each adapter is import-safe without its framework installed: frameworks are only
|
|
4
|
+
touched through duck-typed callbacks, never imported at module load.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .langgraph import NectoCallbackHandler, attach
|
|
8
|
+
from .openai_agents import NectoProcessor
|
|
9
|
+
from .openai_agents import instrument as instrument_openai_agents
|
|
10
|
+
from .otel import NectoSpanProcessor
|
|
11
|
+
from .otel import instrument as instrument_otel
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"NectoCallbackHandler",
|
|
15
|
+
"NectoProcessor",
|
|
16
|
+
"NectoSpanProcessor",
|
|
17
|
+
"attach",
|
|
18
|
+
"instrument_openai_agents",
|
|
19
|
+
"instrument_otel",
|
|
20
|
+
]
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""LangGraph / LangChain integration.
|
|
2
|
+
|
|
3
|
+
LangGraph uses the LangChain callback system, so one handler covers both.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
|
|
7
|
+
from necto_sdk import Necto
|
|
8
|
+
from necto_sdk.adapters.langgraph import NectoCallbackHandler
|
|
9
|
+
|
|
10
|
+
necto = Necto(endpoint="http://127.0.0.1:8000", framework="langgraph")
|
|
11
|
+
handler = NectoCallbackHandler(necto)
|
|
12
|
+
|
|
13
|
+
result = graph.invoke(inputs, config={"callbacks": [handler]})
|
|
14
|
+
|
|
15
|
+
Mapping:
|
|
16
|
+
|
|
17
|
+
| LangChain callback | necto event |
|
|
18
|
+
|---------------------------|----------------------------|
|
|
19
|
+
| root `on_chain_start` | creates the run |
|
|
20
|
+
| `on_chain_start` (node) | `agent_start` |
|
|
21
|
+
| node → node transition | `handoff` |
|
|
22
|
+
| `on_chat_model_start/end` | `llm_call` (tokens, ms) |
|
|
23
|
+
| `on_tool_start/end` | `tool_call` / `tool_result` |
|
|
24
|
+
| `*_error` | `error` |
|
|
25
|
+
| root `on_chain_end` | finishes the run |
|
|
26
|
+
|
|
27
|
+
Handoffs are inferred from node-to-node transitions: LangGraph executes nodes in
|
|
28
|
+
sequence, so a change of node is a change of control. This is a heuristic; nested
|
|
29
|
+
subgraphs may produce extra transitions.
|
|
30
|
+
|
|
31
|
+
This module does not import langchain or langgraph — it works with plain objects
|
|
32
|
+
that expose the same callback signatures.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import functools
|
|
38
|
+
import logging
|
|
39
|
+
import time
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from ..tracer import Necto, Run
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _guard(method):
|
|
48
|
+
"""Callbacks must never raise into the framework."""
|
|
49
|
+
|
|
50
|
+
@functools.wraps(method)
|
|
51
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
|
|
52
|
+
try:
|
|
53
|
+
return method(self, *args, **kwargs)
|
|
54
|
+
except Exception: # noqa: BLE001 - never break the user's graph
|
|
55
|
+
logger.warning("necto: langgraph adapter failed in %s", method.__name__, exc_info=True)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
return wrapper
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _node_name(
|
|
62
|
+
metadata: dict[str, Any] | None, serialized: Any, kwargs: dict[str, Any]
|
|
63
|
+
) -> str | None:
|
|
64
|
+
"""Best-effort name of the graph node a callback belongs to."""
|
|
65
|
+
if isinstance(metadata, dict):
|
|
66
|
+
node = metadata.get("langgraph_node")
|
|
67
|
+
if node:
|
|
68
|
+
return str(node)
|
|
69
|
+
name = kwargs.get("name")
|
|
70
|
+
if name:
|
|
71
|
+
return str(name)
|
|
72
|
+
if isinstance(serialized, dict):
|
|
73
|
+
for key in ("name", "id"):
|
|
74
|
+
if serialized.get(key):
|
|
75
|
+
return str(serialized[key])
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _model_name(serialized: Any, kwargs: dict[str, Any]) -> tuple[str, str]:
|
|
80
|
+
"""Return (provider, model) from a serialized runnable, best effort."""
|
|
81
|
+
provider = "unknown"
|
|
82
|
+
model = "unknown"
|
|
83
|
+
if isinstance(serialized, dict):
|
|
84
|
+
for key in ("model_name", "model"):
|
|
85
|
+
value = serialized.get(key)
|
|
86
|
+
if value:
|
|
87
|
+
model = str(value)
|
|
88
|
+
break
|
|
89
|
+
else:
|
|
90
|
+
nested = serialized.get("kwargs")
|
|
91
|
+
if isinstance(nested, dict):
|
|
92
|
+
for key in ("model_name", "model"):
|
|
93
|
+
if nested.get(key):
|
|
94
|
+
model = str(nested[key])
|
|
95
|
+
break
|
|
96
|
+
model_class = str(serialized.get("name") or "")
|
|
97
|
+
lowered = model_class.lower()
|
|
98
|
+
else:
|
|
99
|
+
lowered = ""
|
|
100
|
+
|
|
101
|
+
invocations = kwargs.get("invocation_params")
|
|
102
|
+
if isinstance(invocations, dict):
|
|
103
|
+
for key in ("model_name", "model"):
|
|
104
|
+
if invocations.get(key):
|
|
105
|
+
model = str(invocations[key])
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
if "anthropic" in lowered or "claude" in model.lower():
|
|
109
|
+
provider = "anthropic"
|
|
110
|
+
elif "openai" in lowered or "gpt" in model.lower() or model.startswith("o1"):
|
|
111
|
+
provider = "openai"
|
|
112
|
+
return provider, model
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _token_counts(response: Any) -> tuple[int | None, int | None]:
|
|
116
|
+
"""Extract (prompt_tokens, completion_tokens) from an LLMResult-like object."""
|
|
117
|
+
usage: dict[str, Any] = {}
|
|
118
|
+
llm_output = getattr(response, "llm_output", None)
|
|
119
|
+
if isinstance(llm_output, dict):
|
|
120
|
+
usage = llm_output.get("token_usage") or llm_output.get("usage") or {}
|
|
121
|
+
if isinstance(usage, dict) and (usage.get("prompt_tokens") or usage.get("completion_tokens")):
|
|
122
|
+
return usage.get("prompt_tokens"), usage.get("completion_tokens")
|
|
123
|
+
|
|
124
|
+
# newer chat models expose usage_metadata on the message
|
|
125
|
+
try:
|
|
126
|
+
message = response.generations[0][0].message
|
|
127
|
+
meta = getattr(message, "usage_metadata", None)
|
|
128
|
+
if isinstance(meta, dict):
|
|
129
|
+
return meta.get("input_tokens"), meta.get("output_tokens")
|
|
130
|
+
except Exception: # noqa: BLE001 - shape differs across versions
|
|
131
|
+
pass
|
|
132
|
+
return None, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class NectoCallbackHandler:
|
|
136
|
+
"""LangChain/LangGraph callback handler that emits necto events."""
|
|
137
|
+
|
|
138
|
+
# LangChain's CallbackManager reads these attributes on every handler; without them a
|
|
139
|
+
# single callback error turns into AttributeError inside the framework's error path.
|
|
140
|
+
ignore_llm = False
|
|
141
|
+
ignore_chain = False
|
|
142
|
+
ignore_agent = False
|
|
143
|
+
ignore_retriever = False
|
|
144
|
+
ignore_chat_model = False
|
|
145
|
+
raise_error = False
|
|
146
|
+
run_inline = False
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def always_verbose(self) -> bool:
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
necto: Necto,
|
|
155
|
+
*,
|
|
156
|
+
name: str | None = None,
|
|
157
|
+
meta: dict[str, Any] | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
self.necto = necto
|
|
160
|
+
self.name = name
|
|
161
|
+
self.meta = dict(meta or {})
|
|
162
|
+
self._run: Run | None = None
|
|
163
|
+
self.last_run_id: str | None = None
|
|
164
|
+
self._root_id: Any = None
|
|
165
|
+
self._last_node: str | None = None
|
|
166
|
+
self._chains: dict[Any, str | None] = {}
|
|
167
|
+
self._llm_start: dict[Any, tuple[float, str]] = {}
|
|
168
|
+
self._tool_start: dict[Any, tuple[str, float]] = {}
|
|
169
|
+
|
|
170
|
+
# -- helpers -----------------------------------------------------------
|
|
171
|
+
@property
|
|
172
|
+
def run(self) -> Run | None:
|
|
173
|
+
return self._run
|
|
174
|
+
|
|
175
|
+
def _ensure_run(self, name: str | None) -> Run:
|
|
176
|
+
if self._run is None:
|
|
177
|
+
self._run = self.necto.start_run(name or self.name, self.meta)
|
|
178
|
+
self.last_run_id = self._run.run_id
|
|
179
|
+
return self._run
|
|
180
|
+
|
|
181
|
+
def _node_for(self, run_id: Any) -> str | None:
|
|
182
|
+
node = self._chains.get(run_id)
|
|
183
|
+
return node or self._last_node
|
|
184
|
+
|
|
185
|
+
def close(self) -> None:
|
|
186
|
+
"""Finish an in-flight run (safe to call twice)."""
|
|
187
|
+
if self._run is not None:
|
|
188
|
+
self._run.finish()
|
|
189
|
+
self._run = None
|
|
190
|
+
|
|
191
|
+
# -- chains ------------------------------------------------------------
|
|
192
|
+
@_guard
|
|
193
|
+
def on_chain_start(
|
|
194
|
+
self,
|
|
195
|
+
serialized: Any,
|
|
196
|
+
inputs: Any,
|
|
197
|
+
*,
|
|
198
|
+
run_id: Any = None,
|
|
199
|
+
parent_run_id: Any = None,
|
|
200
|
+
tags: Any = None,
|
|
201
|
+
metadata: dict[str, Any] | None = None,
|
|
202
|
+
**kwargs: Any,
|
|
203
|
+
) -> None:
|
|
204
|
+
node = _node_name(metadata, serialized, kwargs)
|
|
205
|
+
|
|
206
|
+
if parent_run_id is None:
|
|
207
|
+
# top-level chain: the graph itself starts the necto run
|
|
208
|
+
self._root_id = run_id
|
|
209
|
+
self._chains[run_id] = None
|
|
210
|
+
self._ensure_run(node or self.name)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
run = self._ensure_run(node or self.name)
|
|
214
|
+
self._chains[run_id] = node
|
|
215
|
+
|
|
216
|
+
is_node = bool(metadata and metadata.get("langgraph_node")) or (
|
|
217
|
+
parent_run_id == self._root_id
|
|
218
|
+
)
|
|
219
|
+
if not is_node or not node:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
if self._last_node is not None and self._last_node != node:
|
|
223
|
+
run.handoff(self._last_node, node, reason="langgraph_edge")
|
|
224
|
+
self._last_node = node
|
|
225
|
+
run.agent_start(node, name=node)
|
|
226
|
+
|
|
227
|
+
@_guard
|
|
228
|
+
def on_chain_end(
|
|
229
|
+
self,
|
|
230
|
+
outputs: Any,
|
|
231
|
+
*,
|
|
232
|
+
run_id: Any = None,
|
|
233
|
+
parent_run_id: Any = None,
|
|
234
|
+
**kwargs: Any,
|
|
235
|
+
) -> None:
|
|
236
|
+
node = self._chains.get(run_id)
|
|
237
|
+
if node and self._run is not None:
|
|
238
|
+
self._run.agent_finish(node)
|
|
239
|
+
if parent_run_id is None and run_id == self._root_id:
|
|
240
|
+
self.close()
|
|
241
|
+
|
|
242
|
+
@_guard
|
|
243
|
+
def on_chain_error(
|
|
244
|
+
self,
|
|
245
|
+
error: BaseException | None,
|
|
246
|
+
*,
|
|
247
|
+
run_id: Any = None,
|
|
248
|
+
parent_run_id: Any = None,
|
|
249
|
+
**kwargs: Any,
|
|
250
|
+
) -> None:
|
|
251
|
+
if self._run is not None:
|
|
252
|
+
node = (
|
|
253
|
+
self._node_for(run_id) if self._chains.get(run_id) is None else self._chains[run_id]
|
|
254
|
+
)
|
|
255
|
+
self._run.error(node or "unknown", str(error or "chain error"))
|
|
256
|
+
|
|
257
|
+
# -- models ------------------------------------------------------------
|
|
258
|
+
@_guard
|
|
259
|
+
def on_llm_start(
|
|
260
|
+
self,
|
|
261
|
+
serialized: Any,
|
|
262
|
+
prompts: Any,
|
|
263
|
+
*,
|
|
264
|
+
run_id: Any = None,
|
|
265
|
+
parent_run_id: Any = None,
|
|
266
|
+
**kwargs: Any,
|
|
267
|
+
) -> None:
|
|
268
|
+
provider, model = _model_name(serialized, kwargs)
|
|
269
|
+
self._llm_start[run_id] = (time.monotonic(), f"{provider}:{model}")
|
|
270
|
+
|
|
271
|
+
@_guard
|
|
272
|
+
def on_chat_model_start(
|
|
273
|
+
self,
|
|
274
|
+
serialized: Any,
|
|
275
|
+
messages: Any,
|
|
276
|
+
*,
|
|
277
|
+
run_id: Any = None,
|
|
278
|
+
parent_run_id: Any = None,
|
|
279
|
+
**kwargs: Any,
|
|
280
|
+
) -> None:
|
|
281
|
+
self.on_llm_start(
|
|
282
|
+
serialized, messages, run_id=run_id, parent_run_id=parent_run_id, **kwargs
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@_guard
|
|
286
|
+
def on_llm_end(
|
|
287
|
+
self,
|
|
288
|
+
response: Any,
|
|
289
|
+
*,
|
|
290
|
+
run_id: Any = None,
|
|
291
|
+
parent_run_id: Any = None,
|
|
292
|
+
**kwargs: Any,
|
|
293
|
+
) -> None:
|
|
294
|
+
if self._run is None:
|
|
295
|
+
return
|
|
296
|
+
started = self._llm_start.pop(run_id, None)
|
|
297
|
+
latency_ms = int((time.monotonic() - started[0]) * 1000) if started else None
|
|
298
|
+
provider, model = (
|
|
299
|
+
(started[1].split(":", 1) + ["unknown"])[:2] if started else ("unknown", "unknown")
|
|
300
|
+
)
|
|
301
|
+
input_tokens, output_tokens = _token_counts(response)
|
|
302
|
+
node = self._node_for(parent_run_id) or self._last_node or "unknown"
|
|
303
|
+
self._run.llm_call(
|
|
304
|
+
node,
|
|
305
|
+
provider,
|
|
306
|
+
model,
|
|
307
|
+
input_tokens=input_tokens,
|
|
308
|
+
output_tokens=output_tokens,
|
|
309
|
+
latency_ms=latency_ms,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@_guard
|
|
313
|
+
def on_llm_error(
|
|
314
|
+
self,
|
|
315
|
+
error: BaseException | None,
|
|
316
|
+
*,
|
|
317
|
+
run_id: Any = None,
|
|
318
|
+
parent_run_id: Any = None,
|
|
319
|
+
**kwargs: Any,
|
|
320
|
+
) -> None:
|
|
321
|
+
if self._run is not None:
|
|
322
|
+
self._run.error(self._node_for(parent_run_id) or "unknown", str(error or "llm error"))
|
|
323
|
+
|
|
324
|
+
# -- tools -------------------------------------------------------------
|
|
325
|
+
@_guard
|
|
326
|
+
def on_tool_start(
|
|
327
|
+
self,
|
|
328
|
+
serialized: Any,
|
|
329
|
+
input_str: Any,
|
|
330
|
+
*,
|
|
331
|
+
run_id: Any = None,
|
|
332
|
+
parent_run_id: Any = None,
|
|
333
|
+
**kwargs: Any,
|
|
334
|
+
) -> None:
|
|
335
|
+
name = _node_name(None, serialized, kwargs) or "tool"
|
|
336
|
+
self._tool_start[run_id] = (name, time.monotonic())
|
|
337
|
+
if self._run is not None:
|
|
338
|
+
node = self._node_for(parent_run_id) or self._last_node or "unknown"
|
|
339
|
+
self._run.tool_call(node, name, name=name)
|
|
340
|
+
|
|
341
|
+
@_guard
|
|
342
|
+
def on_tool_end(
|
|
343
|
+
self,
|
|
344
|
+
output: Any,
|
|
345
|
+
*,
|
|
346
|
+
run_id: Any = None,
|
|
347
|
+
parent_run_id: Any = None,
|
|
348
|
+
**kwargs: Any,
|
|
349
|
+
) -> None:
|
|
350
|
+
if self._run is None:
|
|
351
|
+
return
|
|
352
|
+
started = self._tool_start.pop(run_id, None)
|
|
353
|
+
name = started[0] if started else "tool"
|
|
354
|
+
latency_ms = int((time.monotonic() - started[1]) * 1000) if started else None
|
|
355
|
+
node = self._node_for(parent_run_id) or self._last_node or "unknown"
|
|
356
|
+
self._run.tool_result(
|
|
357
|
+
name, node, ok=True, latency_ms=latency_ms, output_snippet=str(output)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
@_guard
|
|
361
|
+
def on_tool_error(
|
|
362
|
+
self,
|
|
363
|
+
error: BaseException | None,
|
|
364
|
+
*,
|
|
365
|
+
run_id: Any = None,
|
|
366
|
+
parent_run_id: Any = None,
|
|
367
|
+
**kwargs: Any,
|
|
368
|
+
) -> None:
|
|
369
|
+
started = self._tool_start.pop(run_id, None)
|
|
370
|
+
name = started[0] if started else "tool"
|
|
371
|
+
if self._run is not None:
|
|
372
|
+
node = self._node_for(parent_run_id) or self._last_node or "unknown"
|
|
373
|
+
self._run.tool_result(name, node, ok=False)
|
|
374
|
+
self._run.error(name, str(error or "tool error"))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def attach(handler: NectoCallbackHandler, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
378
|
+
"""Return a LangChain/LangGraph `config` dict with the handler attached."""
|
|
379
|
+
merged = dict(config or {})
|
|
380
|
+
merged["callbacks"] = list(merged.get("callbacks") or []) + [handler]
|
|
381
|
+
return merged
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""OpenAI Agents SDK integration.
|
|
2
|
+
|
|
3
|
+
The OpenAI Agents SDK ships its own tracing layer: a `TracingProcessor` receives
|
|
4
|
+
trace and span lifecycle events. Registering one is the supported way to observe
|
|
5
|
+
runs, so this adapter implements that interface.
|
|
6
|
+
|
|
7
|
+
Usage (two lines):
|
|
8
|
+
|
|
9
|
+
from necto_sdk import Necto
|
|
10
|
+
from necto_sdk.adapters.openai_agents import instrument
|
|
11
|
+
|
|
12
|
+
necto = Necto(endpoint="http://127.0.0.1:8000", framework="openai-agents")
|
|
13
|
+
instrument(necto)
|
|
14
|
+
|
|
15
|
+
result = await Runner.run(agent, "hello")
|
|
16
|
+
|
|
17
|
+
Mapping:
|
|
18
|
+
|
|
19
|
+
| span type | necto event |
|
|
20
|
+
|------------------|------------------------------------|
|
|
21
|
+
| trace start/end | create / finish the run |
|
|
22
|
+
| `agent` | `agent_start` / `agent_finish` |
|
|
23
|
+
| `handoff` | `handoff` |
|
|
24
|
+
| `generation` | `llm_call` (model, tokens, latency) |
|
|
25
|
+
| `function` | `tool_call` / `tool_result` |
|
|
26
|
+
|
|
27
|
+
This module never imports `agents` at module load — the processor is duck-typed,
|
|
28
|
+
so importing it without the SDK installed is safe. Only `instrument()` requires
|
|
29
|
+
the SDK, and it raises a clear ImportError if it is missing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import functools
|
|
35
|
+
import logging
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..tracer import Necto, Run
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _guard(method):
|
|
45
|
+
"""Processors must never raise into the agents runtime."""
|
|
46
|
+
|
|
47
|
+
@functools.wraps(method)
|
|
48
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
|
|
49
|
+
try:
|
|
50
|
+
return method(self, *args, **kwargs)
|
|
51
|
+
except Exception: # noqa: BLE001 - never break the user's agent run
|
|
52
|
+
logger.warning(
|
|
53
|
+
"necto: openai-agents adapter failed in %s", method.__name__, exc_info=True
|
|
54
|
+
)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return wrapper
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _attr(obj: Any, *names: str, default: Any = None) -> Any:
|
|
61
|
+
for name in names:
|
|
62
|
+
value = getattr(obj, name, None)
|
|
63
|
+
if value is not None:
|
|
64
|
+
return value
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _latency_ms(start: Any, end: Any) -> int | None:
|
|
69
|
+
if isinstance(start, datetime) and isinstance(end, datetime):
|
|
70
|
+
return int((end - start).total_seconds() * 1000)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _tokens(usage: Any) -> tuple[int | None, int | None]:
|
|
75
|
+
if not isinstance(usage, dict):
|
|
76
|
+
return None, None
|
|
77
|
+
return (
|
|
78
|
+
usage.get("input_tokens") or usage.get("prompt_tokens"),
|
|
79
|
+
usage.get("output_tokens") or usage.get("completion_tokens"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _provider(model: str | None) -> str:
|
|
84
|
+
lowered = (model or "").lower()
|
|
85
|
+
if "claude" in lowered or "anthropic" in lowered:
|
|
86
|
+
return "anthropic"
|
|
87
|
+
if "gemini" in lowered:
|
|
88
|
+
return "google"
|
|
89
|
+
return "openai"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NectoProcessor:
|
|
93
|
+
"""A `TracingProcessor` that forwards OpenAI Agents activity to necto."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
necto: Necto,
|
|
98
|
+
*,
|
|
99
|
+
name: str | None = None,
|
|
100
|
+
meta: dict[str, Any] | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.necto = necto
|
|
103
|
+
self.name = name
|
|
104
|
+
self.meta = dict(meta or {})
|
|
105
|
+
self._run: Run | None = None
|
|
106
|
+
self.last_run_id: str | None = None
|
|
107
|
+
self._agents: list[str] = []
|
|
108
|
+
self._tools: dict[Any, str] = {}
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def run(self) -> Run | None:
|
|
112
|
+
return self._run
|
|
113
|
+
|
|
114
|
+
def _ensure_run(self, name: str | None = None) -> Run:
|
|
115
|
+
if self._run is None:
|
|
116
|
+
self._run = self.necto.start_run(name or self.name, self.meta)
|
|
117
|
+
self.last_run_id = self._run.run_id
|
|
118
|
+
return self._run
|
|
119
|
+
|
|
120
|
+
def _current_agent(self) -> str:
|
|
121
|
+
return self._agents[-1] if self._agents else "unknown"
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _key(span: Any) -> Any:
|
|
125
|
+
return _attr(span, "span_id", "id", default=id(span))
|
|
126
|
+
|
|
127
|
+
# -- trace lifecycle ---------------------------------------------------
|
|
128
|
+
@_guard
|
|
129
|
+
def on_trace_start(self, trace: Any) -> None:
|
|
130
|
+
name = _attr(trace, "workflow_name", "name") or self.name
|
|
131
|
+
self._ensure_run(str(name) if name else None)
|
|
132
|
+
|
|
133
|
+
@_guard
|
|
134
|
+
def on_trace_end(self, trace: Any) -> None:
|
|
135
|
+
if self._run is not None:
|
|
136
|
+
self._run.finish()
|
|
137
|
+
self._run = None
|
|
138
|
+
|
|
139
|
+
@_guard
|
|
140
|
+
def shutdown(self) -> None:
|
|
141
|
+
if self._run is not None:
|
|
142
|
+
self._run.finish()
|
|
143
|
+
self._run = None
|
|
144
|
+
|
|
145
|
+
@_guard
|
|
146
|
+
def force_flush(self) -> None:
|
|
147
|
+
self.necto.flush()
|
|
148
|
+
|
|
149
|
+
# -- spans -------------------------------------------------------------
|
|
150
|
+
@_guard
|
|
151
|
+
def on_span_start(self, span: Any) -> None:
|
|
152
|
+
data = getattr(span, "span_data", None)
|
|
153
|
+
kind = getattr(data, "type", None)
|
|
154
|
+
run = self._ensure_run()
|
|
155
|
+
|
|
156
|
+
if kind == "agent":
|
|
157
|
+
agent = str(_attr(data, "name", default="agent"))
|
|
158
|
+
self._agents.append(agent)
|
|
159
|
+
run.agent_start(agent, name=agent)
|
|
160
|
+
elif kind == "function":
|
|
161
|
+
name = str(_attr(data, "name", default="tool"))
|
|
162
|
+
self._tools[self._key(span)] = name
|
|
163
|
+
run.tool_call(self._current_agent(), name, name=name)
|
|
164
|
+
|
|
165
|
+
@_guard
|
|
166
|
+
def on_span_end(self, span: Any) -> None:
|
|
167
|
+
data = getattr(span, "span_data", None)
|
|
168
|
+
kind = getattr(data, "type", None)
|
|
169
|
+
run = self._ensure_run()
|
|
170
|
+
latency = _latency_ms(_attr(span, "started_at"), _attr(span, "ended_at"))
|
|
171
|
+
|
|
172
|
+
if kind == "agent":
|
|
173
|
+
agent = str(_attr(data, "name", default="agent"))
|
|
174
|
+
run.agent_finish(agent, duration_ms=latency)
|
|
175
|
+
if self._agents and self._agents[-1] == agent:
|
|
176
|
+
self._agents.pop()
|
|
177
|
+
elif kind == "handoff":
|
|
178
|
+
from_agent = str(_attr(data, "from_agent", default="unknown"))
|
|
179
|
+
to_agent = str(_attr(data, "to_agent", default="unknown"))
|
|
180
|
+
run.handoff(from_agent, to_agent, reason="openai_agents_handoff")
|
|
181
|
+
elif kind == "generation":
|
|
182
|
+
model = str(_attr(data, "model", default="unknown"))
|
|
183
|
+
input_tokens, output_tokens = _tokens(_attr(data, "usage"))
|
|
184
|
+
run.llm_call(
|
|
185
|
+
self._current_agent(),
|
|
186
|
+
_provider(model),
|
|
187
|
+
model,
|
|
188
|
+
input_tokens=input_tokens,
|
|
189
|
+
output_tokens=output_tokens,
|
|
190
|
+
latency_ms=latency,
|
|
191
|
+
)
|
|
192
|
+
elif kind == "function":
|
|
193
|
+
name = self._tools.pop(self._key(span), None)
|
|
194
|
+
if name is None:
|
|
195
|
+
name = str(_attr(data, "name", default="tool"))
|
|
196
|
+
output = _attr(data, "output")
|
|
197
|
+
run.tool_result(
|
|
198
|
+
name,
|
|
199
|
+
self._current_agent(),
|
|
200
|
+
ok=True,
|
|
201
|
+
latency_ms=latency,
|
|
202
|
+
output_snippet=str(output) if output is not None else None,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def instrument(
|
|
207
|
+
necto: Necto, *, name: str | None = None, meta: dict[str, Any] | None = None
|
|
208
|
+
) -> NectoProcessor:
|
|
209
|
+
"""Register a necto processor with the OpenAI Agents SDK.
|
|
210
|
+
|
|
211
|
+
Raises ImportError when `openai-agents` is not installed.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
from agents.tracing import add_trace_processor # noqa: PLC0415 - optional dependency
|
|
215
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
216
|
+
raise ImportError(
|
|
217
|
+
"The openai-agents package is required for this adapter: pip install openai-agents"
|
|
218
|
+
) from exc
|
|
219
|
+
|
|
220
|
+
processor = NectoProcessor(necto, name=name, meta=meta)
|
|
221
|
+
add_trace_processor(processor)
|
|
222
|
+
return processor
|