agentx-python 0.6.19__tar.gz → 0.6.21__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.19 → agentx_python-0.6.21}/PKG-INFO +1 -1
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/__init__.py +1 -0
- agentx_python-0.6.21/agentx/integrations/moveworks.py +525 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/tracing/tracer.py +11 -6
- agentx_python-0.6.21/agentx/version.py +1 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx_python.egg-info/PKG-INFO +1 -1
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx_python.egg-info/SOURCES.txt +1 -0
- agentx_python-0.6.21/agentx_python.egg-info/entry_points.txt +3 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/setup.py +6 -1
- agentx_python-0.6.19/agentx/version.py +0 -1
- agentx_python-0.6.19/agentx_python.egg-info/entry_points.txt +0 -2
- {agentx_python-0.6.19 → agentx_python-0.6.21}/LICENSE +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/README.md +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/__init__.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/agentx.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/cli.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/client.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/redaction.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/runner.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/exceptions.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/feedback.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/__init__.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/client.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/models.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/outcomes.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/py.typed +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/resources/agent.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx/util.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/setup.cfg +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/tests/test_integration.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/tests/test_integrations.py +0 -0
- {agentx_python-0.6.19 → agentx_python-0.6.21}/tests/test_span_tree.py +0 -0
|
@@ -7,3 +7,4 @@
|
|
|
7
7
|
# from agentx.integrations.anthropic import patch_anthropic_client
|
|
8
8
|
# from agentx.integrations.google_adk import AgentXADKPlugin
|
|
9
9
|
# from agentx.integrations.google_genai import patch_genai_client
|
|
10
|
+
# from agentx.integrations.moveworks import MoveworksImporter # Data API pull sync, not in-process
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Moveworks integration for AgentX production tracing.
|
|
3
|
+
|
|
4
|
+
Moveworks agents run inside the Moveworks cloud (Agent Studio + Reasoning Engine), so unlike the
|
|
5
|
+
LangChain/CrewAI integrations there is no in-process callback to hook: Moveworks Python Script
|
|
6
|
+
Actions have outbound network access disabled at the infrastructure level. The sanctioned export
|
|
7
|
+
surface is the read-only Moveworks **Data API** (OData, ``https://api.moveworks.ai/export/v1``),
|
|
8
|
+
which exposes ``conversations``, ``interactions``, ``plugin-calls``, ``plugin-resources`` and
|
|
9
|
+
``users`` tables. This module pulls those records and replays them into AgentX as traces:
|
|
10
|
+
|
|
11
|
+
- conversation -> AgentX session (``session_id = mw_<conversation_id>``)
|
|
12
|
+
- interaction -> one AgentX trace (input/output, timestamps, latency where derivable)
|
|
13
|
+
- plugin call -> ``tool_calls`` entries on its interaction's trace
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
from agentx.integrations.moveworks import MoveworksDataAPIClient, MoveworksImporter
|
|
18
|
+
|
|
19
|
+
client = MoveworksDataAPIClient(api_key="mw-data-api-key")
|
|
20
|
+
importer = MoveworksImporter(
|
|
21
|
+
client,
|
|
22
|
+
agentx_api_key="agtx_local_...",
|
|
23
|
+
agentx_base_url="http://localhost:4700/api/v1", # self-host engine
|
|
24
|
+
)
|
|
25
|
+
report = importer.sync(since=datetime.now(timezone.utc) - timedelta(days=1))
|
|
26
|
+
print(report)
|
|
27
|
+
|
|
28
|
+
Or from a shell / cron job (keeps an incremental cursor between runs)::
|
|
29
|
+
|
|
30
|
+
export MOVEWORKS_API_KEY=... AGENTX_API_KEY=... AGENTX_API_BASE_URL=http://localhost:4700/api/v1
|
|
31
|
+
agentx-moveworks sync --since 7d
|
|
32
|
+
|
|
33
|
+
Notes and limits:
|
|
34
|
+
|
|
35
|
+
- The Data API retains 30 days of history and follows a ~24-hour freshness SLA - this is
|
|
36
|
+
near-real-time observability, not live tracing.
|
|
37
|
+
- Re-syncing a window is safe: every trace carries a deterministic ``span_id``
|
|
38
|
+
(``mw:<interaction_id>``) and the engine skips spans it has already ingested.
|
|
39
|
+
- Moveworks does not export token counts, so these traces do not contribute to the LLM cost
|
|
40
|
+
chart; plugin calls carry served/used flags but no failure semantics, so no tool failures are
|
|
41
|
+
fabricated.
|
|
42
|
+
- Data API credentials are minted by a Moveworks superadmin (Setup -> Credentials). Field names
|
|
43
|
+
in Data API records vary between deployments/versions; the importer reads each field from a
|
|
44
|
+
list of known candidates and tolerates absences.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import sys
|
|
52
|
+
from datetime import datetime, timedelta, timezone
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
55
|
+
|
|
56
|
+
import requests
|
|
57
|
+
|
|
58
|
+
from agentx.tracing.ingest_client import IngestClient
|
|
59
|
+
from agentx.version import VERSION
|
|
60
|
+
|
|
61
|
+
_DEFAULT_BASE_URL = "https://api.moveworks.ai/export/v1"
|
|
62
|
+
_DEFAULT_TIMESTAMP_FIELD = "created_time"
|
|
63
|
+
_DEFAULT_CURSOR_FILE = Path.home() / ".agentx" / "moveworks_cursor.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ----------------------------------------------------------------------------------------------
|
|
67
|
+
# Defensive record readers - Data API field names vary between deployments/versions.
|
|
68
|
+
# ----------------------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _first(record: Dict[str, Any], *keys: str) -> Any:
|
|
71
|
+
for key in keys:
|
|
72
|
+
value = record.get(key)
|
|
73
|
+
if value not in (None, ""):
|
|
74
|
+
return value
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_time(value: Any) -> Optional[datetime]:
|
|
79
|
+
"""ISO-8601 strings and epoch seconds/millis, to aware UTC datetimes."""
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
if isinstance(value, (int, float)):
|
|
83
|
+
# Millisecond epochs are > year 33658 when read as seconds; use magnitude to decide.
|
|
84
|
+
seconds = value / 1000.0 if value > 1e11 else float(value)
|
|
85
|
+
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
text = value.strip().replace("Z", "+00:00")
|
|
88
|
+
try:
|
|
89
|
+
parsed = datetime.fromisoformat(text)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return None
|
|
92
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _record_time(record: Dict[str, Any]) -> Optional[datetime]:
|
|
97
|
+
return _parse_time(
|
|
98
|
+
_first(record, "created_time", "created_at", "timestamp", "event_time", "time", "start_time")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MoveworksDataAPIClient:
|
|
103
|
+
"""
|
|
104
|
+
Minimal client for the Moveworks Data API (OData, GET-only).
|
|
105
|
+
|
|
106
|
+
``api_key`` is sent as ``Authorization: Bearer <key>`` by default; override ``auth_header`` /
|
|
107
|
+
``auth_scheme`` if your deployment differs. Pagination follows ``odata.nextlink``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
api_key: str,
|
|
113
|
+
*,
|
|
114
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
115
|
+
auth_header: str = "Authorization",
|
|
116
|
+
auth_scheme: str = "Bearer",
|
|
117
|
+
timeout: float = 30.0,
|
|
118
|
+
session: Optional[requests.Session] = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
if not api_key:
|
|
121
|
+
raise ValueError("A Moveworks Data API key is required (minted by a Moveworks superadmin)")
|
|
122
|
+
self._base_url = base_url.rstrip("/")
|
|
123
|
+
self._timeout = timeout
|
|
124
|
+
self._session = session or requests.Session()
|
|
125
|
+
value = f"{auth_scheme} {api_key}".strip() if auth_scheme else api_key
|
|
126
|
+
self._session.headers.update({auth_header: value, "Accept": "application/json"})
|
|
127
|
+
|
|
128
|
+
def records(
|
|
129
|
+
self,
|
|
130
|
+
table: str,
|
|
131
|
+
*,
|
|
132
|
+
since: Optional[datetime] = None,
|
|
133
|
+
until: Optional[datetime] = None,
|
|
134
|
+
timestamp_field: str = _DEFAULT_TIMESTAMP_FIELD,
|
|
135
|
+
extra_params: Optional[Dict[str, str]] = None,
|
|
136
|
+
max_pages: int = 1000,
|
|
137
|
+
) -> Iterator[Dict[str, Any]]:
|
|
138
|
+
"""Yield records from ``/records/<table>``, following OData pagination."""
|
|
139
|
+
filters: List[str] = []
|
|
140
|
+
if since is not None:
|
|
141
|
+
filters.append(f"{timestamp_field} ge {since.astimezone(timezone.utc).isoformat()}")
|
|
142
|
+
if until is not None:
|
|
143
|
+
filters.append(f"{timestamp_field} lt {until.astimezone(timezone.utc).isoformat()}")
|
|
144
|
+
params: Dict[str, str] = dict(extra_params or {})
|
|
145
|
+
if filters:
|
|
146
|
+
params["$filter"] = " and ".join(filters)
|
|
147
|
+
|
|
148
|
+
url: Optional[str] = f"{self._base_url}/records/{table}"
|
|
149
|
+
for _ in range(max_pages):
|
|
150
|
+
if not url:
|
|
151
|
+
return
|
|
152
|
+
response = self._session.get(url, params=params, timeout=self._timeout)
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
body = response.json()
|
|
155
|
+
for record in body.get("value", []):
|
|
156
|
+
if isinstance(record, dict):
|
|
157
|
+
yield record
|
|
158
|
+
# nextlink is absolute and already carries its own query string.
|
|
159
|
+
url = body.get("odata.nextlink") or body.get("@odata.nextLink")
|
|
160
|
+
params = {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class MoveworksSyncReport:
|
|
164
|
+
def __init__(self) -> None:
|
|
165
|
+
self.conversations = 0
|
|
166
|
+
self.interactions = 0
|
|
167
|
+
self.ingested = 0
|
|
168
|
+
self.failed = 0
|
|
169
|
+
self.plugin_calls_attached = 0
|
|
170
|
+
self.skipped_no_time = 0
|
|
171
|
+
# Distinct AgentX session ids touched by this run - what judge_sessions() operates on.
|
|
172
|
+
self.session_ids: List[str] = []
|
|
173
|
+
self.sessions_judged = 0
|
|
174
|
+
self.sessions_judge_skipped = 0
|
|
175
|
+
self.sessions_judge_failed = 0
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str: # also what the CLI prints
|
|
178
|
+
base = (
|
|
179
|
+
f"MoveworksSyncReport(conversations={self.conversations}, interactions={self.interactions}, "
|
|
180
|
+
f"ingested={self.ingested}, failed={self.failed}, "
|
|
181
|
+
f"plugin_calls_attached={self.plugin_calls_attached}, skipped_no_time={self.skipped_no_time}"
|
|
182
|
+
)
|
|
183
|
+
if self.sessions_judged or self.sessions_judge_skipped or self.sessions_judge_failed:
|
|
184
|
+
base += (
|
|
185
|
+
f", sessions_judged={self.sessions_judged}, "
|
|
186
|
+
f"sessions_judge_skipped={self.sessions_judge_skipped}, "
|
|
187
|
+
f"sessions_judge_failed={self.sessions_judge_failed}"
|
|
188
|
+
)
|
|
189
|
+
return base + ")"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class MoveworksImporter:
|
|
193
|
+
"""
|
|
194
|
+
Pulls Moveworks Data API records and replays them into AgentX as traces.
|
|
195
|
+
|
|
196
|
+
``agent_name`` overrides the agent every trace is attributed to; by default traces group per
|
|
197
|
+
Moveworks domain as ``moveworks-<domain>`` (falling back to ``moveworks-assistant``).
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
client: MoveworksDataAPIClient,
|
|
203
|
+
*,
|
|
204
|
+
agentx_api_key: str,
|
|
205
|
+
agentx_base_url: Optional[str] = None,
|
|
206
|
+
agent_name: Optional[str] = None,
|
|
207
|
+
timestamp_field: str = _DEFAULT_TIMESTAMP_FIELD,
|
|
208
|
+
) -> None:
|
|
209
|
+
self._client = client
|
|
210
|
+
self._agent_name = agent_name
|
|
211
|
+
self._timestamp_field = timestamp_field
|
|
212
|
+
self._ingest = IngestClient(agentx_api_key, sdk_version=VERSION, base_url=agentx_base_url)
|
|
213
|
+
|
|
214
|
+
# -- record -> wire mapping ----------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def _conversation_index(self, since: datetime, until: Optional[datetime]) -> Dict[str, Dict[str, Any]]:
|
|
217
|
+
index: Dict[str, Dict[str, Any]] = {}
|
|
218
|
+
try:
|
|
219
|
+
for conversation in self._client.records(
|
|
220
|
+
"conversations", since=since, until=until, timestamp_field=self._timestamp_field
|
|
221
|
+
):
|
|
222
|
+
conversation_id = _first(conversation, "conversation_id", "id")
|
|
223
|
+
if conversation_id:
|
|
224
|
+
index[str(conversation_id)] = conversation
|
|
225
|
+
except requests.RequestException:
|
|
226
|
+
# Conversations only enrich metadata (domain/route); interactions alone still import.
|
|
227
|
+
pass
|
|
228
|
+
return index
|
|
229
|
+
|
|
230
|
+
def _plugin_calls_index(self, since: datetime, until: Optional[datetime]) -> Dict[str, List[Dict[str, Any]]]:
|
|
231
|
+
index: Dict[str, List[Dict[str, Any]]] = {}
|
|
232
|
+
try:
|
|
233
|
+
for call in self._client.records(
|
|
234
|
+
"plugin-calls", since=since, until=until, timestamp_field=self._timestamp_field
|
|
235
|
+
):
|
|
236
|
+
key = _first(call, "interaction_id", "conversation_id")
|
|
237
|
+
if key:
|
|
238
|
+
index.setdefault(str(key), []).append(call)
|
|
239
|
+
except requests.RequestException:
|
|
240
|
+
pass
|
|
241
|
+
return index
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _tool_call_wire(call: Dict[str, Any]) -> Dict[str, Any]:
|
|
245
|
+
wire: Dict[str, Any] = {"name": str(_first(call, "plugin_name", "name") or "moveworks-plugin")}
|
|
246
|
+
served = _first(call, "served", "is_served")
|
|
247
|
+
used = _first(call, "used", "is_used")
|
|
248
|
+
# served/used are surfacing metrics, not success/failure - never fabricate a failure.
|
|
249
|
+
if served is not None:
|
|
250
|
+
wire["served"] = bool(served)
|
|
251
|
+
if used is not None:
|
|
252
|
+
wire["used"] = bool(used)
|
|
253
|
+
return wire
|
|
254
|
+
|
|
255
|
+
def _interaction_wire(
|
|
256
|
+
self,
|
|
257
|
+
interaction: Dict[str, Any],
|
|
258
|
+
conversations: Dict[str, Dict[str, Any]],
|
|
259
|
+
plugin_calls: Dict[str, List[Dict[str, Any]]],
|
|
260
|
+
) -> Optional[Dict[str, Any]]:
|
|
261
|
+
started = _record_time(interaction)
|
|
262
|
+
if started is None:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
interaction_id = str(_first(interaction, "interaction_id", "id") or "")
|
|
266
|
+
conversation_id = _first(interaction, "conversation_id")
|
|
267
|
+
conversation = conversations.get(str(conversation_id)) if conversation_id else None
|
|
268
|
+
|
|
269
|
+
domain = _first(interaction, "domain") or (conversation and _first(conversation, "domain"))
|
|
270
|
+
route = _first(interaction, "route") or (conversation and _first(conversation, "route"))
|
|
271
|
+
interaction_type = _first(interaction, "interaction_type", "type")
|
|
272
|
+
|
|
273
|
+
user_text = _first(interaction, "utterance", "user_input", "text", "message", "query", "content")
|
|
274
|
+
response_text = _first(interaction, "response", "bot_response", "reply", "answer")
|
|
275
|
+
|
|
276
|
+
calls: List[Dict[str, Any]] = []
|
|
277
|
+
if interaction_id and interaction_id in plugin_calls:
|
|
278
|
+
calls = plugin_calls[interaction_id]
|
|
279
|
+
elif conversation_id and str(conversation_id) in plugin_calls:
|
|
280
|
+
calls = plugin_calls[str(conversation_id)]
|
|
281
|
+
|
|
282
|
+
metadata: Dict[str, Any] = {"source": "moveworks"}
|
|
283
|
+
for key, value in (
|
|
284
|
+
("conversationId", conversation_id),
|
|
285
|
+
("interactionId", interaction_id or None),
|
|
286
|
+
("interactionType", interaction_type),
|
|
287
|
+
("domain", domain),
|
|
288
|
+
("route", route),
|
|
289
|
+
("userId", _first(interaction, "user_id")),
|
|
290
|
+
):
|
|
291
|
+
if value:
|
|
292
|
+
metadata[key] = value
|
|
293
|
+
|
|
294
|
+
latency_ms: Optional[int] = None
|
|
295
|
+
ended = _parse_time(_first(interaction, "resolved_time", "completed_time", "end_time", "updated_time"))
|
|
296
|
+
if ended is not None and ended >= started:
|
|
297
|
+
latency_ms = int((ended - started).total_seconds() * 1000)
|
|
298
|
+
|
|
299
|
+
name = self._agent_name or (f"moveworks-{domain}" if domain else "moveworks-assistant")
|
|
300
|
+
|
|
301
|
+
wire: Dict[str, Any] = {
|
|
302
|
+
"name": name,
|
|
303
|
+
"framework": "moveworks",
|
|
304
|
+
"metadata": metadata,
|
|
305
|
+
"started_at_unix_nano": str(int(started.timestamp() * 1_000_000_000)),
|
|
306
|
+
}
|
|
307
|
+
if interaction_id:
|
|
308
|
+
wire["span_id"] = f"mw:{interaction_id}"
|
|
309
|
+
if conversation_id:
|
|
310
|
+
wire["session_id"] = f"mw_{conversation_id}"
|
|
311
|
+
if user_text is not None:
|
|
312
|
+
wire["input"] = user_text
|
|
313
|
+
else:
|
|
314
|
+
wire["input"] = json.dumps({"interaction_type": interaction_type or "unknown"})
|
|
315
|
+
if response_text is not None:
|
|
316
|
+
wire["output"] = response_text
|
|
317
|
+
if latency_ms is not None:
|
|
318
|
+
wire["latency_ms"] = latency_ms
|
|
319
|
+
if calls:
|
|
320
|
+
wire["tool_calls"] = [self._tool_call_wire(call) for call in calls]
|
|
321
|
+
return wire
|
|
322
|
+
|
|
323
|
+
# -- public --------------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def sync(
|
|
326
|
+
self,
|
|
327
|
+
since: datetime,
|
|
328
|
+
until: Optional[datetime] = None,
|
|
329
|
+
*,
|
|
330
|
+
monitor: bool = False,
|
|
331
|
+
judge_sessions: bool = False,
|
|
332
|
+
dry_run: bool = False,
|
|
333
|
+
on_payload: Optional[Any] = None,
|
|
334
|
+
) -> MoveworksSyncReport:
|
|
335
|
+
"""
|
|
336
|
+
Import every interaction in ``[since, until)``. Safe to re-run over the same window - the
|
|
337
|
+
engine deduplicates on the deterministic ``span_id`` (and skips re-judging deduped spans).
|
|
338
|
+
|
|
339
|
+
``monitor=True`` sets ``monitor: true`` on every trace - the engine's explicit opt-in that
|
|
340
|
+
runs pattern/built-in checks (PII, empty response, tool failure, active patterns) on each
|
|
341
|
+
imported trace. ``judge_sessions=True`` additionally asks the engine to judge every
|
|
342
|
+
imported session with each enabled session-scoped evaluator after the sync; the request
|
|
343
|
+
carries ``ifStale=true`` so a session already scored (e.g. by the engine's own 24h sweep)
|
|
344
|
+
is never judged twice. Returns a summary report.
|
|
345
|
+
"""
|
|
346
|
+
report = MoveworksSyncReport()
|
|
347
|
+
conversations = self._conversation_index(since, until)
|
|
348
|
+
report.conversations = len(conversations)
|
|
349
|
+
plugin_calls = self._plugin_calls_index(since, until)
|
|
350
|
+
session_ids: List[str] = []
|
|
351
|
+
|
|
352
|
+
for interaction in self._client.records(
|
|
353
|
+
"interactions", since=since, until=until, timestamp_field=self._timestamp_field
|
|
354
|
+
):
|
|
355
|
+
report.interactions += 1
|
|
356
|
+
wire = self._interaction_wire(interaction, conversations, plugin_calls)
|
|
357
|
+
if wire is None:
|
|
358
|
+
report.skipped_no_time += 1
|
|
359
|
+
continue
|
|
360
|
+
if monitor:
|
|
361
|
+
wire["monitor"] = True
|
|
362
|
+
report.plugin_calls_attached += len(wire.get("tool_calls", []))
|
|
363
|
+
session_id = wire.get("session_id")
|
|
364
|
+
if session_id and session_id not in session_ids:
|
|
365
|
+
session_ids.append(session_id)
|
|
366
|
+
if on_payload is not None:
|
|
367
|
+
on_payload(wire)
|
|
368
|
+
if dry_run:
|
|
369
|
+
continue
|
|
370
|
+
if self._ingest.send_trace_sync(wire) is not None:
|
|
371
|
+
report.ingested += 1
|
|
372
|
+
else:
|
|
373
|
+
report.failed += 1
|
|
374
|
+
|
|
375
|
+
report.session_ids = session_ids
|
|
376
|
+
if judge_sessions and not dry_run:
|
|
377
|
+
self.judge_sessions(session_ids, report=report)
|
|
378
|
+
return report
|
|
379
|
+
|
|
380
|
+
def judge_sessions(
|
|
381
|
+
self, session_ids: List[str], *, report: Optional[MoveworksSyncReport] = None
|
|
382
|
+
) -> MoveworksSyncReport:
|
|
383
|
+
"""
|
|
384
|
+
Judge each session with every enabled session-scoped evaluator via the engine's on-demand
|
|
385
|
+
judge route, with ``ifStale=true`` so an up-to-date verdict (from the sweep or a previous
|
|
386
|
+
run) is left alone instead of judged again.
|
|
387
|
+
"""
|
|
388
|
+
report = report or MoveworksSyncReport()
|
|
389
|
+
# Same base/key the ingest client already resolved - the judge routes live under
|
|
390
|
+
# /agent-monitoring on the same engine.
|
|
391
|
+
base = self._ingest._base_url
|
|
392
|
+
http = self._ingest._session
|
|
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
|
|
419
|
+
return report
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ----------------------------------------------------------------------------------------------
|
|
423
|
+
# CLI: agentx-moveworks sync
|
|
424
|
+
# ----------------------------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def _parse_since(value: str) -> datetime:
|
|
427
|
+
"""Either an ISO timestamp or a relative shorthand like ``7d`` / ``24h`` / ``30m``."""
|
|
428
|
+
text = value.strip().lower()
|
|
429
|
+
if text and text[-1] in ("d", "h", "m") and text[:-1].isdigit():
|
|
430
|
+
amount = int(text[:-1])
|
|
431
|
+
delta = {"d": timedelta(days=amount), "h": timedelta(hours=amount), "m": timedelta(minutes=amount)}[text[-1]]
|
|
432
|
+
return datetime.now(timezone.utc) - delta
|
|
433
|
+
parsed = _parse_time(value)
|
|
434
|
+
if parsed is None:
|
|
435
|
+
raise SystemExit(f"agentx-moveworks: could not parse --since {value!r} (use ISO-8601 or e.g. 7d)")
|
|
436
|
+
return parsed
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _read_cursor(path: Path) -> Optional[datetime]:
|
|
440
|
+
try:
|
|
441
|
+
return _parse_time(json.loads(path.read_text()).get("last_synced"))
|
|
442
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _write_cursor(path: Path, value: datetime) -> None:
|
|
447
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
path.write_text(json.dumps({"last_synced": value.isoformat()}))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def cli_main(argv: Optional[List[str]] = None) -> None:
|
|
452
|
+
parser = argparse.ArgumentParser(
|
|
453
|
+
prog="agentx-moveworks",
|
|
454
|
+
description="Sync Moveworks Data API activity into AgentX as traces/sessions.",
|
|
455
|
+
)
|
|
456
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
457
|
+
sync = sub.add_parser("sync", help="Import a time window of Moveworks activity")
|
|
458
|
+
sync.add_argument("--since", help="ISO timestamp or relative (7d, 24h). Default: cursor file, else 24h")
|
|
459
|
+
sync.add_argument("--until", help="ISO timestamp (default: now)")
|
|
460
|
+
sync.add_argument("--cursor-file", default=str(_DEFAULT_CURSOR_FILE), help="Incremental cursor path")
|
|
461
|
+
sync.add_argument("--no-cursor", action="store_true", help="Ignore and don't update the cursor file")
|
|
462
|
+
sync.add_argument("--agent-name", help="Attribute every trace to this agent (default: per Moveworks domain)")
|
|
463
|
+
sync.add_argument("--timestamp-field", default=_DEFAULT_TIMESTAMP_FIELD, help="Data API record timestamp field")
|
|
464
|
+
sync.add_argument("--moveworks-base-url", default=os.getenv("MOVEWORKS_BASE_URL", _DEFAULT_BASE_URL))
|
|
465
|
+
sync.add_argument(
|
|
466
|
+
"--monitor",
|
|
467
|
+
action="store_true",
|
|
468
|
+
help="Run pattern/built-in checks (PII, empty response, tool failure, active patterns) on each imported trace",
|
|
469
|
+
)
|
|
470
|
+
sync.add_argument(
|
|
471
|
+
"--judge-sessions",
|
|
472
|
+
action="store_true",
|
|
473
|
+
help="After the sync, judge each imported session with every enabled session-scoped evaluator "
|
|
474
|
+
"(skips sessions that already have an up-to-date verdict, so the engine's own sweep is never duplicated)",
|
|
475
|
+
)
|
|
476
|
+
sync.add_argument("--dry-run", action="store_true", help="Map and print payloads without ingesting")
|
|
477
|
+
|
|
478
|
+
args = parser.parse_args(argv)
|
|
479
|
+
|
|
480
|
+
moveworks_key = os.getenv("MOVEWORKS_API_KEY", "")
|
|
481
|
+
agentx_key = os.getenv("AGENTX_API_KEY", "")
|
|
482
|
+
if not moveworks_key:
|
|
483
|
+
raise SystemExit("agentx-moveworks: set MOVEWORKS_API_KEY (Data API credential from Moveworks Setup)")
|
|
484
|
+
if not agentx_key and not args.dry_run:
|
|
485
|
+
raise SystemExit("agentx-moveworks: set AGENTX_API_KEY (your AgentX project API key)")
|
|
486
|
+
|
|
487
|
+
cursor_path = Path(args.cursor_file)
|
|
488
|
+
since = (
|
|
489
|
+
_parse_since(args.since)
|
|
490
|
+
if args.since
|
|
491
|
+
else ((None if args.no_cursor else _read_cursor(cursor_path)) or (datetime.now(timezone.utc) - timedelta(hours=24)))
|
|
492
|
+
)
|
|
493
|
+
until = _parse_time(args.until) if args.until else datetime.now(timezone.utc)
|
|
494
|
+
|
|
495
|
+
client = MoveworksDataAPIClient(moveworks_key, base_url=args.moveworks_base_url)
|
|
496
|
+
importer = MoveworksImporter(
|
|
497
|
+
client,
|
|
498
|
+
agentx_api_key=agentx_key or "dry-run",
|
|
499
|
+
agentx_base_url=os.getenv("AGENTX_API_BASE_URL"),
|
|
500
|
+
agent_name=args.agent_name,
|
|
501
|
+
timestamp_field=args.timestamp_field,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
print(f"agentx-moveworks: syncing {since.isoformat()} -> {until.isoformat()}")
|
|
505
|
+
on_payload = (lambda wire: print(json.dumps(wire))) if args.dry_run else None
|
|
506
|
+
report = importer.sync(
|
|
507
|
+
since,
|
|
508
|
+
until,
|
|
509
|
+
monitor=args.monitor,
|
|
510
|
+
judge_sessions=args.judge_sessions,
|
|
511
|
+
dry_run=args.dry_run,
|
|
512
|
+
on_payload=on_payload,
|
|
513
|
+
)
|
|
514
|
+
print(report)
|
|
515
|
+
|
|
516
|
+
if not args.dry_run and not args.no_cursor and report.failed == 0:
|
|
517
|
+
_write_cursor(cursor_path, until)
|
|
518
|
+
print(f"agentx-moveworks: cursor -> {cursor_path}")
|
|
519
|
+
elif report.failed:
|
|
520
|
+
print("agentx-moveworks: failures occurred - cursor NOT advanced, re-run to retry", file=sys.stderr)
|
|
521
|
+
sys.exit(1)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
cli_main()
|
|
@@ -99,9 +99,12 @@ class _TraceSpan:
|
|
|
99
99
|
self._parent_span_id: Optional[str] = None
|
|
100
100
|
# Numbers auto-named "LLM Call N"/"Retrieval N" child spans - see _merge_child_run.
|
|
101
101
|
self._child_span_count = 0
|
|
102
|
-
# Monitor:
|
|
103
|
-
# required
|
|
104
|
-
#
|
|
102
|
+
# Monitor: True checks this trace against patterns immediately on ingest, no dashboard
|
|
103
|
+
# profile required; False explicitly OPTS OUT of every ingest-time check (patterns,
|
|
104
|
+
# built-ins, online/custom evaluators, topics) - what eval-run traces send, since the
|
|
105
|
+
# run's own evaluator already judges each case. None (default) leaves the server's
|
|
106
|
+
# standard behavior. pattern_ids (if given) fully defines what's checked. See
|
|
107
|
+
# Tracer.trace()'s monitor/pattern_ids params.
|
|
105
108
|
self._monitor = monitor
|
|
106
109
|
self._pattern_ids = pattern_ids
|
|
107
110
|
|
|
@@ -152,7 +155,7 @@ class _TraceSpan:
|
|
|
152
155
|
|
|
153
156
|
self._trace_id = self._tracer._send(
|
|
154
157
|
sync=self._sync,
|
|
155
|
-
monitor=self._monitor
|
|
158
|
+
monitor=self._monitor,
|
|
156
159
|
pattern_ids=self._pattern_ids,
|
|
157
160
|
name=self.name,
|
|
158
161
|
agent_id=self._agent_id,
|
|
@@ -769,7 +772,7 @@ class Tracer:
|
|
|
769
772
|
model: Optional[str] = None,
|
|
770
773
|
session_id: Optional[str] = None,
|
|
771
774
|
sync: bool = False,
|
|
772
|
-
monitor: bool =
|
|
775
|
+
monitor: Optional[bool] = None,
|
|
773
776
|
pattern_ids: Optional[List[str]] = None,
|
|
774
777
|
agent_id: Optional[str] = None,
|
|
775
778
|
) -> _TraceSpan:
|
|
@@ -799,7 +802,9 @@ class Tracer:
|
|
|
799
802
|
return {"output": resp, "trace_id": span.trace_id}
|
|
800
803
|
|
|
801
804
|
Pass ``monitor=True`` to check this trace against Monitor patterns immediately, with no
|
|
802
|
-
dashboard profile required. ``
|
|
805
|
+
dashboard profile required. Pass ``monitor=False`` to explicitly skip EVERY ingest-time
|
|
806
|
+
check (patterns, built-ins, online/custom evaluators, topics) - use this for traces
|
|
807
|
+
created inside an evaluation run, which the run's own evaluator already judges. ``pattern_ids`` (ids from ``client.monitor.patterns.builder(
|
|
803
808
|
...).publish()``) restricts detection to exactly those patterns; omit it to run the full
|
|
804
809
|
default sweep (built-in checks plus every enabled workspace pattern)::
|
|
805
810
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.6.21"
|
|
@@ -36,6 +36,7 @@ agentx/integrations/google_genai.py
|
|
|
36
36
|
agentx/integrations/langchain.py
|
|
37
37
|
agentx/integrations/litellm.py
|
|
38
38
|
agentx/integrations/llamaindex.py
|
|
39
|
+
agentx/integrations/moveworks.py
|
|
39
40
|
agentx/integrations/openai.py
|
|
40
41
|
agentx/integrations/openai_agents.py
|
|
41
42
|
agentx/monitor/__init__.py
|
|
@@ -30,7 +30,12 @@ setup(
|
|
|
30
30
|
# Thin launcher for the self-hostable governance engine (AgentX-ai/AgentX-trace-eval),
|
|
31
31
|
# not this SDK itself, see agentx/cli.py's module docstring for why it lazily downloads
|
|
32
32
|
# rather than bundling that engine in this package.
|
|
33
|
-
"console_scripts": [
|
|
33
|
+
"console_scripts": [
|
|
34
|
+
"agentx-trace-eval=agentx.cli:main",
|
|
35
|
+
# Moveworks Data API -> AgentX traces sync (see agentx/integrations/moveworks.py) -
|
|
36
|
+
# a pull importer, since Moveworks agents run in their cloud with no in-process hook.
|
|
37
|
+
"agentx-moveworks=agentx.integrations.moveworks:cli_main",
|
|
38
|
+
],
|
|
34
39
|
},
|
|
35
40
|
install_requires=[
|
|
36
41
|
"urllib3>=1.26.11",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.6.19"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|