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.
Files changed (49) hide show
  1. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/PKG-INFO +1 -1
  2. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/pyproject.toml +1 -1
  3. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/runs.py +20 -8
  4. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/config.py +2 -0
  5. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/main.py +36 -1
  6. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/otlp.py +13 -1
  7. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/run_lifecycle.py +22 -1
  8. trace_agent_server-0.5.0/src/trace_agent_server/services/stored_redaction.py +57 -0
  9. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/PKG-INFO +1 -1
  10. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/SOURCES.txt +3 -0
  11. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_audit_guardrails_api.py +16 -0
  12. trace_agent_server-0.5.0/tests/test_inactivity_sweep.py +50 -0
  13. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_otlp_ingest.py +34 -0
  14. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_run_lifecycle.py +42 -0
  15. trace_agent_server-0.5.0/tests/test_stored_redaction.py +148 -0
  16. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/README.md +0 -0
  17. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/setup.cfg +0 -0
  18. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/__init__.py +0 -0
  19. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/__init__.py +0 -0
  20. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/api/otlp.py +0 -0
  21. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/cli.py +0 -0
  22. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/db.py +0 -0
  23. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/models.py +0 -0
  24. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/schemas.py +0 -0
  25. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/__init__.py +0 -0
  26. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/analytics.py +0 -0
  27. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/audit_guardrails.py +0 -0
  28. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/compare.py +0 -0
  29. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/dashboard.py +0 -0
  30. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/events.py +0 -0
  31. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/explanation.py +0 -0
  32. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/graph.py +0 -0
  33. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/observability.py +0 -0
  34. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/otlp_decode.py +0 -0
  35. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/provider.py +0 -0
  36. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server/services/run_summary.py +0 -0
  37. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/dependency_links.txt +0 -0
  38. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/entry_points.txt +0 -0
  39. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/requires.txt +0 -0
  40. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/src/trace_agent_server.egg-info/top_level.txt +0 -0
  41. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_dashboard.py +0 -0
  42. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_events.py +0 -0
  43. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_framework_observations.py +0 -0
  44. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_otlp_decode.py +0 -0
  45. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_providers.py +0 -0
  46. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_runs_api.py +0 -0
  47. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_sdk_wrappers.py +0 -0
  48. {trace_agent_server-0.3.0 → trace_agent_server-0.5.0}/tests/test_sse.py +0 -0
  49. {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.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trace-agent-server"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "TraceAgent FastAPI backend server."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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": sanitized_messages} if capture_full else None,
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=sanitized_messages,
366
- assistant_message=response.assistant_message,
367
- tool_calls=[{"id": c.id, "name": c.name, "arguments": c.arguments, "step_id": c.step_id} for c in response.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": response.assistant_message, "tool_calls": turn.tool_calls} if capture_full else None,
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,
@@ -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
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trace-agent-server
3
- Version: 0.3.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
@@ -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"