capsule-trace 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.
@@ -0,0 +1,22 @@
1
+ """Capsule — Deterministic replay & time-travel debugger for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from capsule_trace.core.decorator import trace
8
+ from capsule_trace.core.session import Session, get_current_session
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["trace", "Session", "get_current_session", "last_session_path"]
12
+
13
+
14
+ def last_session_path() -> Path | None:
15
+ """Return the path of the most recently saved .capsule file, or None."""
16
+ candidates: list[Path] = []
17
+ for search_dir in (Path.home() / ".capsule", Path.cwd()):
18
+ if search_dir.is_dir():
19
+ candidates.extend(search_dir.glob("*.capsule"))
20
+ if not candidates:
21
+ return None
22
+ return max(candidates, key=lambda p: p.stat().st_mtime)
@@ -0,0 +1,3 @@
1
+ from capsule_trace.cli.main import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,5 @@
1
+ """Allow running the CLI as `python -m capsule.cli`."""
2
+ from capsule_trace.cli.main import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,429 @@
1
+ """Capsule CLI — entry point for all `capsule` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(package_name="capsule-trace")
18
+ def main() -> None:
19
+ """Capsule — deterministic replay & time-travel debugger for AI agents."""
20
+
21
+
22
+ # ── capsule list ──────────────────────────────────────────────
23
+
24
+
25
+ @main.command("list")
26
+ @click.option("--agent", default=None, help="Filter by agent name")
27
+ @click.option("--status", default=None, help="Filter by status (success|failed)")
28
+ @click.option("--limit", default=20, show_default=True, help="Max results")
29
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
30
+ def list_sessions(agent: str | None, status: str | None, limit: int, as_json: bool) -> None:
31
+ """List captured sessions."""
32
+ from capsule_trace.storage.sqlite import SQLiteBackend
33
+
34
+ backend = SQLiteBackend.default()
35
+ sessions = backend.list_sessions(limit=limit)
36
+
37
+ if agent:
38
+ sessions = [s for s in sessions if agent.lower() in s.agent_name.lower()]
39
+ if status:
40
+ sessions = [s for s in sessions if s.status.value == status]
41
+
42
+ if as_json:
43
+ click.echo(json.dumps([s.model_dump(mode="json") for s in sessions], indent=2, default=str))
44
+ return
45
+
46
+ if not sessions:
47
+ console.print("[dim]No sessions found.[/dim]")
48
+ return
49
+
50
+ table = Table(show_header=True, header_style="bold")
51
+ table.add_column("ID", style="cyan", no_wrap=True)
52
+ table.add_column("Agent")
53
+ table.add_column("Status")
54
+ table.add_column("Steps", justify="right")
55
+ table.add_column("Duration")
56
+ table.add_column("Started At")
57
+
58
+ for s in sessions:
59
+ status_style = "green" if s.status.value == "success" else "red"
60
+ duration = f"{s.duration_ms:.0f}ms" if s.duration_ms else "-"
61
+ table.add_row(
62
+ s.session_id[:26],
63
+ s.agent_name,
64
+ f"[{status_style}]{s.status.value}[/{status_style}]",
65
+ str(s.step_count),
66
+ duration,
67
+ str(s.started_at)[:19],
68
+ )
69
+
70
+ console.print(table)
71
+
72
+
73
+ # ── capsule show ──────────────────────────────────────────────
74
+
75
+
76
+ @main.command("show")
77
+ @click.argument("session_id")
78
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
79
+ def show_session(session_id: str, as_json: bool) -> None:
80
+ """Show details of a session."""
81
+ from capsule_trace.storage.sqlite import SQLiteBackend
82
+
83
+ backend = SQLiteBackend.default()
84
+ try:
85
+ meta = backend.read_session_metadata(session_id)
86
+ events = backend.read_events(session_id)
87
+ except KeyError:
88
+ console.print(f"[red]Session not found:[/red] {session_id}")
89
+ sys.exit(1)
90
+
91
+ if as_json:
92
+ out = {
93
+ "session": meta.model_dump(mode="json"),
94
+ "events": [e.model_dump_json_safe() for e in events],
95
+ }
96
+ click.echo(json.dumps(out, indent=2, default=str))
97
+ return
98
+
99
+ console.print(f"\n[bold]Session[/bold] [cyan]{meta.session_id}[/cyan]")
100
+ console.print(f" Agent: {meta.agent_name}")
101
+ console.print(f" Status: {meta.status.value}")
102
+ console.print(f" Steps: {meta.step_count}")
103
+ console.print(f" Duration: {meta.duration_ms:.0f}ms" if meta.duration_ms else " Duration: -")
104
+ console.print(f" Started: {meta.started_at}")
105
+
106
+ if meta.error:
107
+ console.print(f"\n[red]Error:[/red] {meta.error.type}: {meta.error.message}")
108
+
109
+ console.print(f"\n[bold]Events[/bold] ({len(events)})")
110
+ for event in events:
111
+ console.print(f" [{event.step_index:03d}] {event.event_type.value} — {event.duration_ms:.1f}ms")
112
+
113
+
114
+ # ── capsule export ────────────────────────────────────────────
115
+
116
+
117
+ @main.command("export")
118
+ @click.argument("session_id")
119
+ @click.option(
120
+ "--output",
121
+ "-o",
122
+ default=None,
123
+ type=click.Path(),
124
+ help="Output .capsule file path (default: <session_id>.capsule)",
125
+ )
126
+ def export_session(session_id: str, output: str | None) -> None:
127
+ """Export a session to a .capsule file."""
128
+ from capsule_trace.core.exporter import export_capsule
129
+ from capsule_trace.storage.sqlite import SQLiteBackend
130
+
131
+ backend = SQLiteBackend.default()
132
+ out_path = Path(output) if output else Path(f"{session_id}.capsule")
133
+
134
+ try:
135
+ result = export_capsule(session_id, backend, out_path)
136
+ size_kb = result.stat().st_size / 1024
137
+ console.print(f"[green]Exported[/green] → {result} ({size_kb:.1f} KB)")
138
+ except KeyError:
139
+ console.print(f"[red]Session not found:[/red] {session_id}")
140
+ sys.exit(1)
141
+
142
+
143
+ # ── capsule import ────────────────────────────────────────────
144
+
145
+
146
+ @main.command("import")
147
+ @click.argument("capsule_file", type=click.Path(exists=True))
148
+ def import_capsule(capsule_file: str) -> None:
149
+ """Import a .capsule file into the local store."""
150
+ from capsule_trace.core.importer import import_capsule_file
151
+
152
+ path = Path(capsule_file)
153
+ try:
154
+ session_id = import_capsule_file(path)
155
+ console.print(f"[green]Imported[/green] session {session_id}")
156
+ except Exception as exc:
157
+ console.print(f"[red]Import failed:[/red] {exc}")
158
+ sys.exit(1)
159
+
160
+
161
+ # ── capsule replay ────────────────────────────────────────────
162
+
163
+
164
+ @main.command("replay")
165
+ @click.argument("session_id_or_file")
166
+ @click.option("--mode", default="cassette", type=click.Choice(["cassette", "live"]))
167
+ @click.option("--json", "as_json", is_flag=True, help="Output result as JSON")
168
+ def replay_session(session_id_or_file: str, mode: str, as_json: bool) -> None:
169
+ """Replay a captured session deterministically from cassettes."""
170
+ from capsule_trace.replay.engine import Replayer
171
+ from pathlib import Path
172
+
173
+ try:
174
+ p = Path(session_id_or_file)
175
+ if p.exists() and p.suffix == ".capsule":
176
+ replayer = Replayer.from_file(p)
177
+ else:
178
+ replayer = Replayer.from_session_id(session_id_or_file)
179
+ except Exception as exc:
180
+ console.print(f"[red]Failed to load session:[/red] {exc}")
181
+ sys.exit(1)
182
+
183
+ console.print(
184
+ f"Replaying [cyan]{replayer.session_id}[/cyan] "
185
+ f"({replayer.step_count} steps) in [bold]{mode}[/bold] mode..."
186
+ )
187
+
188
+ result = replayer.replay()
189
+
190
+ if as_json:
191
+ click.echo(json.dumps({
192
+ "session_id": result.session_id,
193
+ "replayed_steps": result.replayed_step_count,
194
+ "original_steps": result.original_step_count,
195
+ "is_deterministic": result.is_deterministic,
196
+ "integrity_ok": result.integrity_ok,
197
+ }, indent=2))
198
+ return
199
+
200
+ status = "[green]deterministic[/green]" if result.is_deterministic else "[yellow]mismatch[/yellow]"
201
+ console.print(f"\nResult: {status}")
202
+ console.print(f" Steps replayed: {result.replayed_step_count}/{result.original_step_count}")
203
+ console.print(f" Integrity check: {'✓' if result.integrity_ok else '✗'}")
204
+
205
+ for e in result.events:
206
+ has_cassette = "[dim](cassette)[/dim]" if e.event_type.value in ("llm_call", "tool_call") else ""
207
+ console.print(f" [{e.step_index:03d}] {e.event_type.value} {has_cassette}")
208
+
209
+
210
+ # ── capsule branch ────────────────────────────────────────────
211
+
212
+
213
+ @main.command("branch")
214
+ @click.argument("session_id")
215
+ @click.option("--from-step", "-s", required=True, type=int, help="Step index to branch from")
216
+ @click.option("--modify", "-m", multiple=True, help="key=value modifications (e.g. temperature=0.0)")
217
+ def branch_session(session_id: str, from_step: int, modify: tuple[str, ...]) -> None:
218
+ """Branch a session from a specific step with optional modifications."""
219
+ modifications: dict[str, str] = {}
220
+ for item in modify:
221
+ if "=" not in item:
222
+ console.print(f"[red]Invalid modification (expected key=value):[/red] {item}")
223
+ sys.exit(1)
224
+ k, v = item.split("=", 1)
225
+ modifications[k.strip()] = v.strip()
226
+
227
+ from capsule_trace.replay.engine import Replayer
228
+
229
+ try:
230
+ replayer = Replayer.from_session_id(session_id)
231
+ except Exception as exc:
232
+ console.print(f"[red]Failed to load session:[/red] {exc}")
233
+ sys.exit(1)
234
+
235
+ try:
236
+ branch = replayer.branch_from_step(from_step, modifications)
237
+ except IndexError as exc:
238
+ console.print(f"[red]{exc}[/red]")
239
+ sys.exit(1)
240
+
241
+ console.print(
242
+ f"Branch context ready: [cyan]{session_id}[/cyan] @ step {from_step}"
243
+ )
244
+ console.print(f" Pre-branch events: {len(branch.pre_branch_events)}")
245
+ console.print(f" Modifications: {branch.modifications or '(none)'}")
246
+ console.print(
247
+ "\n[dim]Re-run your agent code under this branch context to continue "
248
+ "from step {from_step} with live LLM calls.[/dim]"
249
+ )
250
+
251
+
252
+ # ── capsule diff ──────────────────────────────────────────────
253
+
254
+
255
+ @main.command("diff")
256
+ @click.argument("session_id_1")
257
+ @click.argument("session_id_2")
258
+ def diff_sessions(session_id_1: str, session_id_2: str) -> None:
259
+ """Show differences between two sessions."""
260
+ from capsule_trace.storage.sqlite import SQLiteBackend
261
+
262
+ backend = SQLiteBackend.default()
263
+ try:
264
+ m1 = backend.read_session_metadata(session_id_1)
265
+ m2 = backend.read_session_metadata(session_id_2)
266
+ e1 = backend.read_events(session_id_1)
267
+ e2 = backend.read_events(session_id_2)
268
+ except KeyError as exc:
269
+ console.print(f"[red]Session not found:[/red] {exc}")
270
+ sys.exit(1)
271
+
272
+ console.print(f"\n[bold]Session A[/bold] [cyan]{session_id_1}[/cyan]: {m1.status.value}, {len(e1)} steps")
273
+ console.print(f"[bold]Session B[/bold] [cyan]{session_id_2}[/cyan]: {m2.status.value}, {len(e2)} steps")
274
+ console.print(f"\nStep count diff: {len(e1)} vs {len(e2)} ({len(e2) - len(e1):+d})")
275
+
276
+ min_len = min(len(e1), len(e2))
277
+ diffs = 0
278
+ for i in range(min_len):
279
+ if e1[i].event_type != e2[i].event_type:
280
+ console.print(
281
+ f" Step {i:03d}: [yellow]event type changed[/yellow] "
282
+ f"{e1[i].event_type.value} → {e2[i].event_type.value}"
283
+ )
284
+ diffs += 1
285
+
286
+ if diffs == 0 and len(e1) == len(e2):
287
+ console.print("\n[green]Sessions have identical event structure.[/green]")
288
+
289
+
290
+ # ── capsule delete ────────────────────────────────────────────
291
+
292
+
293
+ @main.command("delete")
294
+ @click.argument("session_id")
295
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt")
296
+ def delete_session(session_id: str, yes: bool) -> None:
297
+ """Delete a session from the local store."""
298
+ from capsule_trace.storage.sqlite import SQLiteBackend
299
+
300
+ if not yes:
301
+ click.confirm(f"Delete session {session_id}?", abort=True)
302
+
303
+ backend = SQLiteBackend.default()
304
+ try:
305
+ backend.delete_session(session_id)
306
+ console.print(f"[green]Deleted[/green] session {session_id}")
307
+ except Exception as exc:
308
+ console.print(f"[red]Delete failed:[/red] {exc}")
309
+ sys.exit(1)
310
+
311
+
312
+ # ── capsule serve ─────────────────────────────────────────────
313
+
314
+
315
+ @main.command("serve")
316
+ @click.option("--port", default=7842, show_default=True)
317
+ def serve(port: int) -> None:
318
+ """Start the local web UI."""
319
+ console.print(f"[yellow]Local web UI coming in Sprint 6 (cloud platform).[/yellow]")
320
+ console.print(f"Would start on http://localhost:{port}")
321
+
322
+
323
+ # ── capsule upload ────────────────────────────────────────────
324
+
325
+
326
+ @main.command("upload")
327
+ @click.argument("session_id")
328
+ @click.option("--workspace", "-w", default=None, envvar="CAPSULE_WORKSPACE_ID",
329
+ help="Workspace ID (or set CAPSULE_WORKSPACE_ID)")
330
+ @click.option("--api-key", "-k", default=None, envvar="CAPSULE_API_KEY",
331
+ help="API key (or set CAPSULE_API_KEY)")
332
+ @click.option("--cloud-url", default=None, envvar="CAPSULE_CLOUD_URL",
333
+ help="Cloud API base URL (default: https://api.capsule.dev)")
334
+ @click.option("--tag", "tags", multiple=True, help="Extra tags to attach")
335
+ @click.option("--redact", is_flag=True, help="Auto-redact PII before upload")
336
+ @click.option("--json", "as_json", is_flag=True, help="Output response as JSON")
337
+ def upload_session_cmd(
338
+ session_id: str,
339
+ workspace: str | None,
340
+ api_key: str | None,
341
+ cloud_url: str | None,
342
+ tags: tuple[str, ...],
343
+ redact: bool,
344
+ as_json: bool,
345
+ ) -> None:
346
+ """Upload a local session to Capsule Cloud."""
347
+ import os as _os
348
+
349
+ if workspace:
350
+ _os.environ["CAPSULE_WORKSPACE_ID"] = workspace
351
+ if api_key:
352
+ _os.environ["CAPSULE_API_KEY"] = api_key
353
+ if cloud_url:
354
+ _os.environ["CAPSULE_CLOUD_URL"] = cloud_url
355
+
356
+ try:
357
+ from capsule_trace.cloud.uploader import upload_session
358
+
359
+ console.print(f"Uploading session [cyan]{session_id}[/cyan]…")
360
+ result = upload_session(
361
+ session_id,
362
+ tags=list(tags) if tags else None,
363
+ auto_redact=redact,
364
+ )
365
+ except RuntimeError as exc:
366
+ console.print(f"[red]Configuration error:[/red] {exc}")
367
+ sys.exit(1)
368
+ except Exception as exc:
369
+ console.print(f"[red]Upload failed:[/red] {exc}")
370
+ sys.exit(1)
371
+
372
+ if as_json:
373
+ click.echo(json.dumps(result, indent=2, default=str))
374
+ return
375
+
376
+ console.print(f"[green]Uploaded![/green] Cloud ID: [cyan]{result.get('id', '?')}[/cyan]")
377
+ if result.get("view_url"):
378
+ console.print(f" View: {result['view_url']}")
379
+
380
+
381
+ # ── capsule cloud ─────────────────────────────────────────────
382
+
383
+
384
+ @main.group("cloud")
385
+ def cloud_group() -> None:
386
+ """Manage Capsule Cloud connection."""
387
+
388
+
389
+ @cloud_group.command("login")
390
+ @click.option("--url", default="https://api.capsule.dev", show_default=True,
391
+ help="Cloud API base URL")
392
+ @click.option("--api-key", prompt="API key", hide_input=True,
393
+ help="Your Capsule Cloud API key (csk_…)")
394
+ @click.option("--workspace", prompt="Workspace ID",
395
+ help="Your workspace ID")
396
+ def cloud_login(url: str, api_key: str, workspace: str) -> None:
397
+ """Save Capsule Cloud credentials to ~/.capsule/cloud.json."""
398
+ import json as _json
399
+
400
+ config_dir = Path.home() / ".capsule"
401
+ config_dir.mkdir(parents=True, exist_ok=True)
402
+ config_file = config_dir / "cloud.json"
403
+
404
+ config_data = {
405
+ "base_url": url,
406
+ "api_key": api_key,
407
+ "workspace_id": workspace,
408
+ }
409
+ config_file.write_text(_json.dumps(config_data, indent=2))
410
+ config_file.chmod(0o600) # restrict to owner
411
+
412
+ console.print(f"[green]Saved cloud config[/green] → {config_file}")
413
+ console.print(f" Workspace: [cyan]{workspace}[/cyan]")
414
+ console.print(f" URL: {url}")
415
+
416
+
417
+ @cloud_group.command("status")
418
+ def cloud_status() -> None:
419
+ """Show current Capsule Cloud connection status."""
420
+ from capsule_trace.cloud.uploader import _get_cloud_config
421
+
422
+ config = _get_cloud_config()
423
+ if config["api_key"]:
424
+ masked = config["api_key"][:8] + "…" if len(config["api_key"]) > 8 else "***"
425
+ console.print(f"[green]Connected[/green] to {config['base_url']}")
426
+ console.print(f" API key: {masked}")
427
+ console.print(f" Workspace: {config['workspace_id'] or '[dim](not set)[/dim]'}")
428
+ else:
429
+ console.print("[yellow]Not connected.[/yellow] Run `capsule cloud login` to configure.")
@@ -0,0 +1 @@
1
+ """Capsule Cloud integration — upload sessions to Capsule Cloud."""
@@ -0,0 +1,108 @@
1
+ """Upload a local session to the Capsule Cloud API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger("capsule.cloud")
12
+
13
+
14
+ def _get_cloud_config() -> dict[str, str]:
15
+ """Read cloud config from env vars or ~/.capsule/cloud.json."""
16
+ base_url = os.environ.get("CAPSULE_CLOUD_URL", "https://api.capsule.dev")
17
+ api_key = os.environ.get("CAPSULE_API_KEY", "")
18
+ workspace_id = os.environ.get("CAPSULE_WORKSPACE_ID", "")
19
+
20
+ # Fall back to config file
21
+ config_file = Path.home() / ".capsule" / "cloud.json"
22
+ if config_file.exists() and (not api_key or not workspace_id):
23
+ try:
24
+ data = json.loads(config_file.read_text())
25
+ base_url = base_url or data.get("base_url", base_url)
26
+ api_key = api_key or data.get("api_key", "")
27
+ workspace_id = workspace_id or data.get("workspace_id", "")
28
+ except Exception:
29
+ pass
30
+
31
+ return {"base_url": base_url, "api_key": api_key, "workspace_id": workspace_id}
32
+
33
+
34
+ def upload_session(
35
+ session_id: str,
36
+ *,
37
+ agent_name: str | None = None,
38
+ agent_version: str | None = None,
39
+ tags: list[str] | None = None,
40
+ user_metadata: dict[str, Any] | None = None,
41
+ auto_redact: bool = False,
42
+ ) -> dict[str, Any]:
43
+ """Export the session to a .capsule file and upload it to Capsule Cloud.
44
+
45
+ Returns the API response dict.
46
+
47
+ Raises:
48
+ RuntimeError: if CAPSULE_API_KEY or CAPSULE_WORKSPACE_ID are not configured.
49
+ httpx.HTTPStatusError: if the upload fails.
50
+ """
51
+ try:
52
+ import httpx
53
+ except ImportError as exc:
54
+ raise ImportError("httpx is required for cloud uploads: pip install httpx") from exc
55
+
56
+ from capsule_trace.core.exporter import export_capsule
57
+ from capsule_trace.storage.sqlite import SQLiteBackend
58
+
59
+ config = _get_cloud_config()
60
+ if not config["api_key"]:
61
+ raise RuntimeError(
62
+ "CAPSULE_API_KEY is not set. "
63
+ "Run `capsule cloud login` or set the env var."
64
+ )
65
+ if not config["workspace_id"]:
66
+ raise RuntimeError(
67
+ "CAPSULE_WORKSPACE_ID is not set. "
68
+ "Set it via env var or `capsule cloud login`."
69
+ )
70
+
71
+ # Export to a temp .capsule file
72
+ import tempfile
73
+
74
+ backend = SQLiteBackend.default()
75
+ meta = backend.read_session_metadata(session_id)
76
+
77
+ with tempfile.TemporaryDirectory() as tmpdir:
78
+ capsule_path = export_capsule(session_id, backend, Path(tmpdir) / f"{session_id}.capsule")
79
+
80
+ upload_metadata = {
81
+ "session_id": session_id,
82
+ "agent_name": agent_name or meta.agent_name,
83
+ "agent_version": agent_version or meta.agent_version,
84
+ "tags": tags if tags is not None else meta.tags,
85
+ "user_metadata": user_metadata if user_metadata is not None else meta.user_metadata,
86
+ "auto_redact": auto_redact,
87
+ }
88
+
89
+ url = f"{config['base_url']}/api/v1/workspaces/{config['workspace_id']}/sessions"
90
+ headers = {"Authorization": f"Bearer {config['api_key']}"}
91
+
92
+ with open(capsule_path, "rb") as f:
93
+ resp = httpx.post(
94
+ url,
95
+ headers=headers,
96
+ files={"file": (capsule_path.name, f, "application/octet-stream")},
97
+ data={"metadata": json.dumps(upload_metadata)},
98
+ timeout=120.0,
99
+ )
100
+
101
+ resp.raise_for_status()
102
+ result: dict[str, Any] = resp.json()
103
+ logger.info(
104
+ "capsule.cloud.uploaded",
105
+ session_id=session_id,
106
+ response_id=result.get("id"),
107
+ )
108
+ return result
@@ -0,0 +1,4 @@
1
+ from capsule_trace.core.decorator import trace
2
+ from capsule_trace.core.session import Session, get_current_session
3
+
4
+ __all__ = ["trace", "Session", "get_current_session"]
@@ -0,0 +1,21 @@
1
+ """ContextVar-based session tracking — async-safe across concurrent agent calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from capsule_trace.core.session import Session
10
+
11
+ _current_session: ContextVar["Session | None"] = ContextVar(
12
+ "capsule_session", default=None
13
+ )
14
+
15
+
16
+ def get_current_session() -> "Session | None":
17
+ return _current_session.get()
18
+
19
+
20
+ def set_current_session(session: "Session | None") -> None:
21
+ _current_session.set(session)
@@ -0,0 +1,88 @@
1
+ """@capsule.trace decorator — wraps a function or coroutine in a Session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import os
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ from capsule_trace.core.session import Session
11
+
12
+ F = TypeVar("F", bound=Callable[..., Any])
13
+
14
+ _DISABLED_ENV = "CAPSULE_DISABLE"
15
+
16
+
17
+ def _is_disabled() -> bool:
18
+ return os.environ.get(_DISABLED_ENV, "").strip().lower() in ("1", "true", "yes")
19
+
20
+
21
+ def trace(
22
+ agent_name: str | None = None,
23
+ agent_version: str | None = None,
24
+ tags: list[str] | None = None,
25
+ user_metadata: dict[str, Any] | None = None,
26
+ redact: list[str] | None = None,
27
+ auto_upload: bool = False,
28
+ storage_backend: Any | None = None,
29
+ ) -> Callable[[F], F]:
30
+ """Decorator that wraps a function in a Capsule session.
31
+
32
+ Usage::
33
+
34
+ @capsule.trace(agent_name="billing-agent")
35
+ def run_agent(customer_id: str) -> str:
36
+ ...
37
+
38
+ @capsule.trace(agent_name="async-agent")
39
+ async def run_async_agent(query: str) -> str:
40
+ ...
41
+ """
42
+
43
+ def decorator(fn: F) -> F:
44
+ name = agent_name or fn.__name__
45
+
46
+ if asyncio.iscoroutinefunction(fn):
47
+
48
+ @functools.wraps(fn)
49
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
50
+ if _is_disabled():
51
+ return await fn(*args, **kwargs)
52
+
53
+ session = Session(
54
+ agent_name=name,
55
+ agent_version=agent_version,
56
+ tags=tags,
57
+ user_metadata=user_metadata,
58
+ redact=redact,
59
+ auto_upload=auto_upload,
60
+ storage_backend=storage_backend,
61
+ )
62
+ async with session:
63
+ return await fn(*args, **kwargs)
64
+
65
+ return async_wrapper # type: ignore[return-value]
66
+
67
+ else:
68
+
69
+ @functools.wraps(fn)
70
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
71
+ if _is_disabled():
72
+ return fn(*args, **kwargs)
73
+
74
+ session = Session(
75
+ agent_name=name,
76
+ agent_version=agent_version,
77
+ tags=tags,
78
+ user_metadata=user_metadata,
79
+ redact=redact,
80
+ auto_upload=auto_upload,
81
+ storage_backend=storage_backend,
82
+ )
83
+ with session:
84
+ return fn(*args, **kwargs)
85
+
86
+ return sync_wrapper # type: ignore[return-value]
87
+
88
+ return decorator