athar 0.1.0__py3-none-any.whl
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.
- athar/__init__.py +25 -0
- athar/adapters/crucible_adapter.py +17 -0
- athar/core/tracer.py +204 -0
- athar/storage/sqlite_store.py +241 -0
- athar/ui/app.py +184 -0
- athar/ui/static/index.html +732 -0
- athar-0.1.0.dist-info/METADATA +236 -0
- athar-0.1.0.dist-info/RECORD +12 -0
- athar-0.1.0.dist-info/WHEEL +5 -0
- athar-0.1.0.dist-info/entry_points.txt +3 -0
- athar-0.1.0.dist-info/licenses/LICENSE +21 -0
- athar-0.1.0.dist-info/top_level.txt +1 -0
athar/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Athar (أثر): A local-first, time-travel debugger for multi-agent LLM systems.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .core.tracer import (
|
|
6
|
+
traced,
|
|
7
|
+
TracerContext,
|
|
8
|
+
get_active_trace_id,
|
|
9
|
+
set_active_trace_id,
|
|
10
|
+
get_trace_store,
|
|
11
|
+
set_trace_store,
|
|
12
|
+
)
|
|
13
|
+
from .storage.sqlite_store import TraceStore
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"traced",
|
|
18
|
+
"TracerContext",
|
|
19
|
+
"TraceStore",
|
|
20
|
+
"get_active_trace_id",
|
|
21
|
+
"set_active_trace_id",
|
|
22
|
+
"get_trace_store",
|
|
23
|
+
"set_trace_store",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from ..core.tracer import traced
|
|
3
|
+
|
|
4
|
+
class CrucibleAdapter:
|
|
5
|
+
"""
|
|
6
|
+
Adapter for Crucible integration.
|
|
7
|
+
"""
|
|
8
|
+
@staticmethod
|
|
9
|
+
def wrap_agent(agent_instance: Any):
|
|
10
|
+
"""
|
|
11
|
+
Wraps a Crucible agent's act or run method with our tracer.
|
|
12
|
+
"""
|
|
13
|
+
if hasattr(agent_instance, "run"):
|
|
14
|
+
agent_instance.run = traced(agent_instance.run)
|
|
15
|
+
elif hasattr(agent_instance, "act"):
|
|
16
|
+
agent_instance.act = traced(agent_instance.act)
|
|
17
|
+
return agent_instance
|
athar/core/tracer.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextvars
|
|
3
|
+
import functools
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Callable, Any, Optional, Dict
|
|
9
|
+
|
|
10
|
+
from ..storage.sqlite_store import TraceStore
|
|
11
|
+
|
|
12
|
+
# Context variables for tracking active trace execution context across async/threads
|
|
13
|
+
_active_trace_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("active_trace_id", default=None)
|
|
14
|
+
_active_parent_node_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("active_parent_node_id", default=None)
|
|
15
|
+
_global_store: Optional[TraceStore] = None
|
|
16
|
+
|
|
17
|
+
def get_trace_store() -> TraceStore:
|
|
18
|
+
"""Returns the singleton or newly initialized default TraceStore."""
|
|
19
|
+
global _global_store
|
|
20
|
+
if _global_store is None:
|
|
21
|
+
_global_store = TraceStore()
|
|
22
|
+
return _global_store
|
|
23
|
+
|
|
24
|
+
def set_trace_store(store: TraceStore):
|
|
25
|
+
"""Overrides the global trace store (useful for testing or custom paths)."""
|
|
26
|
+
global _global_store
|
|
27
|
+
_global_store = store
|
|
28
|
+
|
|
29
|
+
def get_active_trace_id() -> Optional[str]:
|
|
30
|
+
"""Returns the currently active trace ID if running inside a trace context."""
|
|
31
|
+
return _active_trace_id.get()
|
|
32
|
+
|
|
33
|
+
def set_active_trace_id(trace_id: Optional[str]):
|
|
34
|
+
"""Manually sets the active trace ID for the current context."""
|
|
35
|
+
_active_trace_id.set(trace_id)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TracerContext:
|
|
39
|
+
"""
|
|
40
|
+
Context manager to scope a sequence of agent calls under a single trace session.
|
|
41
|
+
Supports both synchronous ('with') and asynchronous ('async with') execution.
|
|
42
|
+
"""
|
|
43
|
+
def __init__(self, trace_id: Optional[str] = None, name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None):
|
|
44
|
+
self.trace_id = trace_id or f"trace_{uuid.uuid4().hex[:12]}"
|
|
45
|
+
self.name = name
|
|
46
|
+
self.metadata = metadata or {}
|
|
47
|
+
self._token_trace: Optional[contextvars.Token] = None
|
|
48
|
+
self._token_parent: Optional[contextvars.Token] = None
|
|
49
|
+
|
|
50
|
+
def __enter__(self):
|
|
51
|
+
store = get_trace_store()
|
|
52
|
+
store.ensure_trace(self.trace_id, name=self.name, metadata=self.metadata)
|
|
53
|
+
self._token_trace = _active_trace_id.set(self.trace_id)
|
|
54
|
+
self._token_parent = _active_parent_node_id.set(None)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
58
|
+
if self._token_trace:
|
|
59
|
+
_active_trace_id.reset(self._token_trace)
|
|
60
|
+
if self._token_parent:
|
|
61
|
+
_active_parent_node_id.reset(self._token_parent)
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self):
|
|
64
|
+
return self.__enter__()
|
|
65
|
+
|
|
66
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
67
|
+
return self.__exit__(exc_type, exc_val, exc_tb)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _compute_hash(inputs_json: str, outputs_json: str) -> str:
|
|
71
|
+
"""Computes a deterministic SHA-256 content hash of inputs and outputs."""
|
|
72
|
+
hasher = hashlib.sha256()
|
|
73
|
+
hasher.update(inputs_json.encode("utf-8"))
|
|
74
|
+
hasher.update(outputs_json.encode("utf-8"))
|
|
75
|
+
return hasher.hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def traced(func: Optional[Callable] = None, *, name: Optional[str] = None, cost: float = 0.0) -> Callable:
|
|
79
|
+
"""
|
|
80
|
+
Decorator to wrap an agent or tool function to record inputs, outputs, state snapshots,
|
|
81
|
+
latency, and content hashes into Athar's local trace store.
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
@traced
|
|
85
|
+
def my_agent(prompt: str):
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
@traced(name="SummarizerTool", cost=0.002)
|
|
89
|
+
async def call_llm(messages):
|
|
90
|
+
...
|
|
91
|
+
"""
|
|
92
|
+
def decorator(fn: Callable) -> Callable:
|
|
93
|
+
func_display_name = name or fn.__name__
|
|
94
|
+
|
|
95
|
+
if asyncio.iscoroutinefunction(fn):
|
|
96
|
+
@functools.wraps(fn)
|
|
97
|
+
async def async_wrapper(*args, **kwargs):
|
|
98
|
+
store = get_trace_store()
|
|
99
|
+
|
|
100
|
+
# Active or new trace session
|
|
101
|
+
trace_id = _active_trace_id.get()
|
|
102
|
+
if not trace_id:
|
|
103
|
+
trace_id = f"trace_{uuid.uuid4().hex[:12]}"
|
|
104
|
+
store.ensure_trace(trace_id, name=f"Run: {func_display_name}")
|
|
105
|
+
|
|
106
|
+
parent_id = _active_parent_node_id.get()
|
|
107
|
+
node_id = f"node_{uuid.uuid4().hex[:12]}"
|
|
108
|
+
|
|
109
|
+
# Serialize inputs
|
|
110
|
+
input_payload = {"args": args, "kwargs": kwargs}
|
|
111
|
+
input_json = json.dumps(input_payload, default=str)
|
|
112
|
+
|
|
113
|
+
start_time = time.time()
|
|
114
|
+
result = None
|
|
115
|
+
error = None
|
|
116
|
+
|
|
117
|
+
# Push this node as parent for subsequent nested calls
|
|
118
|
+
parent_token = _active_parent_node_id.set(node_id)
|
|
119
|
+
trace_token = _active_trace_id.set(trace_id)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = await fn(*args, **kwargs)
|
|
123
|
+
return result
|
|
124
|
+
except Exception as e:
|
|
125
|
+
error = str(e)
|
|
126
|
+
raise
|
|
127
|
+
finally:
|
|
128
|
+
latency = round(time.time() - start_time, 4)
|
|
129
|
+
_active_parent_node_id.reset(parent_token)
|
|
130
|
+
_active_trace_id.reset(trace_token)
|
|
131
|
+
|
|
132
|
+
output_payload = {"result": result, "error": error}
|
|
133
|
+
output_json = json.dumps(output_payload, default=str)
|
|
134
|
+
step_hash = _compute_hash(input_json, output_json)
|
|
135
|
+
|
|
136
|
+
# Store execution step
|
|
137
|
+
store.insert_node(
|
|
138
|
+
node_id=node_id,
|
|
139
|
+
trace_id=trace_id,
|
|
140
|
+
parent_node_id=parent_id,
|
|
141
|
+
func_name=func_display_name,
|
|
142
|
+
input_data=input_payload,
|
|
143
|
+
output_data=output_payload,
|
|
144
|
+
state_snapshot={"args": repr(args), "kwargs": repr(kwargs)},
|
|
145
|
+
latency=latency,
|
|
146
|
+
cost=cost,
|
|
147
|
+
step_hash=step_hash
|
|
148
|
+
)
|
|
149
|
+
return async_wrapper
|
|
150
|
+
else:
|
|
151
|
+
@functools.wraps(fn)
|
|
152
|
+
def sync_wrapper(*args, **kwargs):
|
|
153
|
+
store = get_trace_store()
|
|
154
|
+
|
|
155
|
+
trace_id = _active_trace_id.get()
|
|
156
|
+
if not trace_id:
|
|
157
|
+
trace_id = f"trace_{uuid.uuid4().hex[:12]}"
|
|
158
|
+
store.ensure_trace(trace_id, name=f"Run: {func_display_name}")
|
|
159
|
+
|
|
160
|
+
parent_id = _active_parent_node_id.get()
|
|
161
|
+
node_id = f"node_{uuid.uuid4().hex[:12]}"
|
|
162
|
+
|
|
163
|
+
input_payload = {"args": args, "kwargs": kwargs}
|
|
164
|
+
input_json = json.dumps(input_payload, default=str)
|
|
165
|
+
|
|
166
|
+
start_time = time.time()
|
|
167
|
+
result = None
|
|
168
|
+
error = None
|
|
169
|
+
|
|
170
|
+
parent_token = _active_parent_node_id.set(node_id)
|
|
171
|
+
trace_token = _active_trace_id.set(trace_id)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
result = fn(*args, **kwargs)
|
|
175
|
+
return result
|
|
176
|
+
except Exception as e:
|
|
177
|
+
error = str(e)
|
|
178
|
+
raise
|
|
179
|
+
finally:
|
|
180
|
+
latency = round(time.time() - start_time, 4)
|
|
181
|
+
_active_parent_node_id.reset(parent_token)
|
|
182
|
+
_active_trace_id.reset(trace_token)
|
|
183
|
+
|
|
184
|
+
output_payload = {"result": result, "error": error}
|
|
185
|
+
output_json = json.dumps(output_payload, default=str)
|
|
186
|
+
step_hash = _compute_hash(input_json, output_json)
|
|
187
|
+
|
|
188
|
+
store.insert_node(
|
|
189
|
+
node_id=node_id,
|
|
190
|
+
trace_id=trace_id,
|
|
191
|
+
parent_node_id=parent_id,
|
|
192
|
+
func_name=func_display_name,
|
|
193
|
+
input_data=input_payload,
|
|
194
|
+
output_data=output_payload,
|
|
195
|
+
state_snapshot={"args": repr(args), "kwargs": repr(kwargs)},
|
|
196
|
+
latency=latency,
|
|
197
|
+
cost=cost,
|
|
198
|
+
step_hash=step_hash
|
|
199
|
+
)
|
|
200
|
+
return sync_wrapper
|
|
201
|
+
|
|
202
|
+
if func is not None:
|
|
203
|
+
return decorator(func)
|
|
204
|
+
return decorator
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Dict, Any, Optional, List
|
|
7
|
+
|
|
8
|
+
DEFAULT_DB_PATH = os.environ.get("ATHAR_DB_PATH", "agent_traces.db")
|
|
9
|
+
|
|
10
|
+
class TraceStore:
|
|
11
|
+
"""
|
|
12
|
+
SQLite-backed local storage for trace steps and execution branches.
|
|
13
|
+
Uses WAL mode for high-concurrency non-blocking writes from agents
|
|
14
|
+
and reads from the UI.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
|
17
|
+
self.db_path = db_path
|
|
18
|
+
self._local = threading.local()
|
|
19
|
+
self._init_db()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def conn(self) -> sqlite3.Connection:
|
|
23
|
+
if not hasattr(self._local, "conn"):
|
|
24
|
+
self._local.conn = sqlite3.connect(self.db_path, timeout=10.0)
|
|
25
|
+
self._local.conn.row_factory = sqlite3.Row
|
|
26
|
+
# Enforce WAL mode for concurrent multi-process / multi-thread access
|
|
27
|
+
self._local.conn.execute("PRAGMA journal_mode=WAL;")
|
|
28
|
+
self._local.conn.execute("PRAGMA synchronous=NORMAL;")
|
|
29
|
+
return self._local.conn
|
|
30
|
+
|
|
31
|
+
def _init_db(self):
|
|
32
|
+
c = self.conn.cursor()
|
|
33
|
+
c.execute('''
|
|
34
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
35
|
+
trace_id TEXT PRIMARY KEY,
|
|
36
|
+
name TEXT,
|
|
37
|
+
status TEXT DEFAULT 'completed',
|
|
38
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
39
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
40
|
+
metadata TEXT
|
|
41
|
+
)
|
|
42
|
+
''')
|
|
43
|
+
c.execute('''
|
|
44
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
45
|
+
node_id TEXT PRIMARY KEY,
|
|
46
|
+
trace_id TEXT NOT NULL,
|
|
47
|
+
parent_node_id TEXT,
|
|
48
|
+
branch_id TEXT DEFAULT 'main',
|
|
49
|
+
func_name TEXT NOT NULL,
|
|
50
|
+
input_data TEXT,
|
|
51
|
+
output_data TEXT,
|
|
52
|
+
state_snapshot TEXT,
|
|
53
|
+
cost REAL DEFAULT 0.0,
|
|
54
|
+
latency REAL DEFAULT 0.0,
|
|
55
|
+
step_hash TEXT,
|
|
56
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
57
|
+
FOREIGN KEY(trace_id) REFERENCES traces(trace_id) ON DELETE CASCADE
|
|
58
|
+
)
|
|
59
|
+
''')
|
|
60
|
+
# Indexes for fast querying
|
|
61
|
+
c.execute('CREATE INDEX IF NOT EXISTS idx_nodes_trace_id ON nodes(trace_id);')
|
|
62
|
+
c.execute('CREATE INDEX IF NOT EXISTS idx_nodes_parent_id ON nodes(parent_node_id);')
|
|
63
|
+
self.conn.commit()
|
|
64
|
+
|
|
65
|
+
def ensure_trace(self, trace_id: str, name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None):
|
|
66
|
+
"""Ensures a trace record exists in the traces table."""
|
|
67
|
+
c = self.conn.cursor()
|
|
68
|
+
c.execute("SELECT trace_id FROM traces WHERE trace_id = ?", (trace_id,))
|
|
69
|
+
if not c.fetchone():
|
|
70
|
+
meta_json = json.dumps(metadata or {})
|
|
71
|
+
trace_name = name or f"Trace {trace_id[:8]}"
|
|
72
|
+
c.execute(
|
|
73
|
+
"INSERT INTO traces (trace_id, name, metadata) VALUES (?, ?, ?)",
|
|
74
|
+
(trace_id, trace_name, meta_json)
|
|
75
|
+
)
|
|
76
|
+
self.conn.commit()
|
|
77
|
+
|
|
78
|
+
def insert_node(
|
|
79
|
+
self,
|
|
80
|
+
node_id: str,
|
|
81
|
+
trace_id: str,
|
|
82
|
+
parent_node_id: Optional[str],
|
|
83
|
+
func_name: str,
|
|
84
|
+
input_data: Any,
|
|
85
|
+
output_data: Any,
|
|
86
|
+
state_snapshot: Optional[Any] = None,
|
|
87
|
+
latency: float = 0.0,
|
|
88
|
+
cost: float = 0.0,
|
|
89
|
+
step_hash: Optional[str] = None,
|
|
90
|
+
branch_id: str = "main"
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Inserts a trace node, auto-registering the trace if missing."""
|
|
93
|
+
self.ensure_trace(trace_id)
|
|
94
|
+
c = self.conn.cursor()
|
|
95
|
+
|
|
96
|
+
input_json = json.dumps(input_data, default=str) if not isinstance(input_data, str) else input_data
|
|
97
|
+
output_json = json.dumps(output_data, default=str) if not isinstance(output_data, str) else output_data
|
|
98
|
+
state_json = json.dumps(state_snapshot or {}, default=str) if not isinstance(state_snapshot, str) else state_snapshot
|
|
99
|
+
|
|
100
|
+
c.execute('''
|
|
101
|
+
INSERT INTO nodes (
|
|
102
|
+
node_id, trace_id, parent_node_id, branch_id, func_name,
|
|
103
|
+
input_data, output_data, state_snapshot, cost, latency, step_hash
|
|
104
|
+
)
|
|
105
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
106
|
+
''', (
|
|
107
|
+
node_id, trace_id, parent_node_id, branch_id, func_name,
|
|
108
|
+
input_json, output_json, state_json, cost, latency, step_hash
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
# Update trace timestamp
|
|
112
|
+
c.execute("UPDATE traces SET updated_at = CURRENT_TIMESTAMP WHERE trace_id = ?", (trace_id,))
|
|
113
|
+
self.conn.commit()
|
|
114
|
+
return node_id
|
|
115
|
+
|
|
116
|
+
def list_traces(self) -> List[Dict[str, Any]]:
|
|
117
|
+
"""Returns all traces with aggregated stats (node count, duration)."""
|
|
118
|
+
c = self.conn.cursor()
|
|
119
|
+
c.execute('''
|
|
120
|
+
SELECT
|
|
121
|
+
t.trace_id,
|
|
122
|
+
t.name,
|
|
123
|
+
t.status,
|
|
124
|
+
t.created_at,
|
|
125
|
+
t.updated_at,
|
|
126
|
+
t.metadata,
|
|
127
|
+
COUNT(n.node_id) as node_count,
|
|
128
|
+
COALESCE(SUM(n.latency), 0.0) as total_latency,
|
|
129
|
+
COALESCE(SUM(n.cost), 0.0) as total_cost
|
|
130
|
+
FROM traces t
|
|
131
|
+
LEFT JOIN nodes n ON t.trace_id = n.trace_id
|
|
132
|
+
GROUP BY t.trace_id
|
|
133
|
+
ORDER BY t.created_at DESC
|
|
134
|
+
''')
|
|
135
|
+
rows = c.fetchall()
|
|
136
|
+
result = []
|
|
137
|
+
for row in rows:
|
|
138
|
+
item = dict(row)
|
|
139
|
+
try:
|
|
140
|
+
item["metadata"] = json.loads(item["metadata"]) if item["metadata"] else {}
|
|
141
|
+
except Exception:
|
|
142
|
+
item["metadata"] = {}
|
|
143
|
+
result.append(item)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
def get_trace(self, trace_id: str) -> Optional[Dict[str, Any]]:
|
|
147
|
+
c = self.conn.cursor()
|
|
148
|
+
c.execute("SELECT * FROM traces WHERE trace_id = ?", (trace_id,))
|
|
149
|
+
row = c.fetchone()
|
|
150
|
+
if not row:
|
|
151
|
+
return None
|
|
152
|
+
item = dict(row)
|
|
153
|
+
try:
|
|
154
|
+
item["metadata"] = json.loads(item["metadata"]) if item["metadata"] else {}
|
|
155
|
+
except Exception:
|
|
156
|
+
item["metadata"] = {}
|
|
157
|
+
return item
|
|
158
|
+
|
|
159
|
+
def get_nodes(self, trace_id: str) -> List[Dict[str, Any]]:
|
|
160
|
+
"""Returns all nodes for a given trace sorted chronologically."""
|
|
161
|
+
c = self.conn.cursor()
|
|
162
|
+
c.execute("SELECT * FROM nodes WHERE trace_id = ? ORDER BY created_at ASC", (trace_id,))
|
|
163
|
+
rows = c.fetchall()
|
|
164
|
+
nodes = []
|
|
165
|
+
for row in rows:
|
|
166
|
+
node = dict(row)
|
|
167
|
+
try:
|
|
168
|
+
node["input_data"] = json.loads(node["input_data"]) if node["input_data"] else None
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
node["output_data"] = json.loads(node["output_data"]) if node["output_data"] else None
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
try:
|
|
176
|
+
node["state_snapshot"] = json.loads(node["state_snapshot"]) if node["state_snapshot"] else {}
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
nodes.append(node)
|
|
180
|
+
return nodes
|
|
181
|
+
|
|
182
|
+
def get_node(self, node_id: str) -> Optional[Dict[str, Any]]:
|
|
183
|
+
c = self.conn.cursor()
|
|
184
|
+
c.execute("SELECT * FROM nodes WHERE node_id = ?", (node_id,))
|
|
185
|
+
row = c.fetchone()
|
|
186
|
+
if not row:
|
|
187
|
+
return None
|
|
188
|
+
node = dict(row)
|
|
189
|
+
try:
|
|
190
|
+
node["input_data"] = json.loads(node["input_data"]) if node["input_data"] else None
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
try:
|
|
194
|
+
node["output_data"] = json.loads(node["output_data"]) if node["output_data"] else None
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
try:
|
|
198
|
+
node["state_snapshot"] = json.loads(node["state_snapshot"]) if node["state_snapshot"] else {}
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
return node
|
|
202
|
+
|
|
203
|
+
def delete_trace(self, trace_id: str) -> bool:
|
|
204
|
+
c = self.conn.cursor()
|
|
205
|
+
c.execute("DELETE FROM nodes WHERE trace_id = ?", (trace_id,))
|
|
206
|
+
c.execute("DELETE FROM traces WHERE trace_id = ?", (trace_id,))
|
|
207
|
+
self.conn.commit()
|
|
208
|
+
return c.rowcount > 0
|
|
209
|
+
|
|
210
|
+
def fork_node(self, node_id: str, new_state: Dict[str, Any], branch_name: Optional[str] = None) -> Dict[str, Any]:
|
|
211
|
+
"""
|
|
212
|
+
Creates a branched execution node from an existing step with mutated state.
|
|
213
|
+
"""
|
|
214
|
+
original = self.get_node(node_id)
|
|
215
|
+
if not original:
|
|
216
|
+
raise ValueError(f"Node {node_id} not found.")
|
|
217
|
+
|
|
218
|
+
new_node_id = f"node_{uuid.uuid4().hex[:12]}"
|
|
219
|
+
branch_id = branch_name or f"branch_{uuid.uuid4().hex[:6]}"
|
|
220
|
+
|
|
221
|
+
# Record the forked replay node
|
|
222
|
+
self.insert_node(
|
|
223
|
+
node_id=new_node_id,
|
|
224
|
+
trace_id=original["trace_id"],
|
|
225
|
+
parent_node_id=original["parent_node_id"],
|
|
226
|
+
func_name=f"{original['func_name']} (forked)",
|
|
227
|
+
input_data={"mutated_state": new_state, "forked_from": node_id},
|
|
228
|
+
output_data={"status": "replayed", "message": "Replay execution initialized from mutated state"},
|
|
229
|
+
state_snapshot=new_state,
|
|
230
|
+
latency=0.001,
|
|
231
|
+
cost=0.0,
|
|
232
|
+
step_hash=f"fork_{uuid.uuid4().hex[:8]}",
|
|
233
|
+
branch_id=branch_id
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"original_node_id": node_id,
|
|
238
|
+
"new_node_id": new_node_id,
|
|
239
|
+
"branch_id": branch_id,
|
|
240
|
+
"trace_id": original["trace_id"]
|
|
241
|
+
}
|
athar/ui/app.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
2
|
+
from fastapi.responses import RedirectResponse
|
|
3
|
+
from fastapi.staticfiles import StaticFiles
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import List, Dict, Any, Optional
|
|
8
|
+
|
|
9
|
+
from ..storage.sqlite_store import TraceStore
|
|
10
|
+
|
|
11
|
+
app = FastAPI(
|
|
12
|
+
title="Athar UI & API",
|
|
13
|
+
description="Local-first time-travel debugger for multi-agent LLM systems",
|
|
14
|
+
version="0.1.0"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
|
19
|
+
|
|
20
|
+
if os.path.exists(STATIC_DIR):
|
|
21
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
22
|
+
|
|
23
|
+
def get_store() -> TraceStore:
|
|
24
|
+
db_path = os.environ.get("ATHAR_DB_PATH", "agent_traces.db")
|
|
25
|
+
return TraceStore(db_path=db_path)
|
|
26
|
+
|
|
27
|
+
class ReplayRequest(BaseModel):
|
|
28
|
+
new_state: Dict[str, Any]
|
|
29
|
+
branch_name: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
class CreateTraceRequest(BaseModel):
|
|
32
|
+
name: Optional[str] = None
|
|
33
|
+
trace_id: Optional[str] = None
|
|
34
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
35
|
+
|
|
36
|
+
@app.get("/", include_in_schema=False)
|
|
37
|
+
def root_redirect():
|
|
38
|
+
return RedirectResponse(url="/static/index.html")
|
|
39
|
+
|
|
40
|
+
@app.get("/api/health")
|
|
41
|
+
def health():
|
|
42
|
+
return {
|
|
43
|
+
"status": "healthy",
|
|
44
|
+
"service": "athar",
|
|
45
|
+
"version": "0.1.0",
|
|
46
|
+
"storage": "sqlite-wal"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@app.get("/api/traces")
|
|
50
|
+
def list_traces():
|
|
51
|
+
store = get_store()
|
|
52
|
+
traces = store.list_traces()
|
|
53
|
+
return {"traces": traces, "count": len(traces)}
|
|
54
|
+
|
|
55
|
+
@app.get("/api/traces/{trace_id}")
|
|
56
|
+
def get_trace(trace_id: str):
|
|
57
|
+
store = get_store()
|
|
58
|
+
trace = store.get_trace(trace_id)
|
|
59
|
+
if not trace:
|
|
60
|
+
raise HTTPException(status_code=404, detail="Trace not found")
|
|
61
|
+
return {"trace": trace}
|
|
62
|
+
|
|
63
|
+
@app.get("/api/traces/{trace_id}/nodes")
|
|
64
|
+
def get_trace_nodes(trace_id: str):
|
|
65
|
+
store = get_store()
|
|
66
|
+
nodes = store.get_nodes(trace_id)
|
|
67
|
+
return {"trace_id": trace_id, "nodes": nodes, "count": len(nodes)}
|
|
68
|
+
|
|
69
|
+
@app.post("/api/traces")
|
|
70
|
+
def create_trace(payload: CreateTraceRequest):
|
|
71
|
+
store = get_store()
|
|
72
|
+
trace_id = payload.trace_id or f"trace_{int(time.time()*1000)}"
|
|
73
|
+
store.ensure_trace(trace_id, name=payload.name, metadata=payload.metadata)
|
|
74
|
+
return {"status": "created", "trace_id": trace_id}
|
|
75
|
+
|
|
76
|
+
@app.post("/api/replay/{node_id}")
|
|
77
|
+
def replay_from_node(node_id: str, payload: ReplayRequest):
|
|
78
|
+
"""
|
|
79
|
+
Fork-and-replay from the given step.
|
|
80
|
+
Creates a new branch in the trace graph with mutated state.
|
|
81
|
+
"""
|
|
82
|
+
store = get_store()
|
|
83
|
+
node = store.get_node(node_id)
|
|
84
|
+
if not node:
|
|
85
|
+
raise HTTPException(status_code=404, detail=f"Node {node_id} not found")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
fork_result = store.fork_node(
|
|
89
|
+
node_id=node_id,
|
|
90
|
+
new_state=payload.new_state,
|
|
91
|
+
branch_name=payload.branch_name
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"status": "success",
|
|
95
|
+
"message": f"Successfully forked from node {node_id}",
|
|
96
|
+
"data": fork_result
|
|
97
|
+
}
|
|
98
|
+
except Exception as e:
|
|
99
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
100
|
+
|
|
101
|
+
@app.delete("/api/traces/{trace_id}")
|
|
102
|
+
def delete_trace(trace_id: str):
|
|
103
|
+
store = get_store()
|
|
104
|
+
deleted = store.delete_trace(trace_id)
|
|
105
|
+
if not deleted:
|
|
106
|
+
raise HTTPException(status_code=404, detail="Trace not found")
|
|
107
|
+
return {"status": "deleted", "trace_id": trace_id}
|
|
108
|
+
|
|
109
|
+
@app.post("/api/seed-sample")
|
|
110
|
+
def seed_sample_trace():
|
|
111
|
+
"""Seeds a realistic sample multi-agent research trace for instant demonstration."""
|
|
112
|
+
store = get_store()
|
|
113
|
+
sample_trace_id = f"sample_trace_{int(time.time())}"
|
|
114
|
+
store.ensure_trace(
|
|
115
|
+
sample_trace_id,
|
|
116
|
+
name="Multi-Agent Market Analysis",
|
|
117
|
+
metadata={"author": "Athar Demo", "environment": "local"}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Step 1: User prompt
|
|
121
|
+
n1 = store.insert_node(
|
|
122
|
+
node_id=f"node_root_{int(time.time())}",
|
|
123
|
+
trace_id=sample_trace_id,
|
|
124
|
+
parent_node_id=None,
|
|
125
|
+
func_name="orchestrator_agent",
|
|
126
|
+
input_data={"user_prompt": "Analyze impact of AI code assistants on engineering velocity."},
|
|
127
|
+
output_data={"delegations": ["web_search_tool", "data_synthesis_agent"]},
|
|
128
|
+
state_snapshot={"step": 1, "status": "planning"},
|
|
129
|
+
latency=0.32,
|
|
130
|
+
cost=0.0015,
|
|
131
|
+
step_hash="sha256_root_step_sample"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Step 2: Tool call
|
|
135
|
+
n2 = store.insert_node(
|
|
136
|
+
node_id=f"node_tool_{int(time.time())}",
|
|
137
|
+
trace_id=sample_trace_id,
|
|
138
|
+
parent_node_id=n1,
|
|
139
|
+
func_name="web_search_tool",
|
|
140
|
+
input_data={"query": "AI coding tools benchmark velocity 2026"},
|
|
141
|
+
output_data={"results": ["Benchmark A: 35% speedup in boilerplate", "Benchmark B: 42% PR cycle time drop"]},
|
|
142
|
+
state_snapshot={"step": 2, "cache_hit": False},
|
|
143
|
+
latency=0.74,
|
|
144
|
+
cost=0.0008,
|
|
145
|
+
step_hash="sha256_search_tool_sample"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Step 3: Synthesis
|
|
149
|
+
store.insert_node(
|
|
150
|
+
node_id=f"node_synth_{int(time.time())}",
|
|
151
|
+
trace_id=sample_trace_id,
|
|
152
|
+
parent_node_id=n2,
|
|
153
|
+
func_name="data_synthesis_agent",
|
|
154
|
+
input_data={"raw_metrics": ["35% speedup", "42% PR drop"]},
|
|
155
|
+
output_data={"final_report": "AI assistants show measured 35-42% reduction in cycle times for routine tasks."},
|
|
156
|
+
state_snapshot={"step": 3, "confidence": 0.94},
|
|
157
|
+
latency=1.15,
|
|
158
|
+
cost=0.0042,
|
|
159
|
+
step_hash="sha256_synthesis_sample"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return {"status": "seeded", "trace_id": sample_trace_id}
|
|
163
|
+
|
|
164
|
+
def main():
|
|
165
|
+
import argparse
|
|
166
|
+
import uvicorn
|
|
167
|
+
|
|
168
|
+
parser = argparse.ArgumentParser(description="Athar: Local-first time-travel debugger for LLM agents")
|
|
169
|
+
parser.add_argument("--port", type=int, default=4173, help="Port to run the UI server on (default: 4173)")
|
|
170
|
+
parser.add_argument("--host", type=str, default="127.0.0.1", help="Host interface (default: 127.0.0.1)")
|
|
171
|
+
parser.add_argument("--db", type=str, default=None, help="Custom SQLite trace DB path")
|
|
172
|
+
args = parser.parse_args()
|
|
173
|
+
|
|
174
|
+
if args.db:
|
|
175
|
+
os.environ["ATHAR_DB_PATH"] = args.db
|
|
176
|
+
|
|
177
|
+
print(f"\n=======================================================")
|
|
178
|
+
print(f" ATHAR (أثر) · Local-First Multi-Agent Debugger")
|
|
179
|
+
print(f" Web UI: http://{args.host}:{args.port}")
|
|
180
|
+
print(f"=======================================================\n")
|
|
181
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|