agentx-python 0.8.23__tar.gz → 0.8.24__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.8.23 → agentx_python-0.8.24}/PKG-INFO +1 -1
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/agentx.py +28 -5
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/client.py +19 -9
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/datasets.py +19 -5
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/evaluation_settings.py +12 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/models.py +10 -15
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/reporting.py +1 -1
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/runner.py +27 -9
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/crewai.py +19 -9
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/google_adk.py +110 -15
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/langchain.py +57 -28
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/litellm.py +17 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/llamaindex.py +97 -88
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/openai_agents.py +3 -0
- agentx_python-0.8.24/agentx/monitor/__init__.py +39 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/agents.py +13 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/client.py +25 -9
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/improvement_groups.py +39 -4
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/judge_scorers.py +55 -4
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/models.py +4 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/patterns.py +34 -1
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/profile.py +2 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/review_queue.py +10 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/rules.py +6 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/scorer_groups.py +33 -4
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/scorers.py +37 -4
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/ingest_client.py +22 -5
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/tracer.py +114 -29
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/version.py +2 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/PKG-INFO +1 -1
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_judge_scorers.py +15 -2
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_span_tree.py +73 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_wire_models.py +43 -0
- agentx_python-0.8.23/agentx/monitor/__init__.py +0 -17
- {agentx_python-0.8.23 → agentx_python-0.8.24}/LICENSE +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/README.md +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/__init__.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/cli.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/tool_schemas.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/exceptions.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/export.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/feedback.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/sessions.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/outcomes.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/projects.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/py.typed +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/agent.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/testing.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/traces.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/eval_scope.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/tracing/framework_detect.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx/util.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/SOURCES.txt +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/setup.cfg +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/setup.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_cli_launcher.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_deep_dive_fixes.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_docs_match_sdk.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_error_taxonomy.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_eval_scope.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_framework_detect.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_integration.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_integrations.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_multi_judge.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_pairwise.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_review_queue.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_runner_features.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_selfhost_analysis_fallback.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_selfhost_compat.py +0 -0
- {agentx_python-0.8.23 → agentx_python-0.8.24}/tests/test_testing.py +0 -0
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
6
|
from agentx.util import get_headers, api_base, normalize_base
|
|
7
|
+
from agentx.exceptions import AgentXError
|
|
7
8
|
from agentx.resources.agent import Agent
|
|
8
9
|
from agentx.resources.workforce import Workforce
|
|
9
10
|
|
|
@@ -136,6 +137,11 @@ class AgentX:
|
|
|
136
137
|
return cls(base_url=base_url) if base_url else cls()
|
|
137
138
|
|
|
138
139
|
def get_agent(self, id: str) -> Agent:
|
|
140
|
+
"""Fetch one hosted-platform agent by id.
|
|
141
|
+
|
|
142
|
+
Hosted platform only - the self-host engine does not serve /access/agents;
|
|
143
|
+
use ``client.monitor.agents.list()`` for self-host agent rows instead.
|
|
144
|
+
"""
|
|
139
145
|
url = f"{self.base_url or api_base()}/access/agents/{id}"
|
|
140
146
|
# Make a GET request to the AgentX API
|
|
141
147
|
response = requests.get(url, headers=get_headers(self.api_key))
|
|
@@ -143,9 +149,17 @@ class AgentX:
|
|
|
143
149
|
if response.status_code == 200:
|
|
144
150
|
return Agent(**response.json())
|
|
145
151
|
else:
|
|
146
|
-
raise
|
|
152
|
+
raise AgentXError(
|
|
153
|
+
f"Failed to retrieve agent: {response.reason}. This endpoint is "
|
|
154
|
+
"hosted-platform only - on self-host use client.monitor.agents.list()."
|
|
155
|
+
)
|
|
147
156
|
|
|
148
157
|
def list_agents(self) -> List[Agent]:
|
|
158
|
+
"""List the hosted platform's agents.
|
|
159
|
+
|
|
160
|
+
Hosted platform only - the self-host engine does not serve /access/agents;
|
|
161
|
+
use ``client.monitor.agents.list()`` for self-host agent rows instead.
|
|
162
|
+
"""
|
|
149
163
|
url = f"{self.base_url or api_base()}/access/agents"
|
|
150
164
|
# Make a GET request to the AgentX API
|
|
151
165
|
response = requests.get(url, headers=get_headers(self.api_key))
|
|
@@ -153,7 +167,10 @@ class AgentX:
|
|
|
153
167
|
if response.status_code == 200:
|
|
154
168
|
return [Agent(**agent) for agent in response.json()]
|
|
155
169
|
else:
|
|
156
|
-
raise
|
|
170
|
+
raise AgentXError(
|
|
171
|
+
f"Failed to list agents: {response.reason}. This endpoint is "
|
|
172
|
+
"hosted-platform only - on self-host use client.monitor.agents.list()."
|
|
173
|
+
)
|
|
157
174
|
|
|
158
175
|
@staticmethod
|
|
159
176
|
def list_workforces() -> List["Workforce"]:
|
|
@@ -214,12 +231,18 @@ class AgentX:
|
|
|
214
231
|
return {"ok": True, "base_url": base}
|
|
215
232
|
|
|
216
233
|
def get_profile(self):
|
|
217
|
-
"""Get the current user's profile information.
|
|
234
|
+
"""Get the current user's profile information.
|
|
235
|
+
|
|
236
|
+
Hosted platform only - the self-host engine does not serve /access/getProfile;
|
|
237
|
+
self-host agent/monitoring data lives under ``client.monitor`` (e.g.
|
|
238
|
+
``client.monitor.agents.list()``).
|
|
239
|
+
"""
|
|
218
240
|
url = f"{self.base_url or api_base()}/access/getProfile"
|
|
219
241
|
response = requests.get(url, headers=get_headers(self.api_key))
|
|
220
242
|
if response.status_code == 200:
|
|
221
243
|
return response.json()
|
|
222
244
|
else:
|
|
223
|
-
raise
|
|
224
|
-
f"Failed to get profile: {response.status_code} - {response.reason}"
|
|
245
|
+
raise AgentXError(
|
|
246
|
+
f"Failed to get profile: {response.status_code} - {response.reason}. "
|
|
247
|
+
"This endpoint is hosted-platform only - on self-host use client.monitor."
|
|
225
248
|
)
|
|
@@ -35,7 +35,6 @@ _DEFAULT_BASE_URL = f"{_UTIL_API_BASE}/custom-agent-evaluations"
|
|
|
35
35
|
SDK_NAME = "agentx-python"
|
|
36
36
|
|
|
37
37
|
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
38
|
-
_MAX_RETRIES = 3
|
|
39
38
|
_RETRY_BACKOFF = [1.0, 2.0, 4.0]
|
|
40
39
|
|
|
41
40
|
# The self-host analyze route judges every result before it responds, so the client has to
|
|
@@ -182,8 +181,8 @@ class EvaluationsClient:
|
|
|
182
181
|
if resp.status_code == 422:
|
|
183
182
|
raise AgentXValidationError(resp.text)
|
|
184
183
|
# Gate on the schedule itself so HTTP-status retries walk the SAME full backoff
|
|
185
|
-
# schedule connection errors do -
|
|
186
|
-
#
|
|
184
|
+
# schedule connection errors do - an earlier fixed retry-count gate left the
|
|
185
|
+
# schedule's last entry unreachable for HTTP retries (ingest_client precedent).
|
|
187
186
|
if (
|
|
188
187
|
resp.status_code in _RETRYABLE_STATUS
|
|
189
188
|
and retry
|
|
@@ -248,7 +247,9 @@ class EvaluationsClient:
|
|
|
248
247
|
"""Deletes the dataset, its grading config, and both version histories. Past runs are
|
|
249
248
|
kept (their dataset reference degrades to a bare id). The engine refuses (409) when the
|
|
250
249
|
dataset's config is attached to a live scorer."""
|
|
251
|
-
|
|
250
|
+
# retry=False: a lost response + transport retry would turn a successful
|
|
251
|
+
# delete into a spurious 404.
|
|
252
|
+
self._request("DELETE", f"/datasets/{dataset_id}", retry=False)
|
|
252
253
|
|
|
253
254
|
def list_datasets(self) -> List[Dataset]:
|
|
254
255
|
data = self._request("GET", "/datasets", params=self._workspace_params())
|
|
@@ -440,6 +441,12 @@ class EvaluationsClient:
|
|
|
440
441
|
quality_mode: Optional[str] = None,
|
|
441
442
|
judges: Optional[List[str]] = None,
|
|
442
443
|
) -> Dict[str, Any]:
|
|
444
|
+
"""Start the qualitative AI-analysis job for a run.
|
|
445
|
+
|
|
446
|
+
``mode`` ("auto"/"sync"/"batch") is hosted-only: self-host engines run the analysis
|
|
447
|
+
synchronously and ignore it - check the response's mode field for what actually ran
|
|
448
|
+
(mirrors EvaluationRun.analyze's docstring).
|
|
449
|
+
"""
|
|
443
450
|
# Starts the durable analysis job and returns immediately (e.g. {"jobId": ..., "status":
|
|
444
451
|
# "pending"}); poll get_analysis_status() until it reaches a terminal status, then call
|
|
445
452
|
# get_report(). mode/quality_mode/judges mirror the dashboard's AnalyzeEvaluationRequest.
|
|
@@ -506,9 +513,10 @@ class EvaluationsClient:
|
|
|
506
513
|
return self._report_from_dashboard(run_id)
|
|
507
514
|
|
|
508
515
|
def get_missing_results(self, run_id: str) -> List[Dict[str, Any]]:
|
|
509
|
-
"""Deprecated: on self-host the route
|
|
510
|
-
|
|
511
|
-
``submittedKeys`` - to find out
|
|
516
|
+
"""Deprecated: on self-host the route returns ``missing: []`` deliberately empty -
|
|
517
|
+
the engine cannot know the client's case list - so this always returns ``[]``.
|
|
518
|
+
Use :meth:`get_submitted_keys` - the same route's ``submittedKeys`` - to find out
|
|
519
|
+
what a run still needs."""
|
|
512
520
|
import warnings
|
|
513
521
|
|
|
514
522
|
warnings.warn(
|
|
@@ -517,8 +525,10 @@ class EvaluationsClient:
|
|
|
517
525
|
DeprecationWarning,
|
|
518
526
|
stacklevel=2,
|
|
519
527
|
)
|
|
520
|
-
|
|
521
|
-
|
|
528
|
+
# No request at all: the route returns `missing: []` deliberately empty (the engine
|
|
529
|
+
# cannot know the client's case list), so the round-trip only ever bought an empty
|
|
530
|
+
# result.
|
|
531
|
+
return []
|
|
522
532
|
|
|
523
533
|
def get_submitted_keys(self, run_id: str) -> List[str]:
|
|
524
534
|
"""Idempotency keys this run has already accepted - what execute() uses to resume a
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import csv
|
|
4
4
|
import logging
|
|
5
|
+
import warnings
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
|
|
7
8
|
|
|
@@ -21,7 +22,8 @@ class DatasetBuilder:
|
|
|
21
22
|
``judge_prompt``/``judge_model`` are LLM-as-judge overrides for the dataset's grading
|
|
22
23
|
config. NOTE (self-host): the engine's dataset-create route currently ignores both -
|
|
23
24
|
set them on a judge scorer / the evaluation settings instead. ``sovereignty_models``
|
|
24
|
-
is
|
|
25
|
+
is dropped by the self-host engine on this route - use
|
|
26
|
+
``client.monitor.judge_scorers.builder(sovereignty_models=...)`` which persists it.
|
|
25
27
|
"""
|
|
26
28
|
|
|
27
29
|
def __init__(
|
|
@@ -93,8 +95,9 @@ class DatasetBuilder:
|
|
|
93
95
|
if rouge_score:
|
|
94
96
|
self._payload["rougeScore"] = {"enabled": True}
|
|
95
97
|
# Sovereignty & Portability - the models to compare on this dataset (use
|
|
96
|
-
# client.evaluations.list_models() to discover valid ids). Self-host:
|
|
97
|
-
# the
|
|
98
|
+
# client.evaluations.list_models() to discover valid ids). Self-host: dropped by
|
|
99
|
+
# the engine on this route - use client.monitor.judge_scorers.builder(
|
|
100
|
+
# sovereignty_models=...) which persists it (see class docstring).
|
|
98
101
|
if sovereignty_models:
|
|
99
102
|
self._payload["sovereigntyIndex"] = {
|
|
100
103
|
"enabled": True,
|
|
@@ -161,9 +164,11 @@ class DatasetBuilder:
|
|
|
161
164
|
main["smokeTest"] = {"enabled": True, "count": smoke_test_count}
|
|
162
165
|
if smoke_test_guidance:
|
|
163
166
|
main["smokeTest"]["guidance"] = smoke_test_guidance
|
|
164
|
-
|
|
167
|
+
# `is not None`, not truthiness: an explicit empty list is a real assertion (an empty
|
|
168
|
+
# expectedTrajectory means "this case calls no tools") and must reach the wire.
|
|
169
|
+
if expected_tools is not None:
|
|
165
170
|
main["expectedTrajectory"] = {"tools": expected_tools, "mode": trajectory_match_mode}
|
|
166
|
-
if expected_retrieval_context:
|
|
171
|
+
if expected_retrieval_context is not None:
|
|
167
172
|
main["expectedRetrievalContext"] = expected_retrieval_context
|
|
168
173
|
if splits:
|
|
169
174
|
main["splits"] = splits
|
|
@@ -178,6 +183,15 @@ class DatasetBuilder:
|
|
|
178
183
|
def publish(self) -> Dataset:
|
|
179
184
|
if not self._payload["questions"]:
|
|
180
185
|
raise ValueError("Dataset must have at least one case before publishing")
|
|
186
|
+
# Warn at publish time, where the request is known: the engine's dataset-create
|
|
187
|
+
# route drops sovereigntyIndex, so comparison models set here never persist.
|
|
188
|
+
sov = self._payload.get("sovereigntyIndex")
|
|
189
|
+
if isinstance(sov, dict) and sov.get("models"):
|
|
190
|
+
warnings.warn(
|
|
191
|
+
"Self-host ignores sovereigntyIndex on datasets/grading configs - use "
|
|
192
|
+
"judge_scorers.builder(sovereignty_models=...) for model comparison runs.",
|
|
193
|
+
stacklevel=2,
|
|
194
|
+
)
|
|
181
195
|
logger.info(
|
|
182
196
|
"Publishing dataset '%s' with %d case(s)",
|
|
183
197
|
self._payload["name"],
|
|
@@ -64,8 +64,9 @@ class EvaluationSettingsBuilder:
|
|
|
64
64
|
if rouge_score:
|
|
65
65
|
self._payload["rougeScore"] = {"enabled": True}
|
|
66
66
|
# Sovereignty & Portability - the models to compare when this config runs
|
|
67
|
-
# (use client.evaluations.list_models() to discover valid ids). Self-host:
|
|
68
|
-
#
|
|
67
|
+
# (use client.evaluations.list_models() to discover valid ids). Self-host: dropped
|
|
68
|
+
# by the engine on this route - use client.monitor.judge_scorers.builder(
|
|
69
|
+
# sovereignty_models=...) which persists it (same caveat as DatasetBuilder's).
|
|
69
70
|
if sovereignty_models:
|
|
70
71
|
self._payload["sovereigntyIndex"] = {
|
|
71
72
|
"enabled": True,
|
|
@@ -91,6 +92,15 @@ class EvaluationSettingsBuilder:
|
|
|
91
92
|
]
|
|
92
93
|
|
|
93
94
|
def publish(self) -> EvaluationSettings:
|
|
95
|
+
# Warn at publish time, where the request is known: the engine's settings-create
|
|
96
|
+
# route drops sovereigntyIndex, so comparison models set here never persist.
|
|
97
|
+
sov = self._payload.get("sovereigntyIndex")
|
|
98
|
+
if isinstance(sov, dict) and sov.get("models"):
|
|
99
|
+
warnings.warn(
|
|
100
|
+
"Self-host ignores sovereigntyIndex on datasets/grading configs - use "
|
|
101
|
+
"judge_scorers.builder(sovereignty_models=...) for model comparison runs.",
|
|
102
|
+
stacklevel=2,
|
|
103
|
+
)
|
|
94
104
|
logger.info("Publishing evaluation settings '%s'", self._payload["name"])
|
|
95
105
|
return self._client.create_evaluation_settings(self._payload)
|
|
96
106
|
|
|
@@ -179,27 +179,22 @@ class Prompt(BaseModel):
|
|
|
179
179
|
# Evaluation subject
|
|
180
180
|
# ---------------------------------------------------------------------------
|
|
181
181
|
|
|
182
|
-
FrameworkKind = Literal[
|
|
183
|
-
"raw_python",
|
|
184
|
-
"openai",
|
|
185
|
-
"anthropic",
|
|
186
|
-
"google",
|
|
187
|
-
"langchain",
|
|
188
|
-
"llamaindex",
|
|
189
|
-
"crewai",
|
|
190
|
-
"autogen",
|
|
191
|
-
"n8n",
|
|
192
|
-
"flowise",
|
|
193
|
-
"other",
|
|
194
|
-
]
|
|
195
|
-
|
|
196
182
|
RuntimeKind = Literal["local", "ci", "customer_hosted", "low_code"]
|
|
197
183
|
|
|
198
184
|
|
|
199
185
|
class EvaluationSubject(BaseModel):
|
|
186
|
+
"""Describes the agent under evaluation.
|
|
187
|
+
|
|
188
|
+
``framework`` is an open string - the engine accepts any label (it also stamps
|
|
189
|
+
values like ``openai-agents``, ``langgraph``, ``google-genai``, ``litellm`` from
|
|
190
|
+
the tracing integrations). Common values: ``raw_python``, ``openai``,
|
|
191
|
+
``anthropic``, ``google``, ``langchain``, ``llamaindex``, ``crewai``,
|
|
192
|
+
``autogen``, ``n8n``, ``flowise``, ``other``.
|
|
193
|
+
"""
|
|
194
|
+
|
|
200
195
|
kind: Literal["custom_agent", "agentx_agent", "agentx_team"] = "custom_agent"
|
|
201
196
|
display_name: Optional[str] = Field(default=None, alias="displayName")
|
|
202
|
-
framework: Optional[
|
|
197
|
+
framework: Optional[str] = None
|
|
203
198
|
framework_version: Optional[str] = Field(default=None, alias="frameworkVersion")
|
|
204
199
|
runtime: Optional[RuntimeKind] = "local"
|
|
205
200
|
agent_instructions: Optional[str] = Field(default=None, alias="agentInstructions")
|
|
@@ -172,7 +172,7 @@ def print_report(report: Report) -> None:
|
|
|
172
172
|
if report.low_scoring_cases:
|
|
173
173
|
_section("Low-scoring Cases (rating <= 5)")
|
|
174
174
|
for case in report.low_scoring_cases[:5]:
|
|
175
|
-
q = (case.get("query") or case.get("questionText"
|
|
175
|
+
q = (case.get("query") or case.get("questionText") or "")[:80]
|
|
176
176
|
rating = case.get("rating", "?")
|
|
177
177
|
justification = case.get("justification", "")
|
|
178
178
|
print(f" {red(f'[{rating}]')} {q}")
|
|
@@ -229,6 +229,7 @@ class EvaluationRunContext:
|
|
|
229
229
|
)
|
|
230
230
|
return normalized(case)
|
|
231
231
|
|
|
232
|
+
executor = None
|
|
232
233
|
if concurrency > 1:
|
|
233
234
|
import concurrent.futures
|
|
234
235
|
import contextvars
|
|
@@ -308,6 +309,12 @@ class EvaluationRunContext:
|
|
|
308
309
|
# generator happens to be garbage-collected.
|
|
309
310
|
if results_iter is not None:
|
|
310
311
|
results_iter.close()
|
|
312
|
+
# And release the pool itself here too: close() on a NEVER-STARTED generator
|
|
313
|
+
# (e.g. every case was already submitted, so next() was never called) does not
|
|
314
|
+
# run bounded()'s finally - its executor.shutdown would never fire. shutdown()
|
|
315
|
+
# is idempotent, so the double call on the normal path is harmless.
|
|
316
|
+
if executor is not None:
|
|
317
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
311
318
|
# Flush the trailing partial batch HERE, not after the try: a mid-run exception
|
|
312
319
|
# (agent crash, Ctrl-C) used to discard up to max_batch - 1 already-paid-for
|
|
313
320
|
# results still waiting in it.
|
|
@@ -546,7 +553,7 @@ class EvaluationRunContext:
|
|
|
546
553
|
response's mode field for what actually ran.
|
|
547
554
|
quality_mode: "quality_first" or "balanced" - how many items get a second/third judge.
|
|
548
555
|
judges: 1-3 model ids from ``client.evaluations.list_models()``, e.g.
|
|
549
|
-
``["gpt-5.6-luna", "claude-opus-
|
|
556
|
+
``["gpt-5.6-luna", "claude-opus-5"]``. Omit to let the engine score with its
|
|
550
557
|
platform default model (a single judge, rather than the dashboard's 3-judge
|
|
551
558
|
default - SDK runs are typically lighter-weight, quick-start evaluations).
|
|
552
559
|
poll_interval: seconds between status checks while waiting.
|
|
@@ -566,16 +573,27 @@ class EvaluationRunContext:
|
|
|
566
573
|
judges=judges,
|
|
567
574
|
)
|
|
568
575
|
deadline = time.monotonic() + timeout
|
|
569
|
-
status =
|
|
570
|
-
while
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
576
|
+
status = None
|
|
577
|
+
while True:
|
|
578
|
+
try:
|
|
579
|
+
status = self._client.get_analysis_status(self._run.run_id)
|
|
580
|
+
except Exception as poll_exc:
|
|
581
|
+
# One transient status-poll failure (network blip, engine restart)
|
|
582
|
+
# must not abort the whole wait - the job keeps running
|
|
583
|
+
# server-side, so keep polling until the deadline.
|
|
584
|
+
logger.debug("Analysis status poll failed: %s", poll_exc)
|
|
585
|
+
if status is not None and status.is_terminal:
|
|
586
|
+
break
|
|
587
|
+
if time.monotonic() >= deadline:
|
|
588
|
+
break
|
|
589
|
+
if status is not None:
|
|
590
|
+
level = _ANALYSIS_LEVEL_LABELS.get(status.progress.current_level, "")
|
|
591
|
+
spinner.update(
|
|
592
|
+
f"Analyzing, {level + ' ' if level else ''}{status.progress.overall_percentage}%"
|
|
593
|
+
)
|
|
575
594
|
time.sleep(poll_interval)
|
|
576
|
-
status = self._client.get_analysis_status(self._run.run_id)
|
|
577
595
|
|
|
578
|
-
if not status.is_terminal:
|
|
596
|
+
if status is None or not status.is_terminal:
|
|
579
597
|
_say(f" {yellow('!')} Still running after {int(timeout)}s, check the dashboard for status")
|
|
580
598
|
elif status.status == "failed":
|
|
581
599
|
reason = status.failure_reason.message if status.failure_reason else "unknown error"
|
|
@@ -84,7 +84,7 @@ class AgentXCrewObserver:
|
|
|
84
84
|
if task_timings:
|
|
85
85
|
execution_steps, _ = self._build_steps_from_timings(task_timings, task_outputs)
|
|
86
86
|
elif task_outputs:
|
|
87
|
-
execution_steps, _ = self._build_steps_evenly_divided(task_outputs, latency_ms)
|
|
87
|
+
execution_steps, _ = self._build_steps_evenly_divided(task_outputs, latency_ms, start)
|
|
88
88
|
|
|
89
89
|
# Each task becomes its own real child span. tool_calls isn't passed to
|
|
90
90
|
# _merge_child_run here: _build_steps_from_timings/_build_steps_evenly_divided both
|
|
@@ -262,25 +262,33 @@ class AgentXCrewObserver:
|
|
|
262
262
|
|
|
263
263
|
return execution_steps, tool_calls
|
|
264
264
|
|
|
265
|
-
def _build_steps_evenly_divided(self, task_outputs: List[Any], latency_ms: float) -> tuple:
|
|
265
|
+
def _build_steps_evenly_divided(self, task_outputs: List[Any], latency_ms: float, start: float) -> tuple:
|
|
266
266
|
"""
|
|
267
267
|
Fallback for CrewAI versions predating the events module: no
|
|
268
268
|
per-task timing is available, so attribute the total latency evenly
|
|
269
269
|
across tasks so the timeline still sums to the measured wall-clock
|
|
270
|
-
duration.
|
|
270
|
+
duration. Each synthesized step also gets a real start_time/end_time
|
|
271
|
+
(consecutive per_step_s slices from the kickoff's start) so the
|
|
272
|
+
dashboard timeline can position it, not just size it.
|
|
271
273
|
"""
|
|
272
274
|
tool_calls: List[Dict[str, Any]] = []
|
|
273
275
|
execution_steps: List[Dict[str, Any]] = []
|
|
274
|
-
|
|
276
|
+
per_step_ms = latency_ms / len(task_outputs)
|
|
277
|
+
per_step_s = per_step_ms / 1000.0
|
|
278
|
+
for i, task_out in enumerate(task_outputs):
|
|
275
279
|
description = getattr(task_out, "description", "task")
|
|
276
280
|
name = description[:100]
|
|
277
281
|
task_output = str(getattr(task_out, "raw", ""))
|
|
278
282
|
tool_calls.append({"name": name, "input": description, "output": task_output})
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
step_start = start + i * per_step_s
|
|
284
|
+
execution_steps.append({
|
|
285
|
+
"name": name,
|
|
286
|
+
"duration_ms": per_step_ms,
|
|
287
|
+
"start_time": step_start,
|
|
288
|
+
"end_time": step_start + per_step_s,
|
|
289
|
+
"input": description,
|
|
290
|
+
"output": task_output,
|
|
291
|
+
})
|
|
284
292
|
|
|
285
293
|
return execution_steps, tool_calls
|
|
286
294
|
|
|
@@ -307,4 +315,6 @@ class AgentXCrewObserver:
|
|
|
307
315
|
framework="crewai",
|
|
308
316
|
session_id=session_id or self._session_id,
|
|
309
317
|
sync=sync,
|
|
318
|
+
# Same statement kickoff() makes: the root of a crew run is the agent run itself.
|
|
319
|
+
span_kind="agent",
|
|
310
320
|
)
|
|
@@ -20,12 +20,15 @@ Requires: ``pip install "agentx-python[google-adk]"``
|
|
|
20
20
|
"""
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import logging
|
|
23
24
|
import time
|
|
24
|
-
from typing import Any, Dict, List, Optional
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
26
|
from uuid import uuid4
|
|
26
27
|
|
|
27
28
|
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
28
29
|
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
29
32
|
try:
|
|
30
33
|
from google.adk.plugins.base_plugin import BasePlugin
|
|
31
34
|
except ImportError as exc: # pragma: no cover
|
|
@@ -94,23 +97,34 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
94
97
|
name: str = "google-adk-agent",
|
|
95
98
|
metadata: Optional[Dict[str, Any]] = None,
|
|
96
99
|
session_id: Optional[str] = None,
|
|
100
|
+
max_run_age_seconds: float = 3600.0,
|
|
97
101
|
) -> None:
|
|
98
102
|
super().__init__(name="agentx")
|
|
99
103
|
self._tracer = tracer
|
|
100
104
|
self._agent_name = name
|
|
101
105
|
self._metadata = metadata
|
|
102
106
|
self._session_id = session_id
|
|
107
|
+
# Safety net mirroring langchain.py's _prune_stale_entries: state is normally popped
|
|
108
|
+
# in after_run_callback, but an invocation whose end callback never fires (hard
|
|
109
|
+
# crash, ADK bug) would leak forever in this long-lived plugin. Entries older than
|
|
110
|
+
# this are swept out at the top of before_run_callback.
|
|
111
|
+
self._max_run_age_seconds = max_run_age_seconds
|
|
103
112
|
# invocation_id → accumulated run state
|
|
104
113
|
self._runs: Dict[str, Dict[str, Any]] = {}
|
|
105
|
-
# invocation_id → pre-buffered user input text
|
|
114
|
+
# invocation_id → (pre-buffered user input text, buffered-at time)
|
|
106
115
|
# (on_user_message_callback fires *before* before_run_callback)
|
|
107
|
-
self._pending_inputs: Dict[str, str] = {}
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
#
|
|
116
|
+
self._pending_inputs: Dict[str, Tuple[str, float]] = {}
|
|
117
|
+
# (invocation_id, tool name) → FIFO list of start times, mirroring _model_starts:
|
|
118
|
+
# parallel same-name tool calls each push their own start, so a second start no
|
|
119
|
+
# longer overwrites the first (a scalar here lost the first call's timing). Keyed
|
|
120
|
+
# by invocation_id, not id(tool_context): ADK creates fresh context objects per
|
|
121
|
+
# callback (see _model_starts' comment), and a freed context's id() can be
|
|
122
|
+
# recycled by an unrelated object, pairing a start with the wrong end.
|
|
123
|
+
self._tool_starts: Dict[Tuple[str, str], List[float]] = {}
|
|
124
|
+
# invocation_id → FIFO list of per-call dicts ({"start", "model", "input"})
|
|
111
125
|
# ADK creates new CallbackContext objects for before/after model callbacks,
|
|
112
126
|
# so we cannot use id(callback_context) as a key - use invocation_id instead.
|
|
113
|
-
self._model_starts: Dict[str, List[
|
|
127
|
+
self._model_starts: Dict[str, List[Dict[str, Any]]] = {}
|
|
114
128
|
|
|
115
129
|
# ------------------------------------------------------------------
|
|
116
130
|
# Run lifecycle
|
|
@@ -124,9 +138,64 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
124
138
|
inv_id = invocation_context.invocation_id
|
|
125
139
|
text = _content_to_text(user_message)
|
|
126
140
|
if text:
|
|
127
|
-
self._pending_inputs[inv_id] = text
|
|
141
|
+
self._pending_inputs[inv_id] = (text, time.time())
|
|
142
|
+
|
|
143
|
+
def _prune_stale_entries(self) -> None:
|
|
144
|
+
"""Sweep out invocation entries older than max_run_age_seconds - see __init__'s comment."""
|
|
145
|
+
cutoff = time.time() - self._max_run_age_seconds
|
|
146
|
+
swept = 0
|
|
147
|
+
|
|
148
|
+
stale_inv_ids = [
|
|
149
|
+
inv_id
|
|
150
|
+
for inv_id, state in list(self._runs.items())
|
|
151
|
+
if (getattr(state.get("root_span"), "_start", None) or 0) < cutoff
|
|
152
|
+
]
|
|
153
|
+
for inv_id in stale_inv_ids:
|
|
154
|
+
self._runs.pop(inv_id, None)
|
|
155
|
+
self._model_starts.pop(inv_id, None)
|
|
156
|
+
self._pending_inputs.pop(inv_id, None)
|
|
157
|
+
for key in [k for k in self._tool_starts if k[0] == inv_id]:
|
|
158
|
+
self._tool_starts.pop(key, None)
|
|
159
|
+
swept += len(stale_inv_ids)
|
|
160
|
+
|
|
161
|
+
# Orphaned per-call state whose invocation state is already gone (or never existed) -
|
|
162
|
+
# each entry carries its own timestamp.
|
|
163
|
+
stale_model_ids = [
|
|
164
|
+
inv_id
|
|
165
|
+
for inv_id, starts in list(self._model_starts.items())
|
|
166
|
+
if inv_id not in self._runs
|
|
167
|
+
and (not starts or max(call.get("start", 0) for call in starts) < cutoff)
|
|
168
|
+
]
|
|
169
|
+
for inv_id in stale_model_ids:
|
|
170
|
+
self._model_starts.pop(inv_id, None)
|
|
171
|
+
swept += len(stale_model_ids)
|
|
172
|
+
|
|
173
|
+
stale_tool_keys = [
|
|
174
|
+
key
|
|
175
|
+
for key, starts in list(self._tool_starts.items())
|
|
176
|
+
if not starts or max(starts) < cutoff
|
|
177
|
+
]
|
|
178
|
+
for key in stale_tool_keys:
|
|
179
|
+
self._tool_starts.pop(key, None)
|
|
180
|
+
swept += len(stale_tool_keys)
|
|
181
|
+
|
|
182
|
+
stale_input_ids = [
|
|
183
|
+
inv_id for inv_id, pending in list(self._pending_inputs.items()) if pending[1] < cutoff
|
|
184
|
+
]
|
|
185
|
+
for inv_id in stale_input_ids:
|
|
186
|
+
self._pending_inputs.pop(inv_id, None)
|
|
187
|
+
swept += len(stale_input_ids)
|
|
188
|
+
|
|
189
|
+
if swept:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"AgentXADKPlugin swept %d in-flight invocation record(s) older than %.0fs - "
|
|
192
|
+
"their end callbacks never fired, so their traces were never sent",
|
|
193
|
+
swept,
|
|
194
|
+
self._max_run_age_seconds,
|
|
195
|
+
)
|
|
128
196
|
|
|
129
197
|
async def before_run_callback(self, *, invocation_context: Any) -> None:
|
|
198
|
+
self._prune_stale_entries()
|
|
130
199
|
inv_id = invocation_context.invocation_id
|
|
131
200
|
agent_name = getattr(invocation_context.agent, "name", None) or self._agent_name
|
|
132
201
|
# Held directly (not relied on via tracer.current_span) - ADK callbacks for one
|
|
@@ -134,7 +203,13 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
134
203
|
# same thread/task, so state["root_span"] (keyed by invocation_id, same as everything
|
|
135
204
|
# else here) is the reliable way to address the right parent.
|
|
136
205
|
root_span = self._tracer.trace(
|
|
137
|
-
agent_name,
|
|
206
|
+
agent_name,
|
|
207
|
+
framework="google-adk",
|
|
208
|
+
metadata=self._metadata,
|
|
209
|
+
session_id=self._session_id,
|
|
210
|
+
# The root of a standalone runner invocation is the agent run itself
|
|
211
|
+
# (langchain/llamaindex/crewai/autogen parity).
|
|
212
|
+
span_kind="agent",
|
|
138
213
|
)
|
|
139
214
|
# Deliberately NOT root_span.__enter__() - the same reasoning as openai_agents'
|
|
140
215
|
# on_trace_start: enter pushes onto the CALLING context's active-span stack, but ADK
|
|
@@ -146,10 +221,11 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
146
221
|
root_span._start = time.time()
|
|
147
222
|
if root_span._session_id is None:
|
|
148
223
|
root_span._session_id = f"sdk_{uuid4().hex}"
|
|
224
|
+
pending_input = self._pending_inputs.pop(inv_id, None)
|
|
149
225
|
self._runs[inv_id] = {
|
|
150
226
|
"root_span": root_span,
|
|
151
227
|
"llm_call_count": 0,
|
|
152
|
-
"input":
|
|
228
|
+
"input": pending_input[0] if pending_input else None,
|
|
153
229
|
"output": None,
|
|
154
230
|
"model": None,
|
|
155
231
|
"error": None,
|
|
@@ -160,6 +236,10 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
160
236
|
async def after_run_callback(self, *, invocation_context: Any) -> None:
|
|
161
237
|
inv_id = invocation_context.invocation_id
|
|
162
238
|
state = self._runs.pop(inv_id, None)
|
|
239
|
+
# Drop this invocation's per-call leftovers too - an unpaired before_model_callback
|
|
240
|
+
# (or an input buffered after the run started) would otherwise leak here forever.
|
|
241
|
+
self._model_starts.pop(inv_id, None)
|
|
242
|
+
self._pending_inputs.pop(inv_id, None)
|
|
163
243
|
if state is None:
|
|
164
244
|
return
|
|
165
245
|
# This invocation's own detail already went out as child-span rows via child_span() in
|
|
@@ -285,7 +365,10 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
285
365
|
async def before_tool_callback(
|
|
286
366
|
self, *, tool: Any, tool_args: Dict[str, Any], tool_context: Any
|
|
287
367
|
) -> None:
|
|
288
|
-
|
|
368
|
+
inv_id = tool_context.get_invocation_context().invocation_id
|
|
369
|
+
# FIFO list per (invocation, tool name) - parallel same-name calls each queue
|
|
370
|
+
# their own start (see __init__'s comment).
|
|
371
|
+
self._tool_starts.setdefault((inv_id, getattr(tool, "name", "unknown")), []).append(time.time())
|
|
289
372
|
|
|
290
373
|
async def after_tool_callback(
|
|
291
374
|
self,
|
|
@@ -299,9 +382,15 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
299
382
|
state = self._runs.get(inv_id)
|
|
300
383
|
if state is None:
|
|
301
384
|
return
|
|
302
|
-
start_t = self._tool_starts.pop(id(tool_context), None)
|
|
303
|
-
end_t = time.time()
|
|
304
385
|
tool_name = getattr(tool, "name", "unknown")
|
|
386
|
+
# Pop the earliest queued start (FIFO, _model_starts' pairing), dropping the
|
|
387
|
+
# key once its list drains so entries don't accumulate.
|
|
388
|
+
key = (inv_id, tool_name)
|
|
389
|
+
starts = self._tool_starts.get(key, [])
|
|
390
|
+
start_t = starts.pop(0) if starts else None
|
|
391
|
+
if not starts:
|
|
392
|
+
self._tool_starts.pop(key, None)
|
|
393
|
+
end_t = time.time()
|
|
305
394
|
tool_input = _safe_serialize(tool_args)
|
|
306
395
|
tool_output = str(result) if result is not None else None
|
|
307
396
|
state["root_span"].child_span(
|
|
@@ -331,9 +420,15 @@ class AgentXADKPlugin(BasePlugin):
|
|
|
331
420
|
state = self._runs.get(inv_id)
|
|
332
421
|
if state is None:
|
|
333
422
|
return
|
|
334
|
-
start_t = self._tool_starts.pop(id(tool_context), None)
|
|
335
|
-
end_t = time.time()
|
|
336
423
|
tool_name = getattr(tool, "name", "unknown")
|
|
424
|
+
# Pop the earliest queued start (FIFO, _model_starts' pairing), dropping the
|
|
425
|
+
# key once its list drains so entries don't accumulate.
|
|
426
|
+
key = (inv_id, tool_name)
|
|
427
|
+
starts = self._tool_starts.get(key, [])
|
|
428
|
+
start_t = starts.pop(0) if starts else None
|
|
429
|
+
if not starts:
|
|
430
|
+
self._tool_starts.pop(key, None)
|
|
431
|
+
end_t = time.time()
|
|
337
432
|
tool_input = _safe_serialize(tool_args)
|
|
338
433
|
tool_output = f"ERROR: {error}"
|
|
339
434
|
state["root_span"].child_span(
|