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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexops
3
- Version: 0.2.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.2.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.2.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.1.0" in captured.out
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