| 1 | //! One answer as it runs: the provider streams, asks for tools, Ask runs |
| 2 | //! them as the person asking (ask_tools.rs) and sends the provider the |
| 3 | //! results, round after round, until the model answers. Each line goes to |
| 4 | //! the person as soon as it is known: the answer's text as it is written, |
| 5 | //! each step as it starts and ends, then the sources and `done`. |
| 6 | //! |
| 7 | //! The answer is bounded: at most [`ASK_MAX_TOOL_ROUNDS`] rounds and |
| 8 | //! [`ASK_MAX_TOOL_CALLS`] calls, [`ASK_MAX_TOOL_SECONDS`] of calling tools, |
| 9 | //! [`ASK_MAX_TOOL_RESULT_CHARS`] of any one result and |
| 10 | //! [`ASK_MAX_TOOL_TOTAL_CHARS`] of results in all. Past a bound the model is |
| 11 | //! told so and answers with what it has. The person pressing Stop drops the |
| 12 | //! stream, and with it whatever is in flight. |
| 13 | |
| 14 | use std::collections::VecDeque; |
| 15 | |
| 16 | use futures_util::StreamExt; |
| 17 | use g1t_contracts::identity::RevokeAskTokenArgs; |
| 18 | use g1t_contracts::integrations::{ |
| 19 | ASK_MAX_TOOL_CALLS, ASK_MAX_TOOL_RESULT_CHARS, ASK_MAX_TOOL_ROUNDS, ASK_MAX_TOOL_SECONDS, ASK_MAX_TOOL_TOTAL_CHARS, |
| 20 | AskEvent, AskMessage, AskToolStatus, ConnectionConfig, Provider, |
| 21 | }; |
| 22 | use serde_json::Value; |
| 23 | use worker::{ByteStream, Fetcher, Method}; |
| 24 | |
| 25 | use crate::ask::{self, Answered, Call, Request, Round}; |
| 26 | use crate::ask_tools::{self, Decision, Mcp, Sources, Toolbox}; |
| 27 | use crate::http; |
| 28 | |
| 29 | /// What the model is told when a call is not run because the answer used |
| 30 | /// what it may. |
| 31 | const SPENT: &str = "Not run: this answer has used the tool calls it may make. Answer now with what you have read, and say what you could not check."; |
| 32 | |
| 33 | /// What the model is told once a session is proposed. |
| 34 | const PROPOSED: &str = "Proposed. The person now sees a card under your answer to start this session on their workspace's Anthropic account or copy a Claude Code command for it; nothing has started. Don't ask them to confirm in words: say in a sentence what the session will do."; |
| 35 | |
| 36 | /// The most sessions one answer proposes. |
| 37 | const MAX_PROPOSALS: usize = 5; |
| 38 | |
| 39 | /// What an answer has spent of its bounds. |
| 40 | #[derive(Debug, Default)] |
| 41 | pub struct Budget { |
| 42 | /// When the answer started, in milliseconds. |
| 43 | pub started: u64, |
| 44 | /// Rounds of tool calls run. |
| 45 | pub rounds: usize, |
| 46 | pub calls: usize, |
| 47 | /// Characters of results given to the model. |
| 48 | pub chars: usize, |
| 49 | } |
| 50 | |
| 51 | impl Budget { |
| 52 | pub fn new(started: u64) -> Self { |
| 53 | Budget { started, ..Budget::default() } |
| 54 | } |
| 55 | |
| 56 | /// Whether the answer has used what it may of the tools, at `now`: the |
| 57 | /// model must answer with what it has. |
| 58 | pub fn spent(&self, now: u64) -> bool { |
| 59 | self.rounds >= ASK_MAX_TOOL_ROUNDS |
| 60 | || self.calls >= ASK_MAX_TOOL_CALLS |
| 61 | || self.chars >= ASK_MAX_TOOL_TOTAL_CHARS |
| 62 | || now.saturating_sub(self.started) > ASK_MAX_TOOL_SECONDS * 1000 |
| 63 | } |
| 64 | |
| 65 | /// Takes one call, if one is left. |
| 66 | pub fn take_call(&mut self, now: u64) -> bool { |
| 67 | if self.spent(now) { |
| 68 | return false; |
| 69 | } |
| 70 | self.calls += 1; |
| 71 | true |
| 72 | } |
| 73 | |
| 74 | /// How much of a result the model may be given now. |
| 75 | pub fn room(&self) -> usize { |
| 76 | ASK_MAX_TOOL_TOTAL_CHARS.saturating_sub(self.chars).min(ASK_MAX_TOOL_RESULT_CHARS) |
| 77 | } |
| 78 | |
| 79 | /// A model that keeps asking for tools after it was told to answer: |
| 80 | /// the answer ends with what it has written. |
| 81 | pub fn overrun(&self) -> bool { |
| 82 | self.rounds > ASK_MAX_TOOL_ROUNDS |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// Everything one answer needs, owned, so its stream outlives the request |
| 87 | /// that started it. |
| 88 | pub struct Answering { |
| 89 | pub provider: Provider, |
| 90 | pub config: ConnectionConfig, |
| 91 | pub key: Option<String>, |
| 92 | pub model: String, |
| 93 | pub system: String, |
| 94 | pub messages: Vec<AskMessage>, |
| 95 | pub max_tokens: u32, |
| 96 | pub toolbox: Toolbox, |
| 97 | /// g1t's MCP server as the person asking; none when it could not be |
| 98 | /// reached, and the answer goes without tools. |
| 99 | pub mcp: Option<Mcp>, |
| 100 | pub site: String, |
| 101 | /// Identity, and the credential to revoke when the answer ends. |
| 102 | pub identity: Fetcher, |
| 103 | pub credential: Option<String>, |
| 104 | budget: Budget, |
| 105 | phase: Phase, |
| 106 | out: VecDeque<String>, |
| 107 | /// The rounds of tool calls so far, in the provider's shape. |
| 108 | rounds: Vec<Value>, |
| 109 | /// All the answer's text, for its sources. |
| 110 | written: String, |
| 111 | sources: Sources, |
| 112 | /// Sessions proposed so far, at most [`MAX_PROPOSALS`]. |
| 113 | proposals: usize, |
| 114 | } |
| 115 | |
| 116 | enum Phase { |
| 117 | Streaming { upstream: ByteStream, round: Round }, |
| 118 | /// The model asked for tools: their `running` lines have gone; run them. |
| 119 | Calling { text: String, calls: Vec<Call>, ids: Vec<String> }, |
| 120 | /// Ask the provider again, with the round's results. |
| 121 | Asking, |
| 122 | Finished, |
| 123 | } |
| 124 | |
| 125 | /// Why a provider could not be asked, for the person. |
| 126 | pub fn refused_by(provider: Provider, status: u16, body: String) -> String { |
| 127 | http::Answer { status, body }.problem(provider.label()) |
| 128 | } |
| 129 | |
| 130 | impl Answering { |
| 131 | #[allow(clippy::too_many_arguments)] |
| 132 | pub fn new( |
| 133 | provider: Provider, |
| 134 | config: ConnectionConfig, |
| 135 | key: Option<String>, |
| 136 | model: String, |
| 137 | system: String, |
| 138 | messages: Vec<AskMessage>, |
| 139 | max_tokens: u32, |
| 140 | toolbox: Toolbox, |
| 141 | mcp: Option<Mcp>, |
| 142 | site: String, |
| 143 | identity: Fetcher, |
| 144 | credential: Option<String>, |
| 145 | started: u64, |
| 146 | ) -> Self { |
| 147 | Answering { |
| 148 | provider, |
| 149 | config, |
| 150 | key, |
| 151 | model, |
| 152 | system, |
| 153 | messages, |
| 154 | max_tokens, |
| 155 | toolbox, |
| 156 | mcp, |
| 157 | site, |
| 158 | identity, |
| 159 | credential, |
| 160 | budget: Budget::new(started), |
| 161 | phase: Phase::Asking, |
| 162 | out: VecDeque::new(), |
| 163 | rounds: Vec::new(), |
| 164 | written: String::new(), |
| 165 | sources: Sources::default(), |
| 166 | proposals: 0, |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// The provider's request for the next round. |
| 171 | pub fn request(&self) -> Result<ask::Built, String> { |
| 172 | ask::build(&Request { |
| 173 | provider: self.provider, |
| 174 | config: &self.config, |
| 175 | key: self.key.as_deref(), |
| 176 | model: &self.model, |
| 177 | system: &self.system, |
| 178 | messages: &self.messages, |
| 179 | rounds: &self.rounds, |
| 180 | tools: self.toolbox.specs(), |
| 181 | answer_now: self.budget.spent(g1t_kit::now_ms()), |
| 182 | max_tokens: self.max_tokens, |
| 183 | }) |
| 184 | } |
| 185 | |
| 186 | /// Starts streaming the first round's answer, already opened. |
| 187 | pub fn streaming(&mut self, upstream: ByteStream, first: String) { |
| 188 | self.out.push_back(first); |
| 189 | self.phase = Phase::Streaming { upstream, round: Round::new(self.provider, false) }; |
| 190 | } |
| 191 | |
| 192 | /// The next line to send, or `None` when the answer is over. |
| 193 | pub async fn next(&mut self) -> Option<String> { |
| 194 | loop { |
| 195 | if let Some(line) = self.out.pop_front() { |
| 196 | return Some(line); |
| 197 | } |
| 198 | match std::mem::replace(&mut self.phase, Phase::Finished) { |
| 199 | Phase::Finished => return None, |
| 200 | Phase::Asking => self.ask().await, |
| 201 | Phase::Streaming { mut upstream, mut round } => { |
| 202 | let label = self.provider.label(); |
| 203 | let lines = match upstream.next().await { |
| 204 | Some(Ok(bytes)) => round.feed(&bytes), |
| 205 | Some(Err(error)) => { |
| 206 | round.broke(&format!("The answer from {label} broke off: {error}")); |
| 207 | String::new() |
| 208 | } |
| 209 | None => round.end(), |
| 210 | }; |
| 211 | self.written.push_str(&texts(&lines)); |
| 212 | if !lines.is_empty() { |
| 213 | self.out.push_back(lines); |
| 214 | } |
| 215 | if round.over() { |
| 216 | self.after(round).await; |
| 217 | } else { |
| 218 | self.phase = Phase::Streaming { upstream, round }; |
| 219 | } |
| 220 | } |
| 221 | Phase::Calling { text, calls, ids } => self.call(text, calls, ids).await, |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /// A round ended: run the tools it asked for, or finish. |
| 227 | async fn after(&mut self, round: Round) { |
| 228 | if let Some(problem) = round.error() { |
| 229 | self.out.push_back(ask::line(&AskEvent::Error { message: problem.to_owned() })); |
| 230 | return self.finish().await; |
| 231 | } |
| 232 | let calls = round.calls(); |
| 233 | // No tools asked for, none to give, or a model that keeps asking |
| 234 | // after it was told to answer: what it wrote is the answer. |
| 235 | if calls.is_empty() || self.toolbox.is_empty() || self.budget.overrun() { |
| 236 | let sources = self.sources.finish(&self.site, &self.written); |
| 237 | if !sources.is_empty() { |
| 238 | self.out.push_back(ask::line(&AskEvent::Sources { sources })); |
| 239 | } |
| 240 | self.out.push_back(ask::line(&AskEvent::Done { stop_reason: round.stop_reason() })); |
| 241 | return self.finish().await; |
| 242 | } |
| 243 | // Each step shows as it starts. |
| 244 | let mut ids = Vec::with_capacity(calls.len()); |
| 245 | for call in &calls { |
| 246 | let id = format!("step_{}_{}", self.budget.rounds, ids.len()); |
| 247 | self.out.push_back(ask::line(&self.step(&id, call, AskToolStatus::Running, None))); |
| 248 | ids.push(id); |
| 249 | } |
| 250 | self.phase = Phase::Calling { text: round.text().to_owned(), calls, ids }; |
| 251 | } |
| 252 | |
| 253 | fn step(&self, id: &str, call: &Call, status: AskToolStatus, summary: Option<String>) -> AskEvent { |
| 254 | AskEvent::Tool { |
| 255 | id: id.to_owned(), |
| 256 | name: call.name.clone(), |
| 257 | action: call.input["action"].as_str().map(str::to_owned), |
| 258 | label: ask_tools::label(&call.name, &call.input), |
| 259 | status, |
| 260 | summary, |
| 261 | link: ask_tools::link(&self.site, &call.name, &call.input), |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | /// Runs a round's calls, all at once, and asks the provider again. |
| 266 | async fn call(&mut self, text: String, calls: Vec<Call>, ids: Vec<String>) { |
| 267 | enum Plan { |
| 268 | Run, |
| 269 | Skip(String, AskToolStatus), |
| 270 | } |
| 271 | let mut plans = Vec::with_capacity(calls.len()); |
| 272 | for call in &calls { |
| 273 | let now = g1t_kit::now_ms(); |
| 274 | let plan = if self.budget.spent(now) { |
| 275 | Plan::Skip(SPENT.to_owned(), AskToolStatus::Refused) |
| 276 | } else { |
| 277 | match self.toolbox.decide(&call.name, &call.input) { |
| 278 | Decision::Run if self.budget.take_call(now) => Plan::Run, |
| 279 | Decision::Run => Plan::Skip(SPENT.to_owned(), AskToolStatus::Refused), |
| 280 | // Dispatch: the answer shows a proposal the person |
| 281 | // starts themselves; the click is the confirmation. |
| 282 | Decision::Confirm if self.proposals >= MAX_PROPOSALS => { |
| 283 | Plan::Skip("Not run: this answer has proposed as many sessions as it may.".to_owned(), AskToolStatus::Refused) |
| 284 | } |
| 285 | Decision::Confirm => { |
| 286 | let id = format!("proposal_{}_{}", self.budget.rounds, self.proposals); |
| 287 | match ask_tools::proposal(&id, &call.input) { |
| 288 | Ok(proposal) => { |
| 289 | self.proposals += 1; |
| 290 | self.out.push_back(ask::line(&proposal)); |
| 291 | Plan::Skip(PROPOSED.to_owned(), AskToolStatus::Done) |
| 292 | } |
| 293 | Err(why) => Plan::Skip(why, AskToolStatus::Failed), |
| 294 | } |
| 295 | } |
| 296 | Decision::Refuse(why) => Plan::Skip(why, AskToolStatus::Refused), |
| 297 | } |
| 298 | }; |
| 299 | plans.push(plan); |
| 300 | } |
| 301 | let runs = calls.iter().zip(&plans).map(|(call, plan)| { |
| 302 | let mcp = self.mcp.clone(); |
| 303 | let native = self.toolbox.is_native(&call.name); |
| 304 | async move { |
| 305 | match plan { |
| 306 | Plan::Skip(why, status) => (why.clone(), true, *status), |
| 307 | Plan::Run => { |
| 308 | let ran = match (native, mcp) { |
| 309 | (true, _) => ask_tools::run_native(&call.name, &call.input).await, |
| 310 | (false, Some(mcp)) => mcp.call(&call.name, &call.input).await, |
| 311 | (false, None) => Err("g1t's tools could not be reached.".to_owned()), |
| 312 | }; |
| 313 | match ran { |
| 314 | Ok((text, false)) => (text, false, AskToolStatus::Done), |
| 315 | Ok((text, true)) => (text, true, AskToolStatus::Failed), |
| 316 | Err(problem) => (problem, true, AskToolStatus::Failed), |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | }); |
| 322 | let results = futures_util::future::join_all(runs).await; |
| 323 | let mut answered = Vec::with_capacity(calls.len()); |
| 324 | for ((call, id), (text, is_error, status)) in calls.iter().zip(&ids).zip(results) { |
| 325 | let summary = ask_tools::summary(&text, is_error); |
| 326 | let event = self.step(id, call, status, Some(summary)); |
| 327 | if status == AskToolStatus::Done { |
| 328 | let direct = match (&event, call.input["number"].is_u64()) { |
| 329 | (AskEvent::Tool { link: Some(link), label, .. }, true) => Some((link.clone(), label.clone())), |
| 330 | _ => None, |
| 331 | }; |
| 332 | self.sources.read(&self.site, &call.name, &call.input, &text, direct.as_ref().map(|(url, title)| (url.as_str(), title.as_str()))); |
| 333 | } |
| 334 | self.out.push_back(ask::line(&event)); |
| 335 | // What the model is given: trimmed to fit, within what the |
| 336 | // answer may read in all. |
| 337 | let room = self.budget.room(); |
| 338 | let content = if room < 400 && !is_error { |
| 339 | SPENT.to_owned() |
| 340 | } else { |
| 341 | ask_tools::trim_result(&text, room.max(400), ask_tools::Keep::of(&call.name, &call.input)) |
| 342 | }; |
| 343 | self.budget.chars += content.chars().count(); |
| 344 | answered.push(Answered { id: call.id.clone(), content, is_error }); |
| 345 | } |
| 346 | self.rounds.extend(ask::tool_turns(self.provider.api(), &text, &calls, &answered)); |
| 347 | self.budget.rounds += 1; |
| 348 | self.phase = Phase::Asking; |
| 349 | } |
| 350 | |
| 351 | /// Asks the provider for the next round. |
| 352 | async fn ask(&mut self) { |
| 353 | let built = match self.request() { |
| 354 | Ok(built) => built, |
| 355 | Err(problem) => { |
| 356 | self.out.push_back(ask::line(&AskEvent::Error { message: problem })); |
| 357 | return self.finish().await; |
| 358 | } |
| 359 | }; |
| 360 | let headers: Vec<(&str, &str)> = built.headers.iter().map(|(name, value)| (name.as_str(), value.as_str())).collect(); |
| 361 | let label = self.provider.label(); |
| 362 | let opened = http::open(Method::Post, &built.url, &headers, Some(built.body)).await; |
| 363 | let mut response = match opened { |
| 364 | Ok(response) => response, |
| 365 | Err(error) => { |
| 366 | self.out.push_back(ask::line(&AskEvent::Error { message: format!("{label} could not be reached: {error}") })); |
| 367 | return self.finish().await; |
| 368 | } |
| 369 | }; |
| 370 | let status = response.status_code(); |
| 371 | if !(200..300).contains(&status) { |
| 372 | let body = response.text().await.unwrap_or_default(); |
| 373 | self.out.push_back(ask::line(&AskEvent::Error { message: refused_by(self.provider, status, body) })); |
| 374 | return self.finish().await; |
| 375 | } |
| 376 | match response.stream() { |
| 377 | Ok(upstream) => { |
| 378 | self.phase = Phase::Streaming { upstream, round: Round::new(self.provider, !self.written.trim().is_empty()) }; |
| 379 | } |
| 380 | Err(error) => { |
| 381 | self.out.push_back(ask::line(&AskEvent::Error { message: format!("The answer from {label} broke off: {error}") })); |
| 382 | self.finish().await; |
| 383 | } |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | /// The answer is over: its credential ends with it. |
| 388 | async fn finish(&mut self) { |
| 389 | self.phase = Phase::Finished; |
| 390 | if let Some(id) = self.credential.take() { |
| 391 | let _: worker::Result<bool> = g1t_kit::call(&self.identity, "revoke_ask_token", &RevokeAskTokenArgs { id }).await; |
| 392 | } |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | /// The answer's text in some lines, for finding what it cites. |
| 397 | fn texts(lines: &str) -> String { |
| 398 | lines |
| 399 | .lines() |
| 400 | .filter_map(|line| match serde_json::from_str::<AskEvent>(line) { |
| 401 | Ok(AskEvent::Text { text }) => Some(text), |
| 402 | _ => None, |
| 403 | }) |
| 404 | .collect() |
| 405 | } |
| 406 | |
| 407 | #[cfg(test)] |
| 408 | mod tests { |
| 409 | use super::*; |
| 410 | |
| 411 | #[test] |
| 412 | fn the_answers_text_is_read_back_from_its_lines() { |
| 413 | let lines = format!( |
| 414 | "{}{}", |
| 415 | ask::line(&AskEvent::Text { text: "See ".into() }), |
| 416 | ask::line(&AskEvent::Text { text: "https://g1t.sh/a/b/pull/1".into() }) |
| 417 | ); |
| 418 | assert_eq!(texts(&lines), "See https://g1t.sh/a/b/pull/1"); |
| 419 | assert_eq!(texts(""), ""); |
| 420 | } |
| 421 | |
| 422 | #[test] |
| 423 | fn an_answer_stops_calling_tools_at_its_bounds() { |
| 424 | let start = 1_000_000; |
| 425 | let mut budget = Budget::new(start); |
| 426 | assert!(!budget.spent(start)); |
| 427 | // Calls run out. |
| 428 | for _ in 0..ASK_MAX_TOOL_CALLS { |
| 429 | assert!(budget.take_call(start)); |
| 430 | } |
| 431 | assert!(!budget.take_call(start)); |
| 432 | assert!(budget.spent(start)); |
| 433 | // So do rounds. |
| 434 | let mut budget = Budget::new(start); |
| 435 | budget.rounds = ASK_MAX_TOOL_ROUNDS - 1; |
| 436 | assert!(!budget.spent(start)); |
| 437 | budget.rounds += 1; |
| 438 | assert!(budget.spent(start)); |
| 439 | assert!(!budget.overrun(), "one more round, to answer"); |
| 440 | budget.rounds += 1; |
| 441 | assert!(budget.overrun()); |
| 442 | // And time. |
| 443 | let budget = Budget::new(start); |
| 444 | assert!(!budget.spent(start + ASK_MAX_TOOL_SECONDS * 1000)); |
| 445 | assert!(budget.spent(start + ASK_MAX_TOOL_SECONDS * 1000 + 1)); |
| 446 | // And what the model may be given: one result at most, then what is left. |
| 447 | let mut budget = Budget::new(start); |
| 448 | assert_eq!(budget.room(), ASK_MAX_TOOL_RESULT_CHARS); |
| 449 | budget.chars = ASK_MAX_TOOL_TOTAL_CHARS - 1_000; |
| 450 | assert_eq!(budget.room(), 1_000); |
| 451 | budget.chars = ASK_MAX_TOOL_TOTAL_CHARS; |
| 452 | assert_eq!(budget.room(), 0); |
| 453 | assert!(budget.spent(start)); |
| 454 | } |
| 455 | } |