agentx-python 0.4.13__tar.gz → 0.5.1__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.
Files changed (47) hide show
  1. {agentx_python-0.4.13/agentx_python.egg-info → agentx_python-0.5.1}/PKG-INFO +7 -1
  2. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/integrations/__init__.py +2 -0
  3. agentx_python-0.5.1/agentx/integrations/google_adk.py +203 -0
  4. agentx_python-0.5.1/agentx/integrations/google_genai.py +152 -0
  5. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/integrations/langchain.py +156 -22
  6. agentx_python-0.5.1/agentx/integrations/openai_agents.py +224 -0
  7. agentx_python-0.5.1/agentx/version.py +1 -0
  8. {agentx_python-0.4.13 → agentx_python-0.5.1/agentx_python.egg-info}/PKG-INFO +7 -1
  9. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx_python.egg-info/SOURCES.txt +2 -0
  10. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx_python.egg-info/requires.txt +8 -0
  11. {agentx_python-0.4.13 → agentx_python-0.5.1}/setup.py +4 -0
  12. agentx_python-0.4.13/agentx/integrations/openai_agents.py +0 -125
  13. agentx_python-0.4.13/agentx/version.py +0 -1
  14. {agentx_python-0.4.13 → agentx_python-0.5.1}/LICENSE +0 -0
  15. {agentx_python-0.4.13 → agentx_python-0.5.1}/README.md +0 -0
  16. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/__init__.py +0 -0
  17. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/agentx.py +0 -0
  18. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/__init__.py +0 -0
  19. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/_term.py +0 -0
  20. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/adapters/__init__.py +0 -0
  21. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  22. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/adapters/precomputed.py +0 -0
  23. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/adapters/raw.py +0 -0
  24. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/client.py +0 -0
  25. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/datasets.py +0 -0
  26. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/models.py +0 -0
  27. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/redaction.py +0 -0
  28. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/reporting.py +0 -0
  29. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/results.py +0 -0
  30. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/runner.py +0 -0
  31. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/evaluations/tracing.py +0 -0
  32. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/exceptions.py +0 -0
  33. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/integrations/anthropic.py +0 -0
  34. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/integrations/crewai.py +0 -0
  35. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/resources/__init__.py +0 -0
  36. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/resources/agent.py +0 -0
  37. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/resources/conversation.py +0 -0
  38. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/resources/workforce.py +0 -0
  39. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/tracing/__init__.py +0 -0
  40. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/tracing/ci_types.py +0 -0
  41. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/tracing/ingest_client.py +0 -0
  42. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/tracing/tracer.py +0 -0
  43. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx/util.py +0 -0
  44. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx_python.egg-info/dependency_links.txt +0 -0
  45. {agentx_python-0.4.13 → agentx_python-0.5.1}/agentx_python.egg-info/top_level.txt +0 -0
  46. {agentx_python-0.4.13 → agentx_python-0.5.1}/setup.cfg +0 -0
  47. {agentx_python-0.4.13 → agentx_python-0.5.1}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.4.13
3
+ Version: 0.5.1
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -23,11 +23,17 @@ Provides-Extra: openai-agents
23
23
  Requires-Dist: openai-agents>=0.0.3; extra == "openai-agents"
24
24
  Provides-Extra: anthropic
25
25
  Requires-Dist: anthropic>=0.25.0; extra == "anthropic"
26
+ Provides-Extra: google-adk
27
+ Requires-Dist: google-adk>=1.0.0; extra == "google-adk"
28
+ Provides-Extra: google-genai
29
+ Requires-Dist: google-genai>=1.0.0; extra == "google-genai"
26
30
  Provides-Extra: all
27
31
  Requires-Dist: langchain-core>=0.1.0; extra == "all"
28
32
  Requires-Dist: crewai>=0.80.0; extra == "all"
29
33
  Requires-Dist: openai-agents>=0.0.3; extra == "all"
30
34
  Requires-Dist: anthropic>=0.25.0; extra == "all"
35
+ Requires-Dist: google-adk>=1.0.0; extra == "all"
36
+ Requires-Dist: google-genai>=1.0.0; extra == "all"
31
37
  Dynamic: author
32
38
  Dynamic: author-email
33
39
  Dynamic: classifier
@@ -5,3 +5,5 @@
5
5
  # from agentx.integrations.crewai import AgentXCrewObserver
6
6
  # from agentx.integrations.openai_agents import AgentXTracingProcessor
7
7
  # from agentx.integrations.anthropic import patch_anthropic_client
8
+ # from agentx.integrations.google_adk import AgentXADKPlugin
9
+ # from agentx.integrations.google_genai import patch_genai_client
@@ -0,0 +1,203 @@
1
+ """
2
+ Google ADK integration for AgentX production tracing.
3
+
4
+ Usage::
5
+
6
+ from agentx.integrations.google_adk import AgentXADKPlugin
7
+ from google.adk.runners import Runner
8
+ from google.adk.sessions import InMemorySessionService
9
+
10
+ plugin = AgentXADKPlugin(agentx.tracer, name="my-agent")
11
+
12
+ runner = Runner(
13
+ agent=agent,
14
+ app_name="my-app",
15
+ session_service=InMemorySessionService(),
16
+ plugins=[plugin],
17
+ )
18
+
19
+ Requires: ``pip install "agentx-python[google-adk]"``
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from agentx.tracing.tracer import Tracer, _safe_serialize
27
+
28
+ try:
29
+ from google.adk.plugins.base_plugin import BasePlugin
30
+ except ImportError as exc: # pragma: no cover
31
+ raise ImportError(
32
+ "google-adk is required for AgentXADKPlugin. "
33
+ "Install it with: pip install \"agentx-python[google-adk]\""
34
+ ) from exc
35
+
36
+
37
+ def _content_to_text(content: Any) -> Optional[str]:
38
+ """Extract plain text from a google.genai types.Content object."""
39
+ if content is None:
40
+ return None
41
+ if isinstance(content, str):
42
+ return content
43
+ parts = getattr(content, "parts", None)
44
+ if not parts:
45
+ return None
46
+ texts = []
47
+ for part in parts:
48
+ text = getattr(part, "text", None)
49
+ if text and isinstance(text, str):
50
+ texts.append(text)
51
+ return " ".join(texts) if texts else None
52
+
53
+
54
+ class AgentXADKPlugin(BasePlugin):
55
+ """
56
+ Google ADK plugin that sends one AgentX trace per runner invocation.
57
+
58
+ Captures input (user message), output (final model reply), model name,
59
+ tool calls, and latency via the ADK plugin callback hooks.
60
+
61
+ Register via the ``plugins`` list when constructing the ADK ``Runner``.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ tracer: Tracer,
67
+ name: str = "google-adk-agent",
68
+ metadata: Optional[Dict[str, Any]] = None,
69
+ session_id: Optional[str] = None,
70
+ ) -> None:
71
+ super().__init__(name="agentx")
72
+ self._tracer = tracer
73
+ self._agent_name = name
74
+ self._metadata = metadata
75
+ self._session_id = session_id
76
+ # invocation_id → accumulated run state
77
+ self._runs: Dict[str, Dict[str, Any]] = {}
78
+ # id(tool_context) → start time float
79
+ self._tool_starts: Dict[int, float] = {}
80
+
81
+ # ------------------------------------------------------------------
82
+ # Run lifecycle
83
+ # ------------------------------------------------------------------
84
+
85
+ async def before_run_callback(self, *, invocation_context: Any) -> None:
86
+ inv_id = invocation_context.invocation_id
87
+ agent_name = getattr(invocation_context.agent, "name", None) or self._agent_name
88
+ self._runs[inv_id] = {
89
+ "start": time.time(),
90
+ "name": agent_name,
91
+ "input": None,
92
+ "output": None,
93
+ "model": None,
94
+ "tool_calls": [],
95
+ "error": None,
96
+ }
97
+
98
+ async def on_user_message_callback(
99
+ self, *, invocation_context: Any, user_message: Any
100
+ ) -> None:
101
+ inv_id = invocation_context.invocation_id
102
+ state = self._runs.get(inv_id)
103
+ if state is None:
104
+ return
105
+ text = _content_to_text(user_message)
106
+ if text and state["input"] is None:
107
+ state["input"] = text
108
+
109
+ async def after_run_callback(self, *, invocation_context: Any) -> None:
110
+ inv_id = invocation_context.invocation_id
111
+ state = self._runs.pop(inv_id, None)
112
+ if state is None:
113
+ return
114
+ latency_ms = int((time.time() - state["start"]) * 1000)
115
+ self._tracer._send(
116
+ name=state["name"],
117
+ input=state["input"],
118
+ output=state["output"],
119
+ latency_ms=latency_ms,
120
+ framework="google-adk",
121
+ model=state["model"],
122
+ tool_calls=state["tool_calls"] or None,
123
+ metadata=self._metadata,
124
+ session_id=self._session_id,
125
+ )
126
+
127
+ # ------------------------------------------------------------------
128
+ # Model callbacks — capture model name and output
129
+ # ------------------------------------------------------------------
130
+
131
+ async def before_model_callback(
132
+ self, *, callback_context: Any, llm_request: Any
133
+ ) -> None:
134
+ inv_id = callback_context.get_invocation_context().invocation_id
135
+ state = self._runs.get(inv_id)
136
+ if state and not state["model"] and llm_request.model:
137
+ state["model"] = str(llm_request.model)
138
+
139
+ async def after_model_callback(
140
+ self, *, callback_context: Any, llm_response: Any
141
+ ) -> None:
142
+ inv_id = callback_context.get_invocation_context().invocation_id
143
+ state = self._runs.get(inv_id)
144
+ if state is None:
145
+ return
146
+ content = getattr(llm_response, "content", None)
147
+ text = _content_to_text(content)
148
+ # Keep updating — the last non-empty model reply is the final answer
149
+ if text:
150
+ state["output"] = text
151
+
152
+ # ------------------------------------------------------------------
153
+ # Tool callbacks
154
+ # ------------------------------------------------------------------
155
+
156
+ async def before_tool_callback(
157
+ self, *, tool: Any, tool_args: Dict[str, Any], tool_context: Any
158
+ ) -> None:
159
+ self._tool_starts[id(tool_context)] = time.time()
160
+
161
+ async def after_tool_callback(
162
+ self,
163
+ *,
164
+ tool: Any,
165
+ tool_args: Dict[str, Any],
166
+ tool_context: Any,
167
+ result: Dict[str, Any],
168
+ ) -> None:
169
+ inv_id = tool_context.get_invocation_context().invocation_id
170
+ state = self._runs.get(inv_id)
171
+ if state is None:
172
+ return
173
+ start = self._tool_starts.pop(id(tool_context), None)
174
+ tool_call: Dict[str, Any] = {
175
+ "name": getattr(tool, "name", "unknown"),
176
+ "input": _safe_serialize(tool_args),
177
+ "output": str(result)[:500] if result is not None else None,
178
+ }
179
+ if start is not None:
180
+ tool_call["latency_ms"] = max(0, int((time.time() - start) * 1000))
181
+ state["tool_calls"].append(tool_call)
182
+
183
+ async def on_tool_error_callback(
184
+ self,
185
+ *,
186
+ tool: Any,
187
+ tool_args: Dict[str, Any],
188
+ tool_context: Any,
189
+ error: Exception,
190
+ ) -> None:
191
+ inv_id = tool_context.get_invocation_context().invocation_id
192
+ state = self._runs.get(inv_id)
193
+ if state is None:
194
+ return
195
+ start = self._tool_starts.pop(id(tool_context), None)
196
+ tool_call: Dict[str, Any] = {
197
+ "name": getattr(tool, "name", "unknown"),
198
+ "input": _safe_serialize(tool_args),
199
+ "output": f"ERROR: {error}",
200
+ }
201
+ if start is not None:
202
+ tool_call["latency_ms"] = max(0, int((time.time() - start) * 1000))
203
+ state["tool_calls"].append(tool_call)
@@ -0,0 +1,152 @@
1
+ """
2
+ Google Gen AI SDK integration for AgentX production tracing.
3
+
4
+ Usage::
5
+
6
+ from agentx.integrations.google_genai import patch_genai_client
7
+ from google import genai
8
+
9
+ client = genai.Client(api_key="GEMINI_API_KEY")
10
+ patch_genai_client(client, agentx.tracer, name="gemini-agent")
11
+
12
+ # All subsequent client.models.generate_content() calls are now traced.
13
+
14
+ Requires: ``pip install "agentx-python[google-genai]"``
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from typing import Any, Dict, Optional
20
+
21
+ from agentx.tracing.tracer import Tracer, _safe_serialize
22
+
23
+
24
+ def patch_genai_client(
25
+ client: Any,
26
+ tracer: Tracer,
27
+ name: str = "gemini-agent",
28
+ metadata: Optional[Dict[str, Any]] = None,
29
+ session_id: Optional[str] = None,
30
+ ) -> None:
31
+ """
32
+ Monkey-patch ``client.models.generate_content`` (and
33
+ ``client.models.generate_content_stream`` if present) to automatically
34
+ send a trace for every call.
35
+
36
+ The original methods are still called and their return values are passed
37
+ through unchanged — nothing in the caller needs to change.
38
+
39
+ Calling this function on an already-patched client is a no-op.
40
+ """
41
+ models = getattr(client, "models", None)
42
+ if models is None:
43
+ raise ValueError("Provided client does not have a .models attribute")
44
+
45
+ _patch_generate_content(models, tracer, name, metadata, session_id)
46
+ if hasattr(models, "generate_content_stream"):
47
+ _patch_generate_content_stream(models, tracer, name, metadata, session_id)
48
+
49
+
50
+ def _extract_response_text(response: Any) -> Optional[str]:
51
+ """Pull the generated text out of a GenerateContentResponse."""
52
+ # Convenience .text property (available on non-streaming responses)
53
+ text = getattr(response, "text", None)
54
+ if text and isinstance(text, str):
55
+ return text
56
+ # Fallback: walk candidates → content → parts
57
+ candidates = getattr(response, "candidates", None) or []
58
+ for candidate in candidates:
59
+ content = getattr(candidate, "content", None)
60
+ parts = getattr(content, "parts", None) or []
61
+ for part in parts:
62
+ t = getattr(part, "text", None)
63
+ if t and isinstance(t, str):
64
+ return t
65
+ return None
66
+
67
+
68
+ def _patch_generate_content(
69
+ models: Any,
70
+ tracer: Tracer,
71
+ name: str,
72
+ metadata: Optional[Dict[str, Any]],
73
+ session_id: Optional[str],
74
+ ) -> None:
75
+ original = models.generate_content
76
+ if getattr(original, "_agentx_patched", False):
77
+ return
78
+
79
+ def patched(*args, **kwargs):
80
+ start = time.time()
81
+ error: Optional[str] = None
82
+ response = None
83
+ try:
84
+ response = original(*args, **kwargs)
85
+ return response
86
+ except Exception as exc:
87
+ error = str(exc)
88
+ raise
89
+ finally:
90
+ latency_ms = int((time.time() - start) * 1000)
91
+ model = kwargs.get("model") or (args[0] if args else None)
92
+ contents = kwargs.get("contents") or (args[1] if len(args) > 1 else None)
93
+ output = _extract_response_text(response) if response is not None else None
94
+ tracer._send(
95
+ name=name,
96
+ input=contents if isinstance(contents, str) else _safe_serialize(contents),
97
+ output=output,
98
+ latency_ms=latency_ms,
99
+ error=error,
100
+ framework="google-genai",
101
+ model=str(model) if model else None,
102
+ metadata=metadata,
103
+ session_id=session_id,
104
+ )
105
+
106
+ patched._agentx_patched = True
107
+ models.generate_content = patched
108
+
109
+
110
+ def _patch_generate_content_stream(
111
+ models: Any,
112
+ tracer: Tracer,
113
+ name: str,
114
+ metadata: Optional[Dict[str, Any]],
115
+ session_id: Optional[str],
116
+ ) -> None:
117
+ original_stream = models.generate_content_stream
118
+ if getattr(original_stream, "_agentx_patched", False):
119
+ return
120
+
121
+ def patched_stream(*args, **kwargs):
122
+ start = time.time()
123
+ accumulated_text: list[str] = []
124
+ error: Optional[str] = None
125
+
126
+ try:
127
+ for chunk in original_stream(*args, **kwargs):
128
+ text = getattr(chunk, "text", None)
129
+ if text:
130
+ accumulated_text.append(text)
131
+ yield chunk
132
+ except Exception as exc:
133
+ error = str(exc)
134
+ raise
135
+ finally:
136
+ latency_ms = int((time.time() - start) * 1000)
137
+ model = kwargs.get("model") or (args[0] if args else None)
138
+ contents = kwargs.get("contents") or (args[1] if len(args) > 1 else None)
139
+ tracer._send(
140
+ name=name,
141
+ input=contents if isinstance(contents, str) else _safe_serialize(contents),
142
+ output="".join(accumulated_text) or None,
143
+ latency_ms=latency_ms,
144
+ error=error,
145
+ framework="google-genai",
146
+ model=str(model) if model else None,
147
+ metadata=metadata,
148
+ session_id=session_id,
149
+ )
150
+
151
+ patched_stream._agentx_patched = True
152
+ models.generate_content_stream = patched_stream
@@ -10,10 +10,13 @@ Usage::
10
10
  # LCEL chain
11
11
  chain.invoke({"query": q}, config={"callbacks": [handler]})
12
12
 
13
+ # LangGraph agent (create_agent / create_react_agent)
14
+ agent.invoke({"messages": [...]}, config={"callbacks": [handler]})
15
+
13
16
  # AgentExecutor
14
17
  agent.invoke({"input": q}, config={"callbacks": [handler]})
15
18
 
16
- Requires: ``pip install agentx[langchain]``
19
+ Requires: ``pip install "agentx-python[langchain]"``
17
20
  """
18
21
  from __future__ import annotations
19
22
 
@@ -29,14 +32,132 @@ try:
29
32
  except ImportError as exc: # pragma: no cover
30
33
  raise ImportError(
31
34
  "langchain-core is required for AgentXCallbackHandler. "
32
- "Install it with: pip install agentx[langchain]"
35
+ "Install it with: pip install \"agentx-python[langchain]\""
33
36
  ) from exc
34
37
 
35
38
 
39
+ # ---------------------------------------------------------------------------
40
+ # Output extraction helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _extract_output(outputs: Any) -> Any:
44
+ """
45
+ Extract a clean, human-readable output from a chain's return value.
46
+
47
+ - LangGraph agents return {"messages": [HumanMessage, ..., AIMessage(final)]}
48
+ → extract the last AI message's text content
49
+ - AgentExecutor returns {"output": "..."}
50
+ - LCEL chains return {"text": "..."} or a plain string
51
+ """
52
+ if not isinstance(outputs, dict):
53
+ return _safe_serialize(outputs)
54
+
55
+ # LangGraph: {"messages": [...]}
56
+ messages = outputs.get("messages")
57
+ if isinstance(messages, list) and messages:
58
+ for msg in reversed(messages):
59
+ msg_type = getattr(msg, "type", None) or getattr(msg, "role", None)
60
+ if msg_type in ("ai", "assistant"):
61
+ content = getattr(msg, "content", None)
62
+ if content and isinstance(content, str) and content.strip():
63
+ return content
64
+ if isinstance(content, list):
65
+ # Multi-part content blocks
66
+ texts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
67
+ joined = " ".join(t for t in texts if t).strip()
68
+ if joined:
69
+ return joined
70
+
71
+ # Standard chain / AgentExecutor
72
+ for key in ("output", "text", "answer", "result", "response"):
73
+ val = outputs.get(key)
74
+ if val and isinstance(val, str):
75
+ return val
76
+
77
+ return _safe_serialize(outputs)
78
+
79
+
80
+ def _extract_input(inputs: Any) -> Any:
81
+ """
82
+ Extract a clean input from a chain's input dict.
83
+
84
+ - LangGraph: {"messages": [HumanMessage(...)]} → first human message text
85
+ - AgentExecutor: {"input": "..."} → the string
86
+ - LCEL: {"query": "...", "question": "..."} → the string value
87
+ """
88
+ if not isinstance(inputs, dict):
89
+ return _safe_serialize(inputs)
90
+
91
+ # LangGraph: {"messages": [...]}
92
+ messages = inputs.get("messages")
93
+ if isinstance(messages, list) and messages:
94
+ for msg in messages:
95
+ msg_type = getattr(msg, "type", None) or getattr(msg, "role", None)
96
+ if msg_type in ("human", "user"):
97
+ content = getattr(msg, "content", None)
98
+ if isinstance(content, str):
99
+ return content
100
+ # Fallback: first message regardless of type
101
+ first = messages[0]
102
+ content = getattr(first, "content", None)
103
+ if isinstance(content, str):
104
+ return content
105
+
106
+ # Standard
107
+ for key in ("input", "query", "question", "human_input"):
108
+ val = inputs.get(key)
109
+ if val and isinstance(val, str):
110
+ return val
111
+
112
+ return _safe_serialize(inputs)
113
+
114
+
115
+ def _extract_tool_calls_from_messages(outputs: Any) -> List[Dict[str, Any]]:
116
+ """
117
+ Fallback: extract tool calls directly from the message history when
118
+ on_tool_end callbacks were not linked to the top-level run.
119
+ """
120
+ if not isinstance(outputs, dict):
121
+ return []
122
+
123
+ messages = outputs.get("messages")
124
+ if not isinstance(messages, list):
125
+ return []
126
+
127
+ # Build map of tool_call_id → result from ToolMessages
128
+ results: Dict[str, str] = {}
129
+ for msg in messages:
130
+ if getattr(msg, "type", None) == "tool":
131
+ call_id = getattr(msg, "tool_call_id", None)
132
+ content = getattr(msg, "content", "")
133
+ if call_id:
134
+ results[call_id] = str(content)
135
+
136
+ # Extract tool calls from AIMessages
137
+ tool_calls: List[Dict[str, Any]] = []
138
+ for msg in messages:
139
+ if getattr(msg, "type", None) != "ai":
140
+ continue
141
+ for tc in getattr(msg, "tool_calls", []) or []:
142
+ if not isinstance(tc, dict):
143
+ continue
144
+ call_id = tc.get("id", "")
145
+ tool_calls.append({
146
+ "name": tc.get("name", "unknown"),
147
+ "input": str(tc.get("args", "")),
148
+ "output": results.get(call_id, ""),
149
+ })
150
+
151
+ return tool_calls
152
+
153
+
36
154
  class AgentXCallbackHandler(BaseCallbackHandler):
37
155
  """
38
156
  LangChain callback handler that captures the top-level chain run and all
39
157
  nested tool calls, then sends one trace per top-level chain invocation.
158
+
159
+ Compatible with AgentExecutor, LCEL chains, and LangGraph agents
160
+ (``create_agent``, ``create_react_agent``).
40
161
  """
41
162
 
42
163
  def __init__(
@@ -52,10 +173,10 @@ class AgentXCallbackHandler(BaseCallbackHandler):
52
173
  self._metadata = metadata
53
174
  self._session_id = session_id
54
175
 
55
- # Keyed by run_id (UUID) → state dict
56
176
  self._runs: Dict[UUID, Dict[str, Any]] = {}
57
- # Track which run_ids are top-level (no parent)
58
177
  self._top_level: Dict[UUID, bool] = {}
178
+ # Full parent-chain map so _find_top_ancestor can walk arbitrary depth
179
+ self._parents: Dict[UUID, Optional[UUID]] = {}
59
180
 
60
181
  # ------------------------------------------------------------------
61
182
  # Chain lifecycle
@@ -70,12 +191,13 @@ class AgentXCallbackHandler(BaseCallbackHandler):
70
191
  parent_run_id: Optional[UUID] = None,
71
192
  **kwargs,
72
193
  ) -> None:
194
+ self._parents[run_id] = parent_run_id
73
195
  is_top = parent_run_id is None
74
196
  self._top_level[run_id] = is_top
75
197
  if is_top:
76
198
  self._runs[run_id] = {
77
199
  "start": time.time(),
78
- "input": _safe_serialize(inputs),
200
+ "input": _extract_input(inputs),
79
201
  "tool_calls": [],
80
202
  "model": None,
81
203
  }
@@ -94,18 +216,22 @@ class AgentXCallbackHandler(BaseCallbackHandler):
94
216
  if state is None:
95
217
  return
96
218
  latency_ms = int((time.time() - state["start"]) * 1000)
219
+ output = _extract_output(outputs)
220
+ # If callbacks missed tool calls (deep nesting), extract from message history
221
+ tool_calls = state["tool_calls"] or _extract_tool_calls_from_messages(outputs)
97
222
  self._tracer._send(
98
223
  name=self._name,
99
224
  input=state["input"],
100
- output=_safe_serialize(outputs),
225
+ output=output,
101
226
  latency_ms=latency_ms,
102
227
  framework="langchain",
103
228
  model=state.get("model"),
104
- tool_calls=state["tool_calls"] or None,
229
+ tool_calls=tool_calls or None,
105
230
  metadata=self._metadata,
106
231
  session_id=self._session_id,
107
232
  )
108
233
  self._top_level.pop(run_id, None)
234
+ self._parents.pop(run_id, None)
109
235
 
110
236
  def on_chain_error(
111
237
  self,
@@ -133,9 +259,10 @@ class AgentXCallbackHandler(BaseCallbackHandler):
133
259
  session_id=self._session_id,
134
260
  )
135
261
  self._top_level.pop(run_id, None)
262
+ self._parents.pop(run_id, None)
136
263
 
137
264
  # ------------------------------------------------------------------
138
- # LLM lifecycle (captures model name and token counts)
265
+ # LLM lifecycle
139
266
  # ------------------------------------------------------------------
140
267
 
141
268
  def on_llm_start(
@@ -147,18 +274,21 @@ class AgentXCallbackHandler(BaseCallbackHandler):
147
274
  parent_run_id: Optional[UUID] = None,
148
275
  **kwargs,
149
276
  ) -> None:
150
- # Record LLM start time keyed by its own run_id
277
+ self._parents[run_id] = parent_run_id
151
278
  self._runs.setdefault(run_id, {})["llm_start"] = time.time()
152
279
 
153
- # Propagate model name to the top-level run
154
280
  top = self._find_top_ancestor(parent_run_id)
155
281
  if top and not self._runs[top].get("model"):
282
+ # Check all common field locations across langchain versions
283
+ kw = serialized.get("kwargs", {})
156
284
  model = (
157
- serialized.get("kwargs", {}).get("model_name")
158
- or serialized.get("kwargs", {}).get("model")
159
- or serialized.get("id", [None])[-1]
285
+ kw.get("model_name")
286
+ or kw.get("model")
287
+ or kwargs.get("invocation_params", {}).get("model")
288
+ or kwargs.get("invocation_params", {}).get("model_name")
289
+ or serialized.get("name")
160
290
  )
161
- if model:
291
+ if model and model not in ("None", "none"):
162
292
  self._runs[top]["model"] = str(model)
163
293
 
164
294
  def on_llm_end(
@@ -170,6 +300,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
170
300
  **kwargs,
171
301
  ) -> None:
172
302
  self._runs.pop(run_id, None)
303
+ self._parents.pop(run_id, None)
173
304
 
174
305
  # ------------------------------------------------------------------
175
306
  # Tool lifecycle
@@ -184,6 +315,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
184
315
  parent_run_id: Optional[UUID] = None,
185
316
  **kwargs,
186
317
  ) -> None:
318
+ self._parents[run_id] = parent_run_id
187
319
  self._runs[run_id] = {
188
320
  "tool_name": serialized.get("name", "unknown"),
189
321
  "tool_input": input_str,
@@ -199,6 +331,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
199
331
  **kwargs,
200
332
  ) -> None:
201
333
  state = self._runs.pop(run_id, None)
334
+ self._parents.pop(run_id, None)
202
335
  if state is None:
203
336
  return
204
337
  latency_ms = int((time.time() - state["start"]) * 1000)
@@ -221,6 +354,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
221
354
  **kwargs,
222
355
  ) -> None:
223
356
  state = self._runs.pop(run_id, None)
357
+ self._parents.pop(run_id, None)
224
358
  if state is None:
225
359
  return
226
360
  tool_call = {
@@ -237,13 +371,13 @@ class AgentXCallbackHandler(BaseCallbackHandler):
237
371
  # Helpers
238
372
  # ------------------------------------------------------------------
239
373
 
240
- def _find_top_ancestor(self, parent_run_id: Optional[UUID]) -> Optional[UUID]:
241
- """Walk up parent chain to find the top-level run_id."""
242
- current = parent_run_id
243
- while current is not None:
374
+ def _find_top_ancestor(self, start: Optional[UUID]) -> Optional[UUID]:
375
+ """Walk the full parent chain to find the top-level run_id."""
376
+ current = start
377
+ seen: set = set()
378
+ while current is not None and current not in seen:
379
+ seen.add(current)
244
380
  if self._top_level.get(current):
245
381
  return current
246
- # If current is a nested run, keep climbing — for simplicity return
247
- # the immediate parent that is registered as top-level
248
- break
249
- return current if current in self._runs else None
382
+ current = self._parents.get(current)
383
+ return None
@@ -0,0 +1,224 @@
1
+ """
2
+ OpenAI Agents SDK integration for AgentX production tracing.
3
+
4
+ Usage::
5
+
6
+ from agentx.integrations.openai_agents import AgentXTracingProcessor
7
+
8
+ processor = AgentXTracingProcessor(agentx.tracer)
9
+
10
+ # Register once at startup — affects all agent runs in the process
11
+ from agents import add_trace_processor
12
+ add_trace_processor(processor)
13
+
14
+ Requires: ``pip install "agentx-python[openai-agents]"``
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from agentx.tracing.tracer import Tracer, _safe_serialize
23
+
24
+
25
+ def _iso_to_ts(iso: Optional[str]) -> Optional[float]:
26
+ """Parse an ISO-8601 timestamp string to a Unix timestamp float."""
27
+ if not iso:
28
+ return None
29
+ try:
30
+ return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def _span_latency_ms(span: Any) -> Optional[int]:
36
+ """Return span duration in ms from ISO started_at / ended_at strings."""
37
+ t0 = _iso_to_ts(getattr(span, "started_at", None))
38
+ t1 = _iso_to_ts(getattr(span, "ended_at", None))
39
+ if t0 is not None and t1 is not None:
40
+ return max(0, int((t1 - t0) * 1000))
41
+ return None
42
+
43
+
44
+ def _extract_text(output: Any) -> Optional[str]:
45
+ """
46
+ Best-effort extraction of a human-readable string from a GenerationSpanData
47
+ or ResponseSpanData output, which can be a list of message dicts in either
48
+ the Chat Completions or Responses API format.
49
+ """
50
+ if output is None:
51
+ return None
52
+ if isinstance(output, str):
53
+ return output
54
+ if not isinstance(output, (list, tuple)):
55
+ return _safe_serialize(output)
56
+
57
+ for item in reversed(output):
58
+ if not isinstance(item, dict):
59
+ # Could be a pydantic model — try .text or .content
60
+ text = getattr(item, "text", None) or getattr(item, "content", None)
61
+ if text and isinstance(text, str):
62
+ return text
63
+ continue
64
+
65
+ # Responses API: {"type": "message", "content": [{"type": "output_text", "text": "..."}]}
66
+ if item.get("type") == "message":
67
+ content = item.get("content", [])
68
+ if isinstance(content, list):
69
+ for part in content:
70
+ if isinstance(part, dict) and part.get("type") == "output_text":
71
+ return part.get("text")
72
+
73
+ # Chat Completions API: {"role": "assistant", "content": "..."}
74
+ role = item.get("role", "")
75
+ if role == "assistant":
76
+ content = item.get("content")
77
+ if isinstance(content, str):
78
+ return content
79
+ if isinstance(content, list):
80
+ for part in content:
81
+ if isinstance(part, dict) and part.get("type") == "text":
82
+ return part.get("text")
83
+
84
+ return _safe_serialize(output)
85
+
86
+
87
+ def _extract_input_text(input_data: Any) -> Optional[str]:
88
+ """Extract the user's input query from a generation span's input messages."""
89
+ if input_data is None:
90
+ return None
91
+ if isinstance(input_data, str):
92
+ return input_data
93
+ if not isinstance(input_data, (list, tuple)):
94
+ return _safe_serialize(input_data)
95
+
96
+ # Walk messages and grab the last user message content
97
+ user_text = None
98
+ for item in input_data:
99
+ if not isinstance(item, dict):
100
+ continue
101
+ if item.get("role") == "user":
102
+ content = item.get("content")
103
+ if isinstance(content, str):
104
+ user_text = content
105
+ elif isinstance(content, list):
106
+ for part in content:
107
+ if isinstance(part, dict) and part.get("type") in ("text", "input_text"):
108
+ user_text = part.get("text")
109
+
110
+ return user_text or _safe_serialize(input_data)
111
+
112
+
113
+ class AgentXTracingProcessor:
114
+ """
115
+ Implements the ``TracingProcessor`` interface expected by the OpenAI Agents SDK
116
+ (``agents.add_trace_processor``).
117
+
118
+ Sends one AgentX trace per top-level agent run.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ tracer: Tracer,
124
+ metadata: Optional[Dict[str, Any]] = None,
125
+ session_id: Optional[str] = None,
126
+ ) -> None:
127
+ self._tracer = tracer
128
+ self._metadata = metadata
129
+ self._session_id = session_id
130
+ # trace_id → accumulated state
131
+ self._spans: Dict[str, Dict[str, Any]] = {}
132
+
133
+ # ------------------------------------------------------------------
134
+ # TracingProcessor protocol
135
+ # ------------------------------------------------------------------
136
+
137
+ def on_trace_start(self, trace: Any) -> None:
138
+ trace_id = getattr(trace, "trace_id", None) or str(id(trace))
139
+ self._spans[trace_id] = {
140
+ "start": time.time(),
141
+ "name": getattr(trace, "name", "openai-agent"),
142
+ "input": None,
143
+ "output": None,
144
+ "tool_calls": [],
145
+ "model": None,
146
+ }
147
+
148
+ def on_trace_end(self, trace: Any) -> None:
149
+ trace_id = getattr(trace, "trace_id", None) or str(id(trace))
150
+ state = self._spans.pop(trace_id, None)
151
+ if state is None:
152
+ return
153
+ latency_ms = int((time.time() - state["start"]) * 1000)
154
+ self._tracer._send(
155
+ name=state["name"],
156
+ input=state.get("input"),
157
+ output=state.get("output"),
158
+ latency_ms=latency_ms,
159
+ framework="openai-agents",
160
+ model=state.get("model"),
161
+ tool_calls=state["tool_calls"] or None,
162
+ metadata=self._metadata,
163
+ session_id=self._session_id,
164
+ )
165
+
166
+ def on_span_start(self, span: Any) -> None:
167
+ pass # All data is captured in on_span_end when fields are fully populated
168
+
169
+ def on_span_end(self, span: Any) -> None:
170
+ span_data = getattr(span, "span_data", None)
171
+ if span_data is None:
172
+ return
173
+
174
+ trace_id = getattr(span, "trace_id", None)
175
+ if not trace_id or trace_id not in self._spans:
176
+ return
177
+
178
+ state = self._spans[trace_id]
179
+ span_type = getattr(span_data, "type", None)
180
+
181
+ if span_type == "generation":
182
+ # Capture input from the first generation span
183
+ if state["input"] is None and span_data.input:
184
+ state["input"] = _extract_input_text(span_data.input)
185
+ # Always update output to the latest generation (last one wins = final reply)
186
+ if span_data.output:
187
+ state["output"] = _extract_text(span_data.output)
188
+ # Capture model name
189
+ if not state["model"] and span_data.model:
190
+ state["model"] = str(span_data.model)
191
+
192
+ elif span_type == "response":
193
+ # Responses API path — extract from the response object
194
+ response = getattr(span_data, "response", None)
195
+ if response is not None:
196
+ if state["input"] is None:
197
+ raw_input = getattr(span_data, "input", None)
198
+ if raw_input:
199
+ state["input"] = _extract_input_text(raw_input) if isinstance(raw_input, list) else str(raw_input)
200
+ output_items = getattr(response, "output", None)
201
+ if output_items:
202
+ state["output"] = _extract_text(output_items)
203
+ if not state["model"]:
204
+ model = getattr(response, "model", None)
205
+ if model:
206
+ state["model"] = str(model)
207
+
208
+ elif span_type == "function":
209
+ # Tool / function call
210
+ tool_entry: Dict[str, Any] = {
211
+ "name": span_data.name,
212
+ "input": span_data.input,
213
+ "output": str(span_data.output)[:500] if span_data.output is not None else None,
214
+ }
215
+ latency = _span_latency_ms(span)
216
+ if latency is not None:
217
+ tool_entry["latency_ms"] = latency
218
+ state["tool_calls"].append(tool_entry)
219
+
220
+ def force_flush(self) -> None:
221
+ self._tracer.flush()
222
+
223
+ def shutdown(self) -> None:
224
+ self._tracer.flush()
@@ -0,0 +1 @@
1
+ VERSION = "0.5.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.4.13
3
+ Version: 0.5.1
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -23,11 +23,17 @@ Provides-Extra: openai-agents
23
23
  Requires-Dist: openai-agents>=0.0.3; extra == "openai-agents"
24
24
  Provides-Extra: anthropic
25
25
  Requires-Dist: anthropic>=0.25.0; extra == "anthropic"
26
+ Provides-Extra: google-adk
27
+ Requires-Dist: google-adk>=1.0.0; extra == "google-adk"
28
+ Provides-Extra: google-genai
29
+ Requires-Dist: google-genai>=1.0.0; extra == "google-genai"
26
30
  Provides-Extra: all
27
31
  Requires-Dist: langchain-core>=0.1.0; extra == "all"
28
32
  Requires-Dist: crewai>=0.80.0; extra == "all"
29
33
  Requires-Dist: openai-agents>=0.0.3; extra == "all"
30
34
  Requires-Dist: anthropic>=0.25.0; extra == "all"
35
+ Requires-Dist: google-adk>=1.0.0; extra == "all"
36
+ Requires-Dist: google-genai>=1.0.0; extra == "all"
31
37
  Dynamic: author
32
38
  Dynamic: author-email
33
39
  Dynamic: classifier
@@ -23,6 +23,8 @@ agentx/evaluations/adapters/raw.py
23
23
  agentx/integrations/__init__.py
24
24
  agentx/integrations/anthropic.py
25
25
  agentx/integrations/crewai.py
26
+ agentx/integrations/google_adk.py
27
+ agentx/integrations/google_genai.py
26
28
  agentx/integrations/langchain.py
27
29
  agentx/integrations/openai_agents.py
28
30
  agentx/resources/__init__.py
@@ -8,6 +8,8 @@ langchain-core>=0.1.0
8
8
  crewai>=0.80.0
9
9
  openai-agents>=0.0.3
10
10
  anthropic>=0.25.0
11
+ google-adk>=1.0.0
12
+ google-genai>=1.0.0
11
13
 
12
14
  [anthropic]
13
15
  anthropic>=0.25.0
@@ -15,6 +17,12 @@ anthropic>=0.25.0
15
17
  [crewai]
16
18
  crewai>=0.80.0
17
19
 
20
+ [google-adk]
21
+ google-adk>=1.0.0
22
+
23
+ [google-genai]
24
+ google-genai>=1.0.0
25
+
18
26
  [langchain]
19
27
  langchain-core>=0.1.0
20
28
 
@@ -32,11 +32,15 @@ setup(
32
32
  "crewai": ["crewai>=0.80.0"],
33
33
  "openai-agents": ["openai-agents>=0.0.3"],
34
34
  "anthropic": ["anthropic>=0.25.0"],
35
+ "google-adk": ["google-adk>=1.0.0"],
36
+ "google-genai": ["google-genai>=1.0.0"],
35
37
  "all": [
36
38
  "langchain-core>=0.1.0",
37
39
  "crewai>=0.80.0",
38
40
  "openai-agents>=0.0.3",
39
41
  "anthropic>=0.25.0",
42
+ "google-adk>=1.0.0",
43
+ "google-genai>=1.0.0",
40
44
  ],
41
45
  },
42
46
  author="Robin Wang and AgentX Team",
@@ -1,125 +0,0 @@
1
- """
2
- OpenAI Agents SDK integration for AgentX production tracing.
3
-
4
- Usage::
5
-
6
- from agentx.integrations.openai_agents import AgentXTracingProcessor
7
-
8
- processor = AgentXTracingProcessor(agentx.tracer)
9
-
10
- # Register once at startup — affects all agent runs in the process
11
- from agents import add_trace_processor
12
- add_trace_processor(processor)
13
-
14
- Requires: ``pip install agentx[openai-agents]``
15
- """
16
- from __future__ import annotations
17
-
18
- import time
19
- from typing import Any, Dict, Optional
20
-
21
- from agentx.tracing.tracer import Tracer, _safe_serialize
22
-
23
-
24
- class AgentXTracingProcessor:
25
- """
26
- Implements the ``TracingProcessor`` interface expected by the OpenAI Agents SDK
27
- (``agents.add_trace_processor``).
28
-
29
- Sends one AgentX trace per top-level agent run span.
30
- """
31
-
32
- def __init__(
33
- self,
34
- tracer: Tracer,
35
- metadata: Optional[Dict[str, Any]] = None,
36
- session_id: Optional[str] = None,
37
- ) -> None:
38
- self._tracer = tracer
39
- self._metadata = metadata
40
- self._session_id = session_id
41
- # span_id → state for in-flight agent spans
42
- self._spans: Dict[str, Dict[str, Any]] = {}
43
-
44
- # ------------------------------------------------------------------
45
- # TracingProcessor protocol
46
- # ------------------------------------------------------------------
47
-
48
- def on_trace_start(self, trace: Any) -> None:
49
- """Called when a new trace (top-level run) begins."""
50
- trace_id = getattr(trace, "trace_id", None) or str(id(trace))
51
- self._spans[trace_id] = {
52
- "start": time.time(),
53
- "name": getattr(trace, "name", "openai-agent"),
54
- "input": None,
55
- "tool_calls": [],
56
- "model": None,
57
- }
58
-
59
- def on_trace_end(self, trace: Any) -> None:
60
- """Called when the trace ends (agent run complete)."""
61
- trace_id = getattr(trace, "trace_id", None) or str(id(trace))
62
- state = self._spans.pop(trace_id, None)
63
- if state is None:
64
- return
65
- latency_ms = int((time.time() - state["start"]) * 1000)
66
- self._tracer._send(
67
- name=state["name"],
68
- input=state.get("input"),
69
- output=_safe_serialize(getattr(trace, "output", None)),
70
- latency_ms=latency_ms,
71
- framework="openai-agents",
72
- model=state.get("model"),
73
- tool_calls=state["tool_calls"] or None,
74
- metadata=self._metadata,
75
- session_id=self._session_id,
76
- )
77
-
78
- def on_span_start(self, span: Any) -> None:
79
- """Called for each nested span (LLM call, tool call, etc.)."""
80
- span_type = getattr(span, "span_data", None)
81
- if span_type is None:
82
- return
83
-
84
- # Capture model from LLM generation spans
85
- if hasattr(span_type, "model"):
86
- trace_id = getattr(span, "trace_id", None)
87
- if trace_id and trace_id in self._spans and not self._spans[trace_id].get("model"):
88
- self._spans[trace_id]["model"] = str(span_type.model)
89
-
90
- # Capture input on the root span
91
- if hasattr(span_type, "input") and span_type.input:
92
- trace_id = getattr(span, "trace_id", None)
93
- if trace_id and trace_id in self._spans and not self._spans[trace_id].get("input"):
94
- self._spans[trace_id]["input"] = _safe_serialize(span_type.input)
95
-
96
- def on_span_end(self, span: Any) -> None:
97
- """Called when a nested span ends; captures tool-call results."""
98
- span_data = getattr(span, "span_data", None)
99
- if span_data is None:
100
- return
101
-
102
- # Tool / function calls
103
- if hasattr(span_data, "tool_name") or hasattr(span_data, "name"):
104
- trace_id = getattr(span, "trace_id", None)
105
- if trace_id and trace_id in self._spans:
106
- tool_entry: Dict[str, Any] = {
107
- "name": getattr(span_data, "tool_name", None)
108
- or getattr(span_data, "name", "tool"),
109
- "input": _safe_serialize(getattr(span_data, "input", None)),
110
- "output": str(getattr(span_data, "output", ""))[:500],
111
- }
112
- if hasattr(span, "started_at") and hasattr(span, "ended_at"):
113
- try:
114
- tool_entry["latency_ms"] = int(
115
- (span.ended_at - span.started_at) * 1000
116
- )
117
- except Exception:
118
- pass
119
- self._spans[trace_id]["tool_calls"].append(tool_entry)
120
-
121
- def force_flush(self) -> None:
122
- self._tracer.flush()
123
-
124
- def shutdown(self) -> None:
125
- self._tracer.flush()
@@ -1 +0,0 @@
1
- VERSION = "0.4.13"
File without changes
File without changes
File without changes