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 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
+ ]