agenthandler 0.92.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.

Potentially problematic release.


This version of agenthandler might be problematic. Click here for more details.

@@ -0,0 +1,391 @@
1
+ """Session manager — lifecycle management for long-running agent sessions.
2
+
3
+ Wraps a Supervisor + StateStore and adds start / pause / resume / stop.
4
+
5
+ Security invariants:
6
+ - Policy is IMMUTABLE after start(). resume() always rebuilds the Supervisor
7
+ from the original policy stored at creation time — never from agent-controlled
8
+ checkpoint data. An agent cannot weaken its own supervision.
9
+ - Policy integrity is verified via HMAC checksum on resume. If the stored
10
+ policy has been tampered with, resume is rejected.
11
+ - Payload is UNTRUSTED. It is validated for size/serializability on write, and
12
+ agents consuming it after resume should treat it like user input.
13
+ - Audit entries are persisted to the store so they survive crashes.
14
+ - Per-session locking prevents race conditions on concurrent lifecycle operations.
15
+
16
+ Usage:
17
+ from agenthandler import SessionManager, MemoryStore
18
+
19
+ store = MemoryStore()
20
+ mgr = SessionManager(store)
21
+
22
+ session_id = mgr.start("my-agent", {"max_iterations": 10, "tool_timeout": 30})
23
+ sv = mgr.get_supervisor(session_id)
24
+ # ... use sv.call() in your agent loop ...
25
+
26
+ mgr.pause(session_id) # agent will raise AgentPaused on next call()
27
+ mgr.resume(session_id) # rehydrate and continue
28
+ mgr.stop(session_id) # mark complete
29
+ """
30
+
31
+ import hashlib
32
+ import hmac
33
+ import json
34
+
35
+ # Key for HMAC policy checksum — not a secret (it's in the source),
36
+ # but prevents casual tampering of the SQLite file. For real security,
37
+ # set AGENTHANDLER_POLICY_KEY env var.
38
+ import os
39
+ import threading
40
+ from typing import Any, Dict, List, Optional
41
+
42
+ from .approval import ApprovalQueue
43
+ from .audit import AuditLog, MemoryAuditSink
44
+ from .errors import AgentHandlerError
45
+ from .observe import Observer
46
+ from .policy import Policy
47
+ from .store import Checkpoint, SessionStatus, StateStore, new_session_id, validate_payload
48
+ from .supervisor import Supervisor
49
+
50
+ _POLICY_HMAC_KEY = os.environ.get(
51
+ "AGENTHANDLER_POLICY_KEY", "agenthandler-policy-integrity"
52
+ ).encode()
53
+
54
+
55
+ def _policy_checksum(policy_dict: Dict[str, Any]) -> str:
56
+ """Compute an HMAC checksum for a policy dict."""
57
+ serialized = json.dumps(policy_dict, sort_keys=True, default=str).encode()
58
+ return hmac.new(_POLICY_HMAC_KEY, serialized, hashlib.sha256).hexdigest()
59
+
60
+
61
+ class SessionManager:
62
+ """Lifecycle manager for agent sessions backed by a StateStore.
63
+
64
+ Keeps active Supervisors in memory and persists state to the store.
65
+ After a crash, create a new SessionManager with the same store and
66
+ call resume() to pick up where you left off.
67
+
68
+ Thread-safe: uses per-session locks for lifecycle operations to prevent
69
+ race conditions from concurrent pause/resume/stop calls.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ store: StateStore,
75
+ pre_guardrails: Optional[List[Any]] = None,
76
+ post_guardrails: Optional[List[Any]] = None,
77
+ pricing: Optional[Any] = None,
78
+ ):
79
+ from .observe import PricingTable
80
+
81
+ self._store = store
82
+ self._supervisors: Dict[str, Supervisor] = {}
83
+ self._audit_sinks: Dict[str, MemoryAuditSink] = {}
84
+ self._original_policies: Dict[str, Dict[str, Any]] = {}
85
+ self._stateless_sessions: set[str] = set()
86
+ self._approval_queue = ApprovalQueue()
87
+ # Default to the built-in pricing table so costs work out of the box.
88
+ self._observer = Observer(pricing=pricing or PricingTable.default())
89
+ self._pre_guardrails: List[Any] = pre_guardrails or []
90
+ self._post_guardrails: List[Any] = post_guardrails or []
91
+ self._lock = threading.Lock() # protects the dicts above
92
+ self._session_locks: Dict[str, threading.Lock] = {} # per-session locks
93
+
94
+ def _get_session_lock(self, session_id: str) -> threading.Lock:
95
+ """Get or create a per-session lock."""
96
+ with self._lock:
97
+ if session_id not in self._session_locks:
98
+ self._session_locks[session_id] = threading.Lock()
99
+ return self._session_locks[session_id]
100
+
101
+ @property
102
+ def approval_queue(self) -> ApprovalQueue:
103
+ """The shared approval queue for all sessions."""
104
+ return self._approval_queue
105
+
106
+ @property
107
+ def observer(self) -> Observer:
108
+ """The shared observer for all sessions."""
109
+ return self._observer
110
+
111
+ def start(
112
+ self,
113
+ agent_id: str,
114
+ policy_dict: Optional[Dict[str, Any]] = None,
115
+ payload: Optional[Dict[str, Any]] = None,
116
+ stateless: bool = False,
117
+ ) -> str:
118
+ """Start a new agent session.
119
+
120
+ Args:
121
+ agent_id: Identifier for the agent type.
122
+ policy_dict: Policy configuration (passed to Policy.from_dict).
123
+ This is stored immutably — the agent can never change it.
124
+ payload: Opaque JSON the agent can stash intermediate work in.
125
+ Treated as untrusted data. Validated for size limits.
126
+ stateless: If True, the session lives in memory only — no
127
+ checkpoints written to the store, no crash recovery.
128
+ You still get supervision, audit, and pause/stop controls.
129
+
130
+ Returns:
131
+ session_id for the new session.
132
+ """
133
+ sid = new_session_id()
134
+ safe_policy_dict = policy_dict or {}
135
+ policy = Policy.from_dict(safe_policy_dict)
136
+
137
+ # Validate payload before storing
138
+ safe_payload = validate_payload(payload or {})
139
+
140
+ sink = MemoryAuditSink()
141
+ audit = AuditLog(sid, sinks=[sink])
142
+
143
+ sv = Supervisor(
144
+ policy,
145
+ audit=audit,
146
+ store=None if stateless else self._store,
147
+ session_id=sid,
148
+ agent_id=agent_id,
149
+ approval_queue=self._approval_queue,
150
+ observer=self._observer,
151
+ pre_guardrails=self._pre_guardrails,
152
+ post_guardrails=self._post_guardrails,
153
+ )
154
+
155
+ # Store the ORIGINAL policy — this is immutable for the session's lifetime
156
+ frozen_policy = policy.to_dict()
157
+ checksum = _policy_checksum(frozen_policy)
158
+
159
+ checkpoint = Checkpoint(
160
+ session_id=sid,
161
+ agent_id=agent_id,
162
+ status=SessionStatus.RUNNING,
163
+ token_limit=policy.token_budget,
164
+ iteration_limit=policy.max_iterations,
165
+ policy_dict=frozen_policy,
166
+ payload=safe_payload,
167
+ stateless=stateless,
168
+ policy_checksum=checksum,
169
+ )
170
+ self._store.save_checkpoint(checkpoint)
171
+
172
+ with self._lock:
173
+ self._supervisors[sid] = sv
174
+ self._audit_sinks[sid] = sink
175
+ self._original_policies[sid] = frozen_policy
176
+ if stateless:
177
+ self._stateless_sessions.add(sid)
178
+
179
+ return sid
180
+
181
+ def pause(self, session_id: str) -> None:
182
+ """Pause an agent session. The Supervisor will raise AgentPaused on next call()."""
183
+ session_lock = self._get_session_lock(session_id)
184
+ with session_lock:
185
+ cp = self._store.load_checkpoint(session_id)
186
+ if cp is None:
187
+ raise AgentHandlerError.session_not_found(session_id)
188
+
189
+ cp.status = SessionStatus.PAUSED
190
+ self._persist_audit(session_id, cp)
191
+ self._store.save_checkpoint(cp)
192
+
193
+ with self._lock:
194
+ sv = self._supervisors.get(session_id)
195
+ if sv is not None:
196
+ sv.paused = True
197
+
198
+ def resume(self, session_id: str) -> Supervisor:
199
+ """Resume a paused or previously-crashed session.
200
+
201
+ Rehydrates a Supervisor from the last checkpoint. Policy is always
202
+ rebuilt from the ORIGINAL immutable policy stored at start() — never
203
+ from any agent-modified data in the checkpoint.
204
+
205
+ Policy integrity is verified via HMAC checksum — if the stored policy
206
+ has been tampered with, resume is rejected.
207
+
208
+ Stateless sessions cannot be resumed after a crash — their state
209
+ only exists in memory.
210
+
211
+ Returns:
212
+ The restored Supervisor, ready for use.
213
+ """
214
+ session_lock = self._get_session_lock(session_id)
215
+ with session_lock:
216
+ cp = self._store.load_checkpoint(session_id)
217
+ if cp is None:
218
+ raise AgentHandlerError.session_not_found(session_id)
219
+
220
+ # Stateless sessions can be unpaused (supervisor still in memory)
221
+ # but cannot be recovered after a crash
222
+ with self._lock:
223
+ in_memory = session_id in self._supervisors
224
+ if cp.stateless and not in_memory:
225
+ raise AgentHandlerError(
226
+ "session_not_recoverable",
227
+ f"Session {session_id} is stateless and cannot be resumed after a crash",
228
+ )
229
+
230
+ # Crash loop protection
231
+ policy = Policy.from_dict(cp.policy_dict)
232
+ if cp.resume_count >= policy.max_resumes:
233
+ cp.status = SessionStatus.FAILED
234
+ cp.failure_reason = (
235
+ f"Crash loop: resumed {cp.resume_count} times (limit: {policy.max_resumes})"
236
+ )
237
+ self._store.save_checkpoint(cp)
238
+ raise AgentHandlerError.max_resumes_exceeded(
239
+ session_id, cp.resume_count, policy.max_resumes
240
+ )
241
+
242
+ # Policy integrity check — verify HMAC checksum
243
+ with self._lock:
244
+ original = self._original_policies.get(session_id)
245
+ if original is None:
246
+ original = cp.policy_dict
247
+ if cp.policy_checksum:
248
+ expected = _policy_checksum(original)
249
+ if not hmac.compare_digest(cp.policy_checksum, expected):
250
+ cp.status = SessionStatus.FAILED
251
+ cp.failure_reason = "Policy integrity check failed — possible tampering"
252
+ self._store.save_checkpoint(cp)
253
+ raise AgentHandlerError(
254
+ "policy_tampered",
255
+ f"Session {session_id}: policy checksum mismatch. "
256
+ "The stored policy may have been tampered with.",
257
+ )
258
+
259
+ cp.resume_count += 1
260
+ cp.status = SessionStatus.RUNNING
261
+ self._store.save_checkpoint(cp)
262
+
263
+ sink = MemoryAuditSink()
264
+ audit = AuditLog(session_id, sinks=[sink])
265
+
266
+ sv = Supervisor.restore_from_checkpoint(
267
+ cp,
268
+ audit=audit,
269
+ store=self._store,
270
+ policy_override=original,
271
+ approval_queue=self._approval_queue,
272
+ observer=self._observer,
273
+ pre_guardrails=self._pre_guardrails,
274
+ post_guardrails=self._post_guardrails,
275
+ )
276
+ sv.paused = False
277
+
278
+ with self._lock:
279
+ self._supervisors[session_id] = sv
280
+ self._audit_sinks[session_id] = sink
281
+ self._original_policies[session_id] = original
282
+
283
+ return sv
284
+
285
+ def stop(self, session_id: str) -> None:
286
+ """Stop an agent session. Calls supervisor.finish() and marks stopped."""
287
+ session_lock = self._get_session_lock(session_id)
288
+ with session_lock:
289
+ cp = self._store.load_checkpoint(session_id)
290
+ if cp is None:
291
+ raise AgentHandlerError.session_not_found(session_id)
292
+
293
+ with self._lock:
294
+ sv = self._supervisors.get(session_id)
295
+
296
+ if sv is not None:
297
+ sv.finish()
298
+
299
+ cp.status = SessionStatus.STOPPED
300
+ if sv is not None:
301
+ data = sv.to_checkpoint_data()
302
+ cp.iterations = data["iterations"]
303
+ cp.tokens_used = data["tokens_used"]
304
+
305
+ self._persist_audit(session_id, cp)
306
+
307
+ with self._lock:
308
+ self._supervisors.pop(session_id, None)
309
+ self._audit_sinks.pop(session_id, None)
310
+ self._original_policies.pop(session_id, None)
311
+
312
+ self._store.save_checkpoint(cp)
313
+
314
+ def restart(self, session_id: str) -> str:
315
+ """Restart a stopped or failed session with the same policy and agent ID.
316
+
317
+ Creates a fresh session using the original policy from the old session.
318
+ The old session is preserved for audit purposes.
319
+
320
+ Returns:
321
+ The new session_id.
322
+ """
323
+ session_lock = self._get_session_lock(session_id)
324
+ with session_lock:
325
+ cp = self._store.load_checkpoint(session_id)
326
+ if cp is None:
327
+ raise AgentHandlerError.session_not_found(session_id)
328
+
329
+ with self._lock:
330
+ self._supervisors.pop(session_id, None)
331
+ self._audit_sinks.pop(session_id, None)
332
+ self._original_policies.pop(session_id, None)
333
+ self._stateless_sessions.discard(session_id)
334
+
335
+ return self.start(
336
+ agent_id=cp.agent_id,
337
+ policy_dict=cp.policy_dict,
338
+ stateless=cp.stateless,
339
+ )
340
+
341
+ def status(self, session_id: str) -> Optional[Checkpoint]:
342
+ """Get the current checkpoint for a session."""
343
+ return self._store.load_checkpoint(session_id)
344
+
345
+ def list_sessions(self) -> List[Checkpoint]:
346
+ """List all sessions."""
347
+ return self._store.list_sessions()
348
+
349
+ def get_supervisor(self, session_id: str) -> Optional[Supervisor]:
350
+ """Get the active Supervisor for a session (None if not in memory)."""
351
+ with self._lock:
352
+ return self._supervisors.get(session_id)
353
+
354
+ def get_audit_entries(self, session_id: str) -> list[Dict[str, Any]]:
355
+ """Get audit log entries for a session (in-memory + persisted)."""
356
+ cp = self._store.load_checkpoint(session_id)
357
+ persisted = cp.audit_log if cp is not None else []
358
+
359
+ with self._lock:
360
+ sink = self._audit_sinks.get(session_id)
361
+ in_memory = [e.to_dict() for e in sink.entries] if sink is not None else []
362
+
363
+ return persisted + in_memory
364
+
365
+ def update_payload(self, session_id: str, payload: Dict[str, Any]) -> None:
366
+ """Update the opaque payload for a session.
367
+
368
+ The payload is validated for size limits and serializability.
369
+ Treat payload as UNTRUSTED — it may contain agent-controlled data.
370
+ """
371
+ session_lock = self._get_session_lock(session_id)
372
+ with session_lock:
373
+ cp = self._store.load_checkpoint(session_id)
374
+ if cp is None:
375
+ raise AgentHandlerError.session_not_found(session_id)
376
+ cp.payload = validate_payload(payload)
377
+ self._store.save_checkpoint(cp)
378
+
379
+ def delete_expired(self, max_age_seconds: float) -> int:
380
+ """Delete sessions older than max_age_seconds."""
381
+ if hasattr(self._store, "delete_expired"):
382
+ count: int = self._store.delete_expired(max_age_seconds)
383
+ return count
384
+ return 0
385
+
386
+ def _persist_audit(self, session_id: str, cp: Checkpoint) -> None:
387
+ """Flush in-memory audit entries into the checkpoint for persistence."""
388
+ with self._lock:
389
+ sink = self._audit_sinks.get(session_id)
390
+ if sink is not None:
391
+ cp.audit_log = cp.audit_log + [e.to_dict() for e in sink.entries]
agenthandler/skill.py ADDED
@@ -0,0 +1,265 @@
1
+ """Skill definitions — framework-agnostic skill format with file-based CRUD."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import threading
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+
12
+ @dataclass
13
+ class ToolParameter:
14
+ """A parameter on a tool."""
15
+
16
+ name: str
17
+ type: str = "string"
18
+ description: str = ""
19
+ required: bool = False
20
+ default: Any = None
21
+
22
+
23
+ @dataclass
24
+ class ToolDefinition:
25
+ """A tool that a skill can use."""
26
+
27
+ name: str
28
+ description: str = ""
29
+ parameters: List[ToolParameter] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class Skill:
34
+ """A complete skill definition — what the agent does, how, and within what limits.
35
+
36
+ This is the agenthandler-native skill format. It can be converted to/from
37
+ POML, JSON, YAML, or any other format your agent framework uses.
38
+ """
39
+
40
+ name: str
41
+ description: str
42
+ triggers: List[str] = field(default_factory=list)
43
+ version: str = "1.0"
44
+
45
+ # What the agent should do
46
+ role: str = ""
47
+ task: str = ""
48
+ instructions: List[str] = field(default_factory=list)
49
+ examples: List[Dict[str, str]] = field(default_factory=list)
50
+ output_format: str = ""
51
+
52
+ # What tools are available
53
+ tools: List[ToolDefinition] = field(default_factory=list)
54
+
55
+ # Supervision limits (maps directly to Policy.from_dict)
56
+ supervision: Dict[str, Any] = field(default_factory=dict)
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ """Serialize to a plain dictionary."""
60
+ d = asdict(self)
61
+ # Drop empty/default fields for cleaner output
62
+ return {k: v for k, v in d.items() if v}
63
+
64
+ @classmethod
65
+ def from_dict(cls, d: Dict[str, Any]) -> "Skill":
66
+ """Create a Skill from a dictionary."""
67
+ tools = []
68
+ for t in d.get("tools", []):
69
+ params = [ToolParameter(**p) for p in t.get("parameters", [])]
70
+ tools.append(
71
+ ToolDefinition(
72
+ name=t["name"],
73
+ description=t.get("description", ""),
74
+ parameters=params,
75
+ )
76
+ )
77
+
78
+ return cls(
79
+ name=d["name"],
80
+ description=d.get("description", ""),
81
+ triggers=d.get("triggers", []),
82
+ version=d.get("version", "1.0"),
83
+ role=d.get("role", ""),
84
+ task=d.get("task", ""),
85
+ instructions=d.get("instructions", []),
86
+ examples=d.get("examples", []),
87
+ output_format=d.get("output_format", ""),
88
+ tools=tools,
89
+ supervision=d.get("supervision", {}),
90
+ )
91
+
92
+ def to_poml(self) -> str:
93
+ """Render as POML format (compatible with moneypenny and other POML loaders)."""
94
+ lines = ["<!--"]
95
+ lines.append("metadata:")
96
+ lines.append(f" description: {self.description}")
97
+ if self.triggers:
98
+ lines.append(f" triggers: {', '.join(self.triggers)}")
99
+ if self.version:
100
+ lines.append(f" version: {self.version}")
101
+
102
+ if self.tools:
103
+ lines.append("")
104
+ lines.append("tools:")
105
+ for tool in self.tools:
106
+ lines.append(f" - name: {tool.name}")
107
+ if tool.description:
108
+ lines.append(f" description: {tool.description}")
109
+ if tool.parameters:
110
+ lines.append(" parameters:")
111
+ for p in tool.parameters:
112
+ lines.append(f" {p.name}:")
113
+ lines.append(f" type: {p.type}")
114
+ if p.description:
115
+ lines.append(f" description: {p.description}")
116
+ if p.required:
117
+ lines.append(" required: true")
118
+ if p.default is not None:
119
+ lines.append(f" default: {p.default}")
120
+
121
+ if self.supervision:
122
+ lines.append("")
123
+ lines.append("supervision:")
124
+ for k, v in self.supervision.items():
125
+ if isinstance(v, list):
126
+ lines.append(f" {k}:")
127
+ for item in v:
128
+ lines.append(f" - {item}")
129
+ else:
130
+ lines.append(f" {k}: {v}")
131
+
132
+ lines.append("-->")
133
+ lines.append("")
134
+
135
+ if self.role:
136
+ lines.append(f"<role>\n{self.role}\n</role>")
137
+ lines.append("")
138
+
139
+ if self.task:
140
+ lines.append(f"<task>\n{self.task}\n</task>")
141
+ lines.append("")
142
+
143
+ if self.instructions:
144
+ lines.append("<instruction>")
145
+ for inst in self.instructions:
146
+ lines.append(f"- {inst}")
147
+ lines.append("</instruction>")
148
+ lines.append("")
149
+
150
+ for ex in self.examples:
151
+ lines.append("<example>")
152
+ if "user" in ex:
153
+ lines.append(f"User: {ex['user']}")
154
+ if "assistant" in ex:
155
+ lines.append(f"Assistant: {ex['assistant']}")
156
+ lines.append("</example>")
157
+ lines.append("")
158
+
159
+ if self.output_format:
160
+ lines.append(f"<output_format>\n{self.output_format}\n</output_format>")
161
+ lines.append("")
162
+
163
+ return "\n".join(lines)
164
+
165
+ def to_json(self, indent: int = 2) -> str:
166
+ """Serialize to JSON."""
167
+ return json.dumps(self.to_dict(), indent=indent, default=str)
168
+
169
+
170
+ def _slugify(name: str) -> str:
171
+ """Convert a skill name to a safe filename slug."""
172
+ s = name.lower().strip()
173
+ s = re.sub(r"[^a-z0-9]+", "_", s)
174
+ return s.strip("_")
175
+
176
+
177
+ class SkillStore:
178
+ """File-based CRUD for skills. Each skill is a JSON file on disk.
179
+
180
+ Usage:
181
+ store = SkillStore("~/.agenthandler/skills")
182
+ store.save(skill)
183
+ skill = store.get("web_research")
184
+ all_skills = store.list()
185
+ store.delete("web_research")
186
+ """
187
+
188
+ def __init__(self, directory: str):
189
+ self._dir = Path(os.path.expanduser(directory))
190
+ self._dir.mkdir(parents=True, exist_ok=True)
191
+ self._lock = threading.Lock()
192
+
193
+ @property
194
+ def directory(self) -> Path:
195
+ return self._dir
196
+
197
+ def _path(self, name: str) -> Path:
198
+ return self._dir / f"{_slugify(name)}.json"
199
+
200
+ def save(self, skill: Skill) -> Path:
201
+ """Save a skill to disk. Overwrites if exists. Returns file path."""
202
+ path = self._path(skill.name)
203
+ with self._lock:
204
+ path.write_text(skill.to_json())
205
+ return path
206
+
207
+ def get(self, name: str) -> Optional[Skill]:
208
+ """Load a skill by name. Returns None if not found."""
209
+ path = self._path(name)
210
+ if not path.exists():
211
+ return None
212
+ with self._lock:
213
+ data = json.loads(path.read_text())
214
+ return Skill.from_dict(data)
215
+
216
+ def list(self) -> List[Dict[str, str]]:
217
+ """List all skills (name + description). Lightweight — doesn't load full skills."""
218
+ skills = []
219
+ with self._lock:
220
+ for path in sorted(self._dir.glob("*.json")):
221
+ try:
222
+ data = json.loads(path.read_text())
223
+ skills.append(
224
+ {
225
+ "name": data.get("name", path.stem),
226
+ "description": data.get("description", ""),
227
+ "version": data.get("version", ""),
228
+ }
229
+ )
230
+ except (json.JSONDecodeError, KeyError):
231
+ continue
232
+ return skills
233
+
234
+ def delete(self, name: str) -> bool:
235
+ """Delete a skill. Returns True if deleted, False if not found."""
236
+ path = self._path(name)
237
+ with self._lock:
238
+ if path.exists():
239
+ path.unlink()
240
+ return True
241
+ return False
242
+
243
+ def exists(self, name: str) -> bool:
244
+ """Check if a skill exists."""
245
+ return self._path(name).exists()
246
+
247
+ def export_poml(self, name: str) -> Optional[str]:
248
+ """Export a skill as POML format. Returns None if not found."""
249
+ skill = self.get(name)
250
+ if skill is None:
251
+ return None
252
+ return skill.to_poml()
253
+
254
+ def export_all_poml(self, output_dir: str) -> List[Path]:
255
+ """Export all skills as .poml files to a directory."""
256
+ out = Path(os.path.expanduser(output_dir))
257
+ out.mkdir(parents=True, exist_ok=True)
258
+ paths = []
259
+ for info in self.list():
260
+ skill = self.get(info["name"])
261
+ if skill:
262
+ path = out / f"{_slugify(skill.name)}.poml"
263
+ path.write_text(skill.to_poml())
264
+ paths.append(path)
265
+ return paths