Skip to content

Commit 5c56962

Browse files
feat: CrewAI integration and LangGraph node-level tracing (#153)
- integrations/crewai.py: new integration patching Crew.kickoff, Agent.execute_task, and Task.execute_sync to emit session_start, llm_request/response, and tool_call/result events - integrations/langchain.py: add LangGraph StateGraph.compile patch that wraps each compiled node with decision + tool_result events; uninstrument_langchain now properly restores all three patches - integrations/__init__.py: register crewai and langgraph aliases, add instrument_crewai() export, add to _DETECTABLE/_FRAMEWORK_PROBE - pyproject.toml: add crewai optional extra and include in all-integrations Closes #131 Co-authored-by: Ona <no-reply@ona.com>
1 parent 954dbd1 commit 5c56962

6 files changed

Lines changed: 322 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ litellm = ["litellm>=1.0.0"]
3333
anthropic = ["anthropic>=0.20.0"]
3434
openai = ["openai>=1.0.0"]
3535
strands = ["strands-agents>=0.1.0"]
36+
crewai = ["crewai>=0.28.0"]
3637
all-integrations = [
3738
"openai-agents>=0.0.1",
3839
"langchain-core>=0.1.0",
3940
"litellm>=1.0.0",
4041
"anthropic>=0.20.0",
4142
"openai>=1.0.0",
4243
"strands-agents>=0.1.0",
44+
"crewai>=0.28.0",
4345
]
4446

4547
[project.scripts]

src/agent_trace/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""agent-trace: strace for AI agents."""
22

3-
__version__ = "0.55.0"
3+
__version__ = "0.56.0"

src/agent_trace/integrations/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@
3232
"openai-agents": ("agent_trace.integrations.openai_agents", "instrument_openai_agents"),
3333
"openai_agents": ("agent_trace.integrations.openai_agents", "instrument_openai_agents"),
3434
"langchain": ("agent_trace.integrations.langchain", "instrument_langchain"),
35+
"langgraph": ("agent_trace.integrations.langchain", "instrument_langchain"),
3536
"litellm": ("agent_trace.integrations.litellm", "instrument_litellm"),
3637
"anthropic": ("agent_trace.integrations.anthropic", "instrument_anthropic"),
3738
"openai": ("agent_trace.integrations.openai", "instrument_openai"),
3839
"strands": ("agent_trace.integrations.strands", "instrument_strands"),
40+
"crewai": ("agent_trace.integrations.crewai", "instrument_crewai"),
3941
}
4042

4143
# Frameworks that can be auto-detected by checking importability
@@ -46,6 +48,7 @@
4648
"anthropic",
4749
"openai",
4850
"strands",
51+
"crewai",
4952
]
5053

5154
_FRAMEWORK_PROBE: dict[str, str] = {
@@ -55,6 +58,7 @@
5558
"anthropic": "anthropic",
5659
"openai": "openai",
5760
"strands": "strands",
61+
"crewai": "crewai",
5862
}
5963

6064

@@ -102,6 +106,11 @@ def instrument_strands(**kwargs):
102106
return _import_integration("strands")(**kwargs)
103107

104108

109+
def instrument_crewai(**kwargs):
110+
"""Instrument CrewAI."""
111+
return _import_integration("crewai")(**kwargs)
112+
113+
105114
def detect_and_instrument() -> list[str]:
106115
"""Auto-detect installed frameworks and instrument all of them.
107116
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Auto-instrumentation for CrewAI.
2+
3+
Patches:
4+
- crewai.Crew.kickoff → session_start / session_end
5+
- crewai.Agent.execute_task → llm_request / llm_response
6+
- crewai.Task.execute_sync → tool_call / tool_result
7+
8+
Install:
9+
pip install agent-strace[crewai]
10+
# or: pip install crewai
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import time
16+
17+
_PATCHED = False
18+
_orig_kickoff = None
19+
_orig_execute_task = None
20+
_orig_task_execute = None
21+
22+
23+
def instrument_crewai(agent_name: str = "crewai") -> None:
24+
"""Patch CrewAI to emit agent-trace events. Idempotent."""
25+
global _PATCHED, _orig_kickoff, _orig_execute_task, _orig_task_execute
26+
if _PATCHED:
27+
return
28+
29+
try:
30+
import crewai # noqa: F401
31+
except ImportError as exc:
32+
raise ImportError(
33+
"CrewAI is not installed. "
34+
"Install it with: pip install crewai"
35+
) from exc
36+
37+
from ._base import _get_store, _get_or_create_session, emit
38+
from ..models import EventType
39+
40+
store = _get_store()
41+
42+
# --- Patch Crew.kickoff ---
43+
try:
44+
from crewai import Crew
45+
46+
_orig_kickoff = Crew.kickoff
47+
48+
def _patched_kickoff(self, *args, **kwargs):
49+
sid = _get_or_create_session(store, agent_name)
50+
crew_name = getattr(self, "name", None) or agent_name
51+
emit(EventType.SESSION_START, sid, store,
52+
agent_name=crew_name,
53+
agent_count=len(getattr(self, "agents", [])),
54+
task_count=len(getattr(self, "tasks", [])))
55+
t0 = time.time()
56+
try:
57+
result = _orig_kickoff(self, *args, **kwargs)
58+
emit(EventType.SESSION_END, sid, store,
59+
duration_ms=(time.time() - t0) * 1000)
60+
return result
61+
except Exception as exc:
62+
emit(EventType.ERROR, sid, store,
63+
error=str(exc), error_type=type(exc).__name__)
64+
raise
65+
66+
Crew.kickoff = _patched_kickoff
67+
except (ImportError, AttributeError):
68+
pass
69+
70+
# --- Patch Agent.execute_task ---
71+
try:
72+
from crewai import Agent
73+
74+
_orig_execute_task = Agent.execute_task
75+
76+
def _patched_execute_task(self, task, *args, **kwargs):
77+
sid = _get_or_create_session(store, agent_name)
78+
agent_role = getattr(self, "role", str(self))
79+
task_desc = getattr(task, "description", str(task))[:200]
80+
t0 = time.time()
81+
emit(EventType.LLM_REQUEST, sid, store,
82+
agent_role=agent_role,
83+
task=task_desc)
84+
try:
85+
result = _orig_execute_task(self, task, *args, **kwargs)
86+
emit(EventType.LLM_RESPONSE, sid, store,
87+
agent_role=agent_role,
88+
duration_ms=(time.time() - t0) * 1000,
89+
result_preview=str(result)[:300])
90+
return result
91+
except Exception as exc:
92+
emit(EventType.ERROR, sid, store,
93+
agent_role=agent_role, error=str(exc))
94+
raise
95+
96+
Agent.execute_task = _patched_execute_task
97+
except (ImportError, AttributeError):
98+
pass
99+
100+
# --- Patch Task.execute_sync (tool calls within a task) ---
101+
try:
102+
from crewai import Task
103+
104+
_orig_task_execute = Task.execute_sync
105+
106+
def _patched_task_execute(self, *args, **kwargs):
107+
sid = _get_or_create_session(store, agent_name)
108+
task_desc = getattr(self, "description", str(self))[:200]
109+
t0 = time.time()
110+
emit(EventType.TOOL_CALL, sid, store,
111+
tool_name="crewai.task",
112+
task=task_desc)
113+
try:
114+
result = _orig_task_execute(self, *args, **kwargs)
115+
emit(EventType.TOOL_RESULT, sid, store,
116+
tool_name="crewai.task",
117+
duration_ms=(time.time() - t0) * 1000,
118+
result_preview=str(result)[:300])
119+
return result
120+
except Exception as exc:
121+
emit(EventType.ERROR, sid, store,
122+
tool_name="crewai.task", error=str(exc))
123+
raise
124+
125+
Task.execute_sync = _patched_task_execute
126+
except (ImportError, AttributeError):
127+
pass
128+
129+
_PATCHED = True
130+
131+
132+
def uninstrument_crewai() -> None:
133+
"""Remove patches (for testing)."""
134+
global _PATCHED, _orig_kickoff, _orig_execute_task, _orig_task_execute
135+
if _orig_kickoff is not None:
136+
try:
137+
from crewai import Crew
138+
Crew.kickoff = _orig_kickoff
139+
except ImportError:
140+
pass
141+
if _orig_execute_task is not None:
142+
try:
143+
from crewai import Agent
144+
Agent.execute_task = _orig_execute_task
145+
except ImportError:
146+
pass
147+
if _orig_task_execute is not None:
148+
try:
149+
from crewai import Task
150+
Task.execute_sync = _orig_task_execute
151+
except ImportError:
152+
pass
153+
_PATCHED = False
154+
_orig_kickoff = None
155+
_orig_execute_task = None
156+
_orig_task_execute = None

src/agent_trace/integrations/langchain.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
"""Auto-instrumentation for LangChain / LangGraph.
22
33
Patches:
4-
- langchain_core.runnables.base.Runnable.invoke → chain invocations
54
- langchain_core.tools.BaseTool._run → tool_call / tool_result
65
- langchain_core.language_models.BaseChatModel._generate → llm_request / llm_response
6+
- langgraph.graph.StateGraph.compile → wraps each node with
7+
decision / tool_call events
78
89
Install:
910
pip install agent-strace[langchain]
1011
# or: pip install langchain-core
12+
# LangGraph node tracing also requires: pip install langgraph
1113
"""
1214

1315
from __future__ import annotations
1416

1517
import time
1618

1719
_PATCHED = False
20+
_orig_tool_run = None
21+
_orig_generate = None
22+
_orig_compile = None
1823

1924

2025
def instrument_langchain(agent_name: str = "langchain") -> None:
21-
"""Patch LangChain to emit agent-trace events. Idempotent."""
22-
global _PATCHED
26+
"""Patch LangChain (and LangGraph if installed) to emit agent-trace events. Idempotent."""
27+
global _PATCHED, _orig_tool_run, _orig_generate, _orig_compile
2328
if _PATCHED:
2429
return
2530

@@ -40,7 +45,7 @@ def instrument_langchain(agent_name: str = "langchain") -> None:
4045
try:
4146
from langchain_core.tools import BaseTool
4247

43-
_orig_run = BaseTool._run
48+
_orig_tool_run = BaseTool._run
4449

4550
def _patched_run(self, *args, **kwargs):
4651
sid = _get_or_create_session(store, agent_name)
@@ -49,7 +54,7 @@ def _patched_run(self, *args, **kwargs):
4954
emit(EventType.TOOL_CALL, sid, store,
5055
tool_name=tool_name, arguments={"args": str(args)[:200], **kwargs})
5156
try:
52-
result = _orig_run(self, *args, **kwargs)
57+
result = _orig_tool_run(self, *args, **kwargs)
5358
emit(EventType.TOOL_RESULT, sid, store,
5459
tool_name=tool_name,
5560
result=str(result)[:500],
@@ -91,10 +96,82 @@ def _patched_generate(self, messages, *args, **kwargs):
9196
except (ImportError, AttributeError):
9297
pass
9398

99+
# --- Patch LangGraph StateGraph.compile (optional, best-effort) ---
100+
try:
101+
from langgraph.graph import StateGraph
102+
103+
_orig_compile = StateGraph.compile
104+
105+
def _patched_compile(self, *args, **kwargs):
106+
compiled = _orig_compile(self, *args, **kwargs)
107+
_wrap_langgraph_nodes(compiled, store, agent_name)
108+
return compiled
109+
110+
StateGraph.compile = _patched_compile
111+
except (ImportError, AttributeError):
112+
pass # LangGraph not installed — skip silently
113+
94114
_PATCHED = True
95115

96116

117+
def _wrap_langgraph_nodes(compiled_graph, store, agent_name: str) -> None:
118+
"""Wrap each node in a compiled LangGraph graph to emit decision events."""
119+
from ._base import _get_or_create_session, emit
120+
from ..models import EventType
121+
122+
nodes = getattr(compiled_graph, "nodes", None)
123+
if not isinstance(nodes, dict):
124+
return
125+
126+
for node_name, node_fn in list(nodes.items()):
127+
if node_name in ("__start__", "__end__"):
128+
continue
129+
130+
def _make_wrapper(name, fn):
131+
def _wrapped(state, *args, **kwargs):
132+
sid = _get_or_create_session(store, agent_name)
133+
t0 = time.time()
134+
emit(EventType.DECISION, sid, store,
135+
choice=name,
136+
reason=f"LangGraph node: {name}")
137+
try:
138+
result = fn(state, *args, **kwargs)
139+
emit(EventType.TOOL_RESULT, sid, store,
140+
tool_name=f"langgraph.node.{name}",
141+
duration_ms=(time.time() - t0) * 1000)
142+
return result
143+
except Exception as exc:
144+
emit(EventType.ERROR, sid, store,
145+
tool_name=f"langgraph.node.{name}",
146+
error=str(exc))
147+
raise
148+
return _wrapped
149+
150+
nodes[node_name] = _make_wrapper(node_name, node_fn)
151+
152+
97153
def uninstrument_langchain() -> None:
98154
"""Remove patches (for testing)."""
99-
global _PATCHED
155+
global _PATCHED, _orig_tool_run, _orig_generate, _orig_compile
156+
if _orig_tool_run is not None:
157+
try:
158+
from langchain_core.tools import BaseTool
159+
BaseTool._run = _orig_tool_run
160+
except ImportError:
161+
pass
162+
if _orig_generate is not None:
163+
try:
164+
from langchain_core.language_models import BaseChatModel
165+
BaseChatModel._generate = _orig_generate
166+
except ImportError:
167+
pass
168+
if _orig_compile is not None:
169+
try:
170+
from langgraph.graph import StateGraph
171+
StateGraph.compile = _orig_compile
172+
except ImportError:
173+
pass
100174
_PATCHED = False
175+
_orig_tool_run = None
176+
_orig_generate = None
177+
_orig_compile = None

0 commit comments

Comments
 (0)