Smart Contract

AgentLifecycleHooks

A.91d0a5b7c9832a8b.AgentLifecycleHooks

Valid From

143,525,876

Deployed

1d ago
Feb 27, 2026, 12:08:42 AM UTC

Dependents

0 imports
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