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
//! Approval + user-input handshake for the agent loop.
//!
//! Extracted from `core/engine.rs` (P1.3). The agent loop blocks on these
//! two futures whenever a tool requires explicit approval (`await_tool_approval`)
//! or whenever a tool requests live user input (`await_user_input`). Channels
//! and engine state stay private to the parent module.
use crate::core::events::Event;
use crate::tools::spec::ToolError;
use crate::tools::user_input::{UserInputRequest, UserInputResponse};
use super::Engine;
#[derive(Debug, Clone)]
pub(super) enum ApprovalDecision {
Approved {
id: String,
},
Denied {
id: String,
},
/// Retry a tool with an elevated sandbox policy.
RetryWithPolicy {
id: String,
policy: crate::sandbox::SandboxPolicy,
},
}
#[derive(Debug, Clone)]
pub(super) enum UserInputDecision {
Submitted {
id: String,
response: UserInputResponse,
},
Cancelled {
id: String,
},
}
/// Result of awaiting tool approval from the user.
#[derive(Debug)]
pub(super) enum ApprovalResult {
/// User approved the tool execution.
Approved,
/// User denied the tool execution.
Denied,
/// User requested retry with an elevated sandbox policy.
RetryWithPolicy(crate::sandbox::SandboxPolicy),
}
impl Engine {
/// Format a cancellation suffix when the engine knows the cause.
/// Some internal cancellation paths still use the raw token while
/// #1541 is open; those keep the legacy message without a guessed
/// reason.
fn cancel_reason_suffix(&self) -> String {
let reason = match self.cancel_reason.lock() {
Ok(slot) => *slot,
Err(poisoned) => *poisoned.into_inner(),
};
match reason {
Some(reason) => format!(" (reason: {})", reason.describe()),
None => String::new(),
}
}
pub(super) async fn await_tool_approval(
&mut self,
tool_id: &str,
) -> Result<ApprovalResult, ToolError> {
loop {
tokio::select! {
_ = self.cancel_token.cancelled() => {
let suffix = self.cancel_reason_suffix();
return Err(ToolError::execution_failed(
format!("Request cancelled while awaiting approval{suffix}"),
));
}
decision = self.rx_approval.recv() => {
let Some(decision) = decision else {
return Err(ToolError::execution_failed(
"Approval channel closed — engine is shutting down. \
The approval modal can no longer reach the engine; \
this is typically a teardown race, not a user action."
.to_string(),
));
};
match decision {
ApprovalDecision::Approved { id } if id == tool_id => {
return Ok(ApprovalResult::Approved);
}
ApprovalDecision::Denied { id } if id == tool_id => {
return Ok(ApprovalResult::Denied);
}
ApprovalDecision::RetryWithPolicy { id, policy } if id == tool_id => {
return Ok(ApprovalResult::RetryWithPolicy(policy));
}
_ => continue,
}
}
}
}
}
pub(super) async fn await_user_input(
&mut self,
tool_id: &str,
request: UserInputRequest,
) -> Result<UserInputResponse, ToolError> {
let _ = self
.tx_event
.send(Event::UserInputRequired {
id: tool_id.to_string(),
request,
})
.await;
loop {
tokio::select! {
_ = self.cancel_token.cancelled() => {
let suffix = self.cancel_reason_suffix();
return Err(ToolError::execution_failed(
format!("Request cancelled while awaiting user input{suffix}"),
));
}
decision = self.rx_user_input.recv() => {
let Some(decision) = decision else {
return Err(ToolError::execution_failed(
"User input channel closed".to_string(),
));
};
match decision {
UserInputDecision::Submitted { id, response } if id == tool_id => {
return Ok(response);
}
UserInputDecision::Cancelled { id } if id == tool_id => {
return Err(ToolError::execution_failed(
"User input cancelled".to_string(),
));
}
_ => continue,
}
}
}
}
}
}