loomcache 1.0.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.
loom/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Loom: a content-addressed, deterministic replay engine for agent AI workflows."""
2
+
3
+ from .cache import Cache, DiskCache, default_cache
4
+ from .diff import diff_runs, first_divergence, format_diff
5
+ from .run import Node, Run, current_run, step
6
+ from .tracing import TracedBox, get_node, attach_node, unwrap, unwrap_recursive
7
+
8
+ # Optional extensions – if dependencies are missing, these imports are skipped.
9
+ try:
10
+ from .cache_remote import S3Cache, RedisCache
11
+ except ImportError:
12
+ S3Cache = RedisCache = None
13
+
14
+ try:
15
+ from .langchain import wrap_runnable, CachedRunnable
16
+ except ImportError:
17
+ wrap_runnable = CachedRunnable = None
18
+
19
+ try:
20
+ from .async_run import AsyncRun, async_step, gather
21
+ except ImportError:
22
+ AsyncRun = async_step = gather = None
23
+
24
+ __version__ = "1.0.0"
25
+
26
+ __all__ = [
27
+ "step",
28
+ "Run",
29
+ "Node",
30
+ "current_run",
31
+ "Cache",
32
+ "DiskCache",
33
+ "default_cache",
34
+ "diff_runs",
35
+ "first_divergence",
36
+ "format_diff",
37
+ "get_node",
38
+ "attach_node",
39
+ "unwrap",
40
+ "unwrap_recursive",
41
+ "TracedBox",
42
+ "S3Cache",
43
+ "RedisCache",
44
+ "wrap_runnable",
45
+ "CachedRunnable",
46
+ "AsyncRun",
47
+ "async_step",
48
+ "gather",
49
+ ]
loom/async_run.py ADDED
@@ -0,0 +1,182 @@
1
+ """Async execution support for Loom.
2
+
3
+ Use `AsyncRun` in an async context manager with async steps.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextvars
10
+ import functools
11
+ import json
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Optional, Union
16
+
17
+ from loom import hashing, default_cache
18
+ from loom.run import Node, current_run
19
+ from loom.tracing import attach_node, get_node, unwrap_recursive
20
+ from loom.cache import Cache
21
+
22
+ _current_async_run: contextvars.ContextVar = contextvars.ContextVar(
23
+ "loom_current_async_run", default=None
24
+ )
25
+
26
+
27
+ class AsyncRun:
28
+ """Async context manager for recording async step executions."""
29
+
30
+ def __init__(
31
+ self,
32
+ name: str,
33
+ cache: Optional[Cache] = None,
34
+ root: Union[str, Path] = ".loom_runs",
35
+ ):
36
+ self.name = name
37
+ self.run_id = f"{name}-{uuid.uuid4().hex[:8]}"
38
+ self.cache = cache or default_cache()
39
+ self.root = Path(root)
40
+ self.nodes: list = []
41
+ self.created_at = time.time()
42
+ self._token = None
43
+
44
+ async def __aenter__(self) -> AsyncRun:
45
+ self._token = _current_async_run.set(self)
46
+ return self
47
+
48
+ async def __aexit__(self, exc_type, exc, tb):
49
+ _current_async_run.reset(self._token)
50
+
51
+ def record(self, node: Node) -> None:
52
+ self.nodes.append(node)
53
+
54
+ def to_dict(self) -> dict:
55
+ return {
56
+ "run_id": self.run_id,
57
+ "name": self.name,
58
+ "created_at": self.created_at,
59
+ "nodes": [n.to_dict() for n in self.nodes],
60
+ }
61
+
62
+ def save(self, path: Optional[Union[str, Path]] = None) -> Path:
63
+ self.root.mkdir(parents=True, exist_ok=True)
64
+ out_path = Path(path) if path else self.root / f"{self.run_id}.json"
65
+ out_path.write_text(json.dumps(self.to_dict(), indent=2, default=str))
66
+ return out_path
67
+
68
+ def stats(self) -> dict:
69
+ hits = sum(1 for n in self.nodes if n.cache_hit)
70
+ total = len(self.nodes)
71
+ return {
72
+ "total_nodes": total,
73
+ "cache_hits": hits,
74
+ "cache_misses": total - hits,
75
+ "hit_rate": (hits / total) if total else 0.0,
76
+ "wall_time_s": round(sum(n.duration_s for n in self.nodes), 3),
77
+ "time_saved_s": round(sum(n.time_saved_s for n in self.nodes), 3),
78
+ }
79
+
80
+ @classmethod
81
+ def load(cls, path: Union[str, Path]) -> AsyncRun:
82
+ data = json.loads(Path(path).read_text())
83
+ run = cls.__new__(cls)
84
+ run.name = data["name"]
85
+ run.run_id = data["run_id"]
86
+ run.created_at = data["created_at"]
87
+ run.cache = default_cache()
88
+ run.root = Path(path).parent
89
+ run.nodes = [Node(**n) for n in data["nodes"]]
90
+ run._token = None
91
+ return run
92
+
93
+
94
+ def async_step(func: Optional[Callable] = None, *, cache: Optional[Cache] = None):
95
+ """Decorator that works for async functions.
96
+
97
+ If the function is async, it returns a coroutine that will be awaited
98
+ by the caller. For sync functions, use `@loom.step` instead.
99
+ """
100
+
101
+ def decorator(f: Callable) -> Callable:
102
+ source_hash = hashing.hash_source(f)
103
+ is_coroutine = asyncio.iscoroutinefunction(f)
104
+
105
+ @functools.wraps(f)
106
+ async def async_wrapper(*args, **kwargs):
107
+ run = _current_async_run.get()
108
+ if run is None:
109
+ # Fallback to calling the function directly (could be sync or async)
110
+ if is_coroutine:
111
+ return await f(*args, **kwargs)
112
+ else:
113
+ return f(*args, **kwargs)
114
+
115
+ active_cache = cache or (run.cache if run is not None else default_cache())
116
+ node_hash = hashing.hash_node(f.__qualname__, source_hash, args, kwargs)
117
+
118
+ # Determine parents from traced arguments
119
+ parents = [
120
+ n.node_hash
121
+ for n in (get_node(a) for a in list(args) + list(kwargs.values()))
122
+ if n is not None
123
+ ]
124
+
125
+ entry = active_cache.get(node_hash)
126
+ if entry is not None:
127
+ output = entry.output # already clean
128
+ cache_hit = True
129
+ duration = 0.0
130
+ time_saved = float(entry.metadata.get("duration_s", 0.0))
131
+ clean_output = output
132
+ else:
133
+ # Unwrap arguments
134
+ from loom.run import _unwrap
135
+ call_args = tuple(_unwrap(a) for a in args)
136
+ call_kwargs = {k: _unwrap(v) for k, v in kwargs.items()}
137
+ t0 = time.time()
138
+ if is_coroutine:
139
+ raw_output = await f(*call_args, **call_kwargs)
140
+ else:
141
+ raw_output = f(*call_args, **call_kwargs)
142
+ duration = time.time() - t0
143
+
144
+ clean_output = unwrap_recursive(raw_output)
145
+
146
+ active_cache.put(
147
+ node_hash,
148
+ clean_output,
149
+ metadata={
150
+ "step_name": f.__qualname__,
151
+ "timestamp": time.time(),
152
+ "duration_s": duration,
153
+ },
154
+ )
155
+ cache_hit = False
156
+ time_saved = 0.0
157
+
158
+ node = Node(
159
+ node_hash=node_hash,
160
+ step_name=f.__qualname__,
161
+ parents=parents,
162
+ cache_hit=cache_hit,
163
+ duration_s=duration,
164
+ time_saved_s=time_saved,
165
+ timestamp=time.time(),
166
+ args_repr=repr(args),
167
+ kwargs_repr=repr(kwargs),
168
+ output_repr=repr(clean_output),
169
+ )
170
+ run.record(node)
171
+ return attach_node(clean_output, node)
172
+
173
+ return async_wrapper
174
+
175
+ if func is not None:
176
+ return decorator(func)
177
+ return decorator
178
+
179
+
180
+ async def gather(*coros, return_exceptions=False):
181
+ """Run multiple async steps concurrently, with Loom tracking."""
182
+ return await asyncio.gather(*coros, return_exceptions=return_exceptions)
loom/cache.py ADDED
@@ -0,0 +1,114 @@
1
+ """Content-addressed cache backends.
2
+
3
+ Loom stores every step's output under a key derived purely from its
4
+ content hash — the same idea as Git's object store or Bazel's action
5
+ cache. Two runs (even in different processes, on different days, on
6
+ different machines sharing this cache) that produce the same hash will
7
+ transparently share the same cached output.
8
+
9
+ `Cache` is a tiny abstract interface so alternate backends (Redis, S3,
10
+ a shared network drive for a team) can be dropped in later without
11
+ touching the rest of Loom.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import pickle
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, Optional, Union
20
+
21
+
22
+ @dataclass
23
+ class CacheEntry:
24
+ output: Any
25
+ metadata: dict
26
+
27
+
28
+ class Cache:
29
+ """Abstract cache interface. Subclass to plug in Redis/S3/etc."""
30
+
31
+ def get(self, key: str) -> Optional[CacheEntry]:
32
+ raise NotImplementedError
33
+
34
+ def put(self, key: str, output: Any, metadata: dict) -> None:
35
+ raise NotImplementedError
36
+
37
+ def stats(self) -> dict:
38
+ raise NotImplementedError
39
+
40
+
41
+ class DiskCache(Cache):
42
+ """Default local cache: `<root>/objects/<hash[:2]>/<hash>.pkl`.
43
+
44
+ Sharding by the first two hex characters keeps any one directory
45
+ from accumulating too many files, the same trick Git uses for its
46
+ object store.
47
+ """
48
+
49
+ def __init__(self, root: Union[str, Path] = ".loom_cache"):
50
+ self.root = Path(root)
51
+ self.objects_dir = self.root / "objects"
52
+ self.objects_dir.mkdir(parents=True, exist_ok=True)
53
+ self._hits = 0
54
+ self._misses = 0
55
+
56
+ def _paths(self, key: str):
57
+ shard = self.objects_dir / key[:2]
58
+ shard.mkdir(parents=True, exist_ok=True)
59
+ return shard / f"{key}.pkl", shard / f"{key}.json"
60
+
61
+ def get(self, key: str) -> Optional[CacheEntry]:
62
+ data_path, meta_path = self._paths(key)
63
+ if not data_path.exists():
64
+ self._misses += 1
65
+ return None
66
+
67
+ # Remove empty or corrupted files
68
+ if data_path.stat().st_size == 0:
69
+ data_path.unlink()
70
+ if meta_path.exists():
71
+ meta_path.unlink()
72
+ self._misses += 1
73
+ return None
74
+
75
+ try:
76
+ with open(data_path, "rb") as f:
77
+ output = pickle.load(f)
78
+ except (EOFError, pickle.UnpicklingError, Exception):
79
+ # Corrupt file – delete it and treat as a miss
80
+ data_path.unlink()
81
+ if meta_path.exists():
82
+ meta_path.unlink()
83
+ self._misses += 1
84
+ return None
85
+
86
+ self._hits += 1
87
+ metadata = {}
88
+ if meta_path.exists():
89
+ try:
90
+ metadata = json.loads(meta_path.read_text())
91
+ except Exception:
92
+ metadata = {}
93
+ return CacheEntry(output=output, metadata=metadata)
94
+
95
+ def put(self, key: str, output: Any, metadata: dict) -> None:
96
+ data_path, meta_path = self._paths(key)
97
+ with open(data_path, "wb") as f:
98
+ pickle.dump(output, f)
99
+ meta_path.write_text(json.dumps(metadata, indent=2, default=str))
100
+
101
+ def stats(self) -> dict:
102
+ total = self._hits + self._misses
103
+ hit_rate = self._hits / total if total else 0.0
104
+ return {"hits": self._hits, "misses": self._misses, "hit_rate": hit_rate}
105
+
106
+
107
+ _default_cache: Optional[DiskCache] = None
108
+
109
+
110
+ def default_cache() -> DiskCache:
111
+ global _default_cache
112
+ if _default_cache is None:
113
+ _default_cache = DiskCache()
114
+ return _default_cache
loom/cache_remote.py ADDED
@@ -0,0 +1,143 @@
1
+ """Remote cache backends: S3 and Redis.
2
+
3
+ To use, install the corresponding extra:
4
+
5
+ pip install loomtrace[s3] # for S3
6
+ pip install loomtrace[redis] # for Redis
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import pickle
13
+ from typing import Any, Optional
14
+
15
+ from .cache import Cache, CacheEntry
16
+
17
+
18
+ class S3Cache(Cache):
19
+ """Amazon S3 cache backend.
20
+
21
+ Args:
22
+ bucket: S3 bucket name.
23
+ prefix: Key prefix (e.g., "loom_cache/").
24
+ region_name: AWS region, optional.
25
+ aws_access_key_id, aws_secret_access_key: Optional credentials.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ bucket: str,
31
+ prefix: str = "loom_cache/",
32
+ region_name: Optional[str] = None,
33
+ aws_access_key_id: Optional[str] = None,
34
+ aws_secret_access_key: Optional[str] = None,
35
+ ):
36
+ try:
37
+ import boto3
38
+ except ImportError as exc:
39
+ raise ImportError("Install boto3: pip install loomtrace[s3]") from exc
40
+
41
+ self.bucket = bucket
42
+ self.prefix = prefix.rstrip("/") + "/"
43
+ self.s3 = boto3.client(
44
+ "s3",
45
+ region_name=region_name,
46
+ aws_access_key_id=aws_access_key_id,
47
+ aws_secret_access_key=aws_secret_access_key,
48
+ )
49
+ self._hits = 0
50
+ self._misses = 0
51
+
52
+ def _key(self, key: str) -> str:
53
+ return f"{self.prefix}{key[:2]}/{key}.pkl"
54
+
55
+ def _meta_key(self, key: str) -> str:
56
+ return f"{self.prefix}{key[:2]}/{key}.json"
57
+
58
+ def get(self, key: str) -> Optional[CacheEntry]:
59
+ try:
60
+ obj = self.s3.get_object(Bucket=self.bucket, Key=self._key(key))
61
+ output = pickle.loads(obj["Body"].read())
62
+ except self.s3.exceptions.NoSuchKey:
63
+ self._misses += 1
64
+ return None
65
+
66
+ self._hits += 1
67
+ metadata = {}
68
+ try:
69
+ meta_obj = self.s3.get_object(Bucket=self.bucket, Key=self._meta_key(key))
70
+ metadata = json.loads(meta_obj["Body"].read().decode())
71
+ except self.s3.exceptions.NoSuchKey:
72
+ pass
73
+
74
+ return CacheEntry(output=output, metadata=metadata)
75
+
76
+ def put(self, key: str, output: Any, metadata: dict) -> None:
77
+ self.s3.put_object(
78
+ Bucket=self.bucket,
79
+ Key=self._key(key),
80
+ Body=pickle.dumps(output),
81
+ )
82
+ self.s3.put_object(
83
+ Bucket=self.bucket,
84
+ Key=self._meta_key(key),
85
+ Body=json.dumps(metadata).encode(),
86
+ )
87
+
88
+ def stats(self) -> dict:
89
+ total = self._hits + self._misses
90
+ return {
91
+ "hits": self._hits,
92
+ "misses": self._misses,
93
+ "hit_rate": self._hits / total if total else 0.0,
94
+ }
95
+
96
+
97
+ class RedisCache(Cache):
98
+ """Redis cache backend.
99
+
100
+ Args:
101
+ url: Redis URL (e.g., redis://localhost:6379/0).
102
+ key_prefix: Prefix for all keys.
103
+ """
104
+
105
+ def __init__(self, url: str = "redis://localhost:6379/0", key_prefix: str = "loom:"):
106
+ try:
107
+ import redis
108
+ except ImportError as exc:
109
+ raise ImportError("Install redis-py: pip install loomtrace[redis]") from exc
110
+
111
+ self.redis = redis.from_url(url)
112
+ self.key_prefix = key_prefix
113
+ self._hits = 0
114
+ self._misses = 0
115
+
116
+ def _data_key(self, key: str) -> str:
117
+ return f"{self.key_prefix}{key}:data"
118
+
119
+ def _meta_key(self, key: str) -> str:
120
+ return f"{self.key_prefix}{key}:meta"
121
+
122
+ def get(self, key: str) -> Optional[CacheEntry]:
123
+ data = self.redis.get(self._data_key(key))
124
+ if data is None:
125
+ self._misses += 1
126
+ return None
127
+ self._hits += 1
128
+ output = pickle.loads(data)
129
+ meta = self.redis.get(self._meta_key(key))
130
+ metadata = json.loads(meta) if meta else {}
131
+ return CacheEntry(output=output, metadata=metadata)
132
+
133
+ def put(self, key: str, output: Any, metadata: dict) -> None:
134
+ self.redis.set(self._data_key(key), pickle.dumps(output))
135
+ self.redis.set(self._meta_key(key), json.dumps(metadata))
136
+
137
+ def stats(self) -> dict:
138
+ total = self._hits + self._misses
139
+ return {
140
+ "hits": self._hits,
141
+ "misses": self._misses,
142
+ "hit_rate": self._hits / total if total else 0.0,
143
+ }
loom/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """Command-line interface for Loom.
2
+
3
+ loom show <run.json>
4
+ loom diff <run_a.json> <run_b.json>
5
+ loom stats <run.json>
6
+ loom web [--runs-dir DIR] [--host HOST] [--port PORT]
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from .diff import diff_runs, format_diff
13
+ from .run import Run
14
+
15
+
16
+ def cmd_show(args):
17
+ run = Run.load(args.run_path)
18
+ print(f"Run: {run.name} ({run.run_id})")
19
+ print(f"Nodes: {len(run.nodes)}\n")
20
+ for i, node in enumerate(run.nodes):
21
+ tag = "HIT " if node.cache_hit else "MISS"
22
+ print(f"[{i}] {tag} {node.step_name} ({node.node_hash[:10]}...)")
23
+ print(f" -> {node.output_repr}")
24
+ stats = run.stats()
25
+ print(
26
+ f"\nCache hit rate: {stats['hit_rate']:.0%} "
27
+ f"({stats['cache_hits']}/{stats['total_nodes']})"
28
+ )
29
+ print(f"Wall time: {stats['wall_time_s']}s Time saved by cache: {stats['time_saved_s']}s")
30
+
31
+
32
+ def cmd_diff(args):
33
+ run_a = Run.load(args.run_a)
34
+ run_b = Run.load(args.run_b)
35
+ diffs = diff_runs(run_a, run_b)
36
+ print(format_diff(diffs))
37
+ changed = [d for d in diffs if d.status != "same"]
38
+ if changed:
39
+ print(
40
+ f"\n{len(changed)} node(s) differ. First divergence: "
41
+ f"[{changed[0].index}] {changed[0].step_name}"
42
+ )
43
+ else:
44
+ print("\nRuns are identical.")
45
+
46
+
47
+ def cmd_stats(args):
48
+ run = Run.load(args.run_path)
49
+ for k, v in run.stats().items():
50
+ print(f"{k}: {v}")
51
+
52
+
53
+ def cmd_web(args):
54
+ try:
55
+ from loom.web import serve
56
+ except ImportError as exc:
57
+ raise SystemExit("Install loomtrace[web] to use the web UI.") from exc
58
+ serve(runs_dir=args.runs_dir, host=args.host, port=args.port)
59
+
60
+
61
+ def main(argv=None):
62
+ parser = argparse.ArgumentParser(
63
+ prog="loom",
64
+ description="Loom: content-addressed execution engine for agent workflows.",
65
+ )
66
+ sub = parser.add_subparsers(dest="command", required=True)
67
+
68
+ p_show = sub.add_parser("show", help="Show all nodes in a saved run")
69
+ p_show.add_argument("run_path")
70
+ p_show.set_defaults(func=cmd_show)
71
+
72
+ p_diff = sub.add_parser("diff", help="Diff two saved runs node-by-node")
73
+ p_diff.add_argument("run_a")
74
+ p_diff.add_argument("run_b")
75
+ p_diff.set_defaults(func=cmd_diff)
76
+
77
+ p_stats = sub.add_parser("stats", help="Show cache hit-rate stats for a run")
78
+ p_stats.add_argument("run_path")
79
+ p_stats.set_defaults(func=cmd_stats)
80
+
81
+ p_web = sub.add_parser("web", help="Launch web UI")
82
+ p_web.add_argument("--runs-dir", default=".loom_runs", help="Directory containing run JSONs")
83
+ p_web.add_argument("--host", default="127.0.0.1", help="Host to bind")
84
+ p_web.add_argument("--port", type=int, default=5000, help="Port to bind")
85
+ p_web.set_defaults(func=cmd_web)
86
+
87
+ args = parser.parse_args(argv)
88
+ args.func(args)
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
loom/diff.py ADDED
@@ -0,0 +1,58 @@
1
+ """Node-level diffing between two runs.
2
+
3
+ See exactly *where* two pipeline executions first diverge — not just
4
+ whether their final outputs differ.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional
10
+
11
+ from .run import Run, Node
12
+
13
+
14
+ @dataclass
15
+ class NodeDiff:
16
+ index: int
17
+ step_name: str
18
+ status: str # "same" | "changed" | "only_in_a" | "only_in_b"
19
+ node_a: Optional[Node]
20
+ node_b: Optional[Node]
21
+
22
+
23
+ def diff_runs(run_a: Run, run_b: Run) -> List[NodeDiff]:
24
+ """Compare two runs node-by-node, in call order."""
25
+ diffs: List[NodeDiff] = []
26
+ max_len = max(len(run_a.nodes), len(run_b.nodes))
27
+ for i in range(max_len):
28
+ na = run_a.nodes[i] if i < len(run_a.nodes) else None
29
+ nb = run_b.nodes[i] if i < len(run_b.nodes) else None
30
+ if na is None:
31
+ diffs.append(NodeDiff(i, nb.step_name, "only_in_b", na, nb))
32
+ elif nb is None:
33
+ diffs.append(NodeDiff(i, na.step_name, "only_in_a", na, nb))
34
+ elif na.node_hash == nb.node_hash:
35
+ diffs.append(NodeDiff(i, na.step_name, "same", na, nb))
36
+ else:
37
+ diffs.append(NodeDiff(i, na.step_name, "changed", na, nb))
38
+ return diffs
39
+
40
+
41
+ def first_divergence(run_a: Run, run_b: Run) -> Optional[NodeDiff]:
42
+ """The earliest node (in call order) where the two runs differ, or
43
+ None if they are identical."""
44
+ for d in diff_runs(run_a, run_b):
45
+ if d.status != "same":
46
+ return d
47
+ return None
48
+
49
+
50
+ def format_diff(diffs: List[NodeDiff]) -> str:
51
+ symbols = {"same": " ", "changed": "~", "only_in_a": "-", "only_in_b": "+"}
52
+ lines = []
53
+ for d in diffs:
54
+ lines.append(f"{symbols[d.status]} [{d.index}] {d.step_name} ({d.status})")
55
+ if d.status == "changed":
56
+ lines.append(f" a: {d.node_a.output_repr}")
57
+ lines.append(f" b: {d.node_b.output_repr}")
58
+ return "\n".join(lines)