mycelium-runtime 1.1.0__tar.gz
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_runtime-1.1.0/.gitignore +8 -0
- mycelium_runtime-1.1.0/PKG-INFO +378 -0
- mycelium_runtime-1.1.0/README.md +347 -0
- mycelium_runtime-1.1.0/examples/README.md +13 -0
- mycelium_runtime-1.1.0/mycelium/__init__.py +121 -0
- mycelium_runtime-1.1.0/mycelium/__main__.py +64 -0
- mycelium_runtime-1.1.0/mycelium/action_ledger.py +431 -0
- mycelium_runtime-1.1.0/mycelium/audit_receipt.py +252 -0
- mycelium_runtime-1.1.0/mycelium/cache.py +49 -0
- mycelium_runtime-1.1.0/mycelium/config.py +755 -0
- mycelium_runtime-1.1.0/mycelium/history_guard.py +228 -0
- mycelium_runtime-1.1.0/mycelium/message_validator.py +319 -0
- mycelium_runtime-1.1.0/mycelium/protect.py +137 -0
- mycelium_runtime-1.1.0/mycelium/schema.py +41 -0
- mycelium_runtime-1.1.0/mycelium/session.py +50 -0
- mycelium_runtime-1.1.0/mycelium/state_flush.py +246 -0
- mycelium_runtime-1.1.0/mycelium/storage/__init__.py +21 -0
- mycelium_runtime-1.1.0/mycelium/storage/_helpers.py +45 -0
- mycelium_runtime-1.1.0/mycelium/storage/file_lock.py +51 -0
- mycelium_runtime-1.1.0/mycelium/storage/json_file.py +59 -0
- mycelium_runtime-1.1.0/mycelium/storage/postgres_ledger.py +216 -0
- mycelium_runtime-1.1.0/mycelium/storage/redis_ledger.py +168 -0
- mycelium_runtime-1.1.0/mycelium/task_ledger.py +432 -0
- mycelium_runtime-1.1.0/mycelium/templates/mycelium.minimal.yaml +81 -0
- mycelium_runtime-1.1.0/mycelium/templates/mycelium.template.yaml +147 -0
- mycelium_runtime-1.1.0/mycelium/tool_boundary.py +351 -0
- mycelium_runtime-1.1.0/mycelium/tool_registry.py +54 -0
- mycelium_runtime-1.1.0/mycelium/tool_runner.py +122 -0
- mycelium_runtime-1.1.0/pyproject.toml +58 -0
- mycelium_runtime-1.1.0/tests/test_audit_receipt.py +89 -0
- mycelium_runtime-1.1.0/tests/test_cli.py +37 -0
- mycelium_runtime-1.1.0/tests/test_config.py +445 -0
- mycelium_runtime-1.1.0/tests/test_history_guard.py +77 -0
- mycelium_runtime-1.1.0/tests/test_message_validator.py +134 -0
- mycelium_runtime-1.1.0/tests/test_protect.py +97 -0
- mycelium_runtime-1.1.0/tests/test_protect_sync.py +97 -0
- mycelium_runtime-1.1.0/tests/test_session.py +86 -0
- mycelium_runtime-1.1.0/tests/test_state_flush.py +100 -0
- mycelium_runtime-1.1.0/tests/test_storage_backends.py +158 -0
- mycelium_runtime-1.1.0/tests/test_tool_boundary.py +165 -0
- mycelium_runtime-1.1.0/tests/test_tool_registry.py +32 -0
- mycelium_runtime-1.1.0/tests/test_tool_runner.py +118 -0
- mycelium_runtime-1.1.0/uv.lock +574 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mycelium-runtime
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Mycelium runtime — failure prevention for AI agents (AF-006, AF-004, AF-002)
|
|
5
|
+
Project-URL: Homepage, https://github.com/mycelium-labs/mycelium
|
|
6
|
+
Project-URL: Repository, https://github.com/mycelium-labs/mycelium
|
|
7
|
+
Project-URL: Issues, https://github.com/mycelium-labs/mycelium/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: pydantic<3,>=2
|
|
20
|
+
Requires-Dist: pyyaml>=6
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: fakeredis; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
26
|
+
Provides-Extra: postgres
|
|
27
|
+
Requires-Dist: psycopg[binary]>=3; extra == 'postgres'
|
|
28
|
+
Provides-Extra: redis
|
|
29
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Mycelium runtime
|
|
33
|
+
|
|
34
|
+
Runtime failure prevention for AI agents.
|
|
35
|
+
|
|
36
|
+
**PyPI:** `pip install mycelium-runtime` — **import:** `from mycelium import ...`
|
|
37
|
+
|
|
38
|
+
This directory is the **publishable package**. Only `mycelium/` ships on PyPI; everything else here is for development and docs.
|
|
39
|
+
|
|
40
|
+
| Path | In PyPI wheel? |
|
|
41
|
+
|------|----------------|
|
|
42
|
+
| `mycelium/` | Yes (code + `templates/*.yaml`) |
|
|
43
|
+
| `mycelium/templates/` | Yes — use `mycelium init` to write into your project |
|
|
44
|
+
| `tests/` | No — run with `pytest tests/` |
|
|
45
|
+
| `examples/` | No — see `mycelium init` |
|
|
46
|
+
| `README.md` | PyPI project page only (not inside wheel) |
|
|
47
|
+
| `pyproject.toml`, `uv.lock` | No |
|
|
48
|
+
|
|
49
|
+
**v1.1** ships three failure modes: context corruption (AF-006), tool boundary enforcement (AF-004), and **action traceability prevention** (AF-002).
|
|
50
|
+
|
|
51
|
+
> **AF-002 is not an observability platform.** The failure mode is called "observability black hole" because agents act without durable records. Mycelium **prevents** that — via idempotency ledgers, state flush on cancel, and signed receipts — not via traces, spans, or dashboards. For post-hoc tracing, use Langfuse, Helicone, or Opik alongside Mycelium.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
**Requires Python 3.10+** (3.11+ recommended).
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install mycelium-runtime
|
|
59
|
+
mycelium init # writes ./mycelium.yaml (full annotated template)
|
|
60
|
+
mycelium init --minimal # smaller starter config
|
|
61
|
+
# local dev from repo:
|
|
62
|
+
pip install -e ./sdk
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quickstart — AF-006
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from mycelium import protect, Session
|
|
69
|
+
|
|
70
|
+
@protect(entity_param="customer_id", ttl=60)
|
|
71
|
+
async def fetch_customer(customer_id: str) -> dict:
|
|
72
|
+
return await db.get(customer_id)
|
|
73
|
+
|
|
74
|
+
async def handle_request(customer_id: str):
|
|
75
|
+
async with Session():
|
|
76
|
+
return await fetch_customer(customer_id=customer_id)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Sync tools (CrewAI, Smolagents):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from mycelium import protect_sync, Session
|
|
83
|
+
|
|
84
|
+
@protect_sync(entity_param="customer_id", ttl=60)
|
|
85
|
+
def fetch_customer(customer_id: str) -> dict:
|
|
86
|
+
return db.get(customer_id)
|
|
87
|
+
|
|
88
|
+
with Session():
|
|
89
|
+
customer = fetch_customer(customer_id="c1")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## What `@protect` / `protect_sync` / `Session` do
|
|
93
|
+
|
|
94
|
+
- `@protect` / `protect_sync` — TTL cache with per-entity keys; auto-refetch when stale; clear on error
|
|
95
|
+
- `Session` — one cache per agent run; use in production to prevent cross-request leakage
|
|
96
|
+
|
|
97
|
+
## MessageValidator
|
|
98
|
+
|
|
99
|
+
Run before each LLM call to catch broken transcripts:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from mycelium import MessageValidator
|
|
103
|
+
|
|
104
|
+
messages = MessageValidator().repair(messages) # auto-fix what it can
|
|
105
|
+
# or
|
|
106
|
+
messages = MessageValidator().validate(messages) # raise on first issue
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Catches orphan tool results, duplicate tool-call IDs, invalid roles, and related serialization bugs.
|
|
110
|
+
|
|
111
|
+
## HistoryGuard
|
|
112
|
+
|
|
113
|
+
Run before each LLM call to catch oversized or corrupted history:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from mycelium import HistoryGuard
|
|
117
|
+
|
|
118
|
+
guard = HistoryGuard(max_tokens=100_000)
|
|
119
|
+
messages = guard.validate(messages)
|
|
120
|
+
guard.check_for_drops(processed_messages) # after framework trimming
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Raises on token overflow, message count limits, duplicate turns, and silent message drops.
|
|
124
|
+
|
|
125
|
+
## Quickstart — AF-004
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from mycelium import bounded, ToolRegistry, ToolRunner
|
|
129
|
+
|
|
130
|
+
FETCH_CUSTOMER_SCHEMA = {
|
|
131
|
+
"customer_id": {"type": "string", "required": True, "pattern": r"^c\d+$"},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
CUSTOMER_RECORD_SCHEMA = {
|
|
135
|
+
"customer_id": {"type": "string", "required": True},
|
|
136
|
+
"name": {"type": "string", "required": True},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
registry = ToolRegistry(allowed=["fetch_customer"])
|
|
140
|
+
|
|
141
|
+
@registry.register
|
|
142
|
+
@bounded(
|
|
143
|
+
schema=FETCH_CUSTOMER_SCHEMA,
|
|
144
|
+
output_schema=CUSTOMER_RECORD_SCHEMA,
|
|
145
|
+
allowed_paths=["/workspace/src/"],
|
|
146
|
+
)
|
|
147
|
+
async def fetch_customer(customer_id: str) -> dict:
|
|
148
|
+
return await db.get(customer_id)
|
|
149
|
+
|
|
150
|
+
runner = ToolRunner(registry=registry)
|
|
151
|
+
result = await runner.call(fetch_customer, customer_id="c1")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Sync tools:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from mycelium import bounded_sync
|
|
158
|
+
|
|
159
|
+
@bounded_sync(schema=FETCH_CUSTOMER_SCHEMA)
|
|
160
|
+
def fetch_customer(customer_id: str) -> dict:
|
|
161
|
+
return db.get(customer_id)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Field spec keys: `type` (`string`, `integer`, `number`, `boolean`), `required`, `pattern`, `min_length`, `max_length`. You pass plain dicts — Mycelium validates internally; no Pydantic imports in your code.
|
|
165
|
+
|
|
166
|
+
## What `@bounded` / `bounded_sync` do
|
|
167
|
+
|
|
168
|
+
- `@bounded` / `bounded_sync` — validate tool args against your field spec **before** the function runs
|
|
169
|
+
- `output_schema` — validate the return value **after** the function runs; bad results are not propagated
|
|
170
|
+
- `allowed_paths` / `entity_pattern` — user-defined scope gates (path prefixes, entity ID format)
|
|
171
|
+
- On failure, raises `ToolBoundaryError` with `llm_message` for the agent loop — does not retry by itself
|
|
172
|
+
|
|
173
|
+
## ToolRegistry
|
|
174
|
+
|
|
175
|
+
Run before dispatch to enforce which tools this agent may call:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from mycelium import ToolRegistry
|
|
179
|
+
|
|
180
|
+
registry = ToolRegistry(allowed=["search_docs", "summarize"])
|
|
181
|
+
registry.validate_call("fetch_customer") # raises ToolBoundaryError
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Blocks calls to tools outside the developer-defined allowlist.
|
|
185
|
+
|
|
186
|
+
## ToolRunner
|
|
187
|
+
|
|
188
|
+
Run around `@bounded` tools when you want automatic retries:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from mycelium import ToolRunner
|
|
192
|
+
|
|
193
|
+
runner = ToolRunner(registry=registry, max_llm_retries=2, max_tool_retries=3)
|
|
194
|
+
|
|
195
|
+
result, messages = await runner.run_with_llm_retry(
|
|
196
|
+
fetch_customer,
|
|
197
|
+
messages=messages,
|
|
198
|
+
tool_call_id="call_1",
|
|
199
|
+
kwargs={"customer_id": "c1"},
|
|
200
|
+
invoke_llm=llm.ainvoke,
|
|
201
|
+
parse_tool_kwargs=extract_tool_args,
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Input, allowlist, and scope failures → append tool error to messages → LLM retry
|
|
206
|
+
- Output failures → retry the tool up to `max_tool_retries` → then LLM retry
|
|
207
|
+
- Raises `ToolBoundaryExhaustedError` when retries are used up
|
|
208
|
+
|
|
209
|
+
## Quickstart — AF-002 (action traceability prevention)
|
|
210
|
+
|
|
211
|
+
AF-002 prevents the "observability black hole" failure class: duplicate side effects, lost state on cancel, and actions that can't be audited. This is **not** distributed tracing — see the note at the top of this README.
|
|
212
|
+
|
|
213
|
+
### Tool-level idempotency
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from mycelium import ledger_sync
|
|
217
|
+
|
|
218
|
+
@ledger_sync()
|
|
219
|
+
def send_payment(amount: float, recipient: str) -> dict:
|
|
220
|
+
return gateway.charge(amount, recipient)
|
|
221
|
+
|
|
222
|
+
# Same logical call executes only once.
|
|
223
|
+
send_payment(amount=100.0, recipient="acct_123", request_id="invoice-42")
|
|
224
|
+
send_payment(amount=100.0, recipient="acct_123", request_id="invoice-42")
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Async tools:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from mycelium import ledger
|
|
231
|
+
|
|
232
|
+
@ledger()
|
|
233
|
+
async def send_payment(amount: float, recipient: str) -> dict:
|
|
234
|
+
return await gateway.charge(amount, recipient)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## What `@ledger` / `ledger_sync` do
|
|
238
|
+
|
|
239
|
+
- Record every tool invocation in a durable `ActionLedger`
|
|
240
|
+
- Deduplicate retries and redispatches by `request_id` or LLM `tool_call_id`
|
|
241
|
+
- Allow legitimate repeats when the request id differs
|
|
242
|
+
- Persist failed attempts for audit and debugging
|
|
243
|
+
|
|
244
|
+
Storage backends:
|
|
245
|
+
|
|
246
|
+
| Backend | Use case | YAML `storage` |
|
|
247
|
+
|---------|----------|----------------|
|
|
248
|
+
| `memory` | Single process, tests | `memory` (default) |
|
|
249
|
+
| `file` | Local dev, single host (`fcntl` lock) | `file` + `path` |
|
|
250
|
+
| `redis` | Multi-worker, in-flight TTL | `redis` + `url` or `url_env` |
|
|
251
|
+
| `postgres` | Audit/compliance, durable SQL | `postgres` + `dsn` or `dsn_env` |
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from mycelium import ActionLedger, FileLedgerStorage, InMemoryLedgerStorage
|
|
255
|
+
from mycelium import RedisLedgerStorage, PostgresLedgerStorage
|
|
256
|
+
|
|
257
|
+
ledger = ActionLedger(storage=InMemoryLedgerStorage())
|
|
258
|
+
ledger = ActionLedger(storage=FileLedgerStorage("./mycelium-ledger.json"))
|
|
259
|
+
ledger = ActionLedger(storage=RedisLedgerStorage("redis://localhost:6379/0"))
|
|
260
|
+
ledger = ActionLedger(storage=PostgresLedgerStorage("postgresql://localhost/mycelium"))
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Optional extras: `pip install 'mycelium-runtime[redis]'` or `pip install 'mycelium-runtime[postgres]'`.
|
|
264
|
+
|
|
265
|
+
## Quickstart — AF-002 task-level ledger
|
|
266
|
+
|
|
267
|
+
Stop entire tasks from re-running on framework-level retries:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from mycelium import task_ledger_sync
|
|
271
|
+
|
|
272
|
+
@task_ledger_sync()
|
|
273
|
+
def process_invoice(invoice_id: str) -> dict:
|
|
274
|
+
customer = fetch_customer(customer_id=...)
|
|
275
|
+
payment = send_payment(...)
|
|
276
|
+
return {"invoice_id": invoice_id, "status": "paid"}
|
|
277
|
+
|
|
278
|
+
# Framework retries the task with the same task_id
|
|
279
|
+
process_invoice(invoice_id="inv-42", task_id="invoice-42") # executes
|
|
280
|
+
process_invoice(invoice_id="inv-42", task_id="invoice-42") # returns stored result
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Use `id_from` to derive the task id from business keys automatically:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
@task_ledger_sync(id_from=["invoice_id"])
|
|
287
|
+
def process_invoice(invoice_id: str, amount: float) -> dict:
|
|
288
|
+
...
|
|
289
|
+
|
|
290
|
+
# Both calls map to the same task id because invoice_id is the same.
|
|
291
|
+
process_invoice(invoice_id="inv-42", amount=100.0)
|
|
292
|
+
process_invoice(invoice_id="inv-42", amount=200.0) # returns first result
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Correction retries
|
|
296
|
+
|
|
297
|
+
If a completed task produced a bad result and the LLM/agent needs to re-attempt it, use a **new task id**. The framework will normally generate fresh tool call ids for the new attempt, so the task re-executes cleanly.
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
r1 = process_invoice(invoice_id="inv-42", task_id="invoice-42-attempt-1") # bad result
|
|
301
|
+
r2 = process_invoice(invoice_id="inv-42", task_id="invoice-42-attempt-2") # fresh attempt
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## YAML configuration
|
|
305
|
+
|
|
306
|
+
Separate sections per failure mode. Global AF-002 settings inherit into tools/tasks
|
|
307
|
+
so you do not repeat storage paths on every function.
|
|
308
|
+
|
|
309
|
+
**Minimum integration (3 steps):**
|
|
310
|
+
|
|
311
|
+
```yaml
|
|
312
|
+
# mycelium.yaml — global sections (configure once)
|
|
313
|
+
action_ledger:
|
|
314
|
+
storage: file
|
|
315
|
+
path: ./mycelium-ledger.json
|
|
316
|
+
tools: [send_payment] # auto-ledger side-effect tools
|
|
317
|
+
|
|
318
|
+
task_ledger:
|
|
319
|
+
storage: file
|
|
320
|
+
path: ./mycelium-task-ledger.json
|
|
321
|
+
tasks: [process_invoice]
|
|
322
|
+
|
|
323
|
+
state_flush:
|
|
324
|
+
storage: file
|
|
325
|
+
path: ./mycelium-state.json
|
|
326
|
+
|
|
327
|
+
audit_receipt:
|
|
328
|
+
agent_id: my-agent
|
|
329
|
+
signing_key_env: MYCELIUM_SIGNING_KEY
|
|
330
|
+
storage: file
|
|
331
|
+
path: ./mycelium-receipts.jsonl
|
|
332
|
+
|
|
333
|
+
# Per-tool: only what differs (schemas, cache, etc.)
|
|
334
|
+
tools:
|
|
335
|
+
fetch_customer:
|
|
336
|
+
protect: {entity_param: customer_id, ttl: 60}
|
|
337
|
+
bounded:
|
|
338
|
+
schema:
|
|
339
|
+
customer_id: {type: string, required: true, pattern: "^c\\d+$"}
|
|
340
|
+
|
|
341
|
+
send_payment:
|
|
342
|
+
bounded:
|
|
343
|
+
schema:
|
|
344
|
+
amount: {type: number, required: true}
|
|
345
|
+
recipient: {type: string, required: true}
|
|
346
|
+
|
|
347
|
+
tasks:
|
|
348
|
+
process_invoice:
|
|
349
|
+
ledger: true
|
|
350
|
+
id_from: [invoice_id]
|
|
351
|
+
|
|
352
|
+
registry:
|
|
353
|
+
auto: true # allowlist = all configured tools
|
|
354
|
+
|
|
355
|
+
history_guard:
|
|
356
|
+
max_tokens: 100000
|
|
357
|
+
|
|
358
|
+
message_validator:
|
|
359
|
+
enabled: true
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
from mycelium import load_config
|
|
364
|
+
import my_tools
|
|
365
|
+
|
|
366
|
+
config = load_config("mycelium.yaml")
|
|
367
|
+
tools = config.instrument(my_tools) # one call wraps tools + tasks
|
|
368
|
+
|
|
369
|
+
with config.run(thread_id):
|
|
370
|
+
messages = config.prepare_messages(messages) # AF-006 + auto state flush
|
|
371
|
+
...
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`ledger: true` inherits from `action_ledger` / `task_ledger`. When `audit_receipt`
|
|
375
|
+
is configured with `auto: true` (default), all ledgered tools/tasks get signed
|
|
376
|
+
receipts automatically.
|
|
377
|
+
|
|
378
|
+
Legacy per-tool style still works — run `mycelium init` for the full annotated template.
|