cortexops 0.2.0__tar.gz → 0.3.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.
- {cortexops-0.2.0 → cortexops-0.3.0}/PKG-INFO +1 -1
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/__init__.py +2 -2
- cortexops-0.3.0/cortexops/tracer.py +696 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/pyproject.toml +1 -1
- {cortexops-0.2.0 → cortexops-0.3.0}/tests/test_enhancements.py +175 -1
- cortexops-0.2.0/cortexops/tracer.py +0 -278
- {cortexops-0.2.0 → cortexops-0.3.0}/.gitignore +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/LICENSE +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/README.md +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/LICENSE +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/README.md +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/auth.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/cli.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/client.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/eval.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/judge.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/metrics.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/models.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/cortexops/pyproject.toml +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/tests/__init__.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/tests/conftest.py +0 -0
- {cortexops-0.2.0 → cortexops-0.3.0}/tests/test_cortexops.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Reliability infrastructure for AI agents — evaluation, observability, and regression testing
|
|
5
5
|
Project-URL: Homepage, https://getcortexops.com
|
|
6
6
|
Project-URL: Repository, https://github.com/ashishodu2023/cortexops
|
|
@@ -10,6 +10,7 @@ Quickstart:
|
|
|
10
10
|
print(results.summary())
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
from .auth import cmd_login, cmd_logout, cmd_whoami, load_credentials, save_credentials
|
|
13
14
|
from .client import CortexClient
|
|
14
15
|
from .eval import EvalSuite, EvalThresholdError
|
|
15
16
|
from .judge import LLMJudgeMetric
|
|
@@ -31,10 +32,9 @@ from .models import (
|
|
|
31
32
|
Trace,
|
|
32
33
|
TraceNode,
|
|
33
34
|
)
|
|
34
|
-
from .auth import cmd_login, cmd_logout, cmd_whoami, save_credentials, load_credentials
|
|
35
35
|
from .tracer import CortexTracer
|
|
36
36
|
|
|
37
|
-
__version__ = "0.
|
|
37
|
+
__version__ = "0.3.0"
|
|
38
38
|
|
|
39
39
|
__all__ = [
|
|
40
40
|
"CortexTracer",
|
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .models import FailureKind, RunStatus, ToolCall, ToolCallStatus, Trace, TraceNode
|
|
12
|
+
|
|
13
|
+
# ── Key resolution order ───────────────────────────────────────────────────
|
|
14
|
+
# 1. Explicit api_key argument
|
|
15
|
+
# 2. CORTEXOPS_API_KEY environment variable
|
|
16
|
+
# 3. ~/.cortexops/credentials file (written by `cortexops login`)
|
|
17
|
+
# 4. None → local-only mode, no hosted tracing
|
|
18
|
+
|
|
19
|
+
_CREDENTIALS_FILE = Path.home() / ".cortexops" / "credentials"
|
|
20
|
+
_DEFAULT_API_URL = "https://api.getcortexops.com"
|
|
21
|
+
_ENV_KEY = "CORTEXOPS_API_KEY"
|
|
22
|
+
_ENV_URL = "CORTEXOPS_API_URL"
|
|
23
|
+
_ENV_PROJECT = "CORTEXOPS_PROJECT"
|
|
24
|
+
_ENV_ENV = "CORTEXOPS_ENVIRONMENT"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_api_key(explicit: str | None) -> str | None:
|
|
28
|
+
"""Resolve API key from multiple sources in priority order."""
|
|
29
|
+
if explicit:
|
|
30
|
+
return explicit
|
|
31
|
+
# Environment variable
|
|
32
|
+
if env_key := os.getenv(_ENV_KEY):
|
|
33
|
+
return env_key
|
|
34
|
+
# Credentials file written by `cortexops login`
|
|
35
|
+
if _CREDENTIALS_FILE.exists():
|
|
36
|
+
try:
|
|
37
|
+
import json
|
|
38
|
+
creds = json.loads(_CREDENTIALS_FILE.read_text())
|
|
39
|
+
return creds.get("api_key")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_api_url(explicit: str) -> str:
|
|
46
|
+
"""Resolve API URL — explicit arg > env var > default."""
|
|
47
|
+
if explicit != _DEFAULT_API_URL:
|
|
48
|
+
return explicit.rstrip("/")
|
|
49
|
+
return os.getenv(_ENV_URL, _DEFAULT_API_URL).rstrip("/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CortexTracer:
|
|
53
|
+
"""Instruments AI agents with zero-refactor tracing.
|
|
54
|
+
|
|
55
|
+
API key resolution order (most to least specific):
|
|
56
|
+
1. api_key argument
|
|
57
|
+
2. CORTEXOPS_API_KEY environment variable
|
|
58
|
+
3. ~/.cortexops/credentials (written by `cortexops login`)
|
|
59
|
+
4. None — local-only mode, traces stored in memory only
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
# Explicit key
|
|
63
|
+
tracer = CortexTracer(project="payments-agent", api_key="cxo-...")
|
|
64
|
+
|
|
65
|
+
# From environment variable (recommended for CI)
|
|
66
|
+
# export CORTEXOPS_API_KEY=cxo-...
|
|
67
|
+
tracer = CortexTracer(project="payments-agent")
|
|
68
|
+
|
|
69
|
+
# After `cortexops login` (recommended for local dev)
|
|
70
|
+
tracer = CortexTracer(project="payments-agent")
|
|
71
|
+
|
|
72
|
+
graph = tracer.wrap(your_langgraph_app)
|
|
73
|
+
result = graph.invoke({"messages": [...]})
|
|
74
|
+
trace = tracer.last_trace()
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
project: str | None = None,
|
|
80
|
+
api_key: str | None = None,
|
|
81
|
+
api_url: str = _DEFAULT_API_URL,
|
|
82
|
+
environment: str | None = None,
|
|
83
|
+
sample_rate: float = 1.0,
|
|
84
|
+
local_store: bool = True,
|
|
85
|
+
) -> None:
|
|
86
|
+
# Project: arg > env var
|
|
87
|
+
self.project = project or os.getenv(_ENV_PROJECT) or "default"
|
|
88
|
+
# Key: auto-resolved from all sources
|
|
89
|
+
self.api_key = _resolve_api_key(api_key)
|
|
90
|
+
self.api_url = _resolve_api_url(api_url)
|
|
91
|
+
# Environment: arg > env var > "development"
|
|
92
|
+
self.environment = environment or os.getenv(_ENV_ENV, "development")
|
|
93
|
+
self.sample_rate = sample_rate
|
|
94
|
+
self.local_store = local_store
|
|
95
|
+
self._traces: list[Trace] = []
|
|
96
|
+
self._current_trace: Trace | None = None
|
|
97
|
+
|
|
98
|
+
# Inform user where key came from — only in development
|
|
99
|
+
if self.environment == "development" and self.api_key:
|
|
100
|
+
source = "argument"
|
|
101
|
+
if not api_key:
|
|
102
|
+
if os.getenv(_ENV_KEY):
|
|
103
|
+
source = f"env:{_ENV_KEY}"
|
|
104
|
+
elif _CREDENTIALS_FILE.exists():
|
|
105
|
+
source = "~/.cortexops/credentials"
|
|
106
|
+
if source != "argument":
|
|
107
|
+
import logging
|
|
108
|
+
logging.getLogger(__name__).debug(
|
|
109
|
+
"CortexTracer: api_key loaded from %s", source
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_hosted(self) -> bool:
|
|
114
|
+
"""True if traces will be shipped to the hosted API."""
|
|
115
|
+
return bool(self.api_key)
|
|
116
|
+
|
|
117
|
+
# ── Framework detection helpers ─────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _detect_framework(agent: Any) -> str:
|
|
121
|
+
"""Return a string identifying the agent framework."""
|
|
122
|
+
t = type(agent)
|
|
123
|
+
name = t.__name__
|
|
124
|
+
module = t.__module__ or ""
|
|
125
|
+
|
|
126
|
+
# LangGraph
|
|
127
|
+
if name == "CompiledStateGraph":
|
|
128
|
+
return "langgraph"
|
|
129
|
+
# CrewAI
|
|
130
|
+
if name == "Crew" and "crewai" in module:
|
|
131
|
+
return "crewai"
|
|
132
|
+
# OpenAI Agents SDK — Agent class
|
|
133
|
+
if name == "Agent" and "agents" in module:
|
|
134
|
+
return "openai_agents"
|
|
135
|
+
# PydanticAI — Agent class
|
|
136
|
+
if name == "Agent" and "pydantic_ai" in module:
|
|
137
|
+
return "pydantic_ai"
|
|
138
|
+
# Agno / Phidata
|
|
139
|
+
if "agno" in module or "phi" in module:
|
|
140
|
+
return "agno"
|
|
141
|
+
# AutoGen — ConversableAgent, AssistantAgent, UserProxyAgent
|
|
142
|
+
if "autogen" in module and hasattr(agent, "initiate_chat"):
|
|
143
|
+
return "autogen"
|
|
144
|
+
# Google ADK
|
|
145
|
+
if "google" in module and "adk" in module:
|
|
146
|
+
return "google_adk"
|
|
147
|
+
# Smolagents (HuggingFace)
|
|
148
|
+
if "smolagents" in module and hasattr(agent, "run"):
|
|
149
|
+
return "smolagents"
|
|
150
|
+
# LlamaIndex — query engine or chat engine
|
|
151
|
+
if "llama_index" in module or "llama-index" in module:
|
|
152
|
+
if hasattr(agent, "query"):
|
|
153
|
+
return "llamaindex_query"
|
|
154
|
+
if hasattr(agent, "chat"):
|
|
155
|
+
return "llamaindex_chat"
|
|
156
|
+
# Haystack Pipeline
|
|
157
|
+
if name == "Pipeline" and "haystack" in module:
|
|
158
|
+
return "haystack"
|
|
159
|
+
# DSPy — Module subclass with forward()
|
|
160
|
+
if "dspy" in module and hasattr(agent, "forward"):
|
|
161
|
+
return "dspy"
|
|
162
|
+
# Generic fallback
|
|
163
|
+
return "generic"
|
|
164
|
+
|
|
165
|
+
def wrap(self, agent: Any) -> Any:
|
|
166
|
+
"""
|
|
167
|
+
Auto-detect agent framework and return an instrumented wrapper.
|
|
168
|
+
|
|
169
|
+
Supported frameworks:
|
|
170
|
+
LangGraph — CompiledStateGraph (invoke / ainvoke / stream)
|
|
171
|
+
CrewAI — Crew (kickoff / kickoff_async)
|
|
172
|
+
OpenAI Agents — Agent (Runner.run / Runner.run_sync)
|
|
173
|
+
PydanticAI — Agent (run_sync / run / run_stream)
|
|
174
|
+
Agno — Agent (run / arun / print_response)
|
|
175
|
+
AutoGen — ConversableAgent (initiate_chat)
|
|
176
|
+
Google ADK — Agent (run)
|
|
177
|
+
Smolagents — CodeAgent / ToolCallingAgent (run)
|
|
178
|
+
LlamaIndex — query engine (query) / chat engine (chat)
|
|
179
|
+
Haystack — Pipeline (run)
|
|
180
|
+
DSPy — Module subclass (forward / __call__)
|
|
181
|
+
Generic — Any Python callable or object with .invoke()
|
|
182
|
+
"""
|
|
183
|
+
framework = self._detect_framework(agent)
|
|
184
|
+
dispatch = {
|
|
185
|
+
"langgraph": self._wrap_langgraph,
|
|
186
|
+
"crewai": self._wrap_crewai,
|
|
187
|
+
"openai_agents": self._wrap_openai_agents,
|
|
188
|
+
"pydantic_ai": self._wrap_pydantic_ai,
|
|
189
|
+
"agno": self._wrap_agno,
|
|
190
|
+
"autogen": self._wrap_autogen,
|
|
191
|
+
"google_adk": self._wrap_google_adk,
|
|
192
|
+
"smolagents": self._wrap_smolagents,
|
|
193
|
+
"llamaindex_query":self._wrap_llamaindex_query,
|
|
194
|
+
"llamaindex_chat": self._wrap_llamaindex_chat,
|
|
195
|
+
"haystack": self._wrap_haystack,
|
|
196
|
+
"dspy": self._wrap_dspy,
|
|
197
|
+
"generic": self._wrap_callable,
|
|
198
|
+
}
|
|
199
|
+
wrapper_fn = dispatch.get(framework, self._wrap_callable)
|
|
200
|
+
return wrapper_fn(agent)
|
|
201
|
+
|
|
202
|
+
def _wrap_langgraph(self, graph: Any) -> Any:
|
|
203
|
+
tracer = self
|
|
204
|
+
|
|
205
|
+
class InstrumentedGraph:
|
|
206
|
+
def invoke(self_, input: dict, config: dict | None = None, **kwargs) -> dict:
|
|
207
|
+
return tracer._run_traced(
|
|
208
|
+
fn=lambda: graph.invoke(input, config, **kwargs),
|
|
209
|
+
input=input, framework="langgraph",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def ainvoke(self_, input: dict, config: dict | None = None, **kwargs) -> dict:
|
|
213
|
+
import asyncio
|
|
214
|
+
return await asyncio.get_event_loop().run_in_executor(
|
|
215
|
+
None, lambda: tracer._run_traced(
|
|
216
|
+
fn=lambda: graph.invoke(input, config, **kwargs),
|
|
217
|
+
input=input, framework="langgraph",
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def stream(self_, input: dict, config: dict | None = None, **kwargs):
|
|
222
|
+
return graph.stream(input, config, **kwargs)
|
|
223
|
+
|
|
224
|
+
def __getattr__(self_, name: str):
|
|
225
|
+
return getattr(graph, name)
|
|
226
|
+
|
|
227
|
+
return InstrumentedGraph()
|
|
228
|
+
|
|
229
|
+
def _wrap_crewai(self, crew: Any) -> Any:
|
|
230
|
+
tracer = self
|
|
231
|
+
|
|
232
|
+
class InstrumentedCrew:
|
|
233
|
+
def kickoff(self_, inputs: dict | None = None) -> Any:
|
|
234
|
+
return tracer._run_traced(
|
|
235
|
+
fn=lambda: crew.kickoff(inputs=inputs),
|
|
236
|
+
input=inputs or {}, framework="crewai",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def __getattr__(self_, name: str):
|
|
240
|
+
return getattr(crew, name)
|
|
241
|
+
|
|
242
|
+
return InstrumentedCrew()
|
|
243
|
+
|
|
244
|
+
def _wrap_callable(self, fn: Any) -> Any:
|
|
245
|
+
tracer = self
|
|
246
|
+
|
|
247
|
+
if hasattr(fn, "invoke"):
|
|
248
|
+
original_invoke = fn.invoke
|
|
249
|
+
|
|
250
|
+
class InvokeWrapper:
|
|
251
|
+
def invoke(self_, *args, **kwargs):
|
|
252
|
+
input_data = args[0] if args else kwargs
|
|
253
|
+
return tracer._run_traced(
|
|
254
|
+
fn=lambda: original_invoke(*args, **kwargs),
|
|
255
|
+
input=input_data if isinstance(input_data, dict) else {"input": input_data},
|
|
256
|
+
framework="generic",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def __getattr__(self_, name: str):
|
|
260
|
+
return getattr(fn, name)
|
|
261
|
+
|
|
262
|
+
return InvokeWrapper()
|
|
263
|
+
|
|
264
|
+
def wrapper(*args, **kwargs):
|
|
265
|
+
input_data = {"args": list(args), "kwargs": kwargs}
|
|
266
|
+
return tracer._run_traced(fn=lambda: fn(*args, **kwargs), input=input_data, framework="generic")
|
|
267
|
+
|
|
268
|
+
return wrapper
|
|
269
|
+
|
|
270
|
+
# ── OpenAI Agents SDK ────────────────────────────────────────────────
|
|
271
|
+
def _wrap_openai_agents(self, agent: Any) -> Any:
|
|
272
|
+
"""
|
|
273
|
+
Wrap an OpenAI Agents SDK Agent.
|
|
274
|
+
|
|
275
|
+
Usage:
|
|
276
|
+
from agents import Agent, Runner
|
|
277
|
+
from cortexops import CortexTracer
|
|
278
|
+
|
|
279
|
+
my_agent = Agent(name="refund-agent", instructions="...")
|
|
280
|
+
tracer = CortexTracer(project="payments-agent")
|
|
281
|
+
wrapped = tracer.wrap(my_agent)
|
|
282
|
+
|
|
283
|
+
# Sync
|
|
284
|
+
result = wrapped.run_sync("Process refund for order #4821")
|
|
285
|
+
|
|
286
|
+
# Async
|
|
287
|
+
result = await wrapped.run("Process refund for order #4821")
|
|
288
|
+
"""
|
|
289
|
+
tracer = self
|
|
290
|
+
|
|
291
|
+
class InstrumentedOpenAIAgent:
|
|
292
|
+
def run_sync(self_, prompt: str, **kwargs) -> Any:
|
|
293
|
+
try:
|
|
294
|
+
from agents import Runner
|
|
295
|
+
except ImportError as e:
|
|
296
|
+
raise ImportError("pip install openai-agents") from e
|
|
297
|
+
return tracer._run_traced(
|
|
298
|
+
fn=lambda: Runner.run_sync(agent, prompt, **kwargs),
|
|
299
|
+
input={"prompt": prompt},
|
|
300
|
+
framework="openai_agents",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def run(self_, prompt: str, **kwargs) -> Any:
|
|
304
|
+
try:
|
|
305
|
+
from agents import Runner
|
|
306
|
+
except ImportError as e:
|
|
307
|
+
raise ImportError("pip install openai-agents") from e
|
|
308
|
+
import asyncio
|
|
309
|
+
return await asyncio.get_event_loop().run_in_executor(
|
|
310
|
+
None, lambda: Runner.run_sync(agent, prompt, **kwargs)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def __getattr__(self_, name: str) -> Any:
|
|
314
|
+
return getattr(agent, name)
|
|
315
|
+
|
|
316
|
+
return InstrumentedOpenAIAgent()
|
|
317
|
+
|
|
318
|
+
# ── PydanticAI ────────────────────────────────────────────────────────
|
|
319
|
+
def _wrap_pydantic_ai(self, agent: Any) -> Any:
|
|
320
|
+
"""
|
|
321
|
+
Wrap a PydanticAI Agent.
|
|
322
|
+
|
|
323
|
+
Usage:
|
|
324
|
+
from pydantic_ai import Agent
|
|
325
|
+
from cortexops import CortexTracer
|
|
326
|
+
|
|
327
|
+
my_agent = Agent("openai:gpt-4o", instructions="...")
|
|
328
|
+
tracer = CortexTracer(project="payments-agent")
|
|
329
|
+
wrapped = tracer.wrap(my_agent)
|
|
330
|
+
|
|
331
|
+
result = wrapped.run_sync("Process refund for order #4821")
|
|
332
|
+
print(result.data)
|
|
333
|
+
"""
|
|
334
|
+
tracer = self
|
|
335
|
+
|
|
336
|
+
class InstrumentedPydanticAgent:
|
|
337
|
+
def run_sync(self_, prompt: str, **kwargs) -> Any:
|
|
338
|
+
return tracer._run_traced(
|
|
339
|
+
fn=lambda: agent.run_sync(prompt, **kwargs),
|
|
340
|
+
input={"prompt": prompt},
|
|
341
|
+
framework="pydantic_ai",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
async def run(self_, prompt: str, **kwargs) -> Any:
|
|
345
|
+
return await agent.run(prompt, **kwargs)
|
|
346
|
+
|
|
347
|
+
def __getattr__(self_, name: str) -> Any:
|
|
348
|
+
return getattr(agent, name)
|
|
349
|
+
|
|
350
|
+
return InstrumentedPydanticAgent()
|
|
351
|
+
|
|
352
|
+
# ── Agno (Phidata) ────────────────────────────────────────────────────
|
|
353
|
+
def _wrap_agno(self, agent: Any) -> Any:
|
|
354
|
+
"""
|
|
355
|
+
Wrap an Agno (formerly Phidata) Agent.
|
|
356
|
+
|
|
357
|
+
Usage:
|
|
358
|
+
from agno.agent import Agent
|
|
359
|
+
from agno.models.openai import OpenAIChat
|
|
360
|
+
from cortexops import CortexTracer
|
|
361
|
+
|
|
362
|
+
my_agent = Agent(model=OpenAIChat(id="gpt-4o"), ...)
|
|
363
|
+
tracer = CortexTracer(project="payments-agent")
|
|
364
|
+
wrapped = tracer.wrap(my_agent)
|
|
365
|
+
|
|
366
|
+
result = wrapped.run("Process refund for order #4821")
|
|
367
|
+
"""
|
|
368
|
+
tracer = self
|
|
369
|
+
|
|
370
|
+
class InstrumentedAgnoAgent:
|
|
371
|
+
def run(self_, message: str, **kwargs) -> Any:
|
|
372
|
+
return tracer._run_traced(
|
|
373
|
+
fn=lambda: agent.run(message, **kwargs),
|
|
374
|
+
input={"message": message},
|
|
375
|
+
framework="agno",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def print_response(self_, message: str, **kwargs) -> Any:
|
|
379
|
+
return tracer._run_traced(
|
|
380
|
+
fn=lambda: agent.print_response(message, **kwargs),
|
|
381
|
+
input={"message": message},
|
|
382
|
+
framework="agno",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
async def arun(self_, message: str, **kwargs) -> Any:
|
|
386
|
+
import asyncio
|
|
387
|
+
return await asyncio.get_event_loop().run_in_executor(
|
|
388
|
+
None, lambda: agent.run(message, **kwargs)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def __getattr__(self_, name: str) -> Any:
|
|
392
|
+
return getattr(agent, name)
|
|
393
|
+
|
|
394
|
+
return InstrumentedAgnoAgent()
|
|
395
|
+
|
|
396
|
+
# ── AutoGen ───────────────────────────────────────────────────────────
|
|
397
|
+
def _wrap_autogen(self, agent: Any) -> Any:
|
|
398
|
+
"""
|
|
399
|
+
Wrap an AutoGen ConversableAgent / AssistantAgent.
|
|
400
|
+
|
|
401
|
+
Usage:
|
|
402
|
+
import autogen
|
|
403
|
+
from cortexops import CortexTracer
|
|
404
|
+
|
|
405
|
+
assistant = autogen.AssistantAgent("assistant", llm_config={...})
|
|
406
|
+
tracer = CortexTracer(project="payments-agent")
|
|
407
|
+
wrapped = tracer.wrap(assistant)
|
|
408
|
+
|
|
409
|
+
user_proxy.initiate_chat(wrapped, message="Process refund")
|
|
410
|
+
"""
|
|
411
|
+
tracer = self
|
|
412
|
+
original_initiate = agent.initiate_chat
|
|
413
|
+
|
|
414
|
+
class InstrumentedAutoGenAgent:
|
|
415
|
+
def initiate_chat(self_, recipient: Any, message: str, **kwargs) -> Any:
|
|
416
|
+
return tracer._run_traced(
|
|
417
|
+
fn=lambda: original_initiate(recipient, message=message, **kwargs),
|
|
418
|
+
input={"message": message},
|
|
419
|
+
framework="autogen",
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def __getattr__(self_, name: str) -> Any:
|
|
423
|
+
return getattr(agent, name)
|
|
424
|
+
|
|
425
|
+
return InstrumentedAutoGenAgent()
|
|
426
|
+
|
|
427
|
+
# ── Google ADK ────────────────────────────────────────────────────────
|
|
428
|
+
def _wrap_google_adk(self, agent: Any) -> Any:
|
|
429
|
+
"""
|
|
430
|
+
Wrap a Google Agent Development Kit (ADK) agent.
|
|
431
|
+
|
|
432
|
+
Usage:
|
|
433
|
+
from google.adk.agents import Agent
|
|
434
|
+
from cortexops import CortexTracer
|
|
435
|
+
|
|
436
|
+
my_agent = Agent(name="refund-agent", model="gemini-2.0-flash", ...)
|
|
437
|
+
tracer = CortexTracer(project="payments-agent")
|
|
438
|
+
wrapped = tracer.wrap(my_agent)
|
|
439
|
+
|
|
440
|
+
result = wrapped.run("Process refund for order #4821")
|
|
441
|
+
"""
|
|
442
|
+
tracer = self
|
|
443
|
+
|
|
444
|
+
class InstrumentedGoogleADK:
|
|
445
|
+
def run(self_, message: str, **kwargs) -> Any:
|
|
446
|
+
return tracer._run_traced(
|
|
447
|
+
fn=lambda: agent.run(message, **kwargs),
|
|
448
|
+
input={"message": message},
|
|
449
|
+
framework="google_adk",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def __getattr__(self_, name: str) -> Any:
|
|
453
|
+
return getattr(agent, name)
|
|
454
|
+
|
|
455
|
+
return InstrumentedGoogleADK()
|
|
456
|
+
|
|
457
|
+
# ── Smolagents (HuggingFace) ──────────────────────────────────────────
|
|
458
|
+
def _wrap_smolagents(self, agent: Any) -> Any:
|
|
459
|
+
"""
|
|
460
|
+
Wrap a HuggingFace Smolagents CodeAgent or ToolCallingAgent.
|
|
461
|
+
|
|
462
|
+
Usage:
|
|
463
|
+
from smolagents import CodeAgent, HfApiModel
|
|
464
|
+
from cortexops import CortexTracer
|
|
465
|
+
|
|
466
|
+
my_agent = CodeAgent(tools=[...], model=HfApiModel())
|
|
467
|
+
tracer = CortexTracer(project="payments-agent")
|
|
468
|
+
wrapped = tracer.wrap(my_agent)
|
|
469
|
+
|
|
470
|
+
result = wrapped.run("Process refund for order #4821")
|
|
471
|
+
"""
|
|
472
|
+
tracer = self
|
|
473
|
+
|
|
474
|
+
class InstrumentedSmolagent:
|
|
475
|
+
def run(self_, task: str, **kwargs) -> Any:
|
|
476
|
+
return tracer._run_traced(
|
|
477
|
+
fn=lambda: agent.run(task, **kwargs),
|
|
478
|
+
input={"task": task},
|
|
479
|
+
framework="smolagents",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def __getattr__(self_, name: str) -> Any:
|
|
483
|
+
return getattr(agent, name)
|
|
484
|
+
|
|
485
|
+
return InstrumentedSmolagent()
|
|
486
|
+
|
|
487
|
+
# ── LlamaIndex ────────────────────────────────────────────────────────
|
|
488
|
+
def _wrap_llamaindex_query(self, engine: Any) -> Any:
|
|
489
|
+
"""
|
|
490
|
+
Wrap a LlamaIndex query engine.
|
|
491
|
+
|
|
492
|
+
Usage:
|
|
493
|
+
from llama_index.core import VectorStoreIndex
|
|
494
|
+
from cortexops import CortexTracer
|
|
495
|
+
|
|
496
|
+
index = VectorStoreIndex.from_documents(docs)
|
|
497
|
+
engine = index.as_query_engine()
|
|
498
|
+
tracer = CortexTracer(project="payments-agent")
|
|
499
|
+
wrapped = tracer.wrap(engine)
|
|
500
|
+
|
|
501
|
+
result = wrapped.query("What are the refund policies?")
|
|
502
|
+
"""
|
|
503
|
+
tracer = self
|
|
504
|
+
|
|
505
|
+
class InstrumentedQueryEngine:
|
|
506
|
+
def query(self_, query_str: str, **kwargs) -> Any:
|
|
507
|
+
return tracer._run_traced(
|
|
508
|
+
fn=lambda: engine.query(query_str, **kwargs),
|
|
509
|
+
input={"query": query_str},
|
|
510
|
+
framework="llamaindex",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def __getattr__(self_, name: str) -> Any:
|
|
514
|
+
return getattr(engine, name)
|
|
515
|
+
|
|
516
|
+
return InstrumentedQueryEngine()
|
|
517
|
+
|
|
518
|
+
def _wrap_llamaindex_chat(self, engine: Any) -> Any:
|
|
519
|
+
"""
|
|
520
|
+
Wrap a LlamaIndex chat engine.
|
|
521
|
+
|
|
522
|
+
Usage:
|
|
523
|
+
engine = index.as_chat_engine()
|
|
524
|
+
tracer = CortexTracer(project="payments-agent")
|
|
525
|
+
wrapped = tracer.wrap(engine)
|
|
526
|
+
|
|
527
|
+
result = wrapped.chat("Process refund for order #4821")
|
|
528
|
+
"""
|
|
529
|
+
tracer = self
|
|
530
|
+
|
|
531
|
+
class InstrumentedChatEngine:
|
|
532
|
+
def chat(self_, message: str, **kwargs) -> Any:
|
|
533
|
+
return tracer._run_traced(
|
|
534
|
+
fn=lambda: engine.chat(message, **kwargs),
|
|
535
|
+
input={"message": message},
|
|
536
|
+
framework="llamaindex",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def __getattr__(self_, name: str) -> Any:
|
|
540
|
+
return getattr(engine, name)
|
|
541
|
+
|
|
542
|
+
return InstrumentedChatEngine()
|
|
543
|
+
|
|
544
|
+
# ── Haystack ──────────────────────────────────────────────────────────
|
|
545
|
+
def _wrap_haystack(self, pipeline: Any) -> Any:
|
|
546
|
+
"""
|
|
547
|
+
Wrap a Haystack Pipeline.
|
|
548
|
+
|
|
549
|
+
Usage:
|
|
550
|
+
from haystack import Pipeline
|
|
551
|
+
from cortexops import CortexTracer
|
|
552
|
+
|
|
553
|
+
pipeline = Pipeline()
|
|
554
|
+
pipeline.add_component("retriever", ...)
|
|
555
|
+
pipeline.add_component("llm", ...)
|
|
556
|
+
|
|
557
|
+
tracer = CortexTracer(project="payments-agent")
|
|
558
|
+
wrapped = tracer.wrap(pipeline)
|
|
559
|
+
|
|
560
|
+
result = wrapped.run({"retriever": {"query": "refund policy"}})
|
|
561
|
+
"""
|
|
562
|
+
tracer = self
|
|
563
|
+
|
|
564
|
+
class InstrumentedHaystackPipeline:
|
|
565
|
+
def run(self_, data: dict, **kwargs) -> Any:
|
|
566
|
+
return tracer._run_traced(
|
|
567
|
+
fn=lambda: pipeline.run(data, **kwargs),
|
|
568
|
+
input=data,
|
|
569
|
+
framework="haystack",
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def __getattr__(self_, name: str) -> Any:
|
|
573
|
+
return getattr(pipeline, name)
|
|
574
|
+
|
|
575
|
+
return InstrumentedHaystackPipeline()
|
|
576
|
+
|
|
577
|
+
# ── DSPy ─────────────────────────────────────────────────────────────
|
|
578
|
+
def _wrap_dspy(self, module: Any) -> Any:
|
|
579
|
+
"""
|
|
580
|
+
Wrap a DSPy Module (any subclass with forward() / __call__()).
|
|
581
|
+
|
|
582
|
+
Usage:
|
|
583
|
+
import dspy
|
|
584
|
+
from cortexops import CortexTracer
|
|
585
|
+
|
|
586
|
+
class RefundClassifier(dspy.Module):
|
|
587
|
+
def __init__(self):
|
|
588
|
+
self.predict = dspy.Predict("query -> action")
|
|
589
|
+
|
|
590
|
+
def forward(self, query: str):
|
|
591
|
+
return self.predict(query=query)
|
|
592
|
+
|
|
593
|
+
my_module = RefundClassifier()
|
|
594
|
+
tracer = CortexTracer(project="payments-agent")
|
|
595
|
+
wrapped = tracer.wrap(my_module)
|
|
596
|
+
|
|
597
|
+
result = wrapped("Process refund for order #4821")
|
|
598
|
+
"""
|
|
599
|
+
tracer = self
|
|
600
|
+
|
|
601
|
+
class InstrumentedDSPyModule:
|
|
602
|
+
def forward(self_, *args, **kwargs) -> Any:
|
|
603
|
+
input_data = {"args": list(args), **kwargs}
|
|
604
|
+
return tracer._run_traced(
|
|
605
|
+
fn=lambda: module.forward(*args, **kwargs),
|
|
606
|
+
input=input_data,
|
|
607
|
+
framework="dspy",
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def __call__(self_, *args, **kwargs) -> Any:
|
|
611
|
+
return self_.forward(*args, **kwargs)
|
|
612
|
+
|
|
613
|
+
def __getattr__(self_, name: str) -> Any:
|
|
614
|
+
return getattr(module, name)
|
|
615
|
+
|
|
616
|
+
return InstrumentedDSPyModule()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _run_traced(self, fn: Callable, input: dict, framework: str) -> Any:
|
|
620
|
+
import random
|
|
621
|
+
if self.sample_rate < 1.0 and random.random() > self.sample_rate:
|
|
622
|
+
return fn()
|
|
623
|
+
|
|
624
|
+
trace = Trace(project=self.project, input=input)
|
|
625
|
+
self._current_trace = trace
|
|
626
|
+
t0 = time.perf_counter()
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
result = fn()
|
|
630
|
+
trace.total_latency_ms = (time.perf_counter() - t0) * 1000
|
|
631
|
+
trace.status = RunStatus.COMPLETED
|
|
632
|
+
trace.output = result if isinstance(result, dict) else {"result": str(result)}
|
|
633
|
+
except Exception as exc:
|
|
634
|
+
trace.total_latency_ms = (time.perf_counter() - t0) * 1000
|
|
635
|
+
trace.status = RunStatus.FAILED
|
|
636
|
+
trace.failure_kind = FailureKind.UNKNOWN
|
|
637
|
+
trace.failure_detail = str(exc)
|
|
638
|
+
raise
|
|
639
|
+
finally:
|
|
640
|
+
self._traces.append(trace)
|
|
641
|
+
if self.api_key:
|
|
642
|
+
self._flush_trace(trace)
|
|
643
|
+
|
|
644
|
+
return result
|
|
645
|
+
|
|
646
|
+
@contextmanager
|
|
647
|
+
def trace_node(self, node_name: str):
|
|
648
|
+
"""Context manager to manually instrument a single node."""
|
|
649
|
+
node = TraceNode(node_id=str(uuid.uuid4()), node_name=node_name)
|
|
650
|
+
t0 = time.perf_counter()
|
|
651
|
+
try:
|
|
652
|
+
yield node
|
|
653
|
+
finally:
|
|
654
|
+
node.latency_ms = (time.perf_counter() - t0) * 1000
|
|
655
|
+
if self._current_trace:
|
|
656
|
+
self._current_trace.nodes.append(node)
|
|
657
|
+
|
|
658
|
+
def record_tool_call(
|
|
659
|
+
self,
|
|
660
|
+
name: str,
|
|
661
|
+
args: dict | None = None,
|
|
662
|
+
result: Any = None,
|
|
663
|
+
error: str | None = None,
|
|
664
|
+
latency_ms: float = 0.0,
|
|
665
|
+
) -> ToolCall:
|
|
666
|
+
"""Manually record a tool call onto the current active trace."""
|
|
667
|
+
tc = ToolCall(
|
|
668
|
+
name=name, args=args or {}, result=result,
|
|
669
|
+
status=ToolCallStatus.ERROR if error else ToolCallStatus.SUCCESS,
|
|
670
|
+
latency_ms=latency_ms, error=error,
|
|
671
|
+
)
|
|
672
|
+
if self._current_trace and self._current_trace.nodes:
|
|
673
|
+
self._current_trace.nodes[-1].tool_calls.append(tc)
|
|
674
|
+
return tc
|
|
675
|
+
|
|
676
|
+
def last_trace(self) -> Trace | None:
|
|
677
|
+
return self._traces[-1] if self._traces else None
|
|
678
|
+
|
|
679
|
+
def traces(self) -> list[Trace]:
|
|
680
|
+
return list(self._traces)
|
|
681
|
+
|
|
682
|
+
def clear(self) -> None:
|
|
683
|
+
self._traces.clear()
|
|
684
|
+
self._current_trace = None
|
|
685
|
+
|
|
686
|
+
def _flush_trace(self, trace: Trace) -> None:
|
|
687
|
+
try:
|
|
688
|
+
import httpx
|
|
689
|
+
httpx.post(
|
|
690
|
+
f"{self.api_url}/v1/traces",
|
|
691
|
+
json=trace.model_dump(mode="json"),
|
|
692
|
+
headers={"X-API-Key": self.api_key},
|
|
693
|
+
timeout=2.0,
|
|
694
|
+
)
|
|
695
|
+
except Exception:
|
|
696
|
+
pass # non-blocking — tracing never breaks the agent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cortexops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Reliability infrastructure for AI agents — evaluation, observability, and regression testing"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -163,7 +163,7 @@ class TestCLIImports:
|
|
|
163
163
|
cmd_version(argparse.Namespace())
|
|
164
164
|
captured = capsys.readouterr()
|
|
165
165
|
assert "cortexops" in captured.out
|
|
166
|
-
assert "0.
|
|
166
|
+
assert "0.3.0" in captured.out
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
# ---------------------------------------------------------------------------
|
|
@@ -222,3 +222,177 @@ class TestApiKeyPureFunctions:
|
|
|
222
222
|
def test_raw_key_length(self):
|
|
223
223
|
raw, _ = self._gen()
|
|
224
224
|
assert len(raw) == 68 # "cxo-" (4) + "-" (0 included in prefix) + 64 hex chars
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Framework detection tests ────────────────────────────────────────────
|
|
228
|
+
class TestFrameworkDetection:
|
|
229
|
+
"""Test that _detect_framework correctly identifies all supported SDKs."""
|
|
230
|
+
|
|
231
|
+
def _make_mock(self, class_name: str, module: str) -> object:
|
|
232
|
+
from unittest.mock import MagicMock
|
|
233
|
+
obj = MagicMock()
|
|
234
|
+
obj.__class__.__name__ = class_name
|
|
235
|
+
obj.__class__.__module__ = module
|
|
236
|
+
return obj
|
|
237
|
+
|
|
238
|
+
def test_detects_langgraph(self):
|
|
239
|
+
from cortexops.tracer import CortexTracer
|
|
240
|
+
mock = self._make_mock("CompiledStateGraph", "langgraph.graph.graph")
|
|
241
|
+
assert CortexTracer._detect_framework(mock) == "langgraph"
|
|
242
|
+
|
|
243
|
+
def test_detects_crewai(self):
|
|
244
|
+
from cortexops.tracer import CortexTracer
|
|
245
|
+
mock = self._make_mock("Crew", "crewai.crew")
|
|
246
|
+
assert CortexTracer._detect_framework(mock) == "crewai"
|
|
247
|
+
|
|
248
|
+
def test_detects_openai_agents(self):
|
|
249
|
+
from cortexops.tracer import CortexTracer
|
|
250
|
+
mock = self._make_mock("Agent", "agents.agent")
|
|
251
|
+
assert CortexTracer._detect_framework(mock) == "openai_agents"
|
|
252
|
+
|
|
253
|
+
def test_detects_pydantic_ai(self):
|
|
254
|
+
from cortexops.tracer import CortexTracer
|
|
255
|
+
mock = self._make_mock("Agent", "pydantic_ai.agent")
|
|
256
|
+
assert CortexTracer._detect_framework(mock) == "pydantic_ai"
|
|
257
|
+
|
|
258
|
+
def test_detects_agno(self):
|
|
259
|
+
from cortexops.tracer import CortexTracer
|
|
260
|
+
mock = self._make_mock("Agent", "agno.agent.agent")
|
|
261
|
+
assert CortexTracer._detect_framework(mock) == "agno"
|
|
262
|
+
|
|
263
|
+
def test_detects_autogen(self):
|
|
264
|
+
from cortexops.tracer import CortexTracer
|
|
265
|
+
from unittest.mock import MagicMock
|
|
266
|
+
mock = self._make_mock("AssistantAgent", "autogen.agentchat.assistant_agent")
|
|
267
|
+
mock.initiate_chat = MagicMock()
|
|
268
|
+
assert CortexTracer._detect_framework(mock) == "autogen"
|
|
269
|
+
|
|
270
|
+
def test_detects_smolagents(self):
|
|
271
|
+
from cortexops.tracer import CortexTracer
|
|
272
|
+
from unittest.mock import MagicMock
|
|
273
|
+
mock = self._make_mock("CodeAgent", "smolagents.agents")
|
|
274
|
+
mock.run = MagicMock()
|
|
275
|
+
assert CortexTracer._detect_framework(mock) == "smolagents"
|
|
276
|
+
|
|
277
|
+
def test_detects_haystack(self):
|
|
278
|
+
from cortexops.tracer import CortexTracer
|
|
279
|
+
mock = self._make_mock("Pipeline", "haystack.core.pipeline.pipeline")
|
|
280
|
+
assert CortexTracer._detect_framework(mock) == "haystack"
|
|
281
|
+
|
|
282
|
+
def test_detects_dspy(self):
|
|
283
|
+
from cortexops.tracer import CortexTracer
|
|
284
|
+
from unittest.mock import MagicMock
|
|
285
|
+
mock = self._make_mock("RefundClassifier", "dspy.modules")
|
|
286
|
+
mock.forward = MagicMock()
|
|
287
|
+
assert CortexTracer._detect_framework(mock) == "dspy"
|
|
288
|
+
|
|
289
|
+
def test_detects_llamaindex_query(self):
|
|
290
|
+
from cortexops.tracer import CortexTracer
|
|
291
|
+
from unittest.mock import MagicMock
|
|
292
|
+
mock = self._make_mock("RetrieverQueryEngine", "llama_index.core.query_engine")
|
|
293
|
+
mock.query = MagicMock()
|
|
294
|
+
assert CortexTracer._detect_framework(mock) == "llamaindex_query"
|
|
295
|
+
|
|
296
|
+
def test_detects_llamaindex_chat(self):
|
|
297
|
+
from cortexops.tracer import CortexTracer
|
|
298
|
+
from unittest.mock import MagicMock
|
|
299
|
+
mock = self._make_mock("CondensePlusContextChatEngine", "llama_index.core.chat_engine")
|
|
300
|
+
mock.chat = MagicMock()
|
|
301
|
+
# No query attr → should detect as chat engine
|
|
302
|
+
del mock.query
|
|
303
|
+
assert CortexTracer._detect_framework(mock) == "llamaindex_chat"
|
|
304
|
+
|
|
305
|
+
def test_generic_callable_fallback(self):
|
|
306
|
+
from cortexops.tracer import CortexTracer
|
|
307
|
+
def my_fn(x): return x
|
|
308
|
+
assert CortexTracer._detect_framework(my_fn) == "generic"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class TestNewFrameworkWrappers:
|
|
312
|
+
"""Test that new framework wrappers trace correctly using mock agents."""
|
|
313
|
+
|
|
314
|
+
def _tracer(self):
|
|
315
|
+
from cortexops import CortexTracer
|
|
316
|
+
return CortexTracer(project="test", sample_rate=1.0)
|
|
317
|
+
|
|
318
|
+
def test_pydantic_ai_wrap_traces(self):
|
|
319
|
+
from unittest.mock import MagicMock, patch
|
|
320
|
+
tracer = self._tracer()
|
|
321
|
+
mock_agent = MagicMock()
|
|
322
|
+
mock_agent.__class__.__name__ = "Agent"
|
|
323
|
+
mock_agent.__class__.__module__ = "pydantic_ai.agent"
|
|
324
|
+
mock_result = MagicMock()
|
|
325
|
+
mock_result.data = "refund_approved"
|
|
326
|
+
mock_agent.run_sync.return_value = mock_result
|
|
327
|
+
|
|
328
|
+
wrapped = tracer.wrap(mock_agent)
|
|
329
|
+
result = wrapped.run_sync("Process refund #4821")
|
|
330
|
+
|
|
331
|
+
assert result.data == "refund_approved"
|
|
332
|
+
trace = tracer.last_trace()
|
|
333
|
+
assert trace is not None
|
|
334
|
+
assert str(trace.status) in ("completed", "RunStatus.COMPLETED")
|
|
335
|
+
print(f"PydanticAI trace: latency={trace.total_latency_ms:.0f}ms")
|
|
336
|
+
|
|
337
|
+
def test_smolagents_wrap_traces(self):
|
|
338
|
+
from unittest.mock import MagicMock
|
|
339
|
+
tracer = self._tracer()
|
|
340
|
+
mock_agent = MagicMock()
|
|
341
|
+
mock_agent.__class__.__name__ = "CodeAgent"
|
|
342
|
+
mock_agent.__class__.__module__ = "smolagents.agents"
|
|
343
|
+
mock_agent.run.return_value = "Task completed: refund approved"
|
|
344
|
+
|
|
345
|
+
wrapped = tracer.wrap(mock_agent)
|
|
346
|
+
result = wrapped.run("Process refund for order #4821")
|
|
347
|
+
|
|
348
|
+
assert result == "Task completed: refund approved"
|
|
349
|
+
trace = tracer.last_trace()
|
|
350
|
+
assert trace is not None
|
|
351
|
+
assert str(trace.status) in ("completed", "RunStatus.COMPLETED")
|
|
352
|
+
|
|
353
|
+
def test_haystack_wrap_traces(self):
|
|
354
|
+
from unittest.mock import MagicMock
|
|
355
|
+
tracer = self._tracer()
|
|
356
|
+
mock_pipeline = MagicMock()
|
|
357
|
+
mock_pipeline.__class__.__name__ = "Pipeline"
|
|
358
|
+
mock_pipeline.__class__.__module__ = "haystack.core.pipeline.pipeline"
|
|
359
|
+
mock_pipeline.run.return_value = {"llm": {"replies": ["refund approved"]}}
|
|
360
|
+
|
|
361
|
+
wrapped = tracer.wrap(mock_pipeline)
|
|
362
|
+
result = wrapped.run({"retriever": {"query": "refund policy"}})
|
|
363
|
+
|
|
364
|
+
assert "llm" in result
|
|
365
|
+
trace = tracer.last_trace()
|
|
366
|
+
assert trace is not None
|
|
367
|
+
assert str(trace.status) in ("completed", "RunStatus.COMPLETED")
|
|
368
|
+
|
|
369
|
+
def test_dspy_wrap_traces(self):
|
|
370
|
+
from unittest.mock import MagicMock
|
|
371
|
+
tracer = self._tracer()
|
|
372
|
+
mock_module = MagicMock()
|
|
373
|
+
mock_module.__class__.__name__ = "RefundClassifier"
|
|
374
|
+
mock_module.__class__.__module__ = "dspy.modules"
|
|
375
|
+
mock_module.forward.return_value = MagicMock(action="refund_approved")
|
|
376
|
+
mock_module.forward.__name__ = "forward"
|
|
377
|
+
|
|
378
|
+
wrapped = tracer.wrap(mock_module)
|
|
379
|
+
result = wrapped("What should I do with refund #4821?")
|
|
380
|
+
|
|
381
|
+
trace = tracer.last_trace()
|
|
382
|
+
assert trace is not None
|
|
383
|
+
assert str(trace.status) in ("completed", "RunStatus.COMPLETED")
|
|
384
|
+
|
|
385
|
+
def test_agno_wrap_traces(self):
|
|
386
|
+
from unittest.mock import MagicMock
|
|
387
|
+
tracer = self._tracer()
|
|
388
|
+
mock_agent = MagicMock()
|
|
389
|
+
mock_agent.__class__.__name__ = "Agent"
|
|
390
|
+
mock_agent.__class__.__module__ = "agno.agent.agent"
|
|
391
|
+
mock_agent.run.return_value = MagicMock(content="Refund approved for order #4821")
|
|
392
|
+
|
|
393
|
+
wrapped = tracer.wrap(mock_agent)
|
|
394
|
+
result = wrapped.run("Process refund for order #4821")
|
|
395
|
+
|
|
396
|
+
trace = tracer.last_trace()
|
|
397
|
+
assert trace is not None
|
|
398
|
+
assert str(trace.status) in ("completed", "RunStatus.COMPLETED")
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import time
|
|
5
|
-
import uuid
|
|
6
|
-
from collections.abc import Callable
|
|
7
|
-
from contextlib import contextmanager
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
from .models import FailureKind, RunStatus, ToolCall, ToolCallStatus, Trace, TraceNode
|
|
12
|
-
|
|
13
|
-
# ── Key resolution order ───────────────────────────────────────────────────
|
|
14
|
-
# 1. Explicit api_key argument
|
|
15
|
-
# 2. CORTEXOPS_API_KEY environment variable
|
|
16
|
-
# 3. ~/.cortexops/credentials file (written by `cortexops login`)
|
|
17
|
-
# 4. None → local-only mode, no hosted tracing
|
|
18
|
-
|
|
19
|
-
_CREDENTIALS_FILE = Path.home() / ".cortexops" / "credentials"
|
|
20
|
-
_DEFAULT_API_URL = "https://api.getcortexops.com"
|
|
21
|
-
_ENV_KEY = "CORTEXOPS_API_KEY"
|
|
22
|
-
_ENV_URL = "CORTEXOPS_API_URL"
|
|
23
|
-
_ENV_PROJECT = "CORTEXOPS_PROJECT"
|
|
24
|
-
_ENV_ENV = "CORTEXOPS_ENVIRONMENT"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _resolve_api_key(explicit: str | None) -> str | None:
|
|
28
|
-
"""Resolve API key from multiple sources in priority order."""
|
|
29
|
-
if explicit:
|
|
30
|
-
return explicit
|
|
31
|
-
# Environment variable
|
|
32
|
-
if env_key := os.getenv(_ENV_KEY):
|
|
33
|
-
return env_key
|
|
34
|
-
# Credentials file written by `cortexops login`
|
|
35
|
-
if _CREDENTIALS_FILE.exists():
|
|
36
|
-
try:
|
|
37
|
-
import json
|
|
38
|
-
creds = json.loads(_CREDENTIALS_FILE.read_text())
|
|
39
|
-
return creds.get("api_key")
|
|
40
|
-
except Exception:
|
|
41
|
-
pass
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _resolve_api_url(explicit: str) -> str:
|
|
46
|
-
"""Resolve API URL — explicit arg > env var > default."""
|
|
47
|
-
if explicit != _DEFAULT_API_URL:
|
|
48
|
-
return explicit.rstrip("/")
|
|
49
|
-
return os.getenv(_ENV_URL, _DEFAULT_API_URL).rstrip("/")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class CortexTracer:
|
|
53
|
-
"""Instruments AI agents with zero-refactor tracing.
|
|
54
|
-
|
|
55
|
-
API key resolution order (most to least specific):
|
|
56
|
-
1. api_key argument
|
|
57
|
-
2. CORTEXOPS_API_KEY environment variable
|
|
58
|
-
3. ~/.cortexops/credentials (written by `cortexops login`)
|
|
59
|
-
4. None — local-only mode, traces stored in memory only
|
|
60
|
-
|
|
61
|
-
Usage:
|
|
62
|
-
# Explicit key
|
|
63
|
-
tracer = CortexTracer(project="payments-agent", api_key="cxo-...")
|
|
64
|
-
|
|
65
|
-
# From environment variable (recommended for CI)
|
|
66
|
-
# export CORTEXOPS_API_KEY=cxo-...
|
|
67
|
-
tracer = CortexTracer(project="payments-agent")
|
|
68
|
-
|
|
69
|
-
# After `cortexops login` (recommended for local dev)
|
|
70
|
-
tracer = CortexTracer(project="payments-agent")
|
|
71
|
-
|
|
72
|
-
graph = tracer.wrap(your_langgraph_app)
|
|
73
|
-
result = graph.invoke({"messages": [...]})
|
|
74
|
-
trace = tracer.last_trace()
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
def __init__(
|
|
78
|
-
self,
|
|
79
|
-
project: str | None = None,
|
|
80
|
-
api_key: str | None = None,
|
|
81
|
-
api_url: str = _DEFAULT_API_URL,
|
|
82
|
-
environment: str | None = None,
|
|
83
|
-
sample_rate: float = 1.0,
|
|
84
|
-
local_store: bool = True,
|
|
85
|
-
) -> None:
|
|
86
|
-
# Project: arg > env var
|
|
87
|
-
self.project = project or os.getenv(_ENV_PROJECT) or "default"
|
|
88
|
-
# Key: auto-resolved from all sources
|
|
89
|
-
self.api_key = _resolve_api_key(api_key)
|
|
90
|
-
self.api_url = _resolve_api_url(api_url)
|
|
91
|
-
# Environment: arg > env var > "development"
|
|
92
|
-
self.environment = environment or os.getenv(_ENV_ENV, "development")
|
|
93
|
-
self.sample_rate = sample_rate
|
|
94
|
-
self.local_store = local_store
|
|
95
|
-
self._traces: list[Trace] = []
|
|
96
|
-
self._current_trace: Trace | None = None
|
|
97
|
-
|
|
98
|
-
# Inform user where key came from — only in development
|
|
99
|
-
if self.environment == "development" and self.api_key:
|
|
100
|
-
source = "argument"
|
|
101
|
-
if not api_key:
|
|
102
|
-
if os.getenv(_ENV_KEY):
|
|
103
|
-
source = f"env:{_ENV_KEY}"
|
|
104
|
-
elif _CREDENTIALS_FILE.exists():
|
|
105
|
-
source = "~/.cortexops/credentials"
|
|
106
|
-
if source != "argument":
|
|
107
|
-
import logging
|
|
108
|
-
logging.getLogger(__name__).debug(
|
|
109
|
-
"CortexTracer: api_key loaded from %s", source
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
@property
|
|
113
|
-
def is_hosted(self) -> bool:
|
|
114
|
-
"""True if traces will be shipped to the hosted API."""
|
|
115
|
-
return bool(self.api_key)
|
|
116
|
-
|
|
117
|
-
def wrap(self, agent: Any) -> Any:
|
|
118
|
-
"""Auto-detect agent type and return an instrumented wrapper."""
|
|
119
|
-
agent_type = type(agent).__name__
|
|
120
|
-
|
|
121
|
-
if agent_type == "CompiledStateGraph":
|
|
122
|
-
return self._wrap_langgraph(agent)
|
|
123
|
-
if agent_type == "Crew":
|
|
124
|
-
return self._wrap_crewai(agent)
|
|
125
|
-
if callable(agent) or hasattr(agent, "invoke"):
|
|
126
|
-
return self._wrap_callable(agent)
|
|
127
|
-
|
|
128
|
-
raise TypeError(
|
|
129
|
-
f"CortexTracer.wrap() does not support {agent_type}. "
|
|
130
|
-
"Pass a LangGraph CompiledStateGraph, CrewAI Crew, or any callable."
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
def _wrap_langgraph(self, graph: Any) -> Any:
|
|
134
|
-
tracer = self
|
|
135
|
-
|
|
136
|
-
class InstrumentedGraph:
|
|
137
|
-
def invoke(self_, input: dict, config: dict | None = None, **kwargs) -> dict:
|
|
138
|
-
return tracer._run_traced(
|
|
139
|
-
fn=lambda: graph.invoke(input, config, **kwargs),
|
|
140
|
-
input=input, framework="langgraph",
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
async def ainvoke(self_, input: dict, config: dict | None = None, **kwargs) -> dict:
|
|
144
|
-
import asyncio
|
|
145
|
-
return await asyncio.get_event_loop().run_in_executor(
|
|
146
|
-
None, lambda: tracer._run_traced(
|
|
147
|
-
fn=lambda: graph.invoke(input, config, **kwargs),
|
|
148
|
-
input=input, framework="langgraph",
|
|
149
|
-
)
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
def stream(self_, input: dict, config: dict | None = None, **kwargs):
|
|
153
|
-
return graph.stream(input, config, **kwargs)
|
|
154
|
-
|
|
155
|
-
def __getattr__(self_, name: str):
|
|
156
|
-
return getattr(graph, name)
|
|
157
|
-
|
|
158
|
-
return InstrumentedGraph()
|
|
159
|
-
|
|
160
|
-
def _wrap_crewai(self, crew: Any) -> Any:
|
|
161
|
-
tracer = self
|
|
162
|
-
|
|
163
|
-
class InstrumentedCrew:
|
|
164
|
-
def kickoff(self_, inputs: dict | None = None) -> Any:
|
|
165
|
-
return tracer._run_traced(
|
|
166
|
-
fn=lambda: crew.kickoff(inputs=inputs),
|
|
167
|
-
input=inputs or {}, framework="crewai",
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
def __getattr__(self_, name: str):
|
|
171
|
-
return getattr(crew, name)
|
|
172
|
-
|
|
173
|
-
return InstrumentedCrew()
|
|
174
|
-
|
|
175
|
-
def _wrap_callable(self, fn: Any) -> Any:
|
|
176
|
-
tracer = self
|
|
177
|
-
|
|
178
|
-
if hasattr(fn, "invoke"):
|
|
179
|
-
original_invoke = fn.invoke
|
|
180
|
-
|
|
181
|
-
class InvokeWrapper:
|
|
182
|
-
def invoke(self_, *args, **kwargs):
|
|
183
|
-
input_data = args[0] if args else kwargs
|
|
184
|
-
return tracer._run_traced(
|
|
185
|
-
fn=lambda: original_invoke(*args, **kwargs),
|
|
186
|
-
input=input_data if isinstance(input_data, dict) else {"input": input_data},
|
|
187
|
-
framework="generic",
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
def __getattr__(self_, name: str):
|
|
191
|
-
return getattr(fn, name)
|
|
192
|
-
|
|
193
|
-
return InvokeWrapper()
|
|
194
|
-
|
|
195
|
-
def wrapper(*args, **kwargs):
|
|
196
|
-
input_data = {"args": list(args), "kwargs": kwargs}
|
|
197
|
-
return tracer._run_traced(fn=lambda: fn(*args, **kwargs), input=input_data, framework="generic")
|
|
198
|
-
|
|
199
|
-
return wrapper
|
|
200
|
-
|
|
201
|
-
def _run_traced(self, fn: Callable, input: dict, framework: str) -> Any:
|
|
202
|
-
import random
|
|
203
|
-
if self.sample_rate < 1.0 and random.random() > self.sample_rate:
|
|
204
|
-
return fn()
|
|
205
|
-
|
|
206
|
-
trace = Trace(project=self.project, input=input)
|
|
207
|
-
self._current_trace = trace
|
|
208
|
-
t0 = time.perf_counter()
|
|
209
|
-
|
|
210
|
-
try:
|
|
211
|
-
result = fn()
|
|
212
|
-
trace.total_latency_ms = (time.perf_counter() - t0) * 1000
|
|
213
|
-
trace.status = RunStatus.COMPLETED
|
|
214
|
-
trace.output = result if isinstance(result, dict) else {"result": str(result)}
|
|
215
|
-
except Exception as exc:
|
|
216
|
-
trace.total_latency_ms = (time.perf_counter() - t0) * 1000
|
|
217
|
-
trace.status = RunStatus.FAILED
|
|
218
|
-
trace.failure_kind = FailureKind.UNKNOWN
|
|
219
|
-
trace.failure_detail = str(exc)
|
|
220
|
-
raise
|
|
221
|
-
finally:
|
|
222
|
-
self._traces.append(trace)
|
|
223
|
-
if self.api_key:
|
|
224
|
-
self._flush_trace(trace)
|
|
225
|
-
|
|
226
|
-
return result
|
|
227
|
-
|
|
228
|
-
@contextmanager
|
|
229
|
-
def trace_node(self, node_name: str):
|
|
230
|
-
"""Context manager to manually instrument a single node."""
|
|
231
|
-
node = TraceNode(node_id=str(uuid.uuid4()), node_name=node_name)
|
|
232
|
-
t0 = time.perf_counter()
|
|
233
|
-
try:
|
|
234
|
-
yield node
|
|
235
|
-
finally:
|
|
236
|
-
node.latency_ms = (time.perf_counter() - t0) * 1000
|
|
237
|
-
if self._current_trace:
|
|
238
|
-
self._current_trace.nodes.append(node)
|
|
239
|
-
|
|
240
|
-
def record_tool_call(
|
|
241
|
-
self,
|
|
242
|
-
name: str,
|
|
243
|
-
args: dict | None = None,
|
|
244
|
-
result: Any = None,
|
|
245
|
-
error: str | None = None,
|
|
246
|
-
latency_ms: float = 0.0,
|
|
247
|
-
) -> ToolCall:
|
|
248
|
-
"""Manually record a tool call onto the current active trace."""
|
|
249
|
-
tc = ToolCall(
|
|
250
|
-
name=name, args=args or {}, result=result,
|
|
251
|
-
status=ToolCallStatus.ERROR if error else ToolCallStatus.SUCCESS,
|
|
252
|
-
latency_ms=latency_ms, error=error,
|
|
253
|
-
)
|
|
254
|
-
if self._current_trace and self._current_trace.nodes:
|
|
255
|
-
self._current_trace.nodes[-1].tool_calls.append(tc)
|
|
256
|
-
return tc
|
|
257
|
-
|
|
258
|
-
def last_trace(self) -> Trace | None:
|
|
259
|
-
return self._traces[-1] if self._traces else None
|
|
260
|
-
|
|
261
|
-
def traces(self) -> list[Trace]:
|
|
262
|
-
return list(self._traces)
|
|
263
|
-
|
|
264
|
-
def clear(self) -> None:
|
|
265
|
-
self._traces.clear()
|
|
266
|
-
self._current_trace = None
|
|
267
|
-
|
|
268
|
-
def _flush_trace(self, trace: Trace) -> None:
|
|
269
|
-
try:
|
|
270
|
-
import httpx
|
|
271
|
-
httpx.post(
|
|
272
|
-
f"{self.api_url}/v1/traces",
|
|
273
|
-
json=trace.model_dump(mode="json"),
|
|
274
|
-
headers={"X-API-Key": self.api_key},
|
|
275
|
-
timeout=2.0,
|
|
276
|
-
)
|
|
277
|
-
except Exception:
|
|
278
|
-
pass # non-blocking — tracing never breaks the agent
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|