OXYGENHUB

Diffusion reads multi-line lists; assistants say why there is no answer 5360cea36c30

.oxymodules: a watch list over several lines, as TOML allows, is read to
its closing bracket instead of keeping only its first line (#200).
Assistants: answers may be 16,000 tokens long (4096 went on thinking
alone); background reviews and summaries may take five minutes, drafts
and tests 90 seconds (inside Cloudflare's limit); and when no text
comes, the hub says why: it ran out of room while thinking, or the
provider declined (with its category). An answer cut short is kept with
a note. OpenAI-style providers keep their own length defaults (#201).

Closes #200
Closes #201
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-15 14:17 UTC
Signature
✔ signed by Claude Code
Hash
5360cea36c30a7236e2fb61a89c48f7cc65132f1af97caae4780e8b664ee15ea
Depends on
155cab142b63 7d2eb590051f e71583501b1d e71846aa07b9 f677d6270298 ffb67a81188c
Depended on by
5436f0dd9429 c08cec372c81 9a777fec4ed9
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.1 v0.5.2 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
18 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/hub/src/assistants.rs +124 −24
…
21 unchanged lines
22 22
/// Characters of context (prompt, description, diffs) a call may carry
23 23
/// unless the assistant says otherwise.
24 24
pub const DEFAULT_BUDGET: i64 = 120_000;
25 -
/// How long one call may take.
26 -
pub const CALL_TIMEOUT: Duration = Duration::from_secs(60);
25 +
/// How long a call may take while a person waits for it (a draft, a
26 +
/// connection test): inside Cloudflare's 100 seconds for a proxied request.
27 +
pub const CALL_TIMEOUT: Duration = Duration::from_secs(90);
28 +
/// How long a call answered in the background may take (a review, a
29 +
/// summary): a model that thinks first needs minutes on a large proposal
30 +
/// (oxygenhub#201).
31 +
pub const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(300);
32 +
/// The longest answer asked for: room for a model that thinks before it
33 +
/// writes (oxygenhub#201: 4096 was spent on thinking alone).
34 +
pub const MAX_ANSWER_TOKENS: u32 = 16_000;
27 35
/// The most an answer is kept.
28 36
const MAX_ANSWER: usize = 64 * 1024;
29 37
…
401 unchanged lines
431 439
/// Ask an assistant: the system prompt and the request, the answer's
432 440
/// text. Errors are for the person who asked: a status and the first
433 441
/// line of what the endpoint said, or why no answer came.
434 -
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str) -> Result<String, String> {
442 +
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str, wait: Duration) -> Result<String, String> {
435 443
    let Some(token) = app.assistant_key.open(&a.token) else {
436 444
        return Err("its token cannot be read: set it again".into());
437 445
    };
438 446
    let client = reqwest::Client::builder()
439 -
        .timeout(CALL_TIMEOUT)
447 +
        .timeout(wait)
440 448
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
441 449
        .build()
442 450
        .map_err(|e| e.to_string())?;
…
6 unchanged lines
449 457
            req = req.header("anthropic-version", "2023-06-01");
450 458
            serde_json::json!({
451 459
                "model": a.model,
452 -
                "max_tokens": 4096,
460 +
                "max_tokens": MAX_ANSWER_TOKENS,
453 461
                "system": system,
454 462
                "messages": [{ "role": "user", "content": user }],
455 463
            })
…
2 unchanged lines
458 466
            if !token.is_empty() {
459 467
                req = req.header("authorization", format!("Bearer {token}"));
460 468
            }
469 +
            // No length here: providers differ (`max_tokens`, or
470 +
            // `max_completion_tokens` for newer OpenAI models, with
471 +
            // different ceilings), and their defaults are generous.
461 472
            serde_json::json!({
462 473
                "model": a.model,
463 474
                "messages": [
…
8 unchanged lines
472 483
    }
473 484
    let r = req.json(&body).send().await.map_err(|e| {
474 485
        if e.is_timeout() {
475 -
            "no answer within 60 seconds".to_string()
486 +
            format!("no answer within {} seconds", wait.as_secs())
476 487
        } else {
477 488
            scrub(&e.to_string(), &token)
478 489
        }
…
8 unchanged lines
487 498
        return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token));
488 499
    }
489 500
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?;
490 -
    let answer = match a.api.as_str() {
491 -
        "anthropic-messages" => v["content"]
492 -
            .as_array()
493 -
            .map(|parts| parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""))
494 -
            .unwrap_or_default(),
495 -
        _ => match &v["choices"][0]["message"]["content"] {
496 -
            serde_json::Value::String(s) => s.clone(),
497 -
            serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""),
498 -
            _ => String::new(),
499 -
        },
500 -
    };
501 -
    if answer.trim().is_empty() {
502 -
        return Err("the answer carried no text".into());
501 +
    let (answer, stop) = answer_of(&a.api, &v);
502 +
    finish(answer, stop)
503 +
}
504 +
505 +
/// Why an answer ended, as the person who asked needs to know it.
506 +
#[derive(Debug, PartialEq, Eq)]
507 +
enum Stop {
508 +
    /// It finished.
509 +
    Done,
510 +
    /// It reached the length it was allowed (thinking counts against it).
511 +
    Length,
512 +
    /// The provider declined to answer, with its category if it gave one.
513 +
    Declined(Option<String>),
514 +
}
515 +
516 +
/// The text of a provider's answer (text parts only: thinking is not
517 +
/// part of the answer), and why it ended.
518 +
fn answer_of(api: &str, v: &serde_json::Value) -> (String, Stop) {
519 +
    match api {
520 +
        "anthropic-messages" => {
521 +
            let text = v["content"]
522 +
                .as_array()
523 +
                .map(|parts| {
524 +
                    parts
525 +
                        .iter()
526 +
                        .filter(|p| p["type"].as_str().is_none_or(|t| t == "text"))
527 +
                        .filter_map(|p| p["text"].as_str())
528 +
                        .collect::<Vec<_>>()
529 +
                        .join("")
530 +
                })
531 +
                .unwrap_or_default();
532 +
            let stop = match v["stop_reason"].as_str() {
533 +
                Some("max_tokens") => Stop::Length,
534 +
                Some("refusal") => Stop::Declined(v["stop_details"]["category"].as_str().map(str::to_string)),
535 +
                _ => Stop::Done,
536 +
            };
537 +
            (text, stop)
538 +
        }
539 +
        _ => {
540 +
            let choice = &v["choices"][0];
541 +
            let text = match &choice["message"]["content"] {
542 +
                serde_json::Value::String(s) => s.clone(),
543 +
                serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""),
544 +
                _ => String::new(),
545 +
            };
546 +
            let stop = match choice["finish_reason"].as_str() {
547 +
                Some("length") => Stop::Length,
548 +
                Some("content_filter") => Stop::Declined(None),
549 +
                _ => Stop::Done,
550 +
            };
551 +
            (text, stop)
552 +
        }
503 553
    }
504 -
    Ok(answer)
505 554
}
506 555
556 +
/// The answer, or why there is none. An answer cut short is kept, with a
557 +
/// note saying so.
558 +
fn finish(answer: String, stop: Stop) -> Result<String, String> {
559 +
    match stop {
560 +
        Stop::Declined(category) => Err(match category {
561 +
            Some(c) => format!("it declined to answer ({c})"),
562 +
            None => "it declined to answer".into(),
563 +
        }),
564 +
        Stop::Length if answer.trim().is_empty() => {
565 +
            Err("it ran out of room before writing an answer (it spent its length limit thinking): try a smaller proposal".into())
566 +
        }
567 +
        Stop::Length => Ok(format!("{}\n\n*(Cut short: the answer reached its length limit.)*", answer.trim_end())),
568 +
        Stop::Done if answer.trim().is_empty() => Err("the answer carried no text".into()),
569 +
        Stop::Done => Ok(answer),
570 +
    }
571 +
}
572 +
507 573
/// Providers the add form offers, with what they need (#109):
508 574
/// (id, label, name, endpoint, api).
509 575
pub const PRESETS: &[(&str, &str, &str, &str, &str)] = &[
…
58 unchanged lines
568 634
569 635
/// A tiny exchange, for the settings page's TEST: the start of the answer.
570 636
pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> {
571 -
    let answer = ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.").await?;
637 +
    let answer =
638 +
        ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.", CALL_TIMEOUT)
639 +
            .await?;
572 640
    Ok(answer.trim().chars().take(80).collect())
573 641
}
574 642
…
24 unchanged lines
599 667
        assert_eq!(k.open(&[1, 2, 3]), None);
600 668
        assert_eq!(k.seal(""), Vec::<u8>::new());
601 669
        assert_eq!(k.open(&[]).as_deref(), Some(""));
670 +
    }
671 +
672 +
    #[test]
673 +
    fn answers_say_why_they_stopped() {
674 +
        let a = |v: serde_json::Value| {
675 +
            let (text, stop) = answer_of("anthropic-messages", &v);
676 +
            finish(text, stop)
677 +
        };
678 +
        // #201: all of the length spent thinking.
679 +
        let thought = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }] });
680 +
        assert!(a(thought).unwrap_err().contains("ran out of room"));
681 +
        let cut = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }, { "type": "text", "text": "Half" }] });
682 +
        assert!(a(cut).unwrap().starts_with("Half\n\n*(Cut short"));
683 +
        let declined = serde_json::json!({ "stop_reason": "refusal", "stop_details": { "category": "cyber" }, "content": [] });
684 +
        assert_eq!(a(declined).unwrap_err(), "it declined to answer (cyber)");
685 +
        let ok = serde_json::json!({ "stop_reason": "end_turn", "content": [{ "type": "text", "text": "Fine." }] });
686 +
        assert_eq!(a(ok).unwrap(), "Fine.");
687 +
        let chat = |v: serde_json::Value| {
688 +
            let (text, stop) = answer_of("chat-completions", &v);
689 +
            finish(text, stop)
690 +
        };
691 +
        assert!(chat(serde_json::json!({ "choices": [{ "finish_reason": "length", "message": { "content": "" } }] }))
692 +
            .unwrap_err()
693 +
            .contains("ran out of room"));
694 +
        assert_eq!(
695 +
            chat(serde_json::json!({ "choices": [{ "finish_reason": "content_filter", "message": { "content": "" } }] })).unwrap_err(),
696 +
            "it declined to answer"
697 +
        );
698 +
        assert_eq!(
699 +
            chat(serde_json::json!({ "choices": [{ "finish_reason": "stop", "message": { "content": "Yes." } }] })).unwrap(),
700 +
            "Yes."
701 +
        );
602 702
    }
603 703
604 704
    #[test]
…
214 unchanged lines
819 919
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
820 920
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
821 921
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
822 -
    ask(app, a, &system, &user).await
922 +
    ask(app, a, &system, &user, BACKGROUND_TIMEOUT).await
823 923
}
824 924
825 925
// -------------------------------------------------------- summaries
…
64 unchanged lines
890 990
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
891 991
    let system = summary_system_prompt(a, &app.config.site.name, &prompts);
892 992
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
893 -
    ask(app, a, &system, &user).await
993 +
    ask(app, a, &system, &user, BACKGROUND_TIMEOUT).await
894 994
}
895 995
896 996
// -------------------------------------------------------- drafting
…
44 unchanged lines
941 1041
        Ok(prompts) => {
942 1042
            let system = draft_system_prompt(a, &app.config.site.name, &prompts);
943 1043
            let user = draft_context(row, title, target, changes, diffs, budget.saturating_sub(system.chars().count()));
944 -
            ask(app, a, &system, &user).await
1044 +
            ask(app, a, &system, &user, CALL_TIMEOUT).await
945 1045
        }
946 1046
        Err(e) => Err(e),
947 1047
    };
…
3 unchanged lines
modified crates/hub/src/diffusion.rs +24 −2
…
1190 unchanged lines
1191 1191
pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) {
1192 1192
    let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new());
1193 1193
    let mut here = false;
1194 -
    for line in text.lines() {
1194 +
    let mut lines = text.lines();
1195 +
    while let Some(line) = lines.next() {
1195 1196
        let line = line.trim();
1196 1197
        if line.is_empty() || line.starts_with('#') {
1197 1198
            continue;
…
3 unchanged lines
1201 1202
            continue;
1202 1203
        }
1203 1204
        let (true, Some((k, v))) = (here, line.split_once('=')) else { continue };
1204 -
        let v = v.trim();
1205 +
        // A list may run over several lines (oxygenhub#200), as TOML allows:
1206 +
        // read on to its closing `]`.
1207 +
        let mut v = v.trim().to_string();
1208 +
        if v.starts_with('[') && !v.contains(']') {
1209 +
            for more in lines.by_ref() {
1210 +
                let more = more.trim();
1211 +
                if more.starts_with('#') {
1212 +
                    continue;
1213 +
                }
1214 +
                v.push_str(more);
1215 +
                if more.contains(']') {
1216 +
                    break;
1217 +
                }
1218 +
            }
1219 +
        }
1220 +
        let v = v.as_str();
1205 1221
        let unquoted = v.trim_matches('"').trim();
1206 1222
        match k.trim() {
1207 1223
            "follow" => follow = Follow::parse(unquoted),
…
99 unchanged lines
1307 1323
        assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any));
1308 1324
        assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![]));
1309 1325
        assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]);
1326 +
        // #200: a list over several lines, as in trailblazers, comments
1327 +
        // and the keys after it included.
1328 +
        let multi = "[rstar-api]\nurl = https://hub/x\n\nwatch = [\"src/x16/rbm*\",\n         \"src/x16/rdm.c\",\n  # a note\n         \"src/mega65/rsm.c\"]\nfollow = \"green\"\n";
1329 +
        let (f, _, _, watch) = module_keys(multi, "rstar-api");
1330 +
        assert_eq!(watch, vec!["src/x16/rbm*", "src/x16/rdm.c", "src/mega65/rsm.c"]);
1331 +
        assert_eq!(f, Some(Follow::Green), "the key after the list is still read");
1310 1332
    }
1311 1333
1312 1334
    #[test]
…
10 unchanged lines