mycelium-runtime 1.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.
- mycelium/__init__.py +121 -0
- mycelium/__main__.py +64 -0
- mycelium/action_ledger.py +431 -0
- mycelium/audit_receipt.py +252 -0
- mycelium/cache.py +49 -0
- mycelium/config.py +755 -0
- mycelium/history_guard.py +228 -0
- mycelium/message_validator.py +319 -0
- mycelium/protect.py +137 -0
- mycelium/schema.py +41 -0
- mycelium/session.py +50 -0
- mycelium/state_flush.py +246 -0
- mycelium/storage/__init__.py +21 -0
- mycelium/storage/_helpers.py +45 -0
- mycelium/storage/file_lock.py +51 -0
- mycelium/storage/json_file.py +59 -0
- mycelium/storage/postgres_ledger.py +216 -0
- mycelium/storage/redis_ledger.py +168 -0
- mycelium/task_ledger.py +432 -0
- mycelium/templates/mycelium.minimal.yaml +81 -0
- mycelium/templates/mycelium.template.yaml +147 -0
- mycelium/tool_boundary.py +351 -0
- mycelium/tool_registry.py +54 -0
- mycelium/tool_runner.py +122 -0
- mycelium_runtime-1.1.0.dist-info/METADATA +378 -0
- mycelium_runtime-1.1.0.dist-info/RECORD +28 -0
- mycelium_runtime-1.1.0.dist-info/WHEEL +4 -0
- mycelium_runtime-1.1.0.dist-info/entry_points.txt +2 -0
mycelium/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Mycelium runtime — failure prevention for AI agents."""
|
|
2
|
+
|
|
3
|
+
from mycelium.action_ledger import (
|
|
4
|
+
ActionLedger,
|
|
5
|
+
FileLedgerStorage,
|
|
6
|
+
InMemoryLedgerStorage,
|
|
7
|
+
LedgerEntry,
|
|
8
|
+
LedgerError,
|
|
9
|
+
LedgerPendingError,
|
|
10
|
+
LedgerStorage,
|
|
11
|
+
get_ledger,
|
|
12
|
+
ledger,
|
|
13
|
+
ledger_sync,
|
|
14
|
+
)
|
|
15
|
+
from mycelium.audit_receipt import (
|
|
16
|
+
AuditReceiptEmitter,
|
|
17
|
+
AuditReceiptError,
|
|
18
|
+
AuditReceiptRecord,
|
|
19
|
+
FileAuditReceiptStorage,
|
|
20
|
+
InMemoryAuditReceiptStorage,
|
|
21
|
+
verify_receipt,
|
|
22
|
+
)
|
|
23
|
+
from mycelium.config import (
|
|
24
|
+
ConfigError,
|
|
25
|
+
MyceliumConfig,
|
|
26
|
+
load_config,
|
|
27
|
+
load_config_from_string,
|
|
28
|
+
)
|
|
29
|
+
from mycelium.history_guard import HistoryGuard, HistoryTruncatedError
|
|
30
|
+
from mycelium.message_validator import MessageValidationError, MessageValidator
|
|
31
|
+
from mycelium.protect import protect, protect_sync
|
|
32
|
+
from mycelium.session import Session
|
|
33
|
+
from mycelium.state_flush import (
|
|
34
|
+
FileStateFlushStorage,
|
|
35
|
+
InMemoryStateFlushStorage,
|
|
36
|
+
StateFlush,
|
|
37
|
+
StateFlushError,
|
|
38
|
+
StateSnapshot,
|
|
39
|
+
)
|
|
40
|
+
from mycelium.storage.postgres_ledger import PostgresLedgerStorage, PostgresTaskLedgerStorage
|
|
41
|
+
from mycelium.storage.redis_ledger import RedisLedgerStorage, RedisTaskLedgerStorage
|
|
42
|
+
from mycelium.task_ledger import (
|
|
43
|
+
TaskFileLedgerStorage,
|
|
44
|
+
TaskInMemoryLedgerStorage,
|
|
45
|
+
TaskLedger,
|
|
46
|
+
TaskLedgerEntry,
|
|
47
|
+
TaskLedgerError,
|
|
48
|
+
TaskLedgerPendingError,
|
|
49
|
+
TaskLedgerStorage,
|
|
50
|
+
get_task_ledger,
|
|
51
|
+
task_ledger,
|
|
52
|
+
task_ledger_sync,
|
|
53
|
+
)
|
|
54
|
+
from mycelium.tool_boundary import (
|
|
55
|
+
ToolBoundaryError,
|
|
56
|
+
ToolBoundaryExhaustedError,
|
|
57
|
+
bounded,
|
|
58
|
+
bounded_sync,
|
|
59
|
+
tool_error_message,
|
|
60
|
+
)
|
|
61
|
+
from mycelium.tool_registry import ToolRegistry
|
|
62
|
+
from mycelium.tool_runner import ToolRunner
|
|
63
|
+
|
|
64
|
+
__version__ = "1.1.0"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"ActionLedger",
|
|
68
|
+
"FileLedgerStorage",
|
|
69
|
+
"InMemoryLedgerStorage",
|
|
70
|
+
"LedgerEntry",
|
|
71
|
+
"LedgerError",
|
|
72
|
+
"LedgerPendingError",
|
|
73
|
+
"LedgerStorage",
|
|
74
|
+
"get_ledger",
|
|
75
|
+
"ledger",
|
|
76
|
+
"ledger_sync",
|
|
77
|
+
"AuditReceiptEmitter",
|
|
78
|
+
"AuditReceiptError",
|
|
79
|
+
"AuditReceiptRecord",
|
|
80
|
+
"FileAuditReceiptStorage",
|
|
81
|
+
"InMemoryAuditReceiptStorage",
|
|
82
|
+
"verify_receipt",
|
|
83
|
+
"TaskFileLedgerStorage",
|
|
84
|
+
"TaskInMemoryLedgerStorage",
|
|
85
|
+
"TaskLedger",
|
|
86
|
+
"TaskLedgerEntry",
|
|
87
|
+
"TaskLedgerError",
|
|
88
|
+
"TaskLedgerPendingError",
|
|
89
|
+
"TaskLedgerStorage",
|
|
90
|
+
"RedisLedgerStorage",
|
|
91
|
+
"RedisTaskLedgerStorage",
|
|
92
|
+
"PostgresLedgerStorage",
|
|
93
|
+
"PostgresTaskLedgerStorage",
|
|
94
|
+
"get_task_ledger",
|
|
95
|
+
"task_ledger",
|
|
96
|
+
"task_ledger_sync",
|
|
97
|
+
"ConfigError",
|
|
98
|
+
"MyceliumConfig",
|
|
99
|
+
"load_config",
|
|
100
|
+
"load_config_from_string",
|
|
101
|
+
"protect",
|
|
102
|
+
"protect_sync",
|
|
103
|
+
"Session",
|
|
104
|
+
"StateFlush",
|
|
105
|
+
"StateFlushError",
|
|
106
|
+
"StateSnapshot",
|
|
107
|
+
"FileStateFlushStorage",
|
|
108
|
+
"InMemoryStateFlushStorage",
|
|
109
|
+
"MessageValidator",
|
|
110
|
+
"MessageValidationError",
|
|
111
|
+
"HistoryGuard",
|
|
112
|
+
"HistoryTruncatedError",
|
|
113
|
+
"bounded",
|
|
114
|
+
"bounded_sync",
|
|
115
|
+
"ToolBoundaryError",
|
|
116
|
+
"ToolBoundaryExhaustedError",
|
|
117
|
+
"tool_error_message",
|
|
118
|
+
"ToolRegistry",
|
|
119
|
+
"ToolRunner",
|
|
120
|
+
"__version__",
|
|
121
|
+
]
|
mycelium/__main__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""CLI entrypoint: ``mycelium init`` scaffolds a config file in the user's project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_TEMPLATE_FULL = "mycelium.template.yaml"
|
|
11
|
+
_TEMPLATE_MINIMAL = "mycelium.minimal.yaml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_template(minimal: bool) -> str:
|
|
15
|
+
filename = _TEMPLATE_MINIMAL if minimal else _TEMPLATE_FULL
|
|
16
|
+
path = resources.files("mycelium") / "templates" / filename
|
|
17
|
+
return path.read_text(encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_init(output: Path, *, minimal: bool, force: bool) -> int:
|
|
21
|
+
if output.exists() and not force:
|
|
22
|
+
print(f"error: {output} already exists (use --force to overwrite)", file=sys.stderr)
|
|
23
|
+
return 1
|
|
24
|
+
output.write_text(_load_template(minimal), encoding="utf-8")
|
|
25
|
+
variant = "minimal" if minimal else "full"
|
|
26
|
+
print(f"Wrote {output} ({variant} template)")
|
|
27
|
+
print("Next: edit tool/task names, then load_config(...) in your agent code.")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="mycelium",
|
|
34
|
+
description="Mycelium runtime — scaffold config and utilities",
|
|
35
|
+
)
|
|
36
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
37
|
+
|
|
38
|
+
init_parser = sub.add_parser("init", help="Create mycelium.yaml in the current project")
|
|
39
|
+
init_parser.add_argument(
|
|
40
|
+
"-o",
|
|
41
|
+
"--output",
|
|
42
|
+
type=Path,
|
|
43
|
+
default=Path("mycelium.yaml"),
|
|
44
|
+
help="Output path (default: ./mycelium.yaml)",
|
|
45
|
+
)
|
|
46
|
+
init_parser.add_argument(
|
|
47
|
+
"--minimal",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Use the smaller template (fewer commented examples)",
|
|
50
|
+
)
|
|
51
|
+
init_parser.add_argument(
|
|
52
|
+
"--force",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Overwrite an existing file",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
if args.command == "init":
|
|
59
|
+
return cmd_init(args.output, minimal=args.minimal, force=args.force)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""ActionLedger — AF-002 durable action records and idempotency guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
|
15
|
+
|
|
16
|
+
from mycelium.session import Session, _session_var
|
|
17
|
+
from mycelium.storage.json_file import LockedJsonDictFile
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from mycelium.audit_receipt import AuditReceiptEmitter
|
|
21
|
+
|
|
22
|
+
P = ParamSpec("P")
|
|
23
|
+
R = TypeVar("R")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LedgerError(Exception):
|
|
27
|
+
"""Raised when the action ledger cannot record or verify an action."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LedgerPendingError(Exception):
|
|
31
|
+
"""Raised when the same request is already in-flight."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LedgerEntry:
|
|
36
|
+
"""Immutable record of a single tool invocation."""
|
|
37
|
+
|
|
38
|
+
request_id: str
|
|
39
|
+
tool: str
|
|
40
|
+
args: list[Any]
|
|
41
|
+
kwargs: dict[str, Any]
|
|
42
|
+
status: str # "in-flight" | "completed" | "failed"
|
|
43
|
+
result: Any = None
|
|
44
|
+
error: str | None = None
|
|
45
|
+
started_at: float = field(default_factory=time.time)
|
|
46
|
+
finished_at: float | None = None
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"request_id": self.request_id,
|
|
51
|
+
"tool": self.tool,
|
|
52
|
+
"args": self.args,
|
|
53
|
+
"kwargs": self.kwargs,
|
|
54
|
+
"status": self.status,
|
|
55
|
+
"result": self.result,
|
|
56
|
+
"error": self.error,
|
|
57
|
+
"started_at": self.started_at,
|
|
58
|
+
"finished_at": self.finished_at,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict[str, Any]) -> LedgerEntry:
|
|
63
|
+
return cls(**data)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LedgerStorage:
|
|
67
|
+
"""Backend interface for durable action ledger records."""
|
|
68
|
+
|
|
69
|
+
def get(self, request_id: str) -> LedgerEntry | None:
|
|
70
|
+
"""Return the entry for request_id, or None if not found."""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def set(self, entry: LedgerEntry) -> None:
|
|
74
|
+
"""Persist entry, replacing any existing entry with the same request_id."""
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def try_claim_inflight(
|
|
78
|
+
self,
|
|
79
|
+
entry: LedgerEntry,
|
|
80
|
+
) -> tuple[str, LedgerEntry | None]:
|
|
81
|
+
"""Atomically claim an in-flight entry.
|
|
82
|
+
|
|
83
|
+
Returns ``("claimed", None)``, ``("completed", entry)``, or
|
|
84
|
+
``("in_flight", entry)``. Redis/Postgres backends override with
|
|
85
|
+
atomic primitives; file storage uses an exclusive lock.
|
|
86
|
+
"""
|
|
87
|
+
from mycelium.storage._helpers import default_try_claim_inflight
|
|
88
|
+
|
|
89
|
+
return default_try_claim_inflight(self, entry)
|
|
90
|
+
|
|
91
|
+
def list_all(self) -> list[LedgerEntry]:
|
|
92
|
+
"""Return all entries. Intended for debugging/auditing only."""
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class InMemoryLedgerStorage(LedgerStorage):
|
|
97
|
+
"""Default in-memory storage. Survives within the process only."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._entries: dict[str, LedgerEntry] = {}
|
|
101
|
+
|
|
102
|
+
def get(self, request_id: str) -> LedgerEntry | None:
|
|
103
|
+
return self._entries.get(request_id)
|
|
104
|
+
|
|
105
|
+
def set(self, entry: LedgerEntry) -> None:
|
|
106
|
+
self._entries[entry.request_id] = entry
|
|
107
|
+
|
|
108
|
+
def list_all(self) -> list[LedgerEntry]:
|
|
109
|
+
return list(self._entries.values())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FileLedgerStorage(LedgerStorage):
|
|
113
|
+
"""JSON-file-backed storage with ``fcntl`` locking for multi-process safety."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, path: str | Path) -> None:
|
|
116
|
+
self._file = LockedJsonDictFile(path)
|
|
117
|
+
|
|
118
|
+
def get(self, request_id: str) -> LedgerEntry | None:
|
|
119
|
+
def read(data: dict[str, dict[str, Any]]) -> LedgerEntry | None:
|
|
120
|
+
raw = data.get(request_id)
|
|
121
|
+
if raw is None:
|
|
122
|
+
return None
|
|
123
|
+
return LedgerEntry.from_dict(raw)
|
|
124
|
+
|
|
125
|
+
return self._file.read_modify_write_no_save(read)
|
|
126
|
+
|
|
127
|
+
def set(self, entry: LedgerEntry) -> None:
|
|
128
|
+
def mutate(data: dict[str, dict[str, Any]]) -> None:
|
|
129
|
+
data[entry.request_id] = entry.to_dict()
|
|
130
|
+
|
|
131
|
+
self._file.read_modify_write(mutate)
|
|
132
|
+
|
|
133
|
+
def try_claim_inflight(
|
|
134
|
+
self,
|
|
135
|
+
entry: LedgerEntry,
|
|
136
|
+
) -> tuple[str, LedgerEntry | None]:
|
|
137
|
+
outcome: list[tuple[str, LedgerEntry | None]] = []
|
|
138
|
+
|
|
139
|
+
def mutate(data: dict[str, dict[str, Any]]) -> None:
|
|
140
|
+
raw = data.get(entry.request_id)
|
|
141
|
+
if raw is not None:
|
|
142
|
+
existing = LedgerEntry.from_dict(raw)
|
|
143
|
+
if existing.status == "completed":
|
|
144
|
+
outcome.append(("completed", existing))
|
|
145
|
+
return
|
|
146
|
+
if existing.status == "in-flight":
|
|
147
|
+
outcome.append(("in_flight", existing))
|
|
148
|
+
return
|
|
149
|
+
data[entry.request_id] = entry.to_dict()
|
|
150
|
+
outcome.append(("claimed", None))
|
|
151
|
+
|
|
152
|
+
self._file.read_modify_write(mutate)
|
|
153
|
+
return outcome[0]
|
|
154
|
+
|
|
155
|
+
def list_all(self) -> list[LedgerEntry]:
|
|
156
|
+
data = self._file.load()
|
|
157
|
+
return [LedgerEntry.from_dict(raw) for raw in data.values()]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ActionLedger:
|
|
161
|
+
"""Durable ledger of tool invocations for idempotency and audit."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, storage: LedgerStorage | None = None) -> None:
|
|
164
|
+
self._storage = storage if storage is not None else InMemoryLedgerStorage()
|
|
165
|
+
|
|
166
|
+
# --- public API ---
|
|
167
|
+
|
|
168
|
+
def get(self, request_id: str) -> LedgerEntry | None:
|
|
169
|
+
return self._storage.get(request_id)
|
|
170
|
+
|
|
171
|
+
def claim(
|
|
172
|
+
self,
|
|
173
|
+
request_id: str,
|
|
174
|
+
tool: str,
|
|
175
|
+
args: tuple[Any, ...],
|
|
176
|
+
kwargs: dict[str, Any],
|
|
177
|
+
) -> LedgerEntry:
|
|
178
|
+
"""Claim a request idempotency key before execution.
|
|
179
|
+
|
|
180
|
+
Returns the existing completed entry if the request already succeeded.
|
|
181
|
+
Raises LedgerPendingError if the request is currently in-flight.
|
|
182
|
+
"""
|
|
183
|
+
bound = _bind_args(args, kwargs)
|
|
184
|
+
entry = LedgerEntry(
|
|
185
|
+
request_id=request_id,
|
|
186
|
+
tool=tool,
|
|
187
|
+
args=bound["args"],
|
|
188
|
+
kwargs=bound["kwargs"],
|
|
189
|
+
status="in-flight",
|
|
190
|
+
)
|
|
191
|
+
outcome, existing = self._storage.try_claim_inflight(entry)
|
|
192
|
+
if outcome == "completed" and existing is not None:
|
|
193
|
+
return existing
|
|
194
|
+
if outcome == "in_flight":
|
|
195
|
+
raise LedgerPendingError(
|
|
196
|
+
f"Tool {tool!r} request {request_id!r} is already in-flight"
|
|
197
|
+
)
|
|
198
|
+
return entry
|
|
199
|
+
|
|
200
|
+
def complete(self, request_id: str, result: Any) -> LedgerEntry:
|
|
201
|
+
existing = self._storage.get(request_id)
|
|
202
|
+
if existing is None:
|
|
203
|
+
raise LedgerError(f"Cannot complete unknown request {request_id!r}")
|
|
204
|
+
entry = LedgerEntry(
|
|
205
|
+
request_id=existing.request_id,
|
|
206
|
+
tool=existing.tool,
|
|
207
|
+
args=existing.args,
|
|
208
|
+
kwargs=existing.kwargs,
|
|
209
|
+
status="completed",
|
|
210
|
+
result=result,
|
|
211
|
+
started_at=existing.started_at,
|
|
212
|
+
finished_at=time.time(),
|
|
213
|
+
)
|
|
214
|
+
self._storage.set(entry)
|
|
215
|
+
return entry
|
|
216
|
+
|
|
217
|
+
def fail(self, request_id: str, error: BaseException) -> LedgerEntry:
|
|
218
|
+
existing = self._storage.get(request_id)
|
|
219
|
+
if existing is None:
|
|
220
|
+
raise LedgerError(f"Cannot fail unknown request {request_id!r}")
|
|
221
|
+
entry = LedgerEntry(
|
|
222
|
+
request_id=existing.request_id,
|
|
223
|
+
tool=existing.tool,
|
|
224
|
+
args=existing.args,
|
|
225
|
+
kwargs=existing.kwargs,
|
|
226
|
+
status="failed",
|
|
227
|
+
error=f"{type(error).__name__}: {error}",
|
|
228
|
+
started_at=existing.started_at,
|
|
229
|
+
finished_at=time.time(),
|
|
230
|
+
)
|
|
231
|
+
self._storage.set(entry)
|
|
232
|
+
return entry
|
|
233
|
+
|
|
234
|
+
# --- request id derivation ---
|
|
235
|
+
|
|
236
|
+
def derive_request_id(
|
|
237
|
+
self,
|
|
238
|
+
tool: str,
|
|
239
|
+
args: tuple[Any, ...],
|
|
240
|
+
kwargs: dict[str, Any],
|
|
241
|
+
) -> str:
|
|
242
|
+
"""Determine the request id for a tool invocation.
|
|
243
|
+
|
|
244
|
+
Priority:
|
|
245
|
+
1. kwargs["request_id"]
|
|
246
|
+
2. kwargs["tool_call_id"]
|
|
247
|
+
3. Session-derived id (run + tool + args hash)
|
|
248
|
+
4. Random UUID (no idempotency, still audited)
|
|
249
|
+
|
|
250
|
+
Note: valid repeats within the same Session with identical args will be
|
|
251
|
+
deduplicated unless an explicit request_id is supplied.
|
|
252
|
+
"""
|
|
253
|
+
if "request_id" in kwargs:
|
|
254
|
+
return str(kwargs["request_id"])
|
|
255
|
+
if "tool_call_id" in kwargs:
|
|
256
|
+
return str(kwargs["tool_call_id"])
|
|
257
|
+
|
|
258
|
+
session = _session_var.get()
|
|
259
|
+
if session is not None:
|
|
260
|
+
return self._session_request_id(session, tool, args, kwargs)
|
|
261
|
+
|
|
262
|
+
warnings.warn(
|
|
263
|
+
f"Tool {tool!r} has no request_id, tool_call_id, or Session; "
|
|
264
|
+
"ActionLedger cannot deduplicate this call. A random UUID will be used.",
|
|
265
|
+
stacklevel=4,
|
|
266
|
+
)
|
|
267
|
+
return f"no-session:{tool}:{uuid.uuid4()}"
|
|
268
|
+
|
|
269
|
+
def _session_request_id(
|
|
270
|
+
self, session: Session, tool: str, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
271
|
+
) -> str:
|
|
272
|
+
# Stable within the process for the lifetime of the Session object.
|
|
273
|
+
run_key = f"run-{id(session)}"
|
|
274
|
+
args_hash = self._hash_args(args, kwargs)
|
|
275
|
+
return f"{run_key}:{tool}:{args_hash}"
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _hash_args(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
279
|
+
payload = json.dumps(
|
|
280
|
+
{"args": args, "kwargs": kwargs},
|
|
281
|
+
sort_keys=True,
|
|
282
|
+
default=str,
|
|
283
|
+
)
|
|
284
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _bind_args(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
288
|
+
"""Store a serializable snapshot of the call arguments."""
|
|
289
|
+
return {
|
|
290
|
+
"args": list(args),
|
|
291
|
+
"kwargs": dict(kwargs),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _drop_ledger_keys(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
296
|
+
"""Remove Mycelium bookkeeping keys before calling the actual tool."""
|
|
297
|
+
return {k: v for k, v in kwargs.items() if k not in ("request_id", "tool_call_id")}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _emit_tool_receipt(
|
|
301
|
+
audit_emitter: AuditReceiptEmitter | None,
|
|
302
|
+
ledger: ActionLedger,
|
|
303
|
+
request_id: str,
|
|
304
|
+
) -> None:
|
|
305
|
+
if audit_emitter is None:
|
|
306
|
+
return
|
|
307
|
+
entry = ledger.get(request_id)
|
|
308
|
+
if entry is not None and entry.status in ("completed", "failed"):
|
|
309
|
+
audit_emitter.emit_from_tool_entry(entry)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run_ledgered(
|
|
313
|
+
func: Callable[P, R],
|
|
314
|
+
tool_name: str,
|
|
315
|
+
ledger: ActionLedger,
|
|
316
|
+
args: P.args,
|
|
317
|
+
kwargs: P.kwargs,
|
|
318
|
+
audit_emitter: AuditReceiptEmitter | None = None,
|
|
319
|
+
) -> R:
|
|
320
|
+
request_id = ledger.derive_request_id(tool_name, args, kwargs)
|
|
321
|
+
clean_kwargs = _drop_ledger_keys(kwargs)
|
|
322
|
+
existing = ledger.claim(request_id, tool_name, args, clean_kwargs)
|
|
323
|
+
if existing.status == "completed":
|
|
324
|
+
return existing.result
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
result = func(*args, **clean_kwargs)
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
ledger.fail(request_id, exc)
|
|
330
|
+
_emit_tool_receipt(audit_emitter, ledger, request_id)
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
ledger.complete(request_id, result)
|
|
334
|
+
_emit_tool_receipt(audit_emitter, ledger, request_id)
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def _run_ledgered_async(
|
|
339
|
+
func: Callable[P, Awaitable[R]],
|
|
340
|
+
tool_name: str,
|
|
341
|
+
ledger: ActionLedger,
|
|
342
|
+
args: P.args,
|
|
343
|
+
kwargs: P.kwargs,
|
|
344
|
+
audit_emitter: AuditReceiptEmitter | None = None,
|
|
345
|
+
) -> R:
|
|
346
|
+
request_id = ledger.derive_request_id(tool_name, args, kwargs)
|
|
347
|
+
clean_kwargs = _drop_ledger_keys(kwargs)
|
|
348
|
+
existing = ledger.claim(request_id, tool_name, args, clean_kwargs)
|
|
349
|
+
if existing.status == "completed":
|
|
350
|
+
return existing.result
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
result = await func(*args, **clean_kwargs)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
ledger.fail(request_id, exc)
|
|
356
|
+
_emit_tool_receipt(audit_emitter, ledger, request_id)
|
|
357
|
+
raise
|
|
358
|
+
|
|
359
|
+
ledger.complete(request_id, result)
|
|
360
|
+
_emit_tool_receipt(audit_emitter, ledger, request_id)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _mark_ledgered(wrapper: Callable[..., Any], ledger: ActionLedger) -> None:
|
|
365
|
+
wrapper._mycelium_ledger = True # type: ignore[attr-defined]
|
|
366
|
+
wrapper._mycelium_ledger_instance = ledger # type: ignore[attr-defined]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def ledger(
|
|
370
|
+
storage: LedgerStorage | None = None,
|
|
371
|
+
audit_emitter: AuditReceiptEmitter | None = None,
|
|
372
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
|
373
|
+
"""Decorator that records async tool invocations in an ActionLedger."""
|
|
374
|
+
|
|
375
|
+
action_ledger = ActionLedger(storage=storage)
|
|
376
|
+
|
|
377
|
+
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
378
|
+
tool_name = func.__name__
|
|
379
|
+
|
|
380
|
+
@functools.wraps(func)
|
|
381
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
382
|
+
return await _run_ledgered_async(
|
|
383
|
+
func, tool_name, action_ledger, args, kwargs, audit_emitter
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
_mark_ledgered(wrapper, action_ledger)
|
|
387
|
+
return wrapper
|
|
388
|
+
|
|
389
|
+
return decorator
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def ledger_sync(
|
|
393
|
+
storage: LedgerStorage | None = None,
|
|
394
|
+
audit_emitter: AuditReceiptEmitter | None = None,
|
|
395
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
396
|
+
"""Decorator that records sync tool invocations in an ActionLedger."""
|
|
397
|
+
|
|
398
|
+
action_ledger = ActionLedger(storage=storage)
|
|
399
|
+
|
|
400
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
401
|
+
tool_name = func.__name__
|
|
402
|
+
|
|
403
|
+
@functools.wraps(func)
|
|
404
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
405
|
+
return _run_ledgered(
|
|
406
|
+
func, tool_name, action_ledger, args, kwargs, audit_emitter
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
_mark_ledgered(wrapper, action_ledger)
|
|
410
|
+
return wrapper
|
|
411
|
+
|
|
412
|
+
return decorator
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def get_ledger(func: Callable[..., Any]) -> ActionLedger | None:
|
|
416
|
+
"""Return the ActionLedger attached to a wrapped function, if any."""
|
|
417
|
+
return getattr(func, "_mycelium_ledger_instance", None)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
__all__ = [
|
|
421
|
+
"ActionLedger",
|
|
422
|
+
"FileLedgerStorage",
|
|
423
|
+
"InMemoryLedgerStorage",
|
|
424
|
+
"LedgerEntry",
|
|
425
|
+
"LedgerError",
|
|
426
|
+
"LedgerPendingError",
|
|
427
|
+
"LedgerStorage",
|
|
428
|
+
"get_ledger",
|
|
429
|
+
"ledger",
|
|
430
|
+
"ledger_sync",
|
|
431
|
+
]
|