agentium-tracer 0.1.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 (30) hide show
  1. agentium_tracer-0.1.0/PKG-INFO +6 -0
  2. agentium_tracer-0.1.0/agent_runtime_layer/__init__.py +16 -0
  3. agentium_tracer-0.1.0/agent_runtime_layer/budget.py +195 -0
  4. agentium_tracer-0.1.0/agent_runtime_layer/capture.py +246 -0
  5. agentium_tracer-0.1.0/agent_runtime_layer/cli.py +274 -0
  6. agentium_tracer-0.1.0/agent_runtime_layer/client.py +42 -0
  7. agentium_tracer-0.1.0/agent_runtime_layer/integrations/__init__.py +1 -0
  8. agentium_tracer-0.1.0/agent_runtime_layer/integrations/aider.py +454 -0
  9. agentium_tracer-0.1.0/agent_runtime_layer/integrations/claude_code.py +307 -0
  10. agentium_tracer-0.1.0/agent_runtime_layer/integrations/codex.py +1015 -0
  11. agentium_tracer-0.1.0/agent_runtime_layer/integrations/cursor_agent.py +220 -0
  12. agentium_tracer-0.1.0/agent_runtime_layer/otel.py +148 -0
  13. agentium_tracer-0.1.0/agent_runtime_layer/proxy.py +231 -0
  14. agentium_tracer-0.1.0/agent_runtime_layer/redaction.py +35 -0
  15. agentium_tracer-0.1.0/agent_runtime_layer/trace.py +30 -0
  16. agentium_tracer-0.1.0/agent_runtime_layer/tracer.py +546 -0
  17. agentium_tracer-0.1.0/agentium_tracer.egg-info/PKG-INFO +6 -0
  18. agentium_tracer-0.1.0/agentium_tracer.egg-info/SOURCES.txt +28 -0
  19. agentium_tracer-0.1.0/agentium_tracer.egg-info/dependency_links.txt +1 -0
  20. agentium_tracer-0.1.0/agentium_tracer.egg-info/entry_points.txt +2 -0
  21. agentium_tracer-0.1.0/agentium_tracer.egg-info/top_level.txt +1 -0
  22. agentium_tracer-0.1.0/pyproject.toml +13 -0
  23. agentium_tracer-0.1.0/setup.cfg +4 -0
  24. agentium_tracer-0.1.0/tests/test_aider_integration.py +102 -0
  25. agentium_tracer-0.1.0/tests/test_capture.py +73 -0
  26. agentium_tracer-0.1.0/tests/test_claude_code_integration.py +122 -0
  27. agentium_tracer-0.1.0/tests/test_codex_integration.py +346 -0
  28. agentium_tracer-0.1.0/tests/test_cursor_integration.py +77 -0
  29. agentium_tracer-0.1.0/tests/test_otel.py +22 -0
  30. agentium_tracer-0.1.0/tests/test_tracer.py +89 -0
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentium-tracer
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Agent Runtime Layer traces
5
+ License: AGPL-3.0-or-later
6
+ Requires-Python: >=3.10
@@ -0,0 +1,16 @@
1
+ from agent_runtime_layer.capture import capture_command
2
+ from agent_runtime_layer.client import AgentRuntimeClient
3
+ from agent_runtime_layer.integrations.aider import capture_aider
4
+ from agent_runtime_layer.trace import TraceEvent
5
+ from agent_runtime_layer.tracer import AgentRuntimeTracer, context_hash, estimate_cost, prompt_hash
6
+
7
+ __all__ = [
8
+ "AgentRuntimeClient",
9
+ "TraceEvent",
10
+ "capture_command",
11
+ "capture_aider",
12
+ "AgentRuntimeTracer",
13
+ "context_hash",
14
+ "estimate_cost",
15
+ "prompt_hash",
16
+ ]
@@ -0,0 +1,195 @@
1
+ """Phase 1.8: Budget Governor — session cost and retry state tracker.
2
+
3
+ Reads .agentium/config.yaml and enforces per-run budget caps and retry limits.
4
+ Used by Claude Code and Codex hook handlers to block runaway runs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from uuid import uuid4
16
+
17
+
18
+ DEFAULT_CONFIG = {
19
+ "max_cost_per_run": 0.10,
20
+ "max_retries_per_task": 5,
21
+ "alert_threshold": 0.05,
22
+ "token_limit_per_call": 200000,
23
+ "enabled": True,
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class BudgetState:
29
+ session_id: str
30
+ task_id: str | None = None
31
+ cost: float = 0.0
32
+ retries: int = 0
33
+ blocked: bool = False
34
+ block_reason: str = ""
35
+ started_at: float = field(default_factory=time.time)
36
+
37
+
38
+ class BudgetGovernor:
39
+ """Session-scoped budget governor. One instance per agent session."""
40
+
41
+ def __init__(
42
+ self,
43
+ repo_path: Path | str = ".",
44
+ base_url: str = "http://localhost:8000/api",
45
+ session_id: str | None = None,
46
+ ) -> None:
47
+ self.repo_path = Path(repo_path)
48
+ self.base_url = base_url
49
+ self.session_id = session_id or f"bsess_{uuid4().hex[:12]}"
50
+ self._config = self._load_config()
51
+ self._state = BudgetState(session_id=self.session_id)
52
+
53
+ def _load_config(self) -> dict[str, Any]:
54
+ """Load .agentium/config.yaml or fall back to defaults."""
55
+ config_path = self.repo_path / ".agentium" / "config.yaml"
56
+ if config_path.exists():
57
+ try:
58
+ import yaml # type: ignore[import]
59
+ with open(config_path, encoding="utf-8") as f:
60
+ loaded = yaml.safe_load(f) or {}
61
+ return {**DEFAULT_CONFIG, **loaded}
62
+ except Exception:
63
+ pass
64
+ # Try JSON fallback
65
+ json_path = self.repo_path / ".agentium" / "config.json"
66
+ if json_path.exists():
67
+ try:
68
+ with open(json_path, encoding="utf-8") as f:
69
+ loaded = json.load(f)
70
+ return {**DEFAULT_CONFIG, **loaded}
71
+ except Exception:
72
+ pass
73
+ return dict(DEFAULT_CONFIG)
74
+
75
+ @property
76
+ def enabled(self) -> bool:
77
+ return bool(self._config.get("enabled", True))
78
+
79
+ @property
80
+ def max_cost(self) -> float:
81
+ return float(self._config.get("max_cost_per_run", 0.10))
82
+
83
+ @property
84
+ def max_retries(self) -> int:
85
+ return int(self._config.get("max_retries_per_task", 5))
86
+
87
+ @property
88
+ def alert_threshold(self) -> float:
89
+ return float(self._config.get("alert_threshold", 0.05))
90
+
91
+ def set_task(self, task_id: str) -> None:
92
+ self._state.task_id = task_id
93
+
94
+ def add_cost(self, cost_dollars: float) -> None:
95
+ self._state.cost += cost_dollars
96
+
97
+ def increment_retry(self) -> None:
98
+ self._state.retries += 1
99
+
100
+ def check(self) -> tuple[bool, str]:
101
+ """Check if current session is within budget and retry limits.
102
+ Returns (allowed: bool, reason: str).
103
+ """
104
+ if not self.enabled:
105
+ return True, ""
106
+
107
+ if self._state.cost >= self.max_cost:
108
+ reason = (
109
+ f"Budget cap reached: ${self._state.cost:.4f} >= ${self.max_cost:.4f}. "
110
+ f"Run terminated by Agentium Budget Governor."
111
+ )
112
+ self._state.blocked = True
113
+ self._state.block_reason = reason
114
+ self._report_block("budget_cap", reason)
115
+ return False, reason
116
+
117
+ if self._state.retries >= self.max_retries:
118
+ reason = (
119
+ f"Retry limit reached: {self._state.retries} >= {self.max_retries} retries. "
120
+ f"Run terminated by Agentium Budget Governor."
121
+ )
122
+ self._state.blocked = True
123
+ self._state.block_reason = reason
124
+ self._report_block("retry_limit", reason)
125
+ return False, reason
126
+
127
+ if self._state.cost >= self.alert_threshold and self._state.cost < self.max_cost:
128
+ # Alert but don't block
129
+ pass
130
+
131
+ return True, ""
132
+
133
+ def _report_block(self, event_type: str, reason: str) -> None:
134
+ """Report a budget block event to the backend API."""
135
+ try:
136
+ import urllib.request
137
+ payload = json.dumps({
138
+ "event_id": f"bev_{uuid4().hex[:12]}",
139
+ "session_id": self.session_id,
140
+ "task_id": self._state.task_id,
141
+ "event_type": event_type,
142
+ "reason": reason,
143
+ "cost_at_block": self._state.cost,
144
+ "retries_at_block": self._state.retries,
145
+ "budget_limit": self.max_cost,
146
+ "retry_limit": self.max_retries,
147
+ }).encode("utf-8")
148
+ req = urllib.request.Request(
149
+ f"{self.base_url}/budget/events",
150
+ data=payload,
151
+ headers={"Content-Type": "application/json"},
152
+ method="POST",
153
+ )
154
+ urllib.request.urlopen(req, timeout=2)
155
+ except Exception:
156
+ pass # Never block the agent because of reporting failures
157
+
158
+ @property
159
+ def current_cost(self) -> float:
160
+ return self._state.cost
161
+
162
+ @property
163
+ def current_retries(self) -> int:
164
+ return self._state.retries
165
+
166
+ @property
167
+ def summary(self) -> dict[str, Any]:
168
+ return {
169
+ "session_id": self.session_id,
170
+ "cost": self._state.cost,
171
+ "retries": self._state.retries,
172
+ "blocked": self._state.blocked,
173
+ "block_reason": self._state.block_reason,
174
+ "max_cost": self.max_cost,
175
+ "max_retries": self.max_retries,
176
+ }
177
+
178
+
179
+ def write_default_config(repo_path: Path | str = ".") -> Path:
180
+ """Write a default .agentium/config.yaml to the given repo path."""
181
+ config_dir = Path(repo_path) / ".agentium"
182
+ config_dir.mkdir(parents=True, exist_ok=True)
183
+ config_path = config_dir / "config.yaml"
184
+ if not config_path.exists():
185
+ config_path.write_text(
186
+ "# Agentium Phase 1.8 Budget Governor configuration\n"
187
+ "# All settings apply per agent session.\n\n"
188
+ "max_cost_per_run: 0.10 # Stop run if cost exceeds this (dollars)\n"
189
+ "max_retries_per_task: 5 # Stop run after this many retries\n"
190
+ "alert_threshold: 0.05 # Log a warning at this cost (dollars)\n"
191
+ "token_limit_per_call: 200000 # Warn if a single call exceeds this token count\n"
192
+ "enabled: true # Set to false to disable all budget enforcement\n",
193
+ encoding="utf-8",
194
+ )
195
+ return config_path
@@ -0,0 +1,246 @@
1
+ import json
2
+ import subprocess
3
+ import time
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ from agent_runtime_layer.client import AgentRuntimeClient
10
+ from agent_runtime_layer.redaction import redact_text, redact_value
11
+
12
+
13
+ DEFAULT_TRACE_DIR = Path(".agent-runtime") / "traces"
14
+ SUMMARY_LIMIT = 4000
15
+
16
+
17
+ def utc_now() -> str:
18
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
19
+
20
+
21
+ def summarize_stream(value: str, limit: int = SUMMARY_LIMIT) -> str:
22
+ redacted = redact_text(value)
23
+ if len(redacted) <= limit:
24
+ return redacted
25
+ omitted = len(redacted) - limit
26
+ return f"{redacted[:limit]}\n...[truncated {omitted} chars]"
27
+
28
+
29
+ def command_display(command: list[str]) -> str:
30
+ return " ".join(command)
31
+
32
+
33
+ def safe_git_diff_summary() -> str | None:
34
+ try:
35
+ result = subprocess.run(
36
+ ["git", "diff", "--stat", "--no-ext-diff"],
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=10,
40
+ check=False,
41
+ )
42
+ except (OSError, subprocess.SubprocessError):
43
+ return None
44
+ output = (result.stdout or result.stderr or "").strip()
45
+ return summarize_stream(output, 2000) if output else None
46
+
47
+
48
+ @dataclass
49
+ class TraceCaptureResult:
50
+ task_id: str
51
+ trace_path: Path
52
+ exit_code: int
53
+ uploaded: bool = False
54
+ upload_response: dict | None = None
55
+
56
+
57
+ class LocalTraceCapture:
58
+ def __init__(
59
+ self,
60
+ name: str,
61
+ command: list[str],
62
+ project_id: str = "default",
63
+ trace_dir: Path = DEFAULT_TRACE_DIR,
64
+ capture_full_logs: bool = False,
65
+ capture_diff: bool = False,
66
+ ) -> None:
67
+ if not command:
68
+ raise ValueError("A command is required after --")
69
+ self.name = name
70
+ self.command = command
71
+ self.project_id = project_id
72
+ self.trace_dir = trace_dir
73
+ self.capture_full_logs = capture_full_logs
74
+ self.capture_diff = capture_diff
75
+ self.task_id = f"task_local_{uuid4().hex[:12]}"
76
+ self.task_span_id = f"span_task_{uuid4().hex[:8]}"
77
+ self.tool_span_id = f"span_tool_{uuid4().hex[:8]}"
78
+
79
+ def event(
80
+ self,
81
+ event_type: str,
82
+ span_id: str,
83
+ name: str,
84
+ attributes: dict | None = None,
85
+ payload: dict | None = None,
86
+ parent_span_id: str | None = None,
87
+ timestamp: str | None = None,
88
+ ) -> dict:
89
+ return {
90
+ "event_id": f"evt_{uuid4().hex[:12]}",
91
+ "task_id": self.task_id,
92
+ "timestamp": timestamp or utc_now(),
93
+ "event_type": event_type,
94
+ "span_id": span_id,
95
+ "parent_span_id": parent_span_id,
96
+ "name": name,
97
+ "attributes": redact_value(attributes or {}),
98
+ "payload": redact_value(payload or {}),
99
+ }
100
+
101
+ def run(self) -> tuple[dict, int]:
102
+ started_at = utc_now()
103
+ start_time = time.perf_counter()
104
+ display = command_display(self.command)
105
+ events = [
106
+ self.event(
107
+ "task_start",
108
+ self.task_span_id,
109
+ "task_start",
110
+ payload={
111
+ "goal": self.name,
112
+ "agent_type": "local_command",
113
+ "latency_slo_seconds": None,
114
+ },
115
+ timestamp=started_at,
116
+ ),
117
+ self.event(
118
+ "tool_call_start",
119
+ self.tool_span_id,
120
+ "local_command",
121
+ attributes={
122
+ "tool_name": "terminal",
123
+ "command": display,
124
+ "risk_level": "user_provided",
125
+ },
126
+ parent_span_id=self.task_span_id,
127
+ timestamp=started_at,
128
+ ),
129
+ ]
130
+
131
+ completed = subprocess.run(
132
+ self.command,
133
+ capture_output=True,
134
+ text=True,
135
+ check=False,
136
+ )
137
+ ended_at = utc_now()
138
+ duration_ms = int((time.perf_counter() - start_time) * 1000)
139
+ status = "success" if completed.returncode == 0 else "failed"
140
+ stdout_preview = summarize_stream(completed.stdout or "")
141
+ stderr_preview = summarize_stream(completed.stderr or "")
142
+ terminal_payload = {
143
+ "stdout_preview": stdout_preview,
144
+ "stderr_preview": stderr_preview,
145
+ }
146
+ if self.capture_full_logs:
147
+ terminal_payload["stdout"] = redact_text(completed.stdout or "")
148
+ terminal_payload["stderr"] = redact_text(completed.stderr or "")
149
+ if self.capture_diff:
150
+ diff_summary = safe_git_diff_summary()
151
+ if diff_summary:
152
+ terminal_payload["git_diff_summary"] = diff_summary
153
+
154
+ events.extend(
155
+ [
156
+ self.event(
157
+ "terminal_event",
158
+ f"span_terminal_{uuid4().hex[:8]}",
159
+ "local_command_output",
160
+ attributes={
161
+ "command": display,
162
+ "duration_ms": duration_ms,
163
+ "exit_code": completed.returncode,
164
+ "stdout_preview": stdout_preview,
165
+ "stderr_preview": stderr_preview,
166
+ },
167
+ payload=terminal_payload,
168
+ parent_span_id=self.tool_span_id,
169
+ timestamp=ended_at,
170
+ ),
171
+ self.event(
172
+ "tool_call_end",
173
+ self.tool_span_id,
174
+ "local_command_end",
175
+ attributes={
176
+ "latency_ms": duration_ms,
177
+ "status": status,
178
+ "exit_code": completed.returncode,
179
+ },
180
+ parent_span_id=self.task_span_id,
181
+ timestamp=ended_at,
182
+ ),
183
+ self.event(
184
+ "task_end",
185
+ self.task_span_id,
186
+ "task_end",
187
+ payload={
188
+ "status": "completed" if completed.returncode == 0 else "failed",
189
+ "summary": f"Command exited with code {completed.returncode}.",
190
+ },
191
+ timestamp=ended_at,
192
+ ),
193
+ ]
194
+ )
195
+
196
+ trace = {
197
+ "project_id": self.project_id,
198
+ "task": {
199
+ "task_id": self.task_id,
200
+ "project_id": self.project_id,
201
+ "goal": self.name,
202
+ "agent_type": "local_command",
203
+ "budget_dollars": None,
204
+ "latency_slo_seconds": None,
205
+ },
206
+ "events": events,
207
+ }
208
+ return trace, completed.returncode
209
+
210
+ def write(self, trace: dict) -> Path:
211
+ self.trace_dir.mkdir(parents=True, exist_ok=True)
212
+ trace_path = self.trace_dir / f"{self.task_id}.json"
213
+ trace_path.write_text(json.dumps(trace, indent=2), encoding="utf-8")
214
+ return trace_path
215
+
216
+
217
+ def capture_command(
218
+ name: str,
219
+ command: list[str],
220
+ project_id: str = "default",
221
+ trace_dir: Path = DEFAULT_TRACE_DIR,
222
+ capture_full_logs: bool = False,
223
+ capture_diff: bool = False,
224
+ upload: bool = False,
225
+ base_url: str = "http://localhost:8000/api",
226
+ ) -> TraceCaptureResult:
227
+ capture = LocalTraceCapture(
228
+ name=name,
229
+ command=command,
230
+ project_id=project_id,
231
+ trace_dir=trace_dir,
232
+ capture_full_logs=capture_full_logs,
233
+ capture_diff=capture_diff,
234
+ )
235
+ trace, exit_code = capture.run()
236
+ trace_path = capture.write(trace)
237
+ upload_response = None
238
+ if upload:
239
+ upload_response = AgentRuntimeClient(base_url).import_trace_file(trace_path)
240
+ return TraceCaptureResult(
241
+ task_id=capture.task_id,
242
+ trace_path=trace_path,
243
+ exit_code=exit_code,
244
+ uploaded=upload,
245
+ upload_response=upload_response,
246
+ )