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 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()