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
//! Hook trigger logic inside the turn main loop.
//!
//! Extracted from the turn main flow: `decide_turn_end` (before turn-end continuation
//! decision), `fire_*` triggers before/after prompt ingestion and tools, and feedback
//! injection helpers, implemented as methods on [`super::TurnRunner`]. See `crate::hooks`
//! for step types and engine.
use agent_client_protocol_schema::{ContentBlock, StopReason as AcpStopReason, ToolCallId};
use serde_json::Value as JsonValue;
use crate::llm::{Message, MessageContent, Role, ToolResultBody, ToolResultContent};
use super::content::content_block_to_message_content;
use super::tools::ToolResult;
use super::{TurnRunner, TurnState};
/// The result of `fire_user_prompt_submit`.
pub(super) enum UserPromptHookFlow {
Continue(Vec<ContentBlock>),
Refused,
}
/// The result of `fire_pre_tool_use`.
pub(super) enum PreToolHookFlow {
Continue { args: JsonValue },
Block(String),
}
impl TurnRunner<'_> {
/// `before turn-end` decision point.
///
/// Called when the turn is stopping. `stop_reason` / `voluntary` describe *why*:
/// - Voluntary (`EndTurn`, empty tool_use): the LLM chose to stop.
/// - Involuntary `MaxTurnRequests`: the per-turn request cap was hit. Goal mode still
/// wants a say here — otherwise hitting the cap silently abandons an unfinished goal
/// (bypassing the goal gate entirely). The hook decides whether to keep going.
///
/// Lets the hook allow the stop, or keep the turn alive (inject feedback, don't end,
/// loop back to the top). Returns `true` = keep alive (caller `continue`s); `false` =
/// allow stop.
///
/// Keep-alive is bounded by the **hard limit** `max_stop_hook_continues` — once
/// reached, the stop is forced to prevent infinite loops. When a continue is granted,
/// the per-turn request budget is **reset** so `request_limit` acts as a per-logical-
/// turn budget rather than one shared across the whole multi-turn run. The keep-alive
/// feedback is injected into history as a **user message**.
pub(super) async fn decide_turn_end(
&self,
state: &mut TurnState,
stop_reason: AcpStopReason,
voluntary: bool,
) -> bool {
// Hard limit reached: stop asking the hook and force-stop.
if !state.may_stop_hook_continue() {
return false;
}
let mut step = crate::hooks::step::BeforeTurnEnd {
stop_reason,
continues_so_far: state.stop_hook_continues,
voluntary,
feedback: Vec::new(),
};
let control = self.hooks.dispatch(&mut step, self.hook_ctx()).await;
match control {
crate::hooks::step::HookControl::Continue => {
// Inject the feedback as a user message into the history. If the feedback
// is empty, inject a fallback prompt to prevent the LLM from immediately
// saying "I'm done" on the next turn, which would cause a no-op loop
// (invariant: the next turn must always have something to act on).
let blocks = if step.feedback.is_empty() {
vec![ContentBlock::from(
"Continue working — the stop condition is not yet satisfied.",
)]
} else {
step.feedback
};
self.append_user_feedback(blocks);
state.note_stop_hook_continue();
// Reset the per-turn request budget: the continued turn gets a fresh cap.
state.reset_request_budget(self.config.request_limit);
true
}
// Proceed, Break, and Skip all mean "stop" at turn-end.
_ => false,
}
}
/// Inject a set of content blocks into the history as a user message (used for
/// keepalive feedback).
///
/// Fallback role alternation: if the history already ends with a user
/// role, merge into the same message rather than appending an adjacent user, to
/// prevent two wire codecs from encountering consecutive identical roles. Blocks that
/// cannot be decoded are skipped (best effort, does not kill the turn).
pub(super) fn append_user_feedback(&self, blocks: Vec<ContentBlock>) {
let content: Vec<MessageContent> = blocks
.into_iter()
.filter_map(|b| content_block_to_message_content(b).ok())
.flatten()
.collect();
if content.is_empty() {
return;
}
self.history.append(Message {
role: Role::User,
content: content.into(),
});
}
/// Triggers the `UserPromptSubmit` hook.
///
/// Handles three outcomes:
/// - `block` → rejects the turn (caller returns `Refusal`)
/// - `patch = UserPrompt { prepend, append }` → rewrites the prompt order to
/// `[prepend, original, append]`; the rewritten form is used when appending to
/// history
/// - `append` → not yet spliced into the system prompt (currently has no landing
/// point; pending `system_prompt` filled in dynamically after assembly)
pub(super) async fn fire_user_prompt_submit(
&self,
prompt: Vec<ContentBlock>,
) -> UserPromptHookFlow {
// Step model: `before Ingest` (before input ingestion). The hook can rewrite the
// input or `Break` to reject the turn.
// The source is carried by the turn — user turn = User, background continuation
// turn = Background.
let mut step = crate::hooks::step::BeforeIngest {
source: self.ingest_source.clone(),
input: prompt,
};
let control = self.hooks.dispatch(&mut step, self.hook_ctx()).await;
match control {
crate::hooks::step::HookControl::Break { .. } => {
tracing::info!("user prompt blocked by before-ingest hook");
UserPromptHookFlow::Refused
}
// Proceed, Continue, and Skip all mean "continue" at the ingestion point,
// using the hook-rewritten input.
_ => UserPromptHookFlow::Continue(step.input),
}
}
/// Fires the `before ToolApply` hook (per tool).
pub(super) async fn fire_pre_tool_use(
&self,
id: &ToolCallId,
name: &str,
args: &JsonValue,
safety: crate::tool::SafetyClass,
) -> PreToolHookFlow {
let _ = id;
// Step model: `before ToolApply`. The hook may modify `args`, set `result`
// (intercepting the tool = synthetic output), or return `Break`.
let mut step = crate::hooks::step::BeforeToolApply {
tool_name: name.to_string(),
safety,
args: args.clone(),
result: None,
};
let control = self.hooks.dispatch(&mut step, self.hook_ctx()).await;
// If `step.result` is set, the tool is blocked (synthetic output) and the turn
// continues. This maps to the existing `Block` flow: the caller skips tool
// execution and feeds `reason` back as the rejection text.
if let Some(result) = step.result {
let reason = match &result.body {
crate::llm::ToolResultBody::Text { text } => text.clone(),
other => serde_json::to_string(other).unwrap_or_else(|_| "blocked by hook".into()),
};
tracing::info!(tool = %name, "tool short-circuited by before-tool-apply hook");
return PreToolHookFlow::Block(reason);
}
if let crate::hooks::step::HookControl::Break { .. } = control {
tracing::info!(tool = %name, "tool blocked by before-tool-apply hook (break)");
return PreToolHookFlow::Block("blocked by hook".to_string());
}
PreToolHookFlow::Continue { args: step.args }
}
/// Fires the `after ToolApply` hook (per tool). Appends any `additional_context`
/// injected by the hook to the end of `result.body`, so the next LLM turn sees the
/// hook annotation as part of the tool output.
pub(super) async fn fire_post_tool_hook(&self, result: &mut ToolResult) {
// Step model: `after ToolApply`. Observable and injectable (appended to
// `tool_result`).
let mut step = crate::hooks::step::AfterToolApply {
tool_name: result.name.clone(),
is_error: result.is_error,
output: result.body.clone(),
additional_context: Vec::new(),
};
let _ = self.hooks.dispatch(&mut step, self.hook_ctx()).await;
if step.additional_context.is_empty() {
return;
}
// Append text blocks from hook-injected `ContentBlock`s to the tool_result body.
let extra: String = step
.additional_context
.iter()
.filter_map(|b| match b {
ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if extra.is_empty() {
return;
}
match &mut result.body {
ToolResultBody::Text { text } => {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&extra);
}
// For multimodal results, append the extra text as a new text block at the
// end, leaving image blocks unchanged.
ToolResultBody::Content { blocks } => {
blocks.push(ToolResultContent::Text { text: extra });
}
ToolResultBody::Json { .. } => {}
}
}
}