trace-agent-server 0.6.0__tar.gz → 0.8.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 (52) hide show
  1. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/PKG-INFO +1 -1
  2. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/pyproject.toml +1 -1
  3. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/schemas.py +2 -0
  4. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/explanation.py +26 -8
  5. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/otlp.py +2 -1
  6. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/run_lifecycle.py +4 -2
  7. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server.egg-info/PKG-INFO +1 -1
  8. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server.egg-info/SOURCES.txt +1 -0
  9. trace_agent_server-0.8.0/tests/test_flags.py +52 -0
  10. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_framework_observations.py +32 -0
  11. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/README.md +0 -0
  12. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/setup.cfg +0 -0
  13. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/__init__.py +0 -0
  14. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/api/__init__.py +0 -0
  15. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/api/otlp.py +0 -0
  16. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/api/runs.py +0 -0
  17. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/cli.py +0 -0
  18. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/config.py +0 -0
  19. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/db.py +0 -0
  20. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/main.py +0 -0
  21. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/models.py +0 -0
  22. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/__init__.py +0 -0
  23. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/analytics.py +0 -0
  24. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/audit_guardrails.py +0 -0
  25. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/compare.py +0 -0
  26. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/dashboard.py +0 -0
  27. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/events.py +0 -0
  28. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/graph.py +0 -0
  29. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/observability.py +0 -0
  30. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/otlp_decode.py +0 -0
  31. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/provider.py +0 -0
  32. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/run_summary.py +0 -0
  33. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server/services/stored_redaction.py +0 -0
  34. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server.egg-info/dependency_links.txt +0 -0
  35. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server.egg-info/entry_points.txt +0 -0
  36. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server.egg-info/requires.txt +0 -0
  37. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/src/trace_agent_server.egg-info/top_level.txt +0 -0
  38. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_audit_guardrails_api.py +0 -0
  39. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_concurrent_writers.py +0 -0
  40. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_dashboard.py +0 -0
  41. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_events.py +0 -0
  42. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_graph.py +0 -0
  43. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_inactivity_sweep.py +0 -0
  44. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_otlp_decode.py +0 -0
  45. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_otlp_ingest.py +0 -0
  46. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_providers.py +0 -0
  47. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_run_lifecycle.py +0 -0
  48. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_runs_api.py +0 -0
  49. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_sdk_wrappers.py +0 -0
  50. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_sse.py +0 -0
  51. {trace_agent_server-0.6.0 → trace_agent_server-0.8.0}/tests/test_stored_redaction.py +0 -0
  52. {trace_agent_server-0.6.0 → trace_agent_server-0.8.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.6.0
3
+ Version: 0.8.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.6.0"
7
+ version = "0.8.0"
8
8
  description = "TraceAgent FastAPI backend server."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -60,6 +60,8 @@ class ToolResultInput(BaseModel):
60
60
  parent_step_id: str | None = None
61
61
  status: Literal["succeeded", "failed"]
62
62
  args: dict[str, Any] = Field(default_factory=dict)
63
+ # Hash of the arguments as the tool received them (the ones in args may have long texts cut).
64
+ args_hash: str | None = None
63
65
  output_summary: str | None = None
64
66
  # What the tool returned, whole (masked by the sender). Stored only with full payloads on.
65
67
  output: Any | None = None
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from collections import defaultdict
4
5
  from typing import Any
5
6
 
@@ -9,6 +10,17 @@ from sqlalchemy.orm import Session
9
10
  from trace_agent_server.models import Decision, Event, Run
10
11
 
11
12
 
13
+ def command_title(command: str, limit: int = 80) -> str:
14
+ """A short name for a command, not its whole text: the first comment of a script says what it does; without
15
+ one, its first line that is not an import."""
16
+ code = re.sub(r"^\s*(?:python3?|py)\s+-c\s+", "", command)
17
+ lines = [line.strip() for line in code.splitlines() if line.strip()]
18
+ comment = next((line.lstrip("#").strip() for line in lines if line.startswith("#")), None)
19
+ first_code = next((line for line in lines if not line.startswith("#") and not re.match(r"(?:import|from)\s", line)), None)
20
+ text = " ".join((comment or first_code or (lines[0] if lines else command)).split())
21
+ return text if len(text) <= limit else f"{text[: limit - 1]}…"
22
+
23
+
12
24
  def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
13
25
  events = list(session.scalars(select(Event).where(Event.run_id == run.id).order_by(Event.seq)))
14
26
  session.execute(delete(Decision).where(Decision.run_id == run.id))
@@ -17,6 +29,7 @@ def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
17
29
  flags: list[dict[str, Any]] = []
18
30
  seen_flags: set[tuple[str, str]] = set()
19
31
  loop_tracker: dict[tuple[str, str], list[Event]] = defaultdict(list)
32
+ loop_flags: dict[tuple[str, str], dict[str, Any]] = {}
20
33
  failure_streaks: dict[str, int] = defaultdict(int)
21
34
  last_failure_by_tool: dict[str, Event] = {}
22
35
  last_failure_by_command: dict[str, Event] = {}
@@ -78,13 +91,17 @@ def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
78
91
  # "Started X." is identical for every call, so tell calls apart by their arguments.
79
92
  key = (tool_name, (event.payload_hash or event.summary) if derived_request else event.summary)
80
93
  loop_tracker[key].append(event)
81
- if len(loop_tracker[key]) >= 3:
82
- add_flag(
83
- "loop",
84
- f"{tool_name} repeated {len(loop_tracker[key])} times without a new observable branch.",
85
- [item.id for item in loop_tracker[key][-3:]],
86
- {"tool_name": tool_name},
87
- )
94
+ repeats = len(loop_tracker[key])
95
+ if repeats >= 3:
96
+ # One flag per repeated call, kept up to date, not a new flag for every repetition.
97
+ flag = loop_flags.get(key)
98
+ if flag is None:
99
+ flag = {"type": "loop", "label": "", "event_ids": [], "metadata": {"tool_name": tool_name}}
100
+ loop_flags[key] = flag
101
+ flags.append(flag)
102
+ flag["label"] = f"{tool_name} was called {repeats} times with the same arguments."
103
+ flag["event_ids"] = [item.id for item in loop_tracker[key]]
104
+ flag["metadata"] = {"tool_name": tool_name, "count": repeats}
88
105
  if tool_name in last_failure_by_tool:
89
106
  failed_event = last_failure_by_tool[tool_name]
90
107
  retry_count += 1
@@ -141,9 +158,10 @@ def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
141
158
  command_name = str(event.event_metadata.get("command") or "unknown command")
142
159
  last_failure_by_command[command_name] = event
143
160
  failure_streaks[event.step_id or command_name] += 1
161
+ exit_code = event.event_metadata.get("exit_code")
144
162
  add_flag(
145
163
  "command_failure",
146
- f"{command_name} failed and may have blocked the run.",
164
+ f"Command failed{f' (exit code {exit_code})' if exit_code is not None else ''}: {command_title(command_name)}",
147
165
  [event.id],
148
166
  {"command": command_name, "step_id": event.step_id},
149
167
  )
@@ -372,7 +372,8 @@ def _tool_steps(session: Session, run: Run, span: SpanRecord, capture_full: bool
372
372
  parent_step_id=span.parent_span_id,
373
373
  status="failed" if failed else "succeeded",
374
374
  args=redact_payload(arguments if isinstance(arguments, dict) else {"input": arguments} if arguments is not None else {}),
375
- output_summary=summarize_value(_readable(result_text)) if result_text is not None else None,
375
+ args_hash=stable_hash(arguments),
376
+ output_summary=summarize_value(_readable(result_text), sort_keys=False) if result_text is not None else None,
376
377
  output=_parse_json(result_text) if result_text is not None and not failed else None,
377
378
  output_hash=stable_hash(result_text if not failed else {"error": message}),
378
379
  error_code=code,
@@ -67,7 +67,9 @@ def record_tool_result(
67
67
  step_id=result.step_id,
68
68
  parent_step_id=result.parent_step_id,
69
69
  tool_name=result.name,
70
- payload_hash=stable_hash(result.args),
70
+ # The sender hashes the whole arguments: the ones it sends have long texts cut, so two calls that differ
71
+ # only after the cut would look the same (and be flagged as a loop).
72
+ payload_hash=result.args_hash or stable_hash(result.args),
71
73
  payload_full={"args": result.args} if capture_full else None,
72
74
  metadata={"call_id": result.call_id, "args_summary": summarize_value(result.args)},
73
75
  timestamp=started_at,
@@ -157,7 +159,7 @@ def complete_run(
157
159
 
158
160
  The summary of the output is always kept; the whole output only with ``capture_full``.
159
161
  """
160
- final_summary = summarize_value(final_output) if final_output is not None else "Run finished without final output."
162
+ final_summary = summarize_value(final_output, sort_keys=False) if final_output is not None else "Run finished without final output."
161
163
  run.final_output_summary = final_summary
162
164
  run.status = "completed"
163
165
  run.ended_at = ended_at or utcnow()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trace-agent-server
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -35,6 +35,7 @@ tests/test_audit_guardrails_api.py
35
35
  tests/test_concurrent_writers.py
36
36
  tests/test_dashboard.py
37
37
  tests/test_events.py
38
+ tests/test_flags.py
38
39
  tests/test_framework_observations.py
39
40
  tests/test_graph.py
40
41
  tests/test_inactivity_sweep.py
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+
6
+ from trace_agent_server.config import Settings
7
+ from trace_agent_server.main import create_app
8
+ from trace_agent_server.services.explanation import command_title
9
+
10
+ SCRIPT = "python -c \n# OEE de las líneas del 14 al 18 de septiembre\nimport json\nkpis = kpis('L1')\nprint(kpis)"
11
+
12
+
13
+ @pytest.fixture
14
+ def client(tmp_path) -> TestClient:
15
+ return TestClient(create_app(Settings(database_url=f"sqlite:///{tmp_path / 'flags.db'}", cors_origins=["http://localhost:5173"])))
16
+
17
+
18
+ def run_with_command(client: TestClient, **observation) -> dict:
19
+ run_id = client.post("/api/runs", json={"agent_name": "a", "goal": "g"}).json()["run_id"]
20
+ body = {"kind": "command", "step_id": "c1", **observation}
21
+ assert client.post(f"/api/runs/{run_id}/observations", json={"observations": [body]}).status_code == 200
22
+ assert client.post(f"/api/runs/{run_id}/finish", json={"final_output": "done"}).status_code == 200
23
+ return client.get(f"/api/runs/{run_id}/explanation").json()
24
+
25
+
26
+ class TestCommandTitle:
27
+ def test_uses_the_first_comment_of_a_script(self) -> None:
28
+ assert command_title(SCRIPT) == "OEE de las líneas del 14 al 18 de septiembre"
29
+
30
+ def test_without_a_comment_uses_the_first_line_that_is_not_an_import(self) -> None:
31
+ assert command_title("python -c \nimport json\nfrom os import path\nvalor = 1\nprint(valor)") == "valor = 1"
32
+
33
+ def test_a_plain_command_stays_and_a_long_one_is_cut(self) -> None:
34
+ assert command_title("pytest -q packages") == "pytest -q packages"
35
+ cut = command_title("python3 -c " + "x = 1; " * 60)
36
+ assert len(cut) == 80
37
+ assert cut.endswith("…")
38
+
39
+
40
+ class TestCommandFailureFlag:
41
+ def test_says_what_failed_and_with_what_code_instead_of_dumping_the_script(self, client) -> None:
42
+ flags = run_with_command(client, command=SCRIPT, status="failed", exit_code=1)["flags"]
43
+
44
+ failure = next(flag for flag in flags if flag["type"] == "command_failure")
45
+ assert failure["label"] == "Command failed (exit code 1): OEE de las líneas del 14 al 18 de septiembre"
46
+ assert "import json" not in failure["label"]
47
+
48
+ def test_without_an_exit_code_it_still_reads(self, client) -> None:
49
+ flags = run_with_command(client, command="pytest -q", status="failed")["flags"]
50
+
51
+ failure = next(flag for flag in flags if flag["type"] == "command_failure")
52
+ assert failure["label"] == "Command failed: pytest -q"
@@ -269,6 +269,38 @@ class TestDecisionsFromFrameworkTools:
269
269
  flags = explanation(client, run_id)["flags"]
270
270
  assert any(flag["type"] == "loop" for flag in flags)
271
271
 
272
+ def test_a_repeated_call_is_one_flag_with_its_count_in_plain_words(self, client) -> None:
273
+ run_id = start_run(client)
274
+ for step in ("t1", "t2", "t3", "t4", "t5", "t6"):
275
+ post_tool(client, run_id, step, "search", args={"q": "same"})
276
+
277
+ loops = [flag for flag in explanation(client, run_id)["flags"] if flag["type"] == "loop"]
278
+
279
+ assert len(loops) == 1
280
+ assert loops[0]["label"] == "search was called 6 times with the same arguments."
281
+ assert loops[0]["metadata"]["count"] == 6
282
+ assert len(loops[0]["event_ids"]) == 6
283
+
284
+ def test_different_programs_that_share_a_long_start_are_not_a_loop(self, client) -> None:
285
+ # The arguments are cut at 160 characters before they are sent: what tells the calls apart is their hash.
286
+ run_id = start_run(client)
287
+ common = "# OEE de las lineas del 14 al 18 de septiembre " * 6
288
+ for step, ending in (("t1", "kpis('L1')"), ("t2", "kpis('L2')"), ("t3", "kpis('L3')")):
289
+ result = {"call_id": step, "name": "ejecutar_python", "step_id": step, "status": "succeeded",
290
+ "args": {"codigo": common[:157] + "..."}, "args_hash": f"hash-of-the-whole-{ending}"}
291
+ assert client.post(f"/api/runs/{run_id}/tool-results", json={"results": [result]}).status_code == 200
292
+
293
+ assert not any(flag["type"] == "loop" for flag in explanation(client, run_id)["flags"])
294
+
295
+ def test_the_same_whole_arguments_are_a_loop_even_when_what_was_sent_was_cut(self, client) -> None:
296
+ run_id = start_run(client)
297
+ for step in ("t1", "t2", "t3"):
298
+ result = {"call_id": step, "name": "ejecutar_python", "step_id": step, "status": "succeeded",
299
+ "args": {"codigo": "x" * 157 + "..."}, "args_hash": "the-same-program"}
300
+ client.post(f"/api/runs/{run_id}/tool-results", json={"results": [result]})
301
+
302
+ assert any(flag["type"] == "loop" for flag in explanation(client, run_id)["flags"])
303
+
272
304
  def test_the_same_tool_with_different_arguments_is_not_a_loop(self, client) -> None:
273
305
  run_id = start_run(client)
274
306
  for step, query in (("t1", "a"), ("t2", "b"), ("t3", "c")):