necto-sdk 0.1.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.
necto_sdk/__init__.py 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
+ ]
necto_sdk/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the SDK version (read by hatch at build time)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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