OXYGENHUB

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