agentx-python 0.6.21__tar.gz → 0.6.23__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.
- {agentx_python-0.6.21 → agentx_python-0.6.23}/PKG-INFO +3 -1
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/datasets.py +12 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/runner.py +5 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/autogen.py +6 -1
- agentx_python-0.6.23/agentx/integrations/databricks.py +405 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/langchain.py +173 -10
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/moveworks.py +44 -30
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/tracing/tracer.py +19 -10
- agentx_python-0.6.23/agentx/version.py +1 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/PKG-INFO +3 -1
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/SOURCES.txt +1 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/entry_points.txt +1 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/requires.txt +3 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/setup.py +4 -0
- agentx_python-0.6.21/agentx/version.py +0 -1
- {agentx_python-0.6.21 → agentx_python-0.6.23}/LICENSE +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/README.md +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/agentx.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/cli.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/client.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/redaction.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/exceptions.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/feedback.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/client.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/models.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/outcomes.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/py.typed +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/resources/agent.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx/util.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/setup.cfg +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/tests/test_integration.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/tests/test_integrations.py +0 -0
- {agentx_python-0.6.21 → agentx_python-0.6.23}/tests/test_span_tree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-python
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.23
|
|
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
|
|
@@ -36,6 +36,8 @@ Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
|
|
|
36
36
|
Provides-Extra: autogen
|
|
37
37
|
Requires-Dist: autogen-agentchat>=0.4.0; extra == "autogen"
|
|
38
38
|
Requires-Dist: autogen-core>=0.4.0; extra == "autogen"
|
|
39
|
+
Provides-Extra: databricks
|
|
40
|
+
Requires-Dist: mlflow>=3.6.0; extra == "databricks"
|
|
39
41
|
Provides-Extra: all
|
|
40
42
|
Requires-Dist: langchain-core>=0.1.0; extra == "all"
|
|
41
43
|
Requires-Dist: crewai>=0.80.0; extra == "all"
|
|
@@ -86,9 +86,19 @@ class DatasetBuilder:
|
|
|
86
86
|
judge_guideline: Optional[str] = None,
|
|
87
87
|
smoke_test_count: Optional[int] = None,
|
|
88
88
|
smoke_test_guidance: Optional[str] = None,
|
|
89
|
+
expected_tools: Optional[List[str]] = None,
|
|
90
|
+
trajectory_match_mode: str = "strict",
|
|
89
91
|
) -> "DatasetBuilder":
|
|
90
92
|
"""Add a case. `judge_guideline` is optional grading guidance specific to this question.
|
|
91
93
|
|
|
94
|
+
`expected_tools` declares the tool calls a correct run of this case should make. When a
|
|
95
|
+
result links its trace (return `{"output": ..., "trace_id": span.trace_id}` from the
|
|
96
|
+
agent function), the engine matches the trace's actual tool-call sequence against it and
|
|
97
|
+
reports a pass/fail "Trajectory match" scorer row on the result. `trajectory_match_mode`
|
|
98
|
+
follows agentevals semantics: "strict" (same calls, same order), "unordered" (same calls,
|
|
99
|
+
any order), "superset" (all expected present, extras allowed), or "subset" (no unexpected
|
|
100
|
+
calls, missing allowed).
|
|
101
|
+
|
|
92
102
|
`smoke_test_count`, when set (1-10), asks this question that many extra ways each
|
|
93
103
|
evaluation run, LLM-paraphrased server-side, to catch agents that are brittle to phrasing
|
|
94
104
|
rather than genuinely wrong. `smoke_test_guidance` optionally steers what kind of variants
|
|
@@ -111,6 +121,8 @@ class DatasetBuilder:
|
|
|
111
121
|
main["smokeTest"] = {"enabled": True, "count": smoke_test_count}
|
|
112
122
|
if smoke_test_guidance:
|
|
113
123
|
main["smokeTest"]["guidance"] = smoke_test_guidance
|
|
124
|
+
if expected_tools:
|
|
125
|
+
main["expectedTrajectory"] = {"tools": expected_tools, "mode": trajectory_match_mode}
|
|
114
126
|
self._payload["questions"].append(
|
|
115
127
|
{
|
|
116
128
|
"main_question": main,
|
|
@@ -271,6 +271,11 @@ class EvaluationRunContext:
|
|
|
271
271
|
# UI reads, computed once in the API.
|
|
272
272
|
# ------------------------------------------------------------------
|
|
273
273
|
|
|
274
|
+
@property
|
|
275
|
+
def run_id(self) -> str:
|
|
276
|
+
"""The server-side run id - handy for fetching the run's full results afterwards."""
|
|
277
|
+
return self._run.run_id
|
|
278
|
+
|
|
274
279
|
@property
|
|
275
280
|
def rated_count(self) -> int:
|
|
276
281
|
"""Number of submitted results that have received a rating so far."""
|
|
@@ -178,8 +178,13 @@ class AgentXAutoGenObserver:
|
|
|
178
178
|
if input_tokens is not None or out_tokens is not None:
|
|
179
179
|
total_input_tokens += input_tokens or 0
|
|
180
180
|
total_output_tokens += out_tokens or 0
|
|
181
|
+
# Prefer the speaking agent's name (message.source) over a generic counter -
|
|
182
|
+
# in a multi-agent team the step sequence then reads as the actual
|
|
183
|
+
# agent-turn trajectory ("planner" -> "coder" -> "reviewer"), which is what
|
|
184
|
+
# trajectory evaluation cares about.
|
|
185
|
+
source = getattr(message, "source", None)
|
|
181
186
|
execution_steps.append({
|
|
182
|
-
"name": f"LLM Call {len(execution_steps) + 1}",
|
|
187
|
+
"name": str(source) if source else f"LLM Call {len(execution_steps) + 1}",
|
|
183
188
|
"duration_ms": (end_t - start_t) * 1000,
|
|
184
189
|
"start_time": start_t,
|
|
185
190
|
"end_time": end_t,
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Databricks / MLflow integration for AgentX.
|
|
3
|
+
|
|
4
|
+
Two complementary paths for agents built on Databricks (Agent Bricks or the Mosaic AI Agent
|
|
5
|
+
Framework - both are auto-instrumented by MLflow 3 Tracing):
|
|
6
|
+
|
|
7
|
+
1. **Push (live)** - ``enable_mlflow_export()``: point MLflow Tracing's native OTLP exporter at
|
|
8
|
+
the AgentX engine's OTel endpoint. Works anywhere MLflow traces run (notebooks, jobs, Model
|
|
9
|
+
Serving endpoints via environment variables). Dual export keeps Databricks' own MLflow UI and
|
|
10
|
+
inference tables working alongside AgentX::
|
|
11
|
+
|
|
12
|
+
from agentx.integrations.databricks import enable_mlflow_export
|
|
13
|
+
|
|
14
|
+
enable_mlflow_export(
|
|
15
|
+
api_key=os.environ["AGENTX_API_KEY"],
|
|
16
|
+
base_url="http://localhost:4700/api/v1", # your AgentX engine
|
|
17
|
+
service_name="my-databricks-agent",
|
|
18
|
+
)
|
|
19
|
+
# ... then trace as usual (@mlflow.trace, autolog, Agent Framework, ...)
|
|
20
|
+
|
|
21
|
+
On a Model Serving endpoint, set the equivalent environment variables instead (this helper
|
|
22
|
+
prints them with ``dry_run=True``).
|
|
23
|
+
|
|
24
|
+
2. **Pull (batch)** - ``agentx-databricks sync``: import finished MLflow traces from a Databricks
|
|
25
|
+
(or any MLflow 3) tracking server into AgentX - full span trees, tool calls, sessions -
|
|
26
|
+
deduplicated on deterministic span ids so re-running a window never duplicates, with
|
|
27
|
+
``--monitor`` / ``--judge-sessions`` mirroring ``agentx-moveworks``::
|
|
28
|
+
|
|
29
|
+
export AGENTX_API_KEY=... # AgentX project key
|
|
30
|
+
export DATABRICKS_HOST=... DATABRICKS_TOKEN=... # or MLFLOW_TRACKING_URI
|
|
31
|
+
agentx-databricks sync --experiment-id 123456 --since 24h
|
|
32
|
+
|
|
33
|
+
Requires (pull path): ``pip install "agentx-python[databricks]"`` (mlflow>=3).
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
from datetime import datetime, timedelta, timezone
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
43
|
+
|
|
44
|
+
from agentx.tracing.ingest_client import IngestClient
|
|
45
|
+
from agentx.version import VERSION
|
|
46
|
+
from agentx.integrations.moveworks import (
|
|
47
|
+
_parse_since,
|
|
48
|
+
_parse_time,
|
|
49
|
+
_read_cursor,
|
|
50
|
+
_write_cursor,
|
|
51
|
+
judge_sessions_via_engine,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_DEFAULT_CURSOR_FILE = Path.home() / ".agentx" / "databricks_sync_cursor.json"
|
|
55
|
+
|
|
56
|
+
# MLflow's session grouping metadata key (mlflow.update_current_trace(metadata={...})) - traces
|
|
57
|
+
# sharing it become one AgentX session, judged as a conversation by session-scoped evaluators.
|
|
58
|
+
_SESSION_METADATA_KEYS = ("mlflow.trace.session", "mlflow.trace.session_id", "session_id")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ----------------------------------------------------------------------------------------------
|
|
62
|
+
# Path 1: push - MLflow OTLP export pointed at the AgentX engine
|
|
63
|
+
# ----------------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def enable_mlflow_export(
|
|
66
|
+
*,
|
|
67
|
+
api_key: str,
|
|
68
|
+
base_url: str,
|
|
69
|
+
service_name: str = "databricks-agent",
|
|
70
|
+
dual: bool = True,
|
|
71
|
+
genai_semconv: bool = False,
|
|
72
|
+
dry_run: bool = False,
|
|
73
|
+
) -> Dict[str, str]:
|
|
74
|
+
"""
|
|
75
|
+
Configure MLflow Tracing's built-in OTLP exporter to send every trace to the AgentX engine.
|
|
76
|
+
|
|
77
|
+
Must run BEFORE the first trace starts (MLflow reads these once, at tracer setup). ``dual``
|
|
78
|
+
keeps MLflow's own tracking export too (Databricks MLflow UI / inference tables keep
|
|
79
|
+
working); ``genai_semconv`` switches the wire format to OTel GenAI semantic conventions -
|
|
80
|
+
AgentX ingests both, and the default (MLflow-native attributes) is the higher-fidelity
|
|
81
|
+
mapping for plain ``@mlflow.trace`` functions. Returns the environment variables set - with
|
|
82
|
+
``dry_run=True`` nothing is set, so the dict can be copied onto a Databricks Model Serving
|
|
83
|
+
endpoint's environment variables instead.
|
|
84
|
+
"""
|
|
85
|
+
env = {
|
|
86
|
+
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": f"{base_url.rstrip('/')}/otel/v1/traces",
|
|
87
|
+
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": f"x-api-key={api_key}",
|
|
88
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
|
|
89
|
+
"OTEL_SERVICE_NAME": service_name,
|
|
90
|
+
}
|
|
91
|
+
if dual:
|
|
92
|
+
env["MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT"] = "true"
|
|
93
|
+
if genai_semconv:
|
|
94
|
+
env["MLFLOW_ENABLE_OTEL_GENAI_SEMCONV"] = "true"
|
|
95
|
+
if not dry_run:
|
|
96
|
+
os.environ.update(env)
|
|
97
|
+
return env
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ----------------------------------------------------------------------------------------------
|
|
101
|
+
# Path 2: pull - MLflow trace search -> AgentX span trees
|
|
102
|
+
# ----------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
class DatabricksSyncReport:
|
|
105
|
+
def __init__(self) -> None:
|
|
106
|
+
self.traces = 0
|
|
107
|
+
self.spans = 0
|
|
108
|
+
self.tool_calls = 0
|
|
109
|
+
self.ingested = 0
|
|
110
|
+
self.failed = 0
|
|
111
|
+
self.skipped_in_progress = 0
|
|
112
|
+
self.session_ids: List[str] = []
|
|
113
|
+
self.sessions_judged = 0
|
|
114
|
+
self.sessions_judge_skipped = 0
|
|
115
|
+
self.sessions_judge_failed = 0
|
|
116
|
+
|
|
117
|
+
def __repr__(self) -> str: # also what the CLI prints
|
|
118
|
+
parts = [
|
|
119
|
+
f"traces={self.traces}",
|
|
120
|
+
f"spans={self.spans}",
|
|
121
|
+
f"tool_calls={self.tool_calls}",
|
|
122
|
+
f"ingested={self.ingested}",
|
|
123
|
+
f"failed={self.failed}",
|
|
124
|
+
f"sessions={len(self.session_ids)}",
|
|
125
|
+
]
|
|
126
|
+
if self.skipped_in_progress:
|
|
127
|
+
parts.append(f"skipped_in_progress={self.skipped_in_progress}")
|
|
128
|
+
if self.sessions_judged or self.sessions_judge_skipped or self.sessions_judge_failed:
|
|
129
|
+
parts.append(
|
|
130
|
+
f"judged={self.sessions_judged} judge_skipped={self.sessions_judge_skipped} "
|
|
131
|
+
f"judge_failed={self.sessions_judge_failed}"
|
|
132
|
+
)
|
|
133
|
+
return f"DatabricksSyncReport({' '.join(parts)})"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _span_time_ns(span: Any, attr: str) -> Optional[int]:
|
|
137
|
+
value = getattr(span, attr, None)
|
|
138
|
+
return int(value) if isinstance(value, (int, float)) and value > 0 else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _span_error(span: Any) -> Optional[str]:
|
|
142
|
+
status = getattr(span, "status", None)
|
|
143
|
+
code = str(getattr(status, "status_code", "") or "")
|
|
144
|
+
if "ERROR" in code.upper():
|
|
145
|
+
return str(getattr(status, "description", None) or "error")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _serialize(value: Any) -> Any:
|
|
150
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
151
|
+
return value
|
|
152
|
+
try:
|
|
153
|
+
return json.loads(json.dumps(value, default=str))
|
|
154
|
+
except (TypeError, ValueError):
|
|
155
|
+
return str(value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DatabricksTraceImporter:
|
|
159
|
+
"""
|
|
160
|
+
Replays finished MLflow traces (Databricks-hosted or any MLflow 3 tracking server) into
|
|
161
|
+
AgentX as full span trees. ``span_id`` is deterministic (``dbx:<trace_id>[:<span_id>]``), so
|
|
162
|
+
re-syncing a window is idempotent - the engine dedupes on replay and skips re-judging.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
mlflow_client: Any,
|
|
168
|
+
*,
|
|
169
|
+
agentx_api_key: str,
|
|
170
|
+
agentx_base_url: Optional[str] = None,
|
|
171
|
+
agent_name: Optional[str] = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
self._client = mlflow_client
|
|
174
|
+
self._agent_name = agent_name
|
|
175
|
+
self._ingest = IngestClient(agentx_api_key, sdk_version=VERSION, base_url=agentx_base_url)
|
|
176
|
+
|
|
177
|
+
# -- trace -> wires ------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _trace_wires(self, trace: Any) -> "tuple[List[Dict[str, Any]], Optional[str], int]":
|
|
180
|
+
"""One MLflow Trace -> [root wire, *child wires], its session id, and its tool-call count."""
|
|
181
|
+
info = trace.info
|
|
182
|
+
trace_id = str(getattr(info, "trace_id", None) or getattr(info, "request_id", ""))
|
|
183
|
+
spans = list(getattr(trace.data, "spans", None) or [])
|
|
184
|
+
if not trace_id or not spans:
|
|
185
|
+
return [], None, 0
|
|
186
|
+
|
|
187
|
+
metadata_bag: Dict[str, Any] = {}
|
|
188
|
+
for source in (getattr(info, "trace_metadata", None), getattr(info, "tags", None)):
|
|
189
|
+
if isinstance(source, dict):
|
|
190
|
+
metadata_bag.update(source)
|
|
191
|
+
session_raw = next((metadata_bag[k] for k in _SESSION_METADATA_KEYS if metadata_bag.get(k)), None)
|
|
192
|
+
session_id = f"dbx_{session_raw}" if session_raw else None
|
|
193
|
+
|
|
194
|
+
root = next((s for s in spans if not getattr(s, "parent_id", None)), spans[0])
|
|
195
|
+
root_span_id = str(getattr(root, "span_id", "") or "root")
|
|
196
|
+
wire_span_id = {root_span_id: f"dbx:{trace_id}"}
|
|
197
|
+
for span in spans:
|
|
198
|
+
sid = str(getattr(span, "span_id", "") or "")
|
|
199
|
+
if sid and sid not in wire_span_id:
|
|
200
|
+
wire_span_id[sid] = f"dbx:{trace_id}:{sid}"
|
|
201
|
+
|
|
202
|
+
tool_calls: List[Dict[str, Any]] = []
|
|
203
|
+
wires: List[Dict[str, Any]] = []
|
|
204
|
+
for span in spans:
|
|
205
|
+
sid = str(getattr(span, "span_id", "") or "")
|
|
206
|
+
is_root = span is root
|
|
207
|
+
start_ns = _span_time_ns(span, "start_time_ns")
|
|
208
|
+
end_ns = _span_time_ns(span, "end_time_ns")
|
|
209
|
+
error = _span_error(span)
|
|
210
|
+
span_type = str(getattr(span, "span_type", "") or "").upper()
|
|
211
|
+
wire: Dict[str, Any] = {
|
|
212
|
+
"name": (self._agent_name if is_root and self._agent_name else str(getattr(span, "name", "span"))),
|
|
213
|
+
"framework": "databricks",
|
|
214
|
+
"span_id": wire_span_id.get(sid, f"dbx:{trace_id}:{sid or 'span'}"),
|
|
215
|
+
}
|
|
216
|
+
if not is_root:
|
|
217
|
+
parent_sid = str(getattr(span, "parent_id", "") or "")
|
|
218
|
+
wire["parent_span_id"] = wire_span_id.get(parent_sid, f"dbx:{trace_id}")
|
|
219
|
+
if session_id:
|
|
220
|
+
wire["session_id"] = session_id
|
|
221
|
+
if start_ns:
|
|
222
|
+
wire["started_at_unix_nano"] = str(start_ns)
|
|
223
|
+
if start_ns and end_ns and end_ns > start_ns:
|
|
224
|
+
wire["latency_ms"] = int((end_ns - start_ns) / 1_000_000)
|
|
225
|
+
inputs = _serialize(getattr(span, "inputs", None))
|
|
226
|
+
outputs = _serialize(getattr(span, "outputs", None))
|
|
227
|
+
if inputs is not None:
|
|
228
|
+
wire["input"] = inputs
|
|
229
|
+
if outputs is not None:
|
|
230
|
+
wire["output"] = outputs
|
|
231
|
+
if error:
|
|
232
|
+
wire["error"] = error
|
|
233
|
+
if is_root:
|
|
234
|
+
wire["metadata"] = {
|
|
235
|
+
"source": "databricks",
|
|
236
|
+
"mlflowTraceId": trace_id,
|
|
237
|
+
**({"experimentId": str(getattr(info, "experiment_id", ""))} if getattr(info, "experiment_id", None) else {}),
|
|
238
|
+
}
|
|
239
|
+
if span_type == "TOOL":
|
|
240
|
+
tool_calls.append(
|
|
241
|
+
{
|
|
242
|
+
"name": str(getattr(span, "name", "tool")),
|
|
243
|
+
"input": inputs,
|
|
244
|
+
"output": outputs,
|
|
245
|
+
"latency_ms": wire.get("latency_ms"),
|
|
246
|
+
"success": not error,
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
wires.append(wire)
|
|
250
|
+
|
|
251
|
+
# Root carries the flat tool_calls mirror - what the engine's Tool-failure check and
|
|
252
|
+
# trajectory matching read (same posture as tracer._merge_child_run).
|
|
253
|
+
if tool_calls:
|
|
254
|
+
wires[spans.index(root)]["tool_calls"] = tool_calls
|
|
255
|
+
# Root first so the engine resolves the agent before children arrive.
|
|
256
|
+
wires.sort(key=lambda w: 0 if "parent_span_id" not in w else 1)
|
|
257
|
+
return wires, session_id, len(tool_calls)
|
|
258
|
+
|
|
259
|
+
def _search(self, experiment_ids: List[str], since: datetime, until: datetime) -> Iterable[Any]:
|
|
260
|
+
"""Newest-first paginated search, stopping once a page is entirely older than ``since``."""
|
|
261
|
+
page_token: Optional[str] = None
|
|
262
|
+
since_ms = int(since.timestamp() * 1000)
|
|
263
|
+
until_ms = int(until.timestamp() * 1000)
|
|
264
|
+
while True:
|
|
265
|
+
page = self._client.search_traces(
|
|
266
|
+
experiment_ids=experiment_ids,
|
|
267
|
+
max_results=100,
|
|
268
|
+
page_token=page_token,
|
|
269
|
+
order_by=["timestamp_ms DESC"],
|
|
270
|
+
)
|
|
271
|
+
oldest_seen = None
|
|
272
|
+
for trace in page:
|
|
273
|
+
ts = getattr(trace.info, "request_time", None) or getattr(trace.info, "timestamp_ms", None)
|
|
274
|
+
ts_ms = int(ts.timestamp() * 1000) if isinstance(ts, datetime) else (int(ts) if ts else None)
|
|
275
|
+
oldest_seen = ts_ms if ts_ms is not None else oldest_seen
|
|
276
|
+
if ts_ms is not None and (ts_ms < since_ms or ts_ms >= until_ms):
|
|
277
|
+
if ts_ms < since_ms:
|
|
278
|
+
continue
|
|
279
|
+
continue
|
|
280
|
+
yield trace
|
|
281
|
+
page_token = getattr(page, "token", None)
|
|
282
|
+
if not page_token or (oldest_seen is not None and oldest_seen < since_ms):
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# -- public --------------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def sync(
|
|
288
|
+
self,
|
|
289
|
+
experiment_ids: List[str],
|
|
290
|
+
since: datetime,
|
|
291
|
+
until: Optional[datetime] = None,
|
|
292
|
+
*,
|
|
293
|
+
monitor: bool = False,
|
|
294
|
+
judge_sessions: bool = False,
|
|
295
|
+
dry_run: bool = False,
|
|
296
|
+
on_payload: Optional[Any] = None,
|
|
297
|
+
) -> DatabricksSyncReport:
|
|
298
|
+
"""
|
|
299
|
+
Import every finished MLflow trace in ``[since, until)`` from the given experiments.
|
|
300
|
+
Safe to re-run over the same window (span_id dedupe). ``monitor=True`` opts every
|
|
301
|
+
imported root trace into the engine's ingest-time checks; ``judge_sessions=True`` judges
|
|
302
|
+
each imported session afterwards (``ifStale`` - never duplicates the engine's own sweep).
|
|
303
|
+
"""
|
|
304
|
+
report = DatabricksSyncReport()
|
|
305
|
+
until = until or datetime.now(timezone.utc)
|
|
306
|
+
for trace in self._search(experiment_ids, since, until):
|
|
307
|
+
state = str(getattr(trace.info, "state", "") or "")
|
|
308
|
+
if state and "IN_PROGRESS" in state.upper():
|
|
309
|
+
report.skipped_in_progress += 1
|
|
310
|
+
continue
|
|
311
|
+
wires, session_id, tool_count = self._trace_wires(trace)
|
|
312
|
+
if not wires:
|
|
313
|
+
continue
|
|
314
|
+
report.traces += 1
|
|
315
|
+
report.spans += len(wires)
|
|
316
|
+
report.tool_calls += tool_count
|
|
317
|
+
if session_id and session_id not in report.session_ids:
|
|
318
|
+
report.session_ids.append(session_id)
|
|
319
|
+
for wire in wires:
|
|
320
|
+
if monitor and "parent_span_id" not in wire:
|
|
321
|
+
wire["monitor"] = True
|
|
322
|
+
if on_payload is not None:
|
|
323
|
+
on_payload(wire)
|
|
324
|
+
if dry_run:
|
|
325
|
+
continue
|
|
326
|
+
if self._ingest.send_trace_sync(wire) is not None:
|
|
327
|
+
report.ingested += 1
|
|
328
|
+
else:
|
|
329
|
+
report.failed += 1
|
|
330
|
+
if judge_sessions and not dry_run:
|
|
331
|
+
judged, skipped, failed = judge_sessions_via_engine(self._ingest, report.session_ids)
|
|
332
|
+
report.sessions_judged += judged
|
|
333
|
+
report.sessions_judge_skipped += skipped
|
|
334
|
+
report.sessions_judge_failed += failed
|
|
335
|
+
return report
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ----------------------------------------------------------------------------------------------
|
|
339
|
+
# CLI: agentx-databricks sync
|
|
340
|
+
# ----------------------------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def cli_main(argv: Optional[List[str]] = None) -> None:
|
|
343
|
+
parser = argparse.ArgumentParser(
|
|
344
|
+
prog="agentx-databricks",
|
|
345
|
+
description="Sync MLflow traces (Databricks agents) into AgentX as span trees/sessions.",
|
|
346
|
+
)
|
|
347
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
348
|
+
sync = sub.add_parser("sync", help="Import a time window of MLflow traces")
|
|
349
|
+
sync.add_argument("--experiment-id", action="append", required=True, help="MLflow experiment id (repeatable)")
|
|
350
|
+
sync.add_argument("--since", help="ISO timestamp or relative (7d, 24h). Default: cursor file, else 24h")
|
|
351
|
+
sync.add_argument("--until", help="ISO timestamp (default: now)")
|
|
352
|
+
sync.add_argument("--cursor-file", default=str(_DEFAULT_CURSOR_FILE), help="Incremental cursor path")
|
|
353
|
+
sync.add_argument("--no-cursor", action="store_true", help="Ignore and don't update the cursor file")
|
|
354
|
+
sync.add_argument("--agent-name", help="Attribute every trace to this agent (default: the root span's name)")
|
|
355
|
+
sync.add_argument(
|
|
356
|
+
"--tracking-uri",
|
|
357
|
+
default=os.getenv("MLFLOW_TRACKING_URI", "databricks"),
|
|
358
|
+
help='MLflow tracking URI (default: MLFLOW_TRACKING_URI, else "databricks" - uses DATABRICKS_HOST/TOKEN)',
|
|
359
|
+
)
|
|
360
|
+
sync.add_argument("--monitor", action="store_true", help="Run ingest-time checks on each imported trace")
|
|
361
|
+
sync.add_argument(
|
|
362
|
+
"--judge-sessions",
|
|
363
|
+
action="store_true",
|
|
364
|
+
help="After the sync, judge each imported session with every enabled session-scoped evaluator "
|
|
365
|
+
"(skips sessions that already have an up-to-date verdict)",
|
|
366
|
+
)
|
|
367
|
+
sync.add_argument("--dry-run", action="store_true", help="Map and print payloads without ingesting")
|
|
368
|
+
|
|
369
|
+
args = parser.parse_args(argv)
|
|
370
|
+
|
|
371
|
+
agentx_key = os.getenv("AGENTX_API_KEY", "")
|
|
372
|
+
if not agentx_key and not args.dry_run:
|
|
373
|
+
raise SystemExit("agentx-databricks: set AGENTX_API_KEY (your AgentX project API key)")
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
from mlflow.client import MlflowClient
|
|
377
|
+
except ImportError as exc: # pragma: no cover
|
|
378
|
+
raise SystemExit('agentx-databricks: pip install "agentx-python[databricks]" (needs mlflow>=3)') from exc
|
|
379
|
+
|
|
380
|
+
cursor_path = Path(args.cursor_file)
|
|
381
|
+
since = (
|
|
382
|
+
_parse_since(args.since)
|
|
383
|
+
if args.since
|
|
384
|
+
else ((None if args.no_cursor else _read_cursor(cursor_path)) or (datetime.now(timezone.utc) - timedelta(hours=24)))
|
|
385
|
+
)
|
|
386
|
+
until = _parse_time(args.until) if args.until else datetime.now(timezone.utc)
|
|
387
|
+
|
|
388
|
+
importer = DatabricksTraceImporter(
|
|
389
|
+
MlflowClient(tracking_uri=args.tracking_uri),
|
|
390
|
+
agentx_api_key=agentx_key,
|
|
391
|
+
agentx_base_url=os.getenv("AGENTX_API_BASE_URL") or None,
|
|
392
|
+
agent_name=args.agent_name,
|
|
393
|
+
)
|
|
394
|
+
report = importer.sync(
|
|
395
|
+
args.experiment_id,
|
|
396
|
+
since,
|
|
397
|
+
until,
|
|
398
|
+
monitor=args.monitor,
|
|
399
|
+
judge_sessions=args.judge_sessions,
|
|
400
|
+
dry_run=args.dry_run,
|
|
401
|
+
on_payload=(lambda wire: print(json.dumps(wire, default=str))) if args.dry_run else None,
|
|
402
|
+
)
|
|
403
|
+
print(report)
|
|
404
|
+
if not args.no_cursor and not args.dry_run and until is not None:
|
|
405
|
+
_write_cursor(cursor_path, until)
|
|
@@ -231,6 +231,39 @@ def _extract_llm_output(response: "LLMResult") -> Optional[str]:
|
|
|
231
231
|
return None
|
|
232
232
|
|
|
233
233
|
|
|
234
|
+
# Nested chain runs that are plumbing, not agent structure - LCEL composition wrappers,
|
|
235
|
+
# prompt/parse steps, and LangGraph's internal channel machinery. Skipped when deciding which
|
|
236
|
+
# chain runs become "node" child spans; their LLM/tool descendants re-parent to the nearest
|
|
237
|
+
# non-noise ancestor (see _emit_span_tree's resolve_parent walk).
|
|
238
|
+
_NOISE_CHAIN_NAMES = frozenset({
|
|
239
|
+
"RunnableSequence",
|
|
240
|
+
"RunnableParallel",
|
|
241
|
+
"RunnableLambda",
|
|
242
|
+
"RunnableAssign",
|
|
243
|
+
"RunnablePick",
|
|
244
|
+
"RunnableBinding",
|
|
245
|
+
"RunnableWithFallbacks",
|
|
246
|
+
"RunnableWithMessageHistory",
|
|
247
|
+
"RunnableBranch",
|
|
248
|
+
"ChatPromptTemplate",
|
|
249
|
+
"PromptTemplate",
|
|
250
|
+
"StrOutputParser",
|
|
251
|
+
"JsonOutputParser",
|
|
252
|
+
"ToolsAgentOutputParser",
|
|
253
|
+
"OpenAIFunctionsAgentOutputParser",
|
|
254
|
+
"LangGraph",
|
|
255
|
+
"CompiledStateGraph",
|
|
256
|
+
"Prompt",
|
|
257
|
+
"_Exception",
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
_NOISE_CHAIN_PREFIXES = ("ChannelWrite", "ChannelRead", "Branch<", "RunnableParallel<", "_")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_noise_chain(name: str) -> bool:
|
|
264
|
+
return name in _NOISE_CHAIN_NAMES or name.startswith(_NOISE_CHAIN_PREFIXES)
|
|
265
|
+
|
|
266
|
+
|
|
234
267
|
class AgentXCallbackHandler(BaseCallbackHandler):
|
|
235
268
|
"""
|
|
236
269
|
LangChain callback handler that captures the top-level chain run and all
|
|
@@ -238,6 +271,11 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
238
271
|
|
|
239
272
|
Compatible with AgentExecutor, LCEL chains, and LangGraph agents
|
|
240
273
|
(``create_agent``, ``create_react_agent``).
|
|
274
|
+
|
|
275
|
+
The trace is a real span tree: LangGraph graph nodes (and named non-plumbing sub-chains)
|
|
276
|
+
become child spans, and each LLM call / tool call / retrieval becomes a span parented under
|
|
277
|
+
the node that ran it - so the engine's Execution Timeline shows the actual graph trajectory
|
|
278
|
+
(which nodes ran, in what order, and what each did), not a flat step list.
|
|
241
279
|
"""
|
|
242
280
|
|
|
243
281
|
def __init__(
|
|
@@ -331,7 +369,122 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
331
369
|
"retrieval_steps": pending,
|
|
332
370
|
"input_tokens": 0,
|
|
333
371
|
"output_tokens": 0,
|
|
372
|
+
# Graph structure captured under this top-level run: named nested chain runs
|
|
373
|
+
# (LangGraph nodes, sub-agents) keyed by run_id, and the FULL nested-chain
|
|
374
|
+
# parent map (noise chains included) so _emit_span_tree can walk through
|
|
375
|
+
# skipped plumbing runs to the nearest emitted ancestor.
|
|
376
|
+
"node_runs": {},
|
|
377
|
+
"chain_parents": {},
|
|
334
378
|
}
|
|
379
|
+
else:
|
|
380
|
+
top = self._find_top_ancestor(parent_run_id)
|
|
381
|
+
if top is None:
|
|
382
|
+
return
|
|
383
|
+
state = self._runs.get(top)
|
|
384
|
+
if state is None:
|
|
385
|
+
return
|
|
386
|
+
name = kwargs.get("name") or (serialized or {}).get("name")
|
|
387
|
+
# LangGraph stamps its node runs with metadata.langgraph_node - trust that over the
|
|
388
|
+
# noise heuristic when present (a user's node could legitimately be named
|
|
389
|
+
# "RunnableLambda"-style by a wrapper).
|
|
390
|
+
run_meta = kwargs.get("metadata") or {}
|
|
391
|
+
is_node = bool(name) and (run_meta.get("langgraph_node") == name or not _is_noise_chain(str(name)))
|
|
392
|
+
with self._state_lock:
|
|
393
|
+
state["chain_parents"][run_id] = parent_run_id
|
|
394
|
+
if is_node:
|
|
395
|
+
state["node_runs"][run_id] = {
|
|
396
|
+
"name": str(name),
|
|
397
|
+
"start": time.time(),
|
|
398
|
+
"end": None,
|
|
399
|
+
"input": _extract_input(inputs),
|
|
400
|
+
"output": None,
|
|
401
|
+
"error": None,
|
|
402
|
+
"parent": parent_run_id,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
def _finalize_node(self, run_id: UUID, *, output: Any = None, error: Optional[str] = None) -> None:
|
|
406
|
+
"""Close a nested chain run's node record (if it became one) with end time + result."""
|
|
407
|
+
top = self._find_top_ancestor(run_id)
|
|
408
|
+
if top is None:
|
|
409
|
+
return
|
|
410
|
+
state = self._runs.get(top)
|
|
411
|
+
if state is None:
|
|
412
|
+
return
|
|
413
|
+
with self._state_lock:
|
|
414
|
+
node = state.get("node_runs", {}).get(run_id)
|
|
415
|
+
if node is None:
|
|
416
|
+
return
|
|
417
|
+
node["end"] = time.time()
|
|
418
|
+
if output is not None:
|
|
419
|
+
node["output"] = output
|
|
420
|
+
if error is not None:
|
|
421
|
+
node["error"] = error
|
|
422
|
+
|
|
423
|
+
def _emit_span_tree(self, span, state: Dict[str, Any], tool_calls: List[Dict[str, Any]]) -> None:
|
|
424
|
+
"""
|
|
425
|
+
Emit the recorded run as a hierarchical span tree under ``span``: node runs first (in
|
|
426
|
+
start order, parented to their nearest emitted ancestor), then every LLM call, tool
|
|
427
|
+
call, and retrieval parented under the node that ran it. Records whose parent chain is
|
|
428
|
+
entirely noise (or missing, e.g. pre-run retrievals) land directly under the root span.
|
|
429
|
+
"""
|
|
430
|
+
emitted: Dict[Any, Any] = {}
|
|
431
|
+
chain_parents: Dict[Any, Any] = state.get("chain_parents", {})
|
|
432
|
+
node_runs: Dict[Any, Dict[str, Any]] = state.get("node_runs", {})
|
|
433
|
+
|
|
434
|
+
def resolve_parent(parent_id: Any):
|
|
435
|
+
seen: set = set()
|
|
436
|
+
current = parent_id
|
|
437
|
+
while current is not None and current not in seen:
|
|
438
|
+
seen.add(current)
|
|
439
|
+
if current in emitted:
|
|
440
|
+
return emitted[current]
|
|
441
|
+
current = chain_parents.get(current)
|
|
442
|
+
return span
|
|
443
|
+
|
|
444
|
+
for run_id, node in sorted(node_runs.items(), key=lambda kv: kv[1]["start"]):
|
|
445
|
+
parent_span = resolve_parent(node.get("parent"))
|
|
446
|
+
emitted[run_id] = parent_span.child_span(
|
|
447
|
+
node["name"],
|
|
448
|
+
start_time=node["start"],
|
|
449
|
+
end_time=node.get("end") or node["start"],
|
|
450
|
+
input=node.get("input"),
|
|
451
|
+
output=node.get("output"),
|
|
452
|
+
error=node.get("error"),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
llm_count = 0
|
|
456
|
+
for step in state.get("execution_steps", []):
|
|
457
|
+
llm_count += 1
|
|
458
|
+
resolve_parent(step.get("parent_run_id")).child_span(
|
|
459
|
+
step.get("name") or f"LLM Call {llm_count}",
|
|
460
|
+
start_time=step.get("start_time"),
|
|
461
|
+
end_time=step.get("end_time"),
|
|
462
|
+
duration_ms=step.get("duration_ms"),
|
|
463
|
+
input=step.get("input"),
|
|
464
|
+
output=step.get("output"),
|
|
465
|
+
model=step.get("model"),
|
|
466
|
+
input_tokens=step.get("inputTokenSize"),
|
|
467
|
+
output_tokens=step.get("outputTokenSize"),
|
|
468
|
+
)
|
|
469
|
+
for tc in tool_calls:
|
|
470
|
+
resolve_parent(tc.get("parent_run_id")).child_span(
|
|
471
|
+
tc.get("name") or "Tool call",
|
|
472
|
+
start_time=tc.get("start_time"),
|
|
473
|
+
end_time=tc.get("end_time"),
|
|
474
|
+
duration_ms=tc.get("latency_ms"),
|
|
475
|
+
input=tc.get("input"),
|
|
476
|
+
output=tc.get("output"),
|
|
477
|
+
error=None if tc.get("success", True) else str(tc.get("output") or "Tool call failed"),
|
|
478
|
+
)
|
|
479
|
+
for step in state.get("retrieval_steps", []):
|
|
480
|
+
resolve_parent(step.get("parent_run_id")).child_span(
|
|
481
|
+
step.get("name") or "Retrieval",
|
|
482
|
+
start_time=step.get("start_time"),
|
|
483
|
+
end_time=step.get("end_time"),
|
|
484
|
+
duration_ms=step.get("duration_ms"),
|
|
485
|
+
input=step.get("query"),
|
|
486
|
+
output=step.get("output"),
|
|
487
|
+
)
|
|
335
488
|
|
|
336
489
|
def on_chain_end(
|
|
337
490
|
self,
|
|
@@ -347,9 +500,13 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
347
500
|
# entries behind here would leak forever in a long-lived singleton
|
|
348
501
|
# handler, since nothing else ever cleans up a non-top-level run_id.
|
|
349
502
|
is_top = self._top_level.pop(run_id, None)
|
|
350
|
-
self._parents.pop(run_id, None)
|
|
351
503
|
if not is_top:
|
|
504
|
+
# Close the node record before dropping this run's _parents entry - the top-ancestor
|
|
505
|
+
# walk inside _finalize_node still needs it.
|
|
506
|
+
self._finalize_node(run_id, output=_extract_output(outputs))
|
|
507
|
+
self._parents.pop(run_id, None)
|
|
352
508
|
return
|
|
509
|
+
self._parents.pop(run_id, None)
|
|
353
510
|
state = self._runs.pop(run_id, None)
|
|
354
511
|
if state is None:
|
|
355
512
|
return
|
|
@@ -369,16 +526,16 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
369
526
|
# spanning several chain/agent/retriever calls) - fold this
|
|
370
527
|
# top-level run into it instead of sending an independent trace.
|
|
371
528
|
active_span._merge_child_run(
|
|
372
|
-
execution_steps=state["execution_steps"],
|
|
373
529
|
tool_calls=tool_calls,
|
|
374
|
-
retrieval_steps=state["retrieval_steps"],
|
|
375
530
|
input=state["input"],
|
|
376
531
|
output=output,
|
|
377
532
|
model=state.get("model"),
|
|
378
533
|
framework="langchain",
|
|
379
534
|
input_tokens=state["input_tokens"] or None,
|
|
380
535
|
output_tokens=state["output_tokens"] or None,
|
|
536
|
+
emit_steps=False,
|
|
381
537
|
)
|
|
538
|
+
self._emit_span_tree(active_span, state, tool_calls)
|
|
382
539
|
else:
|
|
383
540
|
# Standalone usage (no enclosing `with tracer.trace()`): open a real root span for
|
|
384
541
|
# this chain invocation and let _merge_child_run explode its accumulated
|
|
@@ -388,16 +545,16 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
388
545
|
# see llamaindex.py's _send_trace for the identical fix and full rationale.
|
|
389
546
|
span._start = state["start"]
|
|
390
547
|
span._merge_child_run(
|
|
391
|
-
execution_steps=state["execution_steps"],
|
|
392
548
|
tool_calls=tool_calls,
|
|
393
|
-
retrieval_steps=state["retrieval_steps"],
|
|
394
549
|
input=state["input"],
|
|
395
550
|
output=output,
|
|
396
551
|
model=state.get("model"),
|
|
397
552
|
framework="langchain",
|
|
398
553
|
input_tokens=state["input_tokens"] or None,
|
|
399
554
|
output_tokens=state["output_tokens"] or None,
|
|
555
|
+
emit_steps=False,
|
|
400
556
|
)
|
|
557
|
+
self._emit_span_tree(span, state, tool_calls)
|
|
401
558
|
|
|
402
559
|
def on_chain_error(
|
|
403
560
|
self,
|
|
@@ -409,9 +566,11 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
409
566
|
) -> None:
|
|
410
567
|
# See on_chain_end's comment - pop for every chain run, not just top-level.
|
|
411
568
|
is_top = self._top_level.pop(run_id, None)
|
|
412
|
-
self._parents.pop(run_id, None)
|
|
413
569
|
if not is_top:
|
|
570
|
+
self._finalize_node(run_id, error=str(error))
|
|
571
|
+
self._parents.pop(run_id, None)
|
|
414
572
|
return
|
|
573
|
+
self._parents.pop(run_id, None)
|
|
415
574
|
state = self._runs.pop(run_id, None)
|
|
416
575
|
if state is None:
|
|
417
576
|
return
|
|
@@ -420,30 +579,30 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
420
579
|
if active_span is not None:
|
|
421
580
|
active_span.set_error(str(error))
|
|
422
581
|
active_span._merge_child_run(
|
|
423
|
-
execution_steps=state["execution_steps"],
|
|
424
582
|
tool_calls=state["tool_calls"],
|
|
425
|
-
retrieval_steps=state["retrieval_steps"],
|
|
426
583
|
input=state["input"],
|
|
427
584
|
model=state.get("model"),
|
|
428
585
|
framework="langchain",
|
|
429
586
|
input_tokens=state["input_tokens"] or None,
|
|
430
587
|
output_tokens=state["output_tokens"] or None,
|
|
588
|
+
emit_steps=False,
|
|
431
589
|
)
|
|
590
|
+
self._emit_span_tree(active_span, state, state["tool_calls"])
|
|
432
591
|
else:
|
|
433
592
|
# See on_chain_end's matching branch - same standalone-usage handling.
|
|
434
593
|
with self._tracer.trace(self._name, metadata=self._metadata, session_id=self._session_id) as span:
|
|
435
594
|
span._start = state["start"]
|
|
436
595
|
span.set_error(str(error))
|
|
437
596
|
span._merge_child_run(
|
|
438
|
-
execution_steps=state["execution_steps"],
|
|
439
597
|
tool_calls=state["tool_calls"],
|
|
440
|
-
retrieval_steps=state["retrieval_steps"],
|
|
441
598
|
input=state["input"],
|
|
442
599
|
model=state.get("model"),
|
|
443
600
|
framework="langchain",
|
|
444
601
|
input_tokens=state["input_tokens"] or None,
|
|
445
602
|
output_tokens=state["output_tokens"] or None,
|
|
603
|
+
emit_steps=False,
|
|
446
604
|
)
|
|
605
|
+
self._emit_span_tree(span, state, state["tool_calls"])
|
|
447
606
|
|
|
448
607
|
# ------------------------------------------------------------------
|
|
449
608
|
# LLM lifecycle
|
|
@@ -545,6 +704,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
545
704
|
if start_t is not None and top and top in self._runs:
|
|
546
705
|
steps = self._runs[top]["execution_steps"]
|
|
547
706
|
steps.append({
|
|
707
|
+
"parent_run_id": parent_run_id,
|
|
548
708
|
"name": f"LLM Call {len(steps) + 1}",
|
|
549
709
|
"duration_ms": (end_t - start_t) * 1000,
|
|
550
710
|
"start_time": start_t,
|
|
@@ -592,6 +752,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
592
752
|
start_t = state["start"]
|
|
593
753
|
latency_ms = int((end_t - start_t) * 1000)
|
|
594
754
|
tool_call = {
|
|
755
|
+
"parent_run_id": parent_run_id,
|
|
595
756
|
"name": state["tool_name"],
|
|
596
757
|
"input": state["tool_input"],
|
|
597
758
|
"output": str(output),
|
|
@@ -623,6 +784,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
623
784
|
end_t = time.time()
|
|
624
785
|
start_t = state.get("start", end_t)
|
|
625
786
|
tool_call = {
|
|
787
|
+
"parent_run_id": parent_run_id,
|
|
626
788
|
"name": state.get("tool_name", "unknown"),
|
|
627
789
|
"input": state.get("tool_input"),
|
|
628
790
|
"output": f"ERROR: {error}",
|
|
@@ -669,6 +831,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
669
831
|
query: Optional[str] = state["query"] or None
|
|
670
832
|
doc_count = len(documents) if hasattr(documents, "__len__") else None
|
|
671
833
|
step: Dict[str, Any] = {
|
|
834
|
+
"parent_run_id": parent_run_id,
|
|
672
835
|
"name": "Retrieval 1", # renumbered below
|
|
673
836
|
"duration_ms": (end_t - start_t) * 1000,
|
|
674
837
|
"start_time": start_t,
|
|
@@ -189,6 +189,46 @@ class MoveworksSyncReport:
|
|
|
189
189
|
return base + ")"
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def judge_sessions_via_engine(ingest: IngestClient, session_ids: List[str]) -> "tuple[int, int, int]":
|
|
193
|
+
"""
|
|
194
|
+
Judge each session with every enabled session-scoped evaluator via the engine's on-demand
|
|
195
|
+
judge route (``ifStale=true``: an up-to-date verdict from the sweep or a previous run is left
|
|
196
|
+
alone). Shared by every pull importer (Moveworks, Databricks). Returns (judged, skipped,
|
|
197
|
+
failed) counts.
|
|
198
|
+
"""
|
|
199
|
+
# Same base/key the ingest client already resolved - the judge routes live under
|
|
200
|
+
# /agent-monitoring on the same engine.
|
|
201
|
+
base = ingest._base_url
|
|
202
|
+
http = ingest._session
|
|
203
|
+
try:
|
|
204
|
+
response = http.get(f"{base}/agent-monitoring/online-evaluators", timeout=30)
|
|
205
|
+
response.raise_for_status()
|
|
206
|
+
evaluators = [
|
|
207
|
+
e for e in response.json().get("evaluators", [])
|
|
208
|
+
if e.get("enabled") and e.get("scope") == "session"
|
|
209
|
+
]
|
|
210
|
+
except requests.RequestException:
|
|
211
|
+
return 0, 0, len(session_ids)
|
|
212
|
+
judged = skipped = failed = 0
|
|
213
|
+
for session_id in session_ids:
|
|
214
|
+
for evaluator in evaluators:
|
|
215
|
+
try:
|
|
216
|
+
result = http.post(
|
|
217
|
+
f"{base}/agent-monitoring/sessions/{session_id}/judge/{evaluator['_id']}",
|
|
218
|
+
params={"ifStale": "true"},
|
|
219
|
+
timeout=120,
|
|
220
|
+
)
|
|
221
|
+
if result.status_code == 200 and result.json().get("skipped"):
|
|
222
|
+
skipped += 1
|
|
223
|
+
elif result.ok:
|
|
224
|
+
judged += 1
|
|
225
|
+
else:
|
|
226
|
+
failed += 1
|
|
227
|
+
except requests.RequestException:
|
|
228
|
+
failed += 1
|
|
229
|
+
return judged, skipped, failed
|
|
230
|
+
|
|
231
|
+
|
|
192
232
|
class MoveworksImporter:
|
|
193
233
|
"""
|
|
194
234
|
Pulls Moveworks Data API records and replays them into AgentX as traces.
|
|
@@ -386,36 +426,10 @@ class MoveworksImporter:
|
|
|
386
426
|
run) is left alone instead of judged again.
|
|
387
427
|
"""
|
|
388
428
|
report = report or MoveworksSyncReport()
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
try:
|
|
394
|
-
response = http.get(f"{base}/agent-monitoring/online-evaluators", timeout=30)
|
|
395
|
-
response.raise_for_status()
|
|
396
|
-
evaluators = [
|
|
397
|
-
e for e in response.json().get("evaluators", [])
|
|
398
|
-
if e.get("enabled") and e.get("scope") == "session"
|
|
399
|
-
]
|
|
400
|
-
except requests.RequestException:
|
|
401
|
-
report.sessions_judge_failed += len(session_ids)
|
|
402
|
-
return report
|
|
403
|
-
for session_id in session_ids:
|
|
404
|
-
for evaluator in evaluators:
|
|
405
|
-
try:
|
|
406
|
-
result = http.post(
|
|
407
|
-
f"{base}/agent-monitoring/sessions/{session_id}/judge/{evaluator['_id']}",
|
|
408
|
-
params={"ifStale": "true"},
|
|
409
|
-
timeout=120,
|
|
410
|
-
)
|
|
411
|
-
if result.status_code == 200 and result.json().get("skipped"):
|
|
412
|
-
report.sessions_judge_skipped += 1
|
|
413
|
-
elif result.ok:
|
|
414
|
-
report.sessions_judged += 1
|
|
415
|
-
else:
|
|
416
|
-
report.sessions_judge_failed += 1
|
|
417
|
-
except requests.RequestException:
|
|
418
|
-
report.sessions_judge_failed += 1
|
|
429
|
+
judged, skipped, failed = judge_sessions_via_engine(self._ingest, session_ids)
|
|
430
|
+
report.sessions_judged += judged
|
|
431
|
+
report.sessions_judge_skipped += skipped
|
|
432
|
+
report.sessions_judge_failed += failed
|
|
419
433
|
return report
|
|
420
434
|
|
|
421
435
|
|
|
@@ -338,6 +338,7 @@ class _TraceSpan:
|
|
|
338
338
|
output_tokens: Optional[int] = None,
|
|
339
339
|
cache_read_tokens: Optional[int] = None,
|
|
340
340
|
cache_write_tokens: Optional[int] = None,
|
|
341
|
+
emit_steps: bool = True,
|
|
341
342
|
) -> None:
|
|
342
343
|
"""
|
|
343
344
|
Explode a whole auto-instrumented sub-run (e.g. one top-level LangChain
|
|
@@ -348,9 +349,15 @@ class _TraceSpan:
|
|
|
348
349
|
tool_calls entries here carry no start/end timing (only latency_ms, see callers e.g.
|
|
349
350
|
langchain.py) - their child span falls back to offset-0 positioning in the tree panel;
|
|
350
351
|
execution_steps (LLM calls) always carry real timing and position correctly.
|
|
352
|
+
|
|
353
|
+
``emit_steps=False`` skips the flat child-span emission but keeps everything else (the
|
|
354
|
+
flat tool_calls mirror for the Monitor tool-failure check, and the summary adoption) -
|
|
355
|
+
for callers that emit their own HIERARCHICAL span tree instead (langchain.py's
|
|
356
|
+
_emit_span_tree parents steps under the graph node that ran them, rather than flat
|
|
357
|
+
under this span).
|
|
351
358
|
"""
|
|
352
359
|
with self._merge_lock:
|
|
353
|
-
for step in execution_steps or []:
|
|
360
|
+
for step in [] if not emit_steps else (execution_steps or []):
|
|
354
361
|
self._child_span_count += 1
|
|
355
362
|
self.child_span(
|
|
356
363
|
step.get("name") or f"LLM Call {self._child_span_count}",
|
|
@@ -371,14 +378,16 @@ class _TraceSpan:
|
|
|
371
378
|
# start_time/end_time; prefer those over latency_ms alone when both know duration
|
|
372
379
|
# since they also let this child span position correctly in the tree panel
|
|
373
380
|
# instead of defaulting to offset 0.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
381
|
+
if emit_steps:
|
|
382
|
+
self.child_span(
|
|
383
|
+
tc.get("name") or "Tool call",
|
|
384
|
+
start_time=tc.get("start_time"),
|
|
385
|
+
end_time=tc.get("end_time"),
|
|
386
|
+
duration_ms=tc.get("latency_ms"),
|
|
387
|
+
input=tc.get("input"),
|
|
388
|
+
output=tc.get("output"),
|
|
389
|
+
error=None if tc.get("success", True) else str(tc.get("output") or "Tool call failed"),
|
|
390
|
+
)
|
|
382
391
|
# Also mirror onto this span's own flat tool_calls list, sent in this span's own
|
|
383
392
|
# wire payload on __exit__ (see tool_calls=self.tool_calls or None below). The
|
|
384
393
|
# child span above is only for the trace detail view's span tree; the engine's
|
|
@@ -393,7 +402,7 @@ class _TraceSpan:
|
|
|
393
402
|
"latency_ms": tc.get("latency_ms"),
|
|
394
403
|
"success": tc.get("success", True),
|
|
395
404
|
})
|
|
396
|
-
for step in retrieval_steps or []:
|
|
405
|
+
for step in [] if not emit_steps else (retrieval_steps or []):
|
|
397
406
|
self._child_span_count += 1
|
|
398
407
|
self.child_span(
|
|
399
408
|
step.get("name") or f"Retrieval {self._child_span_count}",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.6.23"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-python
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.23
|
|
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
|
|
@@ -36,6 +36,8 @@ Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
|
|
|
36
36
|
Provides-Extra: autogen
|
|
37
37
|
Requires-Dist: autogen-agentchat>=0.4.0; extra == "autogen"
|
|
38
38
|
Requires-Dist: autogen-core>=0.4.0; extra == "autogen"
|
|
39
|
+
Provides-Extra: databricks
|
|
40
|
+
Requires-Dist: mlflow>=3.6.0; extra == "databricks"
|
|
39
41
|
Provides-Extra: all
|
|
40
42
|
Requires-Dist: langchain-core>=0.1.0; extra == "all"
|
|
41
43
|
Requires-Dist: crewai>=0.80.0; extra == "all"
|
|
@@ -31,6 +31,7 @@ agentx/integrations/_traced_call.py
|
|
|
31
31
|
agentx/integrations/anthropic.py
|
|
32
32
|
agentx/integrations/autogen.py
|
|
33
33
|
agentx/integrations/crewai.py
|
|
34
|
+
agentx/integrations/databricks.py
|
|
34
35
|
agentx/integrations/google_adk.py
|
|
35
36
|
agentx/integrations/google_genai.py
|
|
36
37
|
agentx/integrations/langchain.py
|
|
@@ -35,6 +35,9 @@ setup(
|
|
|
35
35
|
# Moveworks Data API -> AgentX traces sync (see agentx/integrations/moveworks.py) -
|
|
36
36
|
# a pull importer, since Moveworks agents run in their cloud with no in-process hook.
|
|
37
37
|
"agentx-moveworks=agentx.integrations.moveworks:cli_main",
|
|
38
|
+
# MLflow/Databricks trace sync (see agentx/integrations/databricks.py) - pull importer
|
|
39
|
+
# for agents whose serving endpoints can't set OTLP egress env vars.
|
|
40
|
+
"agentx-databricks=agentx.integrations.databricks:cli_main",
|
|
38
41
|
],
|
|
39
42
|
},
|
|
40
43
|
install_requires=[
|
|
@@ -54,6 +57,7 @@ setup(
|
|
|
54
57
|
"litellm": ["litellm>=1.0.0"],
|
|
55
58
|
"llamaindex": ["llama-index-core>=0.10.0"],
|
|
56
59
|
"autogen": ["autogen-agentchat>=0.4.0", "autogen-core>=0.4.0"],
|
|
60
|
+
"databricks": ["mlflow>=3.6.0"],
|
|
57
61
|
"all": [
|
|
58
62
|
"langchain-core>=0.1.0",
|
|
59
63
|
"crewai>=0.80.0",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.6.21"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|