trace-agent-server 0.3.0__tar.gz → 0.5.0__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.
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/PKG-INFO +1 -1
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/pyproject.toml +1 -1
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/runs.py +20 -8
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/config.py +2 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/main.py +36 -1
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/otlp.py +13 -1
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/run_lifecycle.py +22 -1
- trace_agent_server-0.5.0/src/trace_agent_server/services/stored_redaction.py +57 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/PKG-INFO +1 -1
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/SOURCES.txt +3 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_audit_guardrails_api.py +16 -0
- trace_agent_server-0.5.0/tests/test_inactivity_sweep.py +50 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_otlp_ingest.py +34 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_run_lifecycle.py +42 -0
- trace_agent_server-0.5.0/tests/test_stored_redaction.py +148 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/README.md +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/setup.cfg +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/__init__.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/__init__.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/otlp.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/cli.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/db.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/models.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/schemas.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/__init__.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/analytics.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/audit_guardrails.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/compare.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/dashboard.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/events.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/explanation.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/graph.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/observability.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/otlp_decode.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/provider.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/run_summary.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/dependency_links.txt +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/entry_points.txt +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/requires.txt +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/top_level.txt +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_dashboard.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_events.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_framework_observations.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_otlp_decode.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_providers.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_runs_api.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_sdk_wrappers.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_sse.py +0 -0
- {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_turns.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trace-agent-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: TraceAgent FastAPI backend server.
|
|
5
5
|
Author-email: Enrique Javier Villar Cea <villarcea.enriquejavier@gmail.com>, Javier Rodriguez Castellano <javirodriguezcastellano@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
|
|
|
12
12
|
|
|
13
13
|
from trace_agent_server.db import get_session
|
|
14
14
|
from trace_agent_server.models import Artifact, Decision, Event, Run, Turn
|
|
15
|
-
from trace_agent_sdk.redaction import stable_hash, summarize_value
|
|
15
|
+
from trace_agent_sdk.redaction import redact_keys, stable_hash, summarize_value
|
|
16
16
|
from trace_agent_server.schemas import (
|
|
17
17
|
ArtifactOut,
|
|
18
18
|
DashboardResponse,
|
|
@@ -36,6 +36,7 @@ from trace_agent_server.schemas import (
|
|
|
36
36
|
TurnCreateResponse,
|
|
37
37
|
TurnOut,
|
|
38
38
|
)
|
|
39
|
+
from trace_agent_server.services.stored_redaction import redact_messages, redact_tool_calls
|
|
39
40
|
from trace_agent_server.services.analytics import build_similar_runs, build_tool_graph, build_tool_metrics
|
|
40
41
|
from trace_agent_server.services.compare import compare_runs
|
|
41
42
|
from trace_agent_server.services.events import create_artifact, record_event
|
|
@@ -295,6 +296,8 @@ def create_turn(
|
|
|
295
296
|
raise HTTPException(status_code=400, detail="Input blocked by guardrails.")
|
|
296
297
|
|
|
297
298
|
tool_redaction = {tool.name: tool.redact_fields for tool in payload.tools}
|
|
299
|
+
# What is stored is a masked copy; the provider and the agent keep the real data.
|
|
300
|
+
stored_messages = redact_messages(sanitized_messages, tool_redaction)
|
|
298
301
|
if payload.tools:
|
|
299
302
|
run.run_metadata = {
|
|
300
303
|
**run.run_metadata,
|
|
@@ -315,7 +318,7 @@ def create_turn(
|
|
|
315
318
|
provider=payload.provider,
|
|
316
319
|
model=payload.model,
|
|
317
320
|
payload_hash=stable_hash(sanitized_messages),
|
|
318
|
-
payload_full={"messages":
|
|
321
|
+
payload_full={"messages": stored_messages} if capture_full else None,
|
|
319
322
|
metadata={
|
|
320
323
|
"registered_tool_count": len(payload.tools),
|
|
321
324
|
"tool_names": [tool.name for tool in payload.tools],
|
|
@@ -355,6 +358,10 @@ def create_turn(
|
|
|
355
358
|
run.provider = payload.provider
|
|
356
359
|
run.model = payload.model
|
|
357
360
|
|
|
361
|
+
stored_assistant_message = (
|
|
362
|
+
redact_messages([response.assistant_message], tool_redaction)[0] if response.assistant_message else None
|
|
363
|
+
)
|
|
364
|
+
|
|
358
365
|
# Create Turn record with full messages and token usage
|
|
359
366
|
usage = response.metadata.get("usage", {}) if response.metadata else {}
|
|
360
367
|
turn = Turn(
|
|
@@ -362,9 +369,12 @@ def create_turn(
|
|
|
362
369
|
seq=run.last_event_seq + 1,
|
|
363
370
|
provider=payload.provider,
|
|
364
371
|
model=payload.model,
|
|
365
|
-
messages=
|
|
366
|
-
assistant_message=
|
|
367
|
-
tool_calls=
|
|
372
|
+
messages=stored_messages,
|
|
373
|
+
assistant_message=stored_assistant_message,
|
|
374
|
+
tool_calls=redact_tool_calls(
|
|
375
|
+
[{"id": c.id, "name": c.name, "arguments": c.arguments, "step_id": c.step_id} for c in response.tool_calls],
|
|
376
|
+
tool_redaction,
|
|
377
|
+
),
|
|
368
378
|
prompt_tokens=usage.get("prompt_tokens"),
|
|
369
379
|
completion_tokens=usage.get("completion_tokens"),
|
|
370
380
|
total_tokens=usage.get("total_tokens"),
|
|
@@ -409,7 +419,7 @@ def create_turn(
|
|
|
409
419
|
provider=payload.provider,
|
|
410
420
|
model=payload.model,
|
|
411
421
|
payload_hash=stable_hash(response.assistant_message or [tool_call.__dict__ for tool_call in response.tool_calls]),
|
|
412
|
-
payload_full={"assistant_message":
|
|
422
|
+
payload_full={"assistant_message": stored_assistant_message, "tool_calls": turn.tool_calls} if capture_full else None,
|
|
413
423
|
turn_id=turn.id,
|
|
414
424
|
metadata=response_metadata,
|
|
415
425
|
)
|
|
@@ -430,7 +440,7 @@ def create_turn(
|
|
|
430
440
|
model=payload.model,
|
|
431
441
|
tool_name=call.name,
|
|
432
442
|
payload_hash=stable_hash(call.arguments),
|
|
433
|
-
payload_full={"arguments": call.arguments} if capture_full else None,
|
|
443
|
+
payload_full={"arguments": redact_keys(call.arguments, tool_redaction.get(call.name, []))} if capture_full else None,
|
|
434
444
|
turn_id=turn.id,
|
|
435
445
|
metadata={"call_id": call.id},
|
|
436
446
|
)
|
|
@@ -962,7 +972,7 @@ def get_artifacts(run_id: str, session: Session = Depends(get_session)) -> list[
|
|
|
962
972
|
|
|
963
973
|
|
|
964
974
|
@router.get("/runs/{run_id}/audit")
|
|
965
|
-
def get_run_audit(run_id: str, session: Session = Depends(get_session)) -> dict[str, Any]:
|
|
975
|
+
def get_run_audit(run_id: str, request: Request, session: Session = Depends(get_session)) -> dict[str, Any]:
|
|
966
976
|
"""Return a structured audit report for a run based on guardrail events."""
|
|
967
977
|
run = _get_run_or_404(session, run_id)
|
|
968
978
|
events = list(session.scalars(select(Event).where(Event.run_id == run_id).order_by(Event.seq)))
|
|
@@ -1020,6 +1030,8 @@ def get_run_audit(run_id: str, session: Session = Depends(get_session)) -> dict[
|
|
|
1020
1030
|
|
|
1021
1031
|
return {
|
|
1022
1032
|
"run_id": run_id,
|
|
1033
|
+
# Without guardrails nothing is checked, so the score would only be a meaningless 100.
|
|
1034
|
+
"guardrails_enabled": bool(getattr(request.app.state.settings, "audit_enable_guardrails", False)),
|
|
1023
1035
|
"security_score": security_score,
|
|
1024
1036
|
"pii_hit_count": pii_hit_count,
|
|
1025
1037
|
"injection_hit_count": injection_hit_count,
|
|
@@ -45,6 +45,8 @@ class Settings(BaseSettings):
|
|
|
45
45
|
capture_full_payloads: bool = True
|
|
46
46
|
otlp_enabled: bool = True
|
|
47
47
|
otlp_max_body_bytes: int = 10_000_000
|
|
48
|
+
# A run with no event for this long is closed as failed (0 turns it off).
|
|
49
|
+
run_inactivity_timeout_seconds: int = 3600
|
|
48
50
|
|
|
49
51
|
model_config = SettingsConfigDict(
|
|
50
52
|
env_prefix="TRACE_AGENT_",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
3
8
|
from fastapi import FastAPI
|
|
4
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
5
10
|
|
|
@@ -9,6 +14,7 @@ from trace_agent_server.config import Settings, get_settings
|
|
|
9
14
|
from trace_agent_server.db import Base, create_session_factory
|
|
10
15
|
from trace_agent_server.services.audit_guardrails import AuditGuardrails
|
|
11
16
|
from trace_agent_server.services.observability import AppObservability, configure_opentelemetry
|
|
17
|
+
from trace_agent_server.services.run_lifecycle import expire_inactive_runs
|
|
12
18
|
from trace_agent_server.services.provider import (
|
|
13
19
|
AnthropicProviderAdapter,
|
|
14
20
|
BaseProviderAdapter,
|
|
@@ -41,9 +47,38 @@ def build_provider_registry(settings: Settings) -> dict[str, BaseProviderAdapter
|
|
|
41
47
|
return registry
|
|
42
48
|
|
|
43
49
|
|
|
50
|
+
def _close_inactive_runs(app: FastAPI, timeout: timedelta) -> None:
|
|
51
|
+
with app.state.session_factory() as session:
|
|
52
|
+
expire_inactive_runs(session, timeout=timeout)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
57
|
+
"""While the server is up, close the runs whose agent disappeared without finishing them."""
|
|
58
|
+
seconds = app.state.settings.run_inactivity_timeout_seconds
|
|
59
|
+
task: asyncio.Task[None] | None = None
|
|
60
|
+
if seconds > 0:
|
|
61
|
+
timeout = timedelta(seconds=seconds)
|
|
62
|
+
|
|
63
|
+
async def sweep() -> None:
|
|
64
|
+
while True:
|
|
65
|
+
await asyncio.sleep(min(60.0, max(1.0, seconds / 4)))
|
|
66
|
+
try:
|
|
67
|
+
await asyncio.to_thread(_close_inactive_runs, app, timeout)
|
|
68
|
+
except Exception: # a failed sweep must never take the server down; try again next time
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
task = asyncio.create_task(sweep())
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
if task is not None:
|
|
76
|
+
task.cancel()
|
|
77
|
+
|
|
78
|
+
|
|
44
79
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
45
80
|
current_settings = settings or get_settings()
|
|
46
|
-
app = FastAPI(title=current_settings.app_name)
|
|
81
|
+
app = FastAPI(title=current_settings.app_name, lifespan=_lifespan)
|
|
47
82
|
app.add_middleware(
|
|
48
83
|
CORSMiddleware,
|
|
49
84
|
allow_origins=current_settings.cors_origins,
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/otlp.py
RENAMED
|
@@ -89,6 +89,18 @@ def _parse_json(value: Any) -> Any:
|
|
|
89
89
|
return value
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def _readable(text: Any) -> Any:
|
|
93
|
+
"""Decode the \\uXXXX escapes of a JSON text, so accents show as typed.
|
|
94
|
+
|
|
95
|
+
Some producers serialize results with json.dumps defaults, which escapes every non-ASCII character.
|
|
96
|
+
"""
|
|
97
|
+
if isinstance(text, str) and "\\u" in text:
|
|
98
|
+
parsed = _parse_json(text)
|
|
99
|
+
if parsed is not text:
|
|
100
|
+
return parsed
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
92
104
|
def _error_of(span: SpanRecord) -> tuple[str, str]:
|
|
93
105
|
"""Error code (the exception class name) and message of a failed span."""
|
|
94
106
|
for event in span.events:
|
|
@@ -347,7 +359,7 @@ def _tool_steps(session: Session, run: Run, span: SpanRecord, capture_full: bool
|
|
|
347
359
|
parent_step_id=span.parent_span_id,
|
|
348
360
|
status="failed" if failed else "succeeded",
|
|
349
361
|
args=redact_payload(arguments if isinstance(arguments, dict) else {"input": arguments} if arguments is not None else {}),
|
|
350
|
-
output_summary=summarize_value(result_text) if result_text is not None else None,
|
|
362
|
+
output_summary=summarize_value(_readable(result_text)) if result_text is not None else None,
|
|
351
363
|
output_hash=stable_hash(result_text if not failed else {"error": message}),
|
|
352
364
|
error_code=code,
|
|
353
365
|
error_summary=message,
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Sequence
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
from sqlalchemy import select
|
|
9
10
|
from sqlalchemy.orm import Session
|
|
10
11
|
|
|
11
12
|
from trace_agent_sdk.redaction import stable_hash, summarize_value
|
|
@@ -213,3 +214,23 @@ def fail_run_with(
|
|
|
213
214
|
timestamp=ended_at,
|
|
214
215
|
)
|
|
215
216
|
derive_explanation(session, run)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def expire_inactive_runs(session: Session, *, timeout: timedelta, now: datetime | None = None) -> int:
|
|
220
|
+
"""Fail the runs that are still running but have had no event for ``timeout``.
|
|
221
|
+
|
|
222
|
+
An agent that dies without calling finish or fail would otherwise stay "running" forever and keep
|
|
223
|
+
counting as active. Returns how many runs were closed.
|
|
224
|
+
"""
|
|
225
|
+
now = now or utcnow()
|
|
226
|
+
stale = list(session.scalars(select(Run).where(Run.status == "running", Run.updated_at < now - timeout)))
|
|
227
|
+
minutes = max(1, int(timeout.total_seconds() // 60))
|
|
228
|
+
for run in stale:
|
|
229
|
+
fail_run_with(
|
|
230
|
+
session,
|
|
231
|
+
run,
|
|
232
|
+
"RunAbandoned",
|
|
233
|
+
f"No activity for {minutes} minutes: the agent stopped without finishing the run.",
|
|
234
|
+
)
|
|
235
|
+
session.commit()
|
|
236
|
+
return len(stale)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Copies of a model turn with the sensitive keys of each tool masked, for what the server stores.
|
|
2
|
+
|
|
3
|
+
The provider and the agent still get the real data; only what is saved (events, turns) is masked.
|
|
4
|
+
A tool names its sensitive keys with ``redact_fields``; they apply to that tool's arguments and result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from trace_agent_sdk.redaction import redact_keys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _redact_json_text(text: Any, fields: list[str]) -> Any:
|
|
16
|
+
if not fields or not isinstance(text, str):
|
|
17
|
+
return text
|
|
18
|
+
try:
|
|
19
|
+
parsed = json.loads(text)
|
|
20
|
+
except ValueError:
|
|
21
|
+
return text
|
|
22
|
+
return json.dumps(redact_keys(parsed, fields), ensure_ascii=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def redact_messages(messages: list[dict[str, Any]], tool_redaction: dict[str, list[str]]) -> list[dict[str, Any]]:
|
|
26
|
+
"""The conversation with the arguments and results of each tool masked."""
|
|
27
|
+
if not any(tool_redaction.values()):
|
|
28
|
+
return messages
|
|
29
|
+
|
|
30
|
+
tool_names: dict[str, str] = {}
|
|
31
|
+
for message in messages:
|
|
32
|
+
for call in message.get("tool_calls") or []:
|
|
33
|
+
tool_names[str(call.get("id"))] = str((call.get("function") or {}).get("name"))
|
|
34
|
+
|
|
35
|
+
redacted: list[dict[str, Any]] = []
|
|
36
|
+
for message in messages:
|
|
37
|
+
message = dict(message)
|
|
38
|
+
if message.get("role") == "tool":
|
|
39
|
+
fields = tool_redaction.get(tool_names.get(str(message.get("tool_call_id")), ""), [])
|
|
40
|
+
message["content"] = _redact_json_text(message.get("content"), fields)
|
|
41
|
+
if message.get("tool_calls"):
|
|
42
|
+
calls = []
|
|
43
|
+
for call in message["tool_calls"]:
|
|
44
|
+
function = dict(call.get("function") or {})
|
|
45
|
+
function["arguments"] = _redact_json_text(function.get("arguments"), tool_redaction.get(str(function.get("name")), []))
|
|
46
|
+
calls.append({**call, "function": function})
|
|
47
|
+
message["tool_calls"] = calls
|
|
48
|
+
redacted.append(message)
|
|
49
|
+
return redacted
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def redact_tool_calls(tool_calls: list[dict[str, Any]], tool_redaction: dict[str, list[str]]) -> list[dict[str, Any]]:
|
|
53
|
+
"""Tool calls ({"name", "arguments", ...}) with the arguments of each tool masked."""
|
|
54
|
+
return [
|
|
55
|
+
{**call, "arguments": redact_keys(call.get("arguments"), tool_redaction.get(str(call.get("name")), []))}
|
|
56
|
+
for call in tool_calls
|
|
57
|
+
]
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trace-agent-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: TraceAgent FastAPI backend server.
|
|
5
5
|
Author-email: Enrique Javier Villar Cea <villarcea.enriquejavier@gmail.com>, Javier Rodriguez Castellano <javirodriguezcastellano@gmail.com>
|
|
6
6
|
License: MIT
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/SOURCES.txt
RENAMED
|
@@ -30,10 +30,12 @@ src/trace_agent_server/services/otlp_decode.py
|
|
|
30
30
|
src/trace_agent_server/services/provider.py
|
|
31
31
|
src/trace_agent_server/services/run_lifecycle.py
|
|
32
32
|
src/trace_agent_server/services/run_summary.py
|
|
33
|
+
src/trace_agent_server/services/stored_redaction.py
|
|
33
34
|
tests/test_audit_guardrails_api.py
|
|
34
35
|
tests/test_dashboard.py
|
|
35
36
|
tests/test_events.py
|
|
36
37
|
tests/test_framework_observations.py
|
|
38
|
+
tests/test_inactivity_sweep.py
|
|
37
39
|
tests/test_otlp_decode.py
|
|
38
40
|
tests/test_otlp_ingest.py
|
|
39
41
|
tests/test_providers.py
|
|
@@ -41,4 +43,5 @@ tests/test_run_lifecycle.py
|
|
|
41
43
|
tests/test_runs_api.py
|
|
42
44
|
tests/test_sdk_wrappers.py
|
|
43
45
|
tests/test_sse.py
|
|
46
|
+
tests/test_stored_redaction.py
|
|
44
47
|
tests/test_turns.py
|
|
@@ -137,3 +137,19 @@ def test_metrics_endpoint_is_available_when_enabled(tmp_path) -> None:
|
|
|
137
137
|
body = metrics.text
|
|
138
138
|
assert "TRACE_AGENT_http_requests_total" in body
|
|
139
139
|
assert "TRACE_AGENT_http_request_duration_seconds" in body
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_audit_says_guardrails_are_off_by_default(tmp_path) -> None:
|
|
143
|
+
client = build_client(tmp_path, CountingProvider())
|
|
144
|
+
|
|
145
|
+
report = client.get(f"/api/runs/{_create_run(client)}/audit").json()
|
|
146
|
+
|
|
147
|
+
assert report["guardrails_enabled"] is False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_audit_says_guardrails_are_on_when_enabled(tmp_path) -> None:
|
|
151
|
+
client = build_client(tmp_path, CountingProvider(), audit_enable_guardrails=True)
|
|
152
|
+
|
|
153
|
+
report = client.get(f"/api/runs/{_create_run(client)}/audit").json()
|
|
154
|
+
|
|
155
|
+
assert report["guardrails_enabled"] is True
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from trace_agent_server.config import Settings
|
|
8
|
+
from trace_agent_server.main import create_app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_app(tmp_path, timeout_seconds: int):
|
|
12
|
+
return create_app(
|
|
13
|
+
Settings(
|
|
14
|
+
database_url=f"sqlite:///{tmp_path / 'sweep.db'}",
|
|
15
|
+
cors_origins=["http://localhost:5173"],
|
|
16
|
+
run_inactivity_timeout_seconds=timeout_seconds,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def wait_for_status(client: TestClient, run_id: str, status: str, seconds: float) -> str:
|
|
22
|
+
deadline = time.time() + seconds
|
|
23
|
+
current = ""
|
|
24
|
+
while time.time() < deadline:
|
|
25
|
+
current = client.get(f"/api/runs/{run_id}").json()["status"]
|
|
26
|
+
if current == status:
|
|
27
|
+
break
|
|
28
|
+
time.sleep(0.25)
|
|
29
|
+
return current
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestInactivitySweep:
|
|
33
|
+
def test_a_running_server_closes_a_run_whose_agent_disappeared(self, tmp_path) -> None:
|
|
34
|
+
with TestClient(build_app(tmp_path, timeout_seconds=1)) as client:
|
|
35
|
+
run_id = client.post("/api/runs", json={"agent_name": "a", "goal": "g"}).json()["run_id"]
|
|
36
|
+
|
|
37
|
+
assert wait_for_status(client, run_id, "failed", seconds=8) == "failed"
|
|
38
|
+
|
|
39
|
+
events = client.get(f"/api/runs/{run_id}/events").json()
|
|
40
|
+
events = events if isinstance(events, list) else events["events"]
|
|
41
|
+
assert events[-1]["type"] == "run.failed"
|
|
42
|
+
assert events[-1]["error_code"] == "RunAbandoned"
|
|
43
|
+
|
|
44
|
+
def test_a_timeout_of_zero_turns_the_sweep_off(self, tmp_path) -> None:
|
|
45
|
+
with TestClient(build_app(tmp_path, timeout_seconds=0)) as client:
|
|
46
|
+
run_id = client.post("/api/runs", json={"agent_name": "a", "goal": "g"}).json()["run_id"]
|
|
47
|
+
|
|
48
|
+
time.sleep(2.5)
|
|
49
|
+
|
|
50
|
+
assert client.get(f"/api/runs/{run_id}").json()["status"] == "running"
|
|
@@ -433,6 +433,40 @@ class TestPrivacy:
|
|
|
433
433
|
assert len(next(e for e in events if e.type == "tool.succeeded").summary) <= 220
|
|
434
434
|
|
|
435
435
|
|
|
436
|
+
class TestNonAsciiText:
|
|
437
|
+
def test_accents_in_a_tool_result_are_shown_as_typed(self, client) -> None:
|
|
438
|
+
spans = agent_spans()
|
|
439
|
+
spans[2].attributes["gen_ai__tool__call__result"] = json.dumps({"mensaje": "El OEE bajó"}, ensure_ascii=False)
|
|
440
|
+
|
|
441
|
+
post(client, export(*spans))
|
|
442
|
+
|
|
443
|
+
_, events = load(client)
|
|
444
|
+
summary = next(e for e in events if e.type == "tool.succeeded").summary
|
|
445
|
+
assert "El OEE bajó" in summary
|
|
446
|
+
assert "\\u" not in summary
|
|
447
|
+
|
|
448
|
+
def test_a_result_escaped_by_the_producer_is_decoded(self, client) -> None:
|
|
449
|
+
spans = agent_spans()
|
|
450
|
+
spans[2].attributes["gen_ai__tool__call__result"] = json.dumps({"mensaje": "El OEE bajó"})
|
|
451
|
+
assert "\\u00f3" in spans[2].attributes["gen_ai__tool__call__result"]
|
|
452
|
+
|
|
453
|
+
post(client, export(*spans))
|
|
454
|
+
|
|
455
|
+
_, events = load(client)
|
|
456
|
+
summary = next(e for e in events if e.type == "tool.succeeded").summary
|
|
457
|
+
assert "El OEE bajó" in summary
|
|
458
|
+
assert "\\u" not in summary
|
|
459
|
+
|
|
460
|
+
def test_a_plain_text_result_with_a_backslash_is_left_alone(self, client) -> None:
|
|
461
|
+
spans = agent_spans()
|
|
462
|
+
spans[2].attributes["gen_ai__tool__call__result"] = r"path C:\users\ana"
|
|
463
|
+
|
|
464
|
+
post(client, export(*spans))
|
|
465
|
+
|
|
466
|
+
_, events = load(client)
|
|
467
|
+
assert next(e for e in events if e.type == "tool.succeeded").summary == r"path C:\users\ana"
|
|
468
|
+
|
|
469
|
+
|
|
436
470
|
class TestEndpoint:
|
|
437
471
|
def test_the_response_is_a_valid_protobuf_export_response(self, client) -> None:
|
|
438
472
|
response = post(client, export(*agent_spans()))
|
|
@@ -13,6 +13,7 @@ from trace_agent_server.services.events import record_event
|
|
|
13
13
|
from trace_agent_server.services.run_lifecycle import (
|
|
14
14
|
apply_llm_usage,
|
|
15
15
|
complete_run,
|
|
16
|
+
expire_inactive_runs,
|
|
16
17
|
fail_run_with,
|
|
17
18
|
record_tool_result,
|
|
18
19
|
token_count,
|
|
@@ -115,3 +116,44 @@ class TestRunEnd:
|
|
|
115
116
|
assert (run.status, run.error_count, run.final_output_summary) == ("failed", 1, "boom")
|
|
116
117
|
last = events_of(session, run)[-1]
|
|
117
118
|
assert (last.type, last.error_code) == ("run.failed", "ValueError")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestExpireInactiveRuns:
|
|
122
|
+
def stale_run(self, session, *, minutes_ago: int, status: str = "running") -> Run:
|
|
123
|
+
run = Run(agent_name="agent", goal="goal", status=status)
|
|
124
|
+
session.add(run)
|
|
125
|
+
session.flush()
|
|
126
|
+
run.updated_at = START - timedelta(minutes=minutes_ago)
|
|
127
|
+
session.flush()
|
|
128
|
+
return run
|
|
129
|
+
|
|
130
|
+
def test_a_run_with_no_activity_is_failed_with_a_clear_reason(self, session) -> None:
|
|
131
|
+
run = self.stale_run(session, minutes_ago=90)
|
|
132
|
+
|
|
133
|
+
closed = expire_inactive_runs(session, timeout=timedelta(minutes=60), now=START)
|
|
134
|
+
|
|
135
|
+
assert closed == 1
|
|
136
|
+
assert run.status == "failed"
|
|
137
|
+
assert "No activity for 60 minutes" in run.final_output_summary
|
|
138
|
+
last = events_of(session, run)[-1]
|
|
139
|
+
assert (last.type, last.error_code) == ("run.failed", "RunAbandoned")
|
|
140
|
+
|
|
141
|
+
def test_a_recent_run_is_left_running(self, session) -> None:
|
|
142
|
+
run = self.stale_run(session, minutes_ago=5)
|
|
143
|
+
|
|
144
|
+
assert expire_inactive_runs(session, timeout=timedelta(minutes=60), now=START) == 0
|
|
145
|
+
assert run.status == "running"
|
|
146
|
+
|
|
147
|
+
def test_finished_runs_are_not_touched(self, session) -> None:
|
|
148
|
+
run = self.stale_run(session, minutes_ago=500, status="completed")
|
|
149
|
+
|
|
150
|
+
assert expire_inactive_runs(session, timeout=timedelta(minutes=60), now=START) == 0
|
|
151
|
+
assert run.status == "completed"
|
|
152
|
+
|
|
153
|
+
def test_an_active_run_keeps_going_as_events_arrive(self, session) -> None:
|
|
154
|
+
run = self.stale_run(session, minutes_ago=90)
|
|
155
|
+
record_event(session, run, actor="sdk", event_type="tool.started", summary="still working", timestamp=START)
|
|
156
|
+
run.updated_at = START
|
|
157
|
+
|
|
158
|
+
assert expire_inactive_runs(session, timeout=timedelta(minutes=60), now=START) == 0
|
|
159
|
+
assert run.status == "running"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from trace_agent_sdk import TraceAgentClient
|
|
9
|
+
from trace_agent_server.config import Settings
|
|
10
|
+
from trace_agent_server.main import create_app
|
|
11
|
+
from trace_agent_server.models import Event, Turn
|
|
12
|
+
from trace_agent_server.services.provider import ProviderToolCall, ProviderTurnRequest, ProviderTurnResponse
|
|
13
|
+
from trace_agent_server.services.stored_redaction import redact_messages, redact_tool_calls
|
|
14
|
+
|
|
15
|
+
SECRET = "SECRET-KEY-123"
|
|
16
|
+
CARD = "4111111111111111"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChargingProvider:
|
|
20
|
+
"""Asks for the `charge` tool once, then answers. Keeps what it was sent."""
|
|
21
|
+
|
|
22
|
+
provider_name = "openai"
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.seen: list[list[dict]] = []
|
|
26
|
+
|
|
27
|
+
def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
|
|
28
|
+
self.seen.append(request.messages)
|
|
29
|
+
if not any(m.get("role") == "tool" for m in request.messages):
|
|
30
|
+
return ProviderTurnResponse(
|
|
31
|
+
assistant_message={
|
|
32
|
+
"role": "assistant",
|
|
33
|
+
"content": None,
|
|
34
|
+
"tool_calls": [
|
|
35
|
+
{"id": "call_1", "type": "function", "function": {"name": "charge", "arguments": json.dumps({"customer": "ana", "api_key": SECRET})}}
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
tool_calls=[ProviderToolCall(id="call_1", name="charge", arguments={"customer": "ana", "api_key": SECRET}, step_id="step-1")],
|
|
39
|
+
summary="calls charge",
|
|
40
|
+
)
|
|
41
|
+
return ProviderTurnResponse(assistant_message={"role": "assistant", "content": "Charged."}, summary="Charged.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def provider() -> ChargingProvider:
|
|
46
|
+
return ChargingProvider()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def client(tmp_path, provider) -> TestClient:
|
|
51
|
+
app = create_app(Settings(database_url=f"sqlite:///{tmp_path / 'test.db'}", cors_origins=["http://localhost:5173"]))
|
|
52
|
+
app.state.provider_registry = {"openai": provider}
|
|
53
|
+
return TestClient(app)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def everything_stored(client: TestClient) -> str:
|
|
57
|
+
with client.app.state.session_factory() as session:
|
|
58
|
+
events = [(e.summary, e.payload_full, e.event_metadata) for e in session.query(Event).all()]
|
|
59
|
+
turns = [(t.messages, t.assistant_message, t.tool_calls) for t in session.query(Turn).all()]
|
|
60
|
+
return json.dumps([events, turns], default=str)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestToolRedactionOnStoredData:
|
|
64
|
+
def run_agent(self, client: TestClient, redact_fields: list[str]):
|
|
65
|
+
run = TraceAgentClient("http://testserver", http_client=client).start_run("billing", "Charge ana")
|
|
66
|
+
received: dict = {}
|
|
67
|
+
|
|
68
|
+
@run.tool(name="charge", description="Charge a customer.", redact_fields=redact_fields)
|
|
69
|
+
def charge(customer: str, api_key: str) -> dict:
|
|
70
|
+
received["args"] = {"customer": customer, "api_key": api_key}
|
|
71
|
+
return {"ok": True, "customer": customer, "card": CARD}
|
|
72
|
+
|
|
73
|
+
result = run.create_model_turn([{"role": "user", "content": "Charge ana"}], model="m")
|
|
74
|
+
run.finish(result.assistant_message["content"])
|
|
75
|
+
return received, result
|
|
76
|
+
|
|
77
|
+
def test_nothing_sensitive_is_stored_anywhere(self, client, provider) -> None:
|
|
78
|
+
self.run_agent(client, ["api_key", "card"])
|
|
79
|
+
|
|
80
|
+
stored = everything_stored(client)
|
|
81
|
+
assert SECRET not in stored
|
|
82
|
+
assert CARD not in stored
|
|
83
|
+
assert "[redacted]" in stored
|
|
84
|
+
|
|
85
|
+
def test_the_tool_and_the_model_still_get_the_real_data(self, client, provider) -> None:
|
|
86
|
+
received, _ = self.run_agent(client, ["api_key", "card"])
|
|
87
|
+
|
|
88
|
+
assert received["args"] == {"customer": "ana", "api_key": SECRET}
|
|
89
|
+
tool_message = next(m for m in provider.seen[-1] if m.get("role") == "tool")
|
|
90
|
+
assert CARD in tool_message["content"]
|
|
91
|
+
|
|
92
|
+
def test_the_agent_is_handed_the_real_tool_call_arguments(self, client, provider) -> None:
|
|
93
|
+
_, result = self.run_agent(client, ["api_key", "card"])
|
|
94
|
+
|
|
95
|
+
assert result.executed_tools[0]["status"] == "succeeded"
|
|
96
|
+
|
|
97
|
+
def test_without_redact_fields_the_data_is_stored_as_before(self, client, provider) -> None:
|
|
98
|
+
self.run_agent(client, [])
|
|
99
|
+
|
|
100
|
+
assert SECRET in everything_stored(client)
|
|
101
|
+
|
|
102
|
+
def test_other_values_are_kept_in_full(self, client, provider) -> None:
|
|
103
|
+
self.run_agent(client, ["api_key"])
|
|
104
|
+
|
|
105
|
+
stored = everything_stored(client)
|
|
106
|
+
assert "ana" in stored
|
|
107
|
+
assert CARD in stored # `card` was not listed, so it is not masked
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestRedactMessages:
|
|
111
|
+
def test_masks_the_arguments_of_a_call_and_the_result_of_its_tool(self) -> None:
|
|
112
|
+
messages = [
|
|
113
|
+
{"role": "user", "content": "hi"},
|
|
114
|
+
{"role": "assistant", "content": None, "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "charge", "arguments": json.dumps({"api_key": "k", "n": 1})}}]},
|
|
115
|
+
{"role": "tool", "tool_call_id": "c1", "content": json.dumps({"card": "4111", "ok": True})},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
masked = redact_messages(messages, {"charge": ["api_key", "card"]})
|
|
119
|
+
|
|
120
|
+
assert json.loads(masked[1]["tool_calls"][0]["function"]["arguments"]) == {"api_key": "[redacted]", "n": 1}
|
|
121
|
+
assert json.loads(masked[2]["content"]) == {"card": "[redacted]", "ok": True}
|
|
122
|
+
assert messages[2]["content"] == json.dumps({"card": "4111", "ok": True}) # the original is untouched
|
|
123
|
+
|
|
124
|
+
def test_a_tool_without_redact_fields_and_plain_text_are_left_alone(self) -> None:
|
|
125
|
+
messages = [
|
|
126
|
+
{"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "other", "arguments": '{"a": 1}'}}]},
|
|
127
|
+
{"role": "tool", "tool_call_id": "c1", "content": "plain text"},
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
assert redact_messages(messages, {"charge": ["api_key"]}) == messages
|
|
131
|
+
assert redact_messages(messages, {}) is messages
|
|
132
|
+
|
|
133
|
+
def test_accents_survive_the_masking(self) -> None:
|
|
134
|
+
messages = [
|
|
135
|
+
{"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "t", "arguments": json.dumps({"pw": "x", "msg": "bajó"}, ensure_ascii=False)}}]},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
masked = redact_messages(messages, {"t": ["pw"]})
|
|
139
|
+
|
|
140
|
+
assert "bajó" in masked[0]["tool_calls"][0]["function"]["arguments"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestRedactToolCalls:
|
|
144
|
+
def test_masks_the_arguments_of_the_listed_tools(self) -> None:
|
|
145
|
+
calls = [{"id": "c1", "name": "charge", "arguments": {"api_key": "k", "n": 1}, "step_id": "s"}]
|
|
146
|
+
|
|
147
|
+
assert redact_tool_calls(calls, {"charge": ["api_key"]})[0]["arguments"] == {"api_key": "[redacted]", "n": 1}
|
|
148
|
+
assert calls[0]["arguments"]["api_key"] == "k"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/__init__.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/analytics.py
RENAMED
|
File without changes
|
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/compare.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/dashboard.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/events.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/explanation.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/graph.py
RENAMED
|
File without changes
|
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/otlp_decode.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/provider.py
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/run_summary.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/requires.txt
RENAMED
|
File without changes
|
{trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/top_level.txt
RENAMED
|
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
|