Smart Contract
AgentLifecycleHooks
A.91d0a5b7c9832a8b.AgentLifecycleHooks
1// AgentLifecycleHooks.cdc
2// Port of OpenClaw PR #12082 "Plugin Lifecycle Interception Hook Architecture" to Cadence.
3//
4// PR #12082 PROPOSES:
5// - api.on("<hook_name>", handler, opts) for raw runtime hooks
6// - api.lifecycle.on("<phase>", handler, opts) for canonical phases
7// - Priority-based execution, timeout mgmt, fail-open/fail-closed,
8// retry logic, concurrency limits, scope-based gating
9//
10// WHY CADENCE DOES THIS BETTER:
11//
12// In OpenClaw, lifecycle hooks are in-process callbacks. If the process dies,
13// hooks die with it. The hook system is also purely trust-based — any plugin
14// can register any hook with no access control.
15//
16// In Cadence, we get several things for free:
17//
18// 1. PRE/POST CONDITIONS — Cadence's built-in design-by-contract means every
19// hook phase has compile-time guarantees. A pre_message_send hook can
20// ENFORCE that the message content isn't empty. A post_inference hook can
21// VERIFY that tokens used is non-zero. These aren't runtime checks that
22// can be bypassed — they're protocol-level guarantees.
23//
24// 2. CAPABILITIES — Hook handlers are registered as Capabilities with specific
25// Entitlements. A plugin can be given permission to hook into message_received
26// but NOT tool_execution. This is impossible to express in OpenClaw's system.
27//
28// 3. ENTITLEMENTS — Fine-grained: Read vs Modify vs Intercept. A hook that only
29// needs to observe messages gets ReadOnly. A hook that can modify them gets
30// Modify. A hook that can block/cancel gets Intercept. All enforced by Cadence.
31//
32// 4. RESOURCES — Hook registrations are Resources. They can't be duplicated,
33// they're owned by specific accounts, and when destroyed they cleanly
34// unregister. No orphaned callbacks.
35//
36// 5. EVENTS — Instead of in-process callbacks, hooks emit on-chain events.
37// Multiple off-chain systems can subscribe. The hook execution is verifiable.
38
39access(all) contract AgentLifecycleHooks {
40
41 // -----------------------------------------------------------------------
42 // Events
43 // -----------------------------------------------------------------------
44 access(all) event HookRegistered(
45 hookId: UInt64,
46 phase: String,
47 owner: Address,
48 priority: UInt8,
49 failMode: String
50 )
51 access(all) event HookTriggered(hookId: UInt64, phase: String, owner: Address)
52 access(all) event HookCompleted(hookId: UInt64, phase: String, result: String)
53 access(all) event HookFailed(hookId: UInt64, phase: String, error: String)
54 access(all) event HookUnregistered(hookId: UInt64)
55
56 // Canonical lifecycle phase events — mirrors PR #12082's phases
57 access(all) event PhaseGatewayPreStart(owner: Address)
58 access(all) event PhaseGatewayPostStart(owner: Address)
59 access(all) event PhaseAgentPreStart(owner: Address, agentId: UInt64)
60 access(all) event PhaseAgentPostRun(owner: Address, agentId: UInt64)
61 access(all) event PhaseMessageReceived(owner: Address, sessionId: UInt64, contentHash: String)
62 access(all) event PhasePreInference(owner: Address, sessionId: UInt64, messageCount: UInt64)
63 access(all) event PhasePostInference(owner: Address, sessionId: UInt64, tokensUsed: UInt64)
64 access(all) event PhasePreToolExecution(owner: Address, toolName: String, inputHash: String)
65 access(all) event PhasePostToolExecution(owner: Address, toolName: String, outputHash: String)
66 access(all) event PhasePreMemoryCompaction(owner: Address, entryCount: UInt64)
67 access(all) event PhasePostMemoryCompaction(owner: Address, entriesRemoved: UInt64)
68 access(all) event PhasePreSend(owner: Address, sessionId: UInt64, contentHash: String)
69 access(all) event PhasePostSend(owner: Address, sessionId: UInt64, success: Bool)
70
71 // -----------------------------------------------------------------------
72 // Paths
73 // -----------------------------------------------------------------------
74 access(all) let HookManagerStoragePath: StoragePath
75
76 // -----------------------------------------------------------------------
77 // State
78 // -----------------------------------------------------------------------
79 access(all) var totalHooks: UInt64
80
81 // -----------------------------------------------------------------------
82 // Entitlements — maps to PR #12082's scope-based gating
83 // -----------------------------------------------------------------------
84 access(all) entitlement RegisterHooks
85 access(all) entitlement TriggerHooks
86 access(all) entitlement ReadOnly // Can observe but not modify
87 access(all) entitlement Modify // Can modify data passing through
88 access(all) entitlement Intercept // Can block/cancel operations
89
90 // -----------------------------------------------------------------------
91 // LifecyclePhase — the canonical phases from PR #12082
92 // -----------------------------------------------------------------------
93 access(all) enum LifecyclePhase: UInt8 {
94 // Boot/shutdown cycle
95 access(all) case gatewayPreStart
96 access(all) case gatewayPostStart
97 access(all) case gatewayPreStop
98 access(all) case gatewayPostStop
99
100 // Agent lifecycle
101 access(all) case agentPreStart
102 access(all) case agentPostRun
103 access(all) case agentError
104
105 // Message flow (inbound)
106 access(all) case messageReceived
107 access(all) case preInference
108 access(all) case postInference
109
110 // Message flow (outbound)
111 access(all) case preSend
112 access(all) case postSend
113 access(all) case postSendFailure
114
115 // Tool execution
116 access(all) case preToolCall
117 access(all) case postToolCall
118 access(all) case toolError
119
120 // Memory management
121 access(all) case preMemoryCompaction
122 access(all) case postMemoryCompaction
123
124 // Scheduled tasks
125 access(all) case preScheduledTask
126 access(all) case postScheduledTask
127 }
128
129 // -----------------------------------------------------------------------
130 // FailMode — matches PR #12082's fail-open/fail-closed
131 // -----------------------------------------------------------------------
132 access(all) enum FailMode: UInt8 {
133 access(all) case failOpen // Hook failure doesn't block the operation
134 access(all) case failClosed // Hook failure blocks the operation
135 }
136
137 // -----------------------------------------------------------------------
138 // HookConfig — handler registration options (maps to PR #12082's opts)
139 // -----------------------------------------------------------------------
140 access(all) struct HookConfig {
141 access(all) let phase: LifecyclePhase
142 access(all) let priority: UInt8 // 0-255, higher = runs first
143 access(all) let failMode: FailMode
144 access(all) let timeoutSeconds: UFix64 // Max execution time
145 access(all) let maxRetries: UInt8
146 access(all) let retryBackoffSeconds: UFix64
147 access(all) let description: String
148
149 // Scope-based gating — from PR #12082
150 access(all) let scopeChannels: [String]? // nil = all channels
151 access(all) let scopeTools: [String]? // nil = all tools
152 access(all) let scopeSessionIds: [UInt64]? // nil = all sessions
153
154 init(
155 phase: LifecyclePhase,
156 priority: UInt8,
157 failMode: FailMode,
158 timeoutSeconds: UFix64,
159 maxRetries: UInt8,
160 retryBackoffSeconds: UFix64,
161 description: String,
162 scopeChannels: [String]?,
163 scopeTools: [String]?,
164 scopeSessionIds: [UInt64]?
165 ) {
166 pre {
167 timeoutSeconds > 0.0 && timeoutSeconds <= 300.0:
168 "Timeout must be 1-300 seconds"
169 maxRetries <= 5: "Max retries must be 0-5"
170 }
171 self.phase = phase
172 self.priority = priority
173 self.failMode = failMode
174 self.timeoutSeconds = timeoutSeconds
175 self.maxRetries = maxRetries
176 self.retryBackoffSeconds = retryBackoffSeconds
177 self.description = description
178 self.scopeChannels = scopeChannels
179 self.scopeTools = scopeTools
180 self.scopeSessionIds = scopeSessionIds
181 }
182 }
183
184 // -----------------------------------------------------------------------
185 // HookRegistration — a registered hook (stored on-chain)
186 // -----------------------------------------------------------------------
187 access(all) struct HookRegistration {
188 access(all) let hookId: UInt64
189 access(all) let config: HookConfig
190 access(all) let owner: Address
191 access(all) let registeredAt: UFix64
192 access(all) var isActive: Bool
193 access(all) var triggerCount: UInt64
194 access(all) var failureCount: UInt64
195 access(all) var lastTriggeredAt: UFix64?
196
197 // The handler itself is off-chain (the relay executes it),
198 // but we store a hash of the handler code for verifiability
199 access(all) let handlerHash: String
200
201 init(
202 hookId: UInt64,
203 config: HookConfig,
204 owner: Address,
205 handlerHash: String
206 ) {
207 self.hookId = hookId
208 self.config = config
209 self.owner = owner
210 self.registeredAt = getCurrentBlock().timestamp
211 self.isActive = true
212 self.triggerCount = 0
213 self.failureCount = 0
214 self.lastTriggeredAt = nil
215 self.handlerHash = handlerHash
216 }
217 }
218
219 // -----------------------------------------------------------------------
220 // HookContext — data passed to hook handlers
221 // -----------------------------------------------------------------------
222 access(all) struct HookContext {
223 access(all) let phase: LifecyclePhase
224 access(all) let agentId: UInt64
225 access(all) let sessionId: UInt64?
226 access(all) let timestamp: UFix64
227 access(all) let data: {String: String} // Key-value context data
228
229 init(
230 phase: LifecyclePhase,
231 agentId: UInt64,
232 sessionId: UInt64?,
233 data: {String: String}
234 ) {
235 self.phase = phase
236 self.agentId = agentId
237 self.sessionId = sessionId
238 self.timestamp = getCurrentBlock().timestamp
239 self.data = data
240 }
241 }
242
243 // -----------------------------------------------------------------------
244 // HookResult — what a hook handler returns
245 // -----------------------------------------------------------------------
246 access(all) struct HookResult {
247 access(all) let hookId: UInt64
248 access(all) let success: Bool
249 access(all) let shouldProceed: Bool // false = cancel the operation (Intercept entitlement)
250 access(all) let modifiedData: {String: String}? // nil = no modifications
251 access(all) let message: String?
252
253 init(
254 hookId: UInt64,
255 success: Bool,
256 shouldProceed: Bool,
257 modifiedData: {String: String}?,
258 message: String?
259 ) {
260 self.hookId = hookId
261 self.success = success
262 self.shouldProceed = shouldProceed
263 self.modifiedData = modifiedData
264 self.message = message
265 }
266 }
267
268 // -----------------------------------------------------------------------
269 // HookManager — per-account hook registry
270 // -----------------------------------------------------------------------
271 access(all) resource HookManager {
272 access(self) var hooks: {UInt64: HookRegistration}
273 access(self) var phaseIndex: {UInt8: [UInt64]} // phase -> [hookIds], sorted by priority
274 access(self) var hookResults: {UInt64: [HookResult]} // hookId -> results history
275
276 init() {
277 self.hooks = {}
278 self.phaseIndex = {}
279 self.hookResults = {}
280 }
281
282 // --- RegisterHooks: add/remove hooks ---
283
284 access(RegisterHooks) fun registerHook(
285 config: HookConfig,
286 handlerHash: String
287 ): UInt64 {
288 post {
289 self.hooks[AgentLifecycleHooks.totalHooks] != nil:
290 "Hook must be stored after registration"
291 }
292
293 AgentLifecycleHooks.totalHooks = AgentLifecycleHooks.totalHooks + 1
294 let hookId = AgentLifecycleHooks.totalHooks
295
296 let registration = HookRegistration(
297 hookId: hookId,
298 config: config,
299 owner: self.owner!.address,
300 handlerHash: handlerHash
301 )
302
303 self.hooks[hookId] = registration
304
305 // Add to phase index (maintain priority order)
306 let phaseKey = config.phase.rawValue
307 if self.phaseIndex[phaseKey] == nil {
308 self.phaseIndex[phaseKey] = [hookId]
309 } else {
310 self.phaseIndex[phaseKey]!.append(hookId)
311 // Sort by priority (higher priority first)
312 self.sortHooksByPriority(phaseKey: phaseKey)
313 }
314
315 let failModeStr = config.failMode == FailMode.failOpen ? "fail-open" : "fail-closed"
316
317 emit HookRegistered(
318 hookId: hookId,
319 phase: self.phaseToString(config.phase),
320 owner: self.owner!.address,
321 priority: config.priority,
322 failMode: failModeStr
323 )
324
325 return hookId
326 }
327
328 access(RegisterHooks) fun unregisterHook(hookId: UInt64): Bool {
329 if let hook = self.hooks[hookId] {
330 let phaseKey = hook.config.phase.rawValue
331 if var ids = self.phaseIndex[phaseKey] {
332 var newIds: [UInt64] = []
333 for id in ids {
334 if id != hookId {
335 newIds.append(id)
336 }
337 }
338 self.phaseIndex[phaseKey] = newIds
339 }
340 self.hooks.remove(key: hookId)
341 emit HookUnregistered(hookId: hookId)
342 return true
343 }
344 return false
345 }
346
347 // --- TriggerHooks: fire hooks for a phase ---
348
349 access(TriggerHooks) fun triggerPhase(
350 phase: LifecyclePhase,
351 context: HookContext
352 ): [HookResult] {
353 let phaseKey = phase.rawValue
354 var results: [HookResult] = []
355
356 let hookIdsOpt = self.phaseIndex[phaseKey]
357 if hookIdsOpt == nil {
358 return results // No hooks for this phase
359 }
360 let hookIds = hookIdsOpt!
361
362 for hookId in hookIds {
363 if let hook = self.hooks[hookId] {
364 if !hook.isActive {
365 continue
366 }
367
368 // Check scope gating
369 if !self.hookMatchesScope(hook: hook, context: context) {
370 continue
371 }
372
373 emit HookTriggered(
374 hookId: hookId,
375 phase: self.phaseToString(phase),
376 owner: self.owner!.address
377 )
378
379 // The actual hook execution happens OFF-CHAIN in the relay.
380 // Here we record that it was triggered and emit the event.
381 // The relay processes the event, runs the handler, and posts
382 // the result back via completeHook().
383
384 // For synchronous on-chain hooks (e.g., pre-conditions),
385 // we return a default "proceed" result
386 let result = HookResult(
387 hookId: hookId,
388 success: true,
389 shouldProceed: true,
390 modifiedData: nil,
391 message: nil
392 )
393 results.append(result)
394 }
395 }
396
397 return results
398 }
399
400 // Complete a hook execution (called by relay after off-chain processing)
401 access(TriggerHooks) fun completeHook(
402 hookId: UInt64,
403 result: HookResult
404 ) {
405 pre {
406 self.hooks[hookId] != nil: "Hook not found"
407 }
408
409 if self.hookResults[hookId] == nil {
410 self.hookResults[hookId] = [result]
411 } else {
412 self.hookResults[hookId]!.append(result)
413 }
414
415 if result.success {
416 emit HookCompleted(
417 hookId: hookId,
418 phase: self.phaseToString(self.hooks[hookId]!.config.phase),
419 result: result.message ?? "OK"
420 )
421 } else {
422 emit HookFailed(
423 hookId: hookId,
424 phase: self.phaseToString(self.hooks[hookId]!.config.phase),
425 error: result.message ?? "Unknown error"
426 )
427 }
428 }
429
430 // --- Read ---
431
432 access(all) fun getHook(hookId: UInt64): HookRegistration? {
433 return self.hooks[hookId]
434 }
435
436 access(all) fun getHooksForPhase(phase: LifecyclePhase): [HookRegistration] {
437 let phaseKey = phase.rawValue
438 var result: [HookRegistration] = []
439 if let hookIds = self.phaseIndex[phaseKey] {
440 for hookId in hookIds {
441 if let hook = self.hooks[hookId] {
442 result.append(hook)
443 }
444 }
445 }
446 return result
447 }
448
449 access(all) fun getAllHooks(): [HookRegistration] {
450 var result: [HookRegistration] = []
451 for hookId in self.hooks.keys {
452 if let hook = self.hooks[hookId] {
453 result.append(hook)
454 }
455 }
456 return result
457 }
458
459 // --- Internal helpers ---
460
461 access(self) fun hookMatchesScope(
462 hook: HookRegistration,
463 context: HookContext
464 ): Bool {
465 // Check session scope
466 if let sessionIds = hook.config.scopeSessionIds {
467 if let ctxSession = context.sessionId {
468 if !sessionIds.contains(ctxSession) {
469 return false
470 }
471 }
472 }
473
474 // Check tool scope
475 if let tools = hook.config.scopeTools {
476 if let toolName = context.data["toolName"] {
477 if !tools.contains(toolName) {
478 return false
479 }
480 }
481 }
482
483 // Check channel scope
484 if let channels = hook.config.scopeChannels {
485 if let channel = context.data["channel"] {
486 if !channels.contains(channel) {
487 return false
488 }
489 }
490 }
491
492 return true
493 }
494
495 access(self) fun sortHooksByPriority(phaseKey: UInt8) {
496 if var ids = self.phaseIndex[phaseKey] {
497 // Simple sort by priority (descending)
498 var i = 0
499 while i < ids.length {
500 var j = 0
501 while j < ids.length - 1 - i {
502 let hookA = self.hooks[ids[j]]!
503 let hookB = self.hooks[ids[j + 1]]!
504 if hookA.config.priority < hookB.config.priority {
505 let temp = ids[j]
506 ids[j] = ids[j + 1]
507 ids[j + 1] = temp
508 }
509 j = j + 1
510 }
511 i = i + 1
512 }
513 self.phaseIndex[phaseKey] = ids
514 }
515 }
516
517 access(self) fun phaseToString(_ phase: LifecyclePhase): String {
518 switch phase {
519 case LifecyclePhase.gatewayPreStart: return "gateway.pre_start"
520 case LifecyclePhase.gatewayPostStart: return "gateway.post_start"
521 case LifecyclePhase.gatewayPreStop: return "gateway.pre_stop"
522 case LifecyclePhase.gatewayPostStop: return "gateway.post_stop"
523 case LifecyclePhase.agentPreStart: return "agent.pre_start"
524 case LifecyclePhase.agentPostRun: return "agent.post_run"
525 case LifecyclePhase.agentError: return "agent.error"
526 case LifecyclePhase.messageReceived: return "message.received"
527 case LifecyclePhase.preInference: return "inference.pre"
528 case LifecyclePhase.postInference: return "inference.post"
529 case LifecyclePhase.preSend: return "send.pre"
530 case LifecyclePhase.postSend: return "send.post"
531 case LifecyclePhase.postSendFailure: return "send.post_failure"
532 case LifecyclePhase.preToolCall: return "tool.pre_call"
533 case LifecyclePhase.postToolCall: return "tool.post_call"
534 case LifecyclePhase.toolError: return "tool.error"
535 case LifecyclePhase.preMemoryCompaction: return "memory.pre_compaction"
536 case LifecyclePhase.postMemoryCompaction: return "memory.post_compaction"
537 case LifecyclePhase.preScheduledTask: return "schedule.pre_task"
538 case LifecyclePhase.postScheduledTask: return "schedule.post_task"
539 }
540 return "unknown"
541 }
542 }
543
544 // -----------------------------------------------------------------------
545 // Public factory
546 // -----------------------------------------------------------------------
547 access(all) fun createHookManager(): @HookManager {
548 return <- create HookManager()
549 }
550
551 // -----------------------------------------------------------------------
552 // Init
553 // -----------------------------------------------------------------------
554 init() {
555 self.totalHooks = 0
556 self.HookManagerStoragePath = /storage/FlowClawHookManager
557 }
558}
559