Assistants stream their answers; the time limit is for silence 5436f0dd9429
A call's limit covered the whole answer: Claude (Opus) with a large proposal's 30k-token context and up to 16k tokens of answer could not finish within 90 s and the person got nothing (#248). Answers are now asked for as a stream (Anthropic's messages events, chat-completions deltas) and the limit is the longest the provider may be silent, before it starts or between parts, under a 15-minute cap. A provider that goes quiet part way keeps what it said, with a note; one that answers whole is read as before. Tests: both event formats split at every size, a stream three times the limit read whole, a silent one cut. Refs #248
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 12:51 UTC
- Signature
- ✔ signed by Claude Code
- Hash
5436f0dd942938b88d95fa7451994bfec9477a11cea9c9d15df6854bd9f5a828- Depends on
- 5360cea36c30 9a777fec4ed9 e71846aa07b9
- Depended on by
- c43e4f631d99
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 15 atoms, 0 tree operations, 1 file
Browse the tree at this change
modified crates/hub/src/assistants.rs +230 −5
| … | 33 unchanged lines |
||
| 34 | 34 | pub const MAX_ANSWER_TOKENS: u32 = 16_000; |
|
| 35 | 35 | /// The most an answer is kept. |
|
| 36 | 36 | const MAX_ANSWER: usize = 64 * 1024; |
|
| 37 | + | /// The longest a streamed answer may take in all, however steadily it |
|
| 38 | + | /// comes: the limit a call is given is for silence (oxygenhub#248). |
|
| 39 | + | const STREAM_LIMIT: Duration = Duration::from_secs(15 * 60); |
|
| 37 | 40 | ||
| 38 | 41 | pub use crate::levels::Scope; |
|
| 39 | 42 | ||
| … | 398 unchanged lines |
||
| 438 | 441 | ||
| 439 | 442 | /// Ask an assistant: the system prompt and the request, the answer's |
|
| 440 | 443 | /// text. Errors are for the person who asked: a status and the first |
|
| 441 | - | /// line of what the endpoint said, or why no answer came. |
|
| 444 | + | /// line of what the endpoint said, or why no answer came. The answer is |
|
| 445 | + | /// asked for as a stream, and `wait` is the longest the provider may be |
|
| 446 | + | /// silent, before it starts or between parts, so a long answer that keeps |
|
| 447 | + | /// coming is not cut off (oxygenhub#248); a provider that answers whole |
|
| 448 | + | /// instead is read whole within `wait`. |
|
| 442 | 449 | pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str, wait: Duration) -> Result<String, String> { |
|
| 450 | + | ask_streaming(app, a, system, user, wait, |_| {}).await |
|
| 451 | + | } |
|
| 452 | + | ||
| 453 | + | /// [`ask`], handing each part of the answer to `part` as it arrives. |
|
| 454 | + | pub async fn ask_streaming( |
|
| 455 | + | app: &Shared, |
|
| 456 | + | a: &Assistant, |
|
| 457 | + | system: &str, |
|
| 458 | + | user: &str, |
|
| 459 | + | wait: Duration, |
|
| 460 | + | mut part: impl FnMut(&str), |
|
| 461 | + | ) -> Result<String, String> { |
|
| 443 | 462 | let Some(token) = app.assistant_key.open(&a.token) else { |
|
| 444 | 463 | return Err("its token cannot be read: set it again".into()); |
|
| 445 | 464 | }; |
|
| 446 | - | let client = crate::net::client(&a.endpoint, &app.config.outbound.allow, wait).await?; |
|
| 465 | + | let client = crate::net::client(&a.endpoint, &app.config.outbound.allow, STREAM_LIMIT.max(wait)).await?; |
|
| 447 | 466 | let mut req = client.post(&a.endpoint).header("content-type", "application/json"); |
|
| 448 | 467 | let body = match a.api.as_str() { |
|
| 449 | 468 | "anthropic-messages" => { |
|
| … | 6 unchanged lines |
||
| 456 | 475 | "max_tokens": MAX_ANSWER_TOKENS, |
|
| 457 | 476 | "system": system, |
|
| 458 | 477 | "messages": [{ "role": "user", "content": user }], |
|
| 478 | + | "stream": true, |
|
| 459 | 479 | }) |
|
| 460 | 480 | } |
|
| 461 | 481 | _ => { |
|
| … | 9 unchanged lines |
||
| 471 | 491 | { "role": "system", "content": system }, |
|
| 472 | 492 | { "role": "user", "content": user }, |
|
| 473 | 493 | ], |
|
| 494 | + | "stream": true, |
|
| 474 | 495 | }) |
|
| 475 | 496 | } |
|
| 476 | 497 | }; |
|
| 477 | 498 | for (name, value) in a.header_list() { |
|
| 478 | 499 | req = req.header(name, value); |
|
| 479 | 500 | } |
|
| 480 | - | let r = req.json(&body).send().await.map_err(|e| { |
|
| 501 | + | let quiet = || format!("no answer within {} seconds", wait.as_secs()); |
|
| 502 | + | let r = tokio::time::timeout(wait, req.json(&body).send()).await.map_err(|_| quiet())?.map_err(|e| { |
|
| 481 | 503 | if e.is_timeout() { |
|
| 482 | - | format!("no answer within {} seconds", wait.as_secs()) |
|
| 504 | + | quiet() |
|
| 483 | 505 | } else { |
|
| 484 | 506 | scrub(&e.to_string(), &token) |
|
| 485 | 507 | } |
|
| 486 | 508 | })?; |
|
| 487 | 509 | let status = r.status(); |
|
| 488 | - | let text: String = r.text().await.unwrap_or_default().chars().take(MAX_ANSWER).collect(); |
|
| 510 | + | let streamed = |
|
| 511 | + | r.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|t| t.starts_with("text/event-stream")); |
|
| 512 | + | if status.is_success() && streamed { |
|
| 513 | + | let (answer, stop) = read_stream(&a.api, r, wait, &mut part).await.map_err(|e| scrub(&e, &token))?; |
|
| 514 | + | return finish(answer, stop); |
|
| 515 | + | } |
|
| 516 | + | let text: String = |
|
| 517 | + | tokio::time::timeout(wait, r.text()).await.map_err(|_| quiet())?.unwrap_or_default().chars().take(MAX_ANSWER).collect(); |
|
| 489 | 518 | if !status.is_success() { |
|
| 490 | 519 | let why = serde_json::from_str::<serde_json::Value>(&text) |
|
| 491 | 520 | .ok() |
|
| … | 3 unchanged lines |
||
| 495 | 524 | } |
|
| 496 | 525 | let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?; |
|
| 497 | 526 | let (answer, stop) = answer_of(&a.api, &v); |
|
| 527 | + | if !answer.is_empty() { |
|
| 528 | + | part(&answer); |
|
| 529 | + | } |
|
| 498 | 530 | finish(answer, stop) |
|
| 531 | + | } |
|
| 532 | + | ||
| 533 | + | /// Read a streamed answer (server-sent events), handing out its text as it |
|
| 534 | + | /// comes; at most `quiet` between parts. |
|
| 535 | + | async fn read_stream(api: &str, mut r: reqwest::Response, quiet: Duration, part: &mut impl FnMut(&str)) -> Result<(String, Stop), String> { |
|
| 536 | + | let mut events = Events::new(api); |
|
| 537 | + | loop { |
|
| 538 | + | let chunk = match tokio::time::timeout(quiet, r.chunk()).await { |
|
| 539 | + | Err(_) if events.text.is_empty() => return Err(format!("no answer within {} seconds", quiet.as_secs())), |
|
| 540 | + | // It went quiet part of the way through: what came is kept. |
|
| 541 | + | Err(_) => return Ok((events.text, Stop::Quiet)), |
|
| 542 | + | Ok(Err(e)) if events.text.is_empty() => return Err(e.to_string()), |
|
| 543 | + | Ok(Err(_)) => return Ok((events.text, Stop::Quiet)), |
|
| 544 | + | Ok(Ok(None)) => return Ok((events.text, events.stop.unwrap_or(Stop::Done))), |
|
| 545 | + | Ok(Ok(Some(c))) => c, |
|
| 546 | + | }; |
|
| 547 | + | let before = events.text.len(); |
|
| 548 | + | events.feed(&chunk)?; |
|
| 549 | + | if events.text.len() > before { |
|
| 550 | + | part(&events.text[before..]); |
|
| 551 | + | } |
|
| 552 | + | if events.done || events.text.len() >= MAX_ANSWER { |
|
| 553 | + | return Ok((events.text, events.stop.unwrap_or(Stop::Done))); |
|
| 554 | + | } |
|
| 555 | + | } |
|
| 556 | + | } |
|
| 557 | + | ||
| 558 | + | /// Server-sent events of a streamed answer, as Anthropic's messages API |
|
| 559 | + | /// and the chat-completions APIs send them: text deltas, why it stopped, |
|
| 560 | + | /// and errors. Fed bytes as they come; a line is read once it is whole. |
|
| 561 | + | struct Events { |
|
| 562 | + | anthropic: bool, |
|
| 563 | + | buf: Vec<u8>, |
|
| 564 | + | text: String, |
|
| 565 | + | stop: Option<Stop>, |
|
| 566 | + | done: bool, |
|
| 567 | + | } |
|
| 568 | + | ||
| 569 | + | impl Events { |
|
| 570 | + | fn new(api: &str) -> Events { |
|
| 571 | + | Events { anthropic: api == "anthropic-messages", buf: Vec::new(), text: String::new(), stop: None, done: false } |
|
| 572 | + | } |
|
| 573 | + | ||
| 574 | + | fn feed(&mut self, bytes: &[u8]) -> Result<(), String> { |
|
| 575 | + | self.buf.extend_from_slice(bytes); |
|
| 576 | + | while let Some(end) = self.buf.iter().position(|b| *b == b'\n') { |
|
| 577 | + | let line: Vec<u8> = self.buf.drain(..=end).collect(); |
|
| 578 | + | let line = String::from_utf8_lossy(&line); |
|
| 579 | + | let Some(data) = line.trim_end_matches(['\r', '\n']).strip_prefix("data:") else { |
|
| 580 | + | continue; |
|
| 581 | + | }; |
|
| 582 | + | let data = data.trim_start(); |
|
| 583 | + | if data == "[DONE]" { |
|
| 584 | + | self.done = true; |
|
| 585 | + | continue; |
|
| 586 | + | } |
|
| 587 | + | let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else { |
|
| 588 | + | continue; |
|
| 589 | + | }; |
|
| 590 | + | self.event(&v)?; |
|
| 591 | + | } |
|
| 592 | + | Ok(()) |
|
| 593 | + | } |
|
| 594 | + | ||
| 595 | + | fn event(&mut self, v: &serde_json::Value) -> Result<(), String> { |
|
| 596 | + | if self.anthropic { |
|
| 597 | + | match v["type"].as_str() { |
|
| 598 | + | Some("content_block_delta") if v["delta"]["type"] == "text_delta" => { |
|
| 599 | + | self.text.push_str(v["delta"]["text"].as_str().unwrap_or("")); |
|
| 600 | + | } |
|
| 601 | + | Some("message_delta") => { |
|
| 602 | + | self.stop = match v["delta"]["stop_reason"].as_str() { |
|
| 603 | + | Some("max_tokens") => Some(Stop::Length), |
|
| 604 | + | Some("refusal") => Some(Stop::Declined(v["delta"]["stop_details"]["category"].as_str().map(str::to_string))), |
|
| 605 | + | Some(_) => Some(Stop::Done), |
|
| 606 | + | None => self.stop.take(), |
|
| 607 | + | }; |
|
| 608 | + | } |
|
| 609 | + | Some("message_stop") => self.done = true, |
|
| 610 | + | Some("error") => { |
|
| 611 | + | return Err(v["error"]["message"].as_str().unwrap_or("the provider reported an error").to_string()); |
|
| 612 | + | } |
|
| 613 | + | _ => {} |
|
| 614 | + | } |
|
| 615 | + | } else { |
|
| 616 | + | if let Some(e) = v["error"]["message"].as_str() { |
|
| 617 | + | return Err(e.to_string()); |
|
| 618 | + | } |
|
| 619 | + | let choice = &v["choices"][0]; |
|
| 620 | + | if let Some(t) = choice["delta"]["content"].as_str() { |
|
| 621 | + | self.text.push_str(t); |
|
| 622 | + | } |
|
| 623 | + | match choice["finish_reason"].as_str() { |
|
| 624 | + | Some("length") => self.stop = Some(Stop::Length), |
|
| 625 | + | Some("content_filter") => self.stop = Some(Stop::Declined(None)), |
|
| 626 | + | Some(_) => self.stop = Some(Stop::Done), |
|
| 627 | + | None => {} |
|
| 628 | + | } |
|
| 629 | + | } |
|
| 630 | + | Ok(()) |
|
| 631 | + | } |
|
| 499 | 632 | } |
|
| 500 | 633 | ||
| 501 | 634 | /// Why an answer ended, as the person who asked needs to know it. |
|
| … | 5 unchanged lines |
||
| 507 | 640 | Length, |
|
| 508 | 641 | /// The provider declined to answer, with its category if it gave one. |
|
| 509 | 642 | Declined(Option<String>), |
|
| 643 | + | /// The provider went silent part of the way through a stream. |
|
| 644 | + | Quiet, |
|
| 510 | 645 | } |
|
| 511 | 646 | ||
| 512 | 647 | /// The text of a provider's answer (text parts only: thinking is not |
|
| … | 48 unchanged lines |
||
| 561 | 696 | Err("it ran out of room before writing an answer (it spent its length limit thinking): try a smaller proposal".into()) |
|
| 562 | 697 | } |
|
| 563 | 698 | Stop::Length => Ok(format!("{}\n\n*(Cut short: the answer reached its length limit.)*", answer.trim_end())), |
|
| 699 | + | Stop::Quiet if answer.trim().is_empty() => Err("it went quiet before writing an answer".into()), |
|
| 700 | + | Stop::Quiet => Ok(format!("{}\n\n*(Cut short: the assistant went quiet part of the way through.)*", answer.trim_end())), |
|
| 564 | 701 | Stop::Done if answer.trim().is_empty() => Err("the answer carried no text".into()), |
|
| 565 | 702 | Stop::Done => Ok(answer), |
|
| 566 | 703 | } |
|
| … | 77 unchanged lines |
||
| 644 | 781 | #[cfg(test)] |
|
| 645 | 782 | mod tests { |
|
| 646 | 783 | use super::*; |
|
| 784 | + | ||
| 785 | + | /// Anthropic's events, split anywhere across chunks, give the text, |
|
| 786 | + | /// leave thinking out, and say why it stopped (oxygenhub#248). |
|
| 787 | + | #[test] |
|
| 788 | + | fn anthropic_events_give_the_text_whole() { |
|
| 789 | + | let sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n\ |
|
| 790 | + | data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n\ |
|
| 791 | + | data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Adds a \"}}\n\n\ |
|
| 792 | + | data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"line — café.\"}}\n\n\ |
|
| 793 | + | data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n\ |
|
| 794 | + | data: {\"type\":\"message_stop\"}\n\n"; |
|
| 795 | + | for size in [1, 3, 7, 64, sse.len()] { |
|
| 796 | + | let mut e = Events::new("anthropic-messages"); |
|
| 797 | + | for chunk in sse.as_bytes().chunks(size) { |
|
| 798 | + | e.feed(chunk).unwrap(); |
|
| 799 | + | } |
|
| 800 | + | assert_eq!((e.text.as_str(), e.stop.as_ref(), e.done), ("Adds a line — café.", Some(&Stop::Done), true), "chunks of {size}"); |
|
| 801 | + | } |
|
| 802 | + | let mut cut = Events::new("anthropic-messages"); |
|
| 803 | + | cut.feed(b"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"}}\n").unwrap(); |
|
| 804 | + | assert_eq!(cut.stop, Some(Stop::Length)); |
|
| 805 | + | let mut bad = Events::new("anthropic-messages"); |
|
| 806 | + | let e = bad.feed(b"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n").unwrap_err(); |
|
| 807 | + | assert_eq!(e, "Overloaded"); |
|
| 808 | + | } |
|
| 809 | + | ||
| 810 | + | #[test] |
|
| 811 | + | fn chat_completion_events_give_the_text() { |
|
| 812 | + | let sse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n\ |
|
| 813 | + | data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\ |
|
| 814 | + | data: {\"choices\":[{\"delta\":{\"content\":\", world\"},\"finish_reason\":\"length\"}]}\n\n\ |
|
| 815 | + | data: [DONE]\n\n"; |
|
| 816 | + | let mut e = Events::new("chat-completions"); |
|
| 817 | + | for chunk in sse.as_bytes().chunks(5) { |
|
| 818 | + | e.feed(chunk).unwrap(); |
|
| 819 | + | } |
|
| 820 | + | assert_eq!((e.text.as_str(), e.stop.as_ref(), e.done), ("Hello, world", Some(&Stop::Length), true)); |
|
| 821 | + | } |
|
| 822 | + | ||
| 823 | + | /// A stream that takes longer than the silence limit in all, but never |
|
| 824 | + | /// pauses that long, is read whole; one that stops talking is cut off |
|
| 825 | + | /// with what it said kept. |
|
| 826 | + | #[tokio::test] |
|
| 827 | + | async fn a_slow_steady_stream_is_read_and_a_silent_one_cut() { |
|
| 828 | + | async fn serve(parts: usize, gap_ms: u64, then_silent: bool) -> String { |
|
| 829 | + | let router = axum::Router::new().route( |
|
| 830 | + | "/v1/messages", |
|
| 831 | + | axum::routing::post(move || async move { |
|
| 832 | + | let (tx, rx) = tokio::sync::mpsc::channel::<Result<String, std::io::Error>>(4); |
|
| 833 | + | tokio::spawn(async move { |
|
| 834 | + | for i in 0..parts { |
|
| 835 | + | tokio::time::sleep(Duration::from_millis(gap_ms)).await; |
|
| 836 | + | let ev = format!( |
|
| 837 | + | "data: {{\"type\":\"content_block_delta\",\"delta\":{{\"type\":\"text_delta\",\"text\":\"{i} \"}}}}\n\n" |
|
| 838 | + | ); |
|
| 839 | + | if tx.send(Ok(ev)).await.is_err() { |
|
| 840 | + | return; |
|
| 841 | + | } |
|
| 842 | + | } |
|
| 843 | + | if then_silent { |
|
| 844 | + | tokio::time::sleep(Duration::from_secs(5)).await; |
|
| 845 | + | } |
|
| 846 | + | let _ = tx.send(Ok("data: {\"type\":\"message_stop\"}\n\n".into())).await; |
|
| 847 | + | }); |
|
| 848 | + | let body = axum::body::Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx)); |
|
| 849 | + | ([(axum::http::header::CONTENT_TYPE, "text/event-stream")], body) |
|
| 850 | + | }), |
|
| 851 | + | ); |
|
| 852 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 853 | + | let port = listener.local_addr().unwrap().port(); |
|
| 854 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 855 | + | format!("http://127.0.0.1:{port}/v1/messages") |
|
| 856 | + | } |
|
| 857 | + | let quiet = Duration::from_millis(400); |
|
| 858 | + | // Six parts 200 ms apart: 1.2 s in all, three times the limit. |
|
| 859 | + | let url = serve(6, 200, false).await; |
|
| 860 | + | let r = reqwest::Client::new().post(&url).send().await.unwrap(); |
|
| 861 | + | let mut parts = Vec::new(); |
|
| 862 | + | let (text, stop) = read_stream("anthropic-messages", r, quiet, &mut |p: &str| parts.push(p.to_string())).await.unwrap(); |
|
| 863 | + | assert_eq!((text.as_str(), stop), ("0 1 2 3 4 5 ", Stop::Done)); |
|
| 864 | + | assert_eq!(parts.len(), 6, "each part handed out as it came"); |
|
| 865 | + | // Two parts, then silence: cut off, the two kept. |
|
| 866 | + | let url = serve(2, 50, true).await; |
|
| 867 | + | let r = reqwest::Client::new().post(&url).send().await.unwrap(); |
|
| 868 | + | let (text, stop) = read_stream("anthropic-messages", r, quiet, &mut |_: &str| {}).await.unwrap(); |
|
| 869 | + | assert_eq!((text.as_str(), stop), ("0 1 ", Stop::Quiet)); |
|
| 870 | + | assert!(finish(text, Stop::Quiet).unwrap().contains("went quiet")); |
|
| 871 | + | } |
|
| 647 | 872 | ||
| 648 | 873 | #[test] |
|
| 649 | 874 | fn tokens_seal_and_open_only_under_their_key() { |
|
| … | 393 unchanged lines |