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.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
agenthandler/session.py
ADDED
|
@@ -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
|