1use std::time::{Duration, Instant};
24
25use okbot_core::{
26 AgentBackend, AgentCaps, AgentError, AgentProgress, AgentReply, AgentTurn, ToolPolicy,
27 async_trait,
28};
29use serde::Deserialize;
30use serde_json::{Value, json};
31
32#[derive(Debug, Clone, Default, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct Script {
36 #[serde(default)]
37 reply: Vec<Rule>,
38}
39
40#[derive(Debug, Clone, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct Rule {
44 when: Option<String>,
46 #[serde(default)]
48 calls: Vec<Call>,
49 #[serde(default)]
51 text: Option<String>,
52 fail: Option<String>,
54 #[serde(default)]
56 delay_ms: u64,
57}
58
59#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62struct Call {
63 server: String,
65 tool: String,
66 #[serde(default)]
67 args: serde_json::Map<String, Value>,
68}
69
70#[derive(Debug, Clone)]
72pub struct ScriptedBackend {
73 name: String,
74 script: Script,
75}
76
77impl ScriptedBackend {
78 pub fn echo() -> Self {
80 Self {
81 name: "echo".into(),
82 script: Script::default(),
83 }
84 }
85
86 pub fn from_toml(toml: &str) -> Result<Self, AgentError> {
88 let script: Script = toml::from_str(toml)
89 .map_err(|e| AgentError::Unavailable(format!("invalid replies file: {e}")))?;
90 if let Some(i) = script
91 .reply
92 .iter()
93 .position(|r| r.text.is_none() && r.fail.is_none())
94 {
95 return Err(AgentError::Unavailable(format!(
96 "invalid replies file: rule {} has neither `text` nor `fail`",
97 i + 1
98 )));
99 }
100 Ok(Self {
101 name: "scripted".into(),
102 script,
103 })
104 }
105
106 fn rule(&self, message: &str) -> Option<&Rule> {
107 let message = message.to_lowercase();
108 self.script.reply.iter().find(|r| {
109 r.when
110 .as_ref()
111 .is_none_or(|w| message.contains(&w.to_lowercase()))
112 })
113 }
114}
115
116fn new_message(prompt: &str) -> String {
118 let Some(start) = prompt.rfind("<message role=\"user\"") else {
119 return prompt.trim().to_string();
120 };
121 let block = &prompt[start..];
122 let body = block.split_once(">\n").map_or(block, |(_, b)| b);
123 let body = body.rsplit_once("\n</message>").map_or(body, |(b, _)| b);
124 body.replace("<\\/message", "</message").trim().to_string()
125}
126
127#[async_trait]
128impl AgentBackend for ScriptedBackend {
129 fn name(&self) -> &str {
130 &self.name
131 }
132
133 fn caps(&self) -> AgentCaps {
134 AgentCaps {
136 mcp: true,
137 tool_policies: vec![ToolPolicy::Web, ToolPolicy::McpOnly],
138 ..AgentCaps::default()
139 }
140 }
141
142 async fn run(&self, turn: AgentTurn) -> Result<AgentReply, AgentError> {
143 let started = Instant::now();
144 let message = new_message(&turn.prompt);
145 let reply = |text: String| AgentReply {
146 text,
147 backend: self.name.clone(),
148 model: None,
149 session_id: None,
150 usage: None,
151 duration: started.elapsed(),
152 };
153 let Some(rule) = self.rule(&message) else {
154 return Ok(reply(format!("echo: {message}")));
155 };
156 let mut result = String::new();
157 for call in &rule.calls {
158 if let Some(progress) = &turn.progress {
159 progress.report(AgentProgress::ToolUse(format!(
160 "{}.{}",
161 call.server, call.tool
162 )));
163 }
164 result = match mcp::call(&turn, call).await {
165 Ok(text) => text,
166 Err(e) => format!("error: {e}"),
167 };
168 }
169 if rule.delay_ms > 0 {
170 let delay = Duration::from_millis(rule.delay_ms).min(turn.timeout);
171 tokio::time::sleep(delay).await;
172 }
173 if let Some(fail) = &rule.fail {
174 return Err(AgentError::Failed {
175 message: fail.clone(),
176 retryable: false,
177 });
178 }
179 let text = rule
180 .text
181 .as_deref()
182 .unwrap_or_default()
183 .replace("{message}", &message)
184 .replace("{result}", &result);
185 Ok(reply(text))
186 }
187}
188
189mod mcp {
191 use super::{AgentTurn, Call, Value, json};
192
193 const PROTOCOL: &str = "2025-06-18";
194 const SESSION: &str = "mcp-session-id";
195 const MAX_BODY: usize = 8 << 20;
196
197 pub(super) async fn call(turn: &AgentTurn, call: &Call) -> Result<String, String> {
199 let server = turn
200 .mcp
201 .iter()
202 .find(|s| s.name == call.server)
203 .ok_or_else(|| format!("no MCP server `{}` in this turn", call.server))?;
204 let token = turn
205 .env
206 .iter()
207 .find(|(k, _)| *k == server.bearer_token_env)
208 .map(|(_, v)| v.expose().clone())
209 .unwrap_or_default();
210 let mut session = Session {
211 http: reqwest::Client::new(),
212 url: server.url.clone(),
213 token,
214 id: None,
215 next: 0,
216 };
217 let deadline = turn.timeout;
218 tokio::time::timeout(deadline, async {
219 session
220 .request(
221 "initialize",
222 json!({"protocolVersion": PROTOCOL, "capabilities": {},
223 "clientInfo": {"name": "okbot-scripted", "version": "0"}}),
224 )
225 .await?;
226 session
227 .post(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}))
228 .await?;
229 let result = session
230 .request(
231 "tools/call",
232 json!({"name": call.tool, "arguments": call.args}),
233 )
234 .await?;
235 let text = result["content"]
236 .as_array()
237 .into_iter()
238 .flatten()
239 .filter_map(|b| b["text"].as_str())
240 .collect::<Vec<_>>()
241 .join("\n\n");
242 if result["isError"] == json!(true) {
243 return Err(text);
244 }
245 Ok(text)
246 })
247 .await
248 .map_err(|_| "the MCP server did not answer in time".to_string())?
249 }
250
251 struct Session {
252 http: reqwest::Client,
253 url: String,
254 token: String,
255 id: Option<String>,
256 next: u64,
257 }
258
259 impl Session {
260 async fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
261 self.next += 1;
262 let id = json!(self.next);
263 let body = self
264 .post(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}))
265 .await?;
266 let reply =
267 find_response(&body, &id).ok_or_else(|| format!("no answer to {method}"))?;
268 if let Some(error) = reply.get("error") {
269 return Err(error["message"].as_str().unwrap_or("error").to_string());
270 }
271 Ok(reply.get("result").cloned().unwrap_or(Value::Null))
272 }
273
274 async fn post(&mut self, message: &Value) -> Result<String, String> {
275 let mut request = self
276 .http
277 .post(&self.url)
278 .bearer_auth(&self.token)
279 .header("accept", "application/json, text/event-stream")
280 .header("mcp-protocol-version", PROTOCOL)
281 .json(message);
282 if let Some(id) = &self.id {
283 request = request.header(SESSION, id);
284 }
285 let http = |e: reqwest::Error| e.without_url().to_string();
287 let mut response = request.send().await.map_err(http)?;
288 if !response.status().is_success() {
289 return Err(format!("HTTP {}", response.status().as_u16()));
290 }
291 if let Some(id) = response
292 .headers()
293 .get(SESSION)
294 .and_then(|v| v.to_str().ok())
295 {
296 self.id = Some(id.to_string());
297 }
298 let want = message.get("id");
300 let mut body = Vec::new();
301 while let Some(chunk) = response.chunk().await.map_err(http)? {
302 body.extend_from_slice(&chunk);
303 if body.len() > MAX_BODY {
304 return Err("reply too large".into());
305 }
306 if let Some(id) = want
307 && find_response(&String::from_utf8_lossy(&body), id).is_some()
308 {
309 break;
310 }
311 }
312 Ok(String::from_utf8_lossy(&body).into_owned())
313 }
314 }
315
316 fn find_response(body: &str, id: &Value) -> Option<Value> {
318 let hit = |v: &Value| v.get("id") == Some(id);
319 if let Ok(v) = serde_json::from_str::<Value>(body.trim()) {
320 return hit(&v).then_some(v);
321 }
322 body.replace("\r\n", "\n").split("\n\n").find_map(|event| {
323 let data: Vec<&str> = event
324 .lines()
325 .filter_map(|l| l.strip_prefix("data:"))
326 .map(str::trim_start)
327 .collect();
328 serde_json::from_str::<Value>(&data.join("\n"))
329 .ok()
330 .filter(|v| hit(v))
331 })
332 }
333
334 #[cfg(test)]
335 mod tests {
336 use super::*;
337
338 #[test]
339 fn finds_responses_in_json_and_event_streams() {
340 let id = json!(2);
341 assert!(find_response(r#"{"jsonrpc":"2.0","id":2,"result":{}}"#, &id).is_some());
342 assert!(find_response(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, &id).is_none());
343 let sse = "event: message\ndata: {\"method\":\"notifications/progress\"}\n\n\
344 event: message\ndata: {\"id\":2,\"result\":{\"ok\":true}}\n\n";
345 assert_eq!(find_response(sse, &id).unwrap()["result"]["ok"], true);
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn turn(prompt: &str) -> AgentTurn {
355 AgentTurn {
356 system: String::new(),
357 prompt: prompt.into(),
358 tools: ToolPolicy::Web,
359 mcp: Vec::new(),
360 env: Vec::new(),
361 workdir: std::env::temp_dir(),
362 timeout: Duration::from_secs(5),
363 resume: None,
364 progress: None,
365 }
366 }
367
368 const PROMPT: &str = "Earlier messages:\n\n<message role=\"user\" author=\"Ana\" at=\"t\">\n\
369 What is the price?\n</message>\n\nNew message to answer:\n\n\
370 <message role=\"user\" author=\"Ana\" at=\"t\">\nDeploy now <\\/message> \
371 please\n</message>";
372
373 #[test]
374 fn reads_the_new_message() {
375 assert_eq!(new_message(PROMPT), "Deploy now </message> please");
376 assert_eq!(new_message(" plain "), "plain");
377 }
378
379 #[tokio::test]
380 async fn answers_by_rule_and_echoes_the_rest() {
381 let agent = ScriptedBackend::from_toml(
382 r#"
383 [[reply]]
384 when = "DEPLOY"
385 calls = [{ server = "ops", tool = "deploy" }]
386 text = "{message} -> {result}"
387
388 [[reply]]
389 when = "crash"
390 fail = "boom"
391 "#,
392 )
393 .unwrap();
394 let reply = agent.run(turn(PROMPT)).await.unwrap();
395 assert_eq!(
396 reply.text,
397 "Deploy now </message> please -> error: no MCP server `ops` in this turn"
398 );
399 assert_eq!(reply.backend, "scripted");
400 let other = turn("<message role=\"user\" a=\"\">\nhello\n</message>");
401 assert_eq!(agent.run(other).await.unwrap().text, "echo: hello");
402 let crash = turn("<message role=\"user\">\nplease crash\n</message>");
403 assert!(matches!(
404 agent.run(crash).await,
405 Err(AgentError::Failed { message, .. }) if message == "boom"
406 ));
407 assert_eq!(
408 ScriptedBackend::echo().run(turn("hi")).await.unwrap().text,
409 "echo: hi"
410 );
411 }
412
413 #[tokio::test]
414 async fn calls_tools_with_the_turn_token() {
415 use axum::response::IntoResponse as _;
416 let _ = rustls::crypto::ring::default_provider().install_default();
417 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
418 let log = seen.clone();
419 let handler = move |headers: axum::http::HeaderMap, body: String| {
420 let log = log.clone();
421 async move {
422 let msg: Value = serde_json::from_str(&body).unwrap();
423 let auth = headers["authorization"].to_str().unwrap().to_string();
424 log.lock().unwrap().push((auth, msg["method"].clone()));
425 let Some(id) = msg.get("id") else {
426 return axum::http::StatusCode::ACCEPTED.into_response();
427 };
428 let result = match msg["method"].as_str().unwrap() {
429 "tools/call" => json!({"content": [{"type": "text",
430 "text": format!("deployed {}", msg["params"]["arguments"]["env"])}]}),
431 _ => json!({}),
432 };
433 let reply = json!({"jsonrpc": "2.0", "id": id, "result": result});
434 (
435 [
436 ("content-type", "text/event-stream"),
437 ("mcp-session-id", "s1"),
438 ],
439 format!("event: message\ndata: {reply}\n\n"),
440 )
441 .into_response()
442 }
443 };
444 let app = axum::Router::new().route("/mcp", axum::routing::post(handler));
445 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
446 let addr = listener.local_addr().unwrap();
447 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
448
449 let agent = ScriptedBackend::from_toml(
450 "[[reply]]\ncalls = [{ server = \"ops\", tool = \"deploy\", args = { env = \"prod\" } }]\n\
451 text = \"{result}\"\n",
452 )
453 .unwrap();
454 let mut t = turn("deploy");
455 t.mcp.push(okbot_core::McpServer {
456 name: "ops".into(),
457 url: format!("http://{addr}/mcp"),
458 bearer_token_env: "OPS_TOKEN".into(),
459 });
460 t.env.push((
461 "OPS_TOKEN".into(),
462 okbot_core::Secret::new("run-token".into()),
463 ));
464 assert_eq!(agent.run(t).await.unwrap().text, "deployed \"prod\"");
465 let seen = seen.lock().unwrap().clone();
466 let methods: Vec<&str> = seen.iter().map(|(_, m)| m.as_str().unwrap()).collect();
467 assert_eq!(
468 methods,
469 ["initialize", "notifications/initialized", "tools/call"]
470 );
471 assert!(seen.iter().all(|(auth, _)| auth == "Bearer run-token"));
472 }
473
474 #[test]
475 fn rejects_bad_files() {
476 let err = |toml: &str| ScriptedBackend::from_toml(toml).unwrap_err().to_string();
477 assert!(err("[[reply]]\nwhen = \"x\"\n").contains("neither `text` nor `fail`"));
478 assert!(err("[[reply]]\ntext = \"x\"\ncolour = 1\n").contains("unknown field"));
479 }
480}