1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
//! Hook system types — lifecycle phases and hook results.
//!
//! These types define the contract between the agent loop and its
//! extension points. The trait itself (`AgentHook`) lives in
//! `bamboo-engine` because it depends on `Session`.
use serde::{Deserialize, Serialize};
/// Point-specific data supplied to an [`AgentHookPoint`] callback.
///
/// Payloads own their data deliberately. Hooks are asynchronous and may be
/// shared by concurrent runs, so keeping the public contract free of borrowed
/// engine-only types makes it serializable, easy to test, and preserves the
/// domain/engine dependency boundary.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum HookPayload {
/// A point currently has no additional structured data.
#[default]
None,
/// Session initialization completed for this user turn.
SessionSetup {
initial_message: String,
source: SessionStartSource,
},
/// A round is about to start or has just completed.
Round { round: u32 },
/// A prompt-oriented hook payload reserved for prompt/LLM seams.
Prompt { prompt: String },
/// Read-only observation of one completed provider call.
ModelCallUsage { observation: crate::ModelCallUsage },
/// A parsed tool call immediately before dispatch.
ToolExecution {
tool_name: String,
tool_call_id: String,
parsed_args: serde_json::Value,
},
/// A tool outcome immediately before it is applied to the session.
ToolResult {
tool_name: String,
tool_call_id: String,
outcome: HookToolOutcome,
},
/// Context compression is about to begin.
Compression {
estimated_tokens: u32,
usage_percent: f64,
max_context_tokens: u32,
trigger_context_tokens: u32,
trigger: String,
phase: String,
},
/// A classified, deduplicated notification is being delivered to sinks.
Notification {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
category: String,
priority: String,
title: String,
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
dedup_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
click_url: Option<String>,
},
/// The run is about to emit its terminal completion event.
Finalize { stop_hook_active: bool },
/// The run reached a terminal status and is about to be persisted.
SessionEnd {
status: SessionEndStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
completion_reason: Option<String>,
},
}
/// Why a `SessionStart` hook is running.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SessionStartSource {
#[default]
Startup,
Resume,
}
/// Terminal status exposed to `SessionEnd` hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionEndStatus {
Completed,
Failed,
Cancelled,
}
/// Engine-independent view of a tool execution outcome.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookToolOutcome {
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default)]
pub needs_human: bool,
#[serde(default)]
pub duration_ms: u64,
}
/// Lifecycle phases where hooks can be attached.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentHookPoint {
// Session-level
BeforeSessionSetup,
AfterSessionSetup,
BeforeFinalize,
AfterSessionEnd,
// Round-level
BeforeRound,
AfterRound,
// Prompt assembly
BeforePromptAssembly,
AfterPromptAssembly,
// LLM call
BeforeLlmCall,
AfterLlmCall,
/// Advisory only; observer results never alter the model call.
AfterModelCallUsage,
// Tool execution
BeforeToolExecution,
AfterToolExecution,
// Memory
BeforeMemoryRecall,
AfterMemoryRecall,
// Context compression
BeforeCompression,
AfterCompression,
// Classified notification delivery
AfterNotification,
}
/// Result of running a hook.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum HookResult {
/// Continue with normal flow (no modifications).
#[default]
Continue,
/// State was mutated; downstream should re-read.
Mutated,
/// Explicitly allow the operation represented by the payload.
Allow,
/// Deny the operation represented by the payload.
Deny { reason: String },
/// Ask the owning parent agent to review the operation.
///
/// The engine never turns this into an unowned/manual approval. Tool
/// seams route it through a parent approval delegate/proxy and fail closed
/// when no such route exists.
Ask,
/// Add durable context for the remaining run.
InjectContext { text: String },
/// Apply a control result and inject context from the same hook response.
///
/// Config-driven shell hooks use this when stdout includes both a
/// `decision` and `additional_context`. The runner unwraps the control
/// result before returning its aggregate outcome.
WithContext {
result: Box<HookResult>,
text: String,
},
/// Pause execution; set suspension state.
Suspend { reason: String },
/// Abort the agent run.
Abort { reason: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_point_serialization_round_trip() {
let points = [
AgentHookPoint::BeforeSessionSetup,
AgentHookPoint::AfterSessionSetup,
AgentHookPoint::BeforeFinalize,
AgentHookPoint::AfterSessionEnd,
AgentHookPoint::BeforeRound,
AgentHookPoint::AfterRound,
AgentHookPoint::BeforePromptAssembly,
AgentHookPoint::AfterPromptAssembly,
AgentHookPoint::BeforeLlmCall,
AgentHookPoint::AfterLlmCall,
AgentHookPoint::BeforeToolExecution,
AgentHookPoint::AfterToolExecution,
AgentHookPoint::BeforeMemoryRecall,
AgentHookPoint::AfterMemoryRecall,
AgentHookPoint::BeforeCompression,
AgentHookPoint::AfterCompression,
AgentHookPoint::AfterNotification,
];
for point in &points {
let json = serde_json::to_string(point).unwrap();
let restored: AgentHookPoint = serde_json::from_str(&json).unwrap();
assert_eq!(point, &restored);
}
}
#[test]
fn hook_result_default_is_continue() {
assert_eq!(HookResult::default(), HookResult::Continue);
}
#[test]
fn hook_result_variants_serialize() {
let variants = [
HookResult::Continue,
HookResult::Mutated,
HookResult::Allow,
HookResult::Deny {
reason: "blocked".to_string(),
},
HookResult::Ask,
HookResult::InjectContext {
text: "extra context".to_string(),
},
HookResult::WithContext {
result: Box::new(HookResult::Allow),
text: "allowed context".to_string(),
},
HookResult::Suspend {
reason: "waiting".to_string(),
},
HookResult::Abort {
reason: "error".to_string(),
},
];
for variant in &variants {
let json = serde_json::to_string(variant).unwrap();
let restored: HookResult = serde_json::from_str(&json).unwrap();
assert_eq!(variant, &restored);
}
}
#[test]
fn hook_payload_round_trips_structured_tool_data() {
let payload = HookPayload::ToolExecution {
tool_name: "Bash".to_string(),
tool_call_id: "call-1".to_string(),
parsed_args: serde_json::json!({"command": "pwd"}),
};
let json = serde_json::to_string(&payload).unwrap();
let restored: HookPayload = serde_json::from_str(&json).unwrap();
assert_eq!(restored, payload);
}
}