1use crate::subagent::control::{Up, send_up};
28use crate::subagent::protocol::AgentMsg;
29use crate::subagent::replies::{Replies, Reply};
30use mcp::inbound::{Answer, Handler, Inbound};
31use serde_json::{Value, json};
32use std::sync::Arc;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::time::{Duration, Instant};
35
36pub struct ElicitationBridge {
38 up: Up,
39 replies: Arc<Replies>,
40 cancel: Arc<AtomicBool>,
41 timeout: Duration,
44}
45
46impl ElicitationBridge {
47 pub fn new(
48 up: Up,
49 replies: Arc<Replies>,
50 cancel: Arc<AtomicBool>,
51 timeout: Duration,
52 ) -> ElicitationBridge {
53 ElicitationBridge {
54 up,
55 replies,
56 cancel,
57 timeout,
58 }
59 }
60}
61
62impl Handler for ElicitationBridge {
63 fn handle(&self, req: Inbound) -> Option<Answer> {
64 let Inbound::Elicit {
65 message,
66 requested_schema,
67 } = req
68 else {
69 return None;
72 };
73 if self.cancel.load(Ordering::Relaxed) {
74 return Some(Answer::Cancel);
75 }
76
77 let id = self.replies.next_id();
78 send_up(
79 &self.up,
80 &AgentMsg::ToolRequest {
81 id,
82 name: "ask_human".to_string(),
83 args: json!({
84 "question": message,
85 "schema": requested_schema.clone(),
86 }),
87 },
88 );
89
90 let deadline = Instant::now() + self.timeout;
91 match self.replies.wait(id, deadline, &self.cancel) {
92 Some(Reply::Tool { result, is_error }) => {
93 if is_error {
94 return Some(Answer::Cancel);
96 }
97 if result
98 .get("timed_out")
99 .and_then(Value::as_bool)
100 .unwrap_or(false)
101 {
102 return Some(Answer::Cancel);
103 }
104 Some(shape_reply(
105 result.get("reply").unwrap_or(&Value::Null),
106 &requested_schema,
107 ))
108 }
109 _ => Some(Answer::Cancel),
111 }
112 }
113}
114
115pub(crate) fn shape_reply(reply: &Value, schema: &Value) -> Answer {
121 if reply.is_object() {
124 return Answer::Accept(reply.clone());
125 }
126
127 let text = match reply {
128 Value::String(s) => s.trim().to_string(),
129 Value::Null => return Answer::Cancel,
130 other => other.to_string(),
131 };
132 if text.is_empty() {
133 return Answer::Cancel;
134 }
135
136 if let Ok(Value::Object(m)) = serde_json::from_str::<Value>(&text) {
138 return Answer::Accept(Value::Object(m));
139 }
140
141 let props = schema.get("properties").and_then(Value::as_object);
143 if let Some(props) = props
144 && props.len() == 1
145 && let Some((key, spec)) = props.iter().next()
146 {
147 let declared = spec.get("type").and_then(Value::as_str).unwrap_or("string");
148 if let Some(v) = coerce(&text, declared) {
149 return Answer::Accept(json!({ key: v }));
150 }
151 return Answer::Cancel;
152 }
153
154 Answer::Cancel
157}
158
159fn coerce(text: &str, declared: &str) -> Option<Value> {
162 match declared {
163 "string" => Some(Value::String(text.to_string())),
164 "boolean" => match text.to_ascii_lowercase().as_str() {
165 "true" | "yes" | "y" | "1" | "ok" => Some(Value::Bool(true)),
166 "false" | "no" | "n" | "0" => Some(Value::Bool(false)),
167 _ => None,
168 },
169 "integer" => text.parse::<i64>().ok().map(|n| json!(n)),
170 "number" => text
171 .parse::<f64>()
172 .ok()
173 .and_then(|n| serde_json::Number::from_f64(n).map(Value::Number)),
174 _ => None,
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn one_prop(name: &str, ty: &str) -> Value {
183 json!({"type": "object", "properties": { name: {"type": ty} }})
184 }
185
186 fn accepted(a: Answer) -> Value {
187 match a {
188 Answer::Accept(v) => v,
189 other => panic!("expected Accept, got {other:?}"),
190 }
191 }
192
193 #[test]
194 fn a_structured_reply_passes_through() {
195 let r = shape_reply(&json!({"env": "staging"}), &one_prop("env", "string"));
196 assert_eq!(accepted(r)["env"], "staging");
197 }
198
199 #[test]
200 fn a_single_property_schema_binds_the_text() {
201 let r = shape_reply(&json!("staging"), &one_prop("env", "string"));
202 assert_eq!(accepted(r), json!({"env": "staging"}));
203
204 let r = shape_reply(&json!("42"), &one_prop("count", "integer"));
206 assert_eq!(accepted(r), json!({"count": 42}));
207 let r = shape_reply(&json!("yes"), &one_prop("confirm", "boolean"));
208 assert_eq!(accepted(r), json!({"confirm": true}));
209
210 assert!(matches!(
212 shape_reply(&json!("soon"), &one_prop("count", "integer")),
213 Answer::Cancel
214 ));
215 }
216
217 #[test]
218 fn typed_json_is_honoured_over_the_single_property_shortcut() {
219 let r = shape_reply(
220 &json!(r#"{"env":"prod","force":true}"#),
221 &one_prop("env", "string"),
222 );
223 let v = accepted(r);
224 assert_eq!(v["env"], "prod");
225 assert_eq!(v["force"], true);
226 }
227
228 #[test]
229 fn prose_against_a_multi_property_schema_is_declined_not_guessed() {
230 let schema = json!({"type": "object", "properties": {
233 "env": {"type": "string"}, "force": {"type": "boolean"}
234 }});
235 assert!(matches!(
236 shape_reply(&json!("just do it on staging"), &schema),
237 Answer::Cancel
238 ));
239 }
240
241 #[test]
242 fn nothing_to_say_is_a_cancel() {
243 assert!(matches!(
244 shape_reply(&Value::Null, &one_prop("x", "string")),
245 Answer::Cancel
246 ));
247 assert!(matches!(
248 shape_reply(&json!(" "), &one_prop("x", "string")),
249 Answer::Cancel
250 ));
251 }
252}