OXYGENHUB

Assistants: what to do when a proposal is too much for the model c08cec372c81

Andy saw assistants give no answer on large proposals. The context now
lists every changed file with its +/- counts before the diff, then whole
files while they fit; a file that doesn't is cut where the room ends
(marked) or left out, and those are named, so the model knows what it
didn't see (the diff used to stop mid-file with later files unmentioned). A
provider that refuses the prompt as too long for its model is asked once
more with a third of the budget, and if that fails the event says the
proposal was too large at both sizes and to lower the budget; a timeout says
it may be a slow or local model. The spec notes that Ollama silently cuts
prompts longer than its context. Tests: a stand-in that refuses over a size
gets asked again smaller and answers; refusals for length are told from
others.

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-16 23:50 UTC
Signature
✔ signed by Claude Code
Hash
c08cec372c811493ed7a0232a5681f8c5895aecc62d6549b0ec5bbf0324b3ad6
Depends on
5360cea36c30 62ceb2eec18c 7d2eb590051f 93be9ce4653e acc85ac0f70b de505df5cf8d e71846aa07b9 ffb67a81188c
Depended on by
119fd17d213f
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
12 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/hub/src/assistants.rs +102 −17
…
701 unchanged lines
702 702
    }
703 703
704 704
    #[test]
705 +
    fn a_refusal_for_length_is_told_from_others() {
706 +
        for e in [
707 +
            "400 This model's maximum context length is 8192 tokens. Please reduce the length of the messages.",
708 +
            "400 prompt is too long: 210000 tokens > 200000 maximum",
709 +
            "413 Request too large",
710 +
            "400 input length exceeds the context window",
711 +
        ] {
712 +
            assert!(super::too_long(e), "{e}");
713 +
        }
714 +
        for e in ["401 invalid api key", "500 too long a queue", "no answer within 90 seconds", "400 model not found"] {
715 +
            assert!(!super::too_long(e), "{e}");
716 +
        }
717 +
    }
718 +
719 +
    #[test]
705 720
    fn headers_parse_or_name_the_bad_line() {
706 721
        assert_eq!(
707 722
            parse_headers("X-Org: acme\n\n OpenAI-Beta : v1 ").unwrap(),
…
91 unchanged lines
799 814
            }
800 815
        }
801 816
    }
802 -
    text.push_str("\n## Diff\n\n");
803 -
    let head = text.chars().count();
804 -
    let mut diff = String::new();
817 +
    // Every file with its size first, so a model told only part of the
818 +
    // diff still knows the whole (oxygenhub#248).
819 +
    text.push_str(&format!("\n## Files ({})\n\n", diffs.len()));
820 +
    let mut parts: Vec<(String, String)> = Vec::with_capacity(diffs.len());
805 821
    for f in diffs {
806 -
        diff.push_str(&format!("### {} ({})\n", f.path, f.kind));
822 +
        let (added, removed) = f.hunks.iter().fold((0, 0), |(a, r), h| (a + h.added.len(), r + h.removed.len()));
823 +
        text.push_str(&format!("- {} ({}, +{added} -{removed})\n", f.path, f.kind));
824 +
        let mut diff = format!("### {} ({})\n", f.path, f.kind);
807 825
        if let Some(from) = &f.from {
808 826
            diff.push_str(&format!("renamed from {from}\n"));
809 827
        }
810 828
        let binary = f.hunks.iter().any(|h| h.added.iter().chain(h.removed.iter().map(|(_, l)| l)).any(|l| l.contains(&0)));
811 829
        if binary {
812 830
            diff.push_str("(binary)\n\n");
831 +
            parts.push((f.path.clone(), diff));
813 832
            continue;
814 833
        }
815 834
        diff.push_str("```\n");
…
11 unchanged lines
827 846
            }
828 847
        }
829 848
        diff.push_str("```\n\n");
849 +
        parts.push((f.path.clone(), diff));
830 850
    }
831 -
    let room = budget.saturating_sub(head + 80);
832 -
    if diff.chars().count() > room {
833 -
        let cut: String = diff.chars().take(room).collect();
834 -
        text.push_str(&cut);
835 -
        text.push_str("\n\n(the diff was cut here to fit the context budget)\n");
836 -
    } else {
837 -
        text.push_str(&diff);
851 +
    text.push_str("\n## Diff\n\n");
852 +
    // Whole files, in order, while they fit. One that doesn't is cut where
853 +
    // the room ends if there is a fair amount left, else left out (a
854 +
    // smaller one after it may still go in); either way it is named.
855 +
    let head = text.chars().count();
856 +
    let mut room = budget.saturating_sub(head + 200);
857 +
    let mut left_out = Vec::new();
858 +
    for (path, diff) in parts {
859 +
        let n = diff.chars().count();
860 +
        if n <= room {
861 +
            text.push_str(&diff);
862 +
            room -= n;
863 +
        } else if room >= 1_500 {
864 +
            let keep = room - 80;
865 +
            text.extend(diff.chars().take(keep));
866 +
            text.push_str("\n(the rest of this file was cut to fit)\n```\n\n");
867 +
            left_out.push(format!("{path} (cut)"));
868 +
            room = 0;
869 +
        } else {
870 +
            left_out.push(path);
871 +
        }
872 +
    }
873 +
    if !left_out.is_empty() {
874 +
        let names: String = left_out.join(", ").chars().take(room.max(400)).collect();
875 +
        text.push_str(&format!("(left out to fit the context budget, {} file(s): {names})\n", left_out.len()));
838 876
    }
839 877
    text
840 878
}
…
77 unchanged lines
918 956
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
919 957
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
920 958
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
921 -
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
922 -
    ask(app, a, &system, &user, BACKGROUND_TIMEOUT).await
959 +
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await
960 +
}
961 +
962 +
/// Ask with the context cut to fit `budget` characters, system prompt
963 +
/// included (oxygenhub#248). A provider that says the prompt is too long
964 +
/// for its model is asked once more with a third of the room; whatever
965 +
/// fails is said plainly, with what to do about it.
966 +
async fn ask_within(
967 +
    app: &Shared,
968 +
    a: &Assistant,
969 +
    system: &str,
970 +
    budget: usize,
971 +
    wait: Duration,
972 +
    context: impl Fn(usize) -> String,
973 +
) -> Result<String, String> {
974 +
    let room = |b: usize| b.saturating_sub(system.chars().count());
975 +
    match ask(app, a, system, &context(room(budget)), wait).await {
976 +
        Err(e) if too_long(&e) => {
977 +
            let smaller = (budget / 3).max(1_000);
978 +
            tracing::info!("assistant {}: too long at {budget} characters ({e}); asking again at {smaller}", a.name);
979 +
            ask(app, a, system, &context(room(smaller)), wait).await.map_err(|e2| {
980 +
                format!(
981 +
                    "the proposal was too large for {}'s model at {budget} characters and again at {smaller} ({e2}): lower this assistant's context budget",
982 +
                    a.name
983 +
                )
984 +
            })
985 +
        }
986 +
        Err(e) if e.starts_with("no answer within") => Err(format!(
987 +
            "{e}: a large proposal on a slow or local model can take longer; lower this assistant's context budget, or use a faster model"
988 +
        )),
989 +
        other => other,
990 +
    }
991 +
}
992 +
993 +
/// Whether a provider's refusal says the prompt is too long for its model.
994 +
fn too_long(e: &str) -> bool {
995 +
    let l = e.to_lowercase();
996 +
    (l.starts_with("400") || l.starts_with("413") || l.starts_with("422"))
997 +
        && [
998 +
            "too long",
999 +
            "too large",
1000 +
            "context length",
1001 +
            "context window",
1002 +
            "maximum context",
1003 +
            "too many tokens",
1004 +
            "exceeds",
1005 +
            "reduce the length",
1006 +
            "input length",
1007 +
        ]
1008 +
        .iter()
1009 +
        .any(|k| l.contains(k))
923 1010
}
924 1011
925 1012
// -------------------------------------------------------- summaries
…
63 unchanged lines
989 1076
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
990 1077
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
991 1078
    let system = summary_system_prompt(a, &app.config.site.name, &prompts);
992 -
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
993 -
    ask(app, a, &system, &user, BACKGROUND_TIMEOUT).await
1079 +
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await
994 1080
}
995 1081
996 1082
// -------------------------------------------------------- drafting
…
43 unchanged lines
1040 1126
    let outcome = match prompts {
1041 1127
        Ok(prompts) => {
1042 1128
            let system = draft_system_prompt(a, &app.config.site.name, &prompts);
1043 -
            let user = draft_context(row, title, target, changes, diffs, budget.saturating_sub(system.chars().count()));
1044 -
            ask(app, a, &system, &user, CALL_TIMEOUT).await
1129 +
            ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room)).await
1045 1130
        }
1046 1131
        Err(e) => Err(e),
1047 1132
    };
…
3 unchanged lines
modified crates/hub/tests/proposals.rs +88 −0
…
720 unchanged lines
721 721
    }
722 722
    assert_eq!(s2, "closed", "a change arriving by push closes it, though the hub held it already");
723 723
}
724 +
725 +
/// A stand-in that refuses a prompt longer than `limit` bytes as a provider
726 +
/// does when it overflows the model, and keeps each call's length.
727 +
async fn mock_limited(limit: usize) -> (String, std::sync::Arc<std::sync::Mutex<Vec<usize>>>) {
728 +
    use std::sync::{Arc, Mutex};
729 +
    let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(vec![]));
730 +
    let s2 = seen.clone();
731 +
    let router = axum::Router::new().route(
732 +
        "/v1/chat/completions",
733 +
        axum::routing::post(move |body: String| {
734 +
            let s2 = s2.clone();
735 +
            async move {
736 +
                s2.lock().unwrap().push(body.len());
737 +
                if body.len() > limit {
738 +
                    let msg = format!("This model's maximum context length is {limit} tokens. Please reduce the length of the messages.");
739 +
                    return (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": { "message": msg } })));
740 +
                }
741 +
                (
742 +
                    StatusCode::OK,
743 +
                    axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "Small draft." } }] })),
744 +
                )
745 +
            }
746 +
        }),
747 +
    );
748 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
749 +
    let port = listener.local_addr().unwrap().port();
750 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
751 +
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
752 +
}
753 +
754 +
/// A proposal too large for the model (oxygenhub#248): the provider refuses
755 +
/// it, the hub asks again with a third of the room, and the answer comes.
756 +
#[tokio::test(flavor = "multi_thread")]
757 +
async fn a_proposal_too_large_for_the_model_is_asked_again_smaller() {
758 +
    let h = hub().await;
759 +
    let app = &h.app;
760 +
    let andy = register(app, "andy").await;
761 +
    let repo = RepoName::parse("andy/demo").unwrap();
762 +
    app.repos.create(&repo, None).unwrap();
763 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
764 +
    let work = tempfile::tempdir().unwrap();
765 +
    let mut client = Repo::init(work.path()).unwrap();
766 +
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
767 +
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
768 +
    client.config.set("remote.origin.token", "site").unwrap();
769 +
    client.save_config().unwrap();
770 +
    write(work.path(), "a.txt", b"one\n");
771 +
    client.snapshot("t").unwrap();
772 +
    let base = client.commit("base", &[]).unwrap();
773 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
774 +
    client.bookmark_create("feature", Some(&[base])).unwrap();
775 +
    client.bookmark_switch("feature", false).unwrap();
776 +
    // A big change: 200 lines of 60 characters, about 12 KB of diff.
777 +
    let big: String = (0..200).map(|i| format!("{i:04} {}\n", "x".repeat(55))).collect();
778 +
    write(work.path(), "big.txt", big.as_bytes());
779 +
    client.snapshot("t").unwrap();
780 +
    client.commit("a big file", &[]).unwrap();
781 +
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
782 +
783 +
    let (endpoint, seen) = mock_limited(6_000).await;
784 +
    let admin = get(app, "/admin", Some(&andy)).await.body;
785 +
    let ep = endpoint.replace(':', "%3A").replace('/', "%2F");
786 +
    let r = post(
787 +
        app,
788 +
        "/admin/assistants",
789 +
        Some(&andy),
790 +
        &format!(
791 +
            "_csrf={}&action=add&name=Small&endpoint={ep}&api=chat-completions&model=small-1&token=sk-test&enabled=1&budget=12000",
792 +
            csrf_of(&admin)
793 +
        ),
794 +
    )
795 +
    .await;
796 +
    assert!(r.body.contains("Small added and answered"), "{}", r.body);
797 +
    seen.lock().unwrap().clear();
798 +
    let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await.body;
799 +
    let r = post(
800 +
        app,
801 +
        "/andy/demo/proposals/new/draft",
802 +
        Some(&andy),
803 +
        &format!("_csrf={}&title=Big&body=&target=main&source=feature&assistant=Small", csrf_of(&page)),
804 +
    )
805 +
    .await;
806 +
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
807 +
    assert!(r.body.contains("Small draft."), "{}", r.body);
808 +
    let calls = seen.lock().unwrap().clone();
809 +
    assert_eq!(calls.len(), 2, "refused once, then asked smaller: {calls:?}");
810 +
    assert!(calls[0] > 6_000 && calls[1] <= 6_000, "{calls:?}");
811 +
}
modified docs/spec.md +1 −0
…
571 unchanged lines
572 572
- **Request review.** Under Reviewers on the proposal page, the author or a writer sees an **ask NAME** button per assistant the repository may use. Asking records the event (`asked NAME to review`) and the audit entry (`assistant.asked`), then a background task builds the context (title, description, the list of changes, the diff of each changed file as `-`/`+` lines per hunk, binary files noted, the whole cut to the assistant's context budget less the prompt), prefixes a fixed system prompt (who it is, that it answers for the person who asked, that it cannot approve, the shape of a review) with the levels' prompts, calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** (migration 0036) is the assistant, shown as "bob ↳ agent Claude reviewed" in the timeline, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people, and an assistant's review never replaces the asker's own latest verdict. The proposal's author is told as for any review; the `review` webhook carries `via`. No answer leaves an event (`NAME did not answer: why`) and no review.
573 573
- **Draft the description.** The new-proposal form (anyone who may open one) and an open proposal's editor (the author or a maintainer) have a **draft with NAME** button per assistant. It asks the assistant, now, for a description from the title so far, the changes (titles and the first lines of their messages) and the diff, under a fixed system prompt (description field only, what and why, what to look at, how to verify) plus the levels' prompts, and puts the answer in the editor with a note; nothing is saved until the person updates or opens the proposal. The audit log records `assistant.drafted`; the same per-repository hourly limit applies.
574 574
- **Limits.** One request per assistant per proposal at a time, 30 assistant calls per repository per hour (in memory), a 60-second call timeout, answers kept to 64 KiB, and a context budget (characters) per assistant.
575 +
- **Too much for the model** (oxygenhub#248). The context lists every changed file with its +/− counts before the diff, then gives whole files in order while they fit: one that doesn't is cut where the room ends (marked) when a fair amount is left, else left out, and every file cut or left out is named, so the model knows what it did not see. A provider that refuses the prompt as too long for its model (`400`/`413`/`422` saying so) is asked once more with a third of the budget; if that fails too, the event says the proposal was too large at both sizes and to lower the assistant's budget. A timeout says the same about slow or local models. A local Ollama cuts a prompt longer than its context window without saying so: set its context (`OLLAMA_CONTEXT_LENGTH`, or the model's `num_ctx`) to fit the assistant's budget, about four characters a token.
575 576
- **API.** `POST /repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` (the author or a writer; 202 when started, 409 when that assistant is already answering or the repository is over its hourly limit, 422 for a name the repository cannot use); reviews carry `via`. Settings: `GET`/`POST /repos/{o}/{n}/assistants` (repo:admin; `POST` adds or changes by name, with `preset`, an optional write-only `token` that an empty value keeps, and the model checked against the endpoint's list), `DELETE …/assistants/{name}`, and `GET`/`PUT …/assistants/prompt` (the level's text and what it inherits); the same under `/orgs/{org}/assistants` for its owners and `/admin/assistants` for site administrators. `oh assistant list|add|remove|prompt` uses them, with `--org` or `--site`, and reads a token only from an environment variable (`--token-env`). Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said.
576 577
577 578
### 13.5 A job's identity: the hub as an OIDC issuer (done 2026-10-04)
…
182 unchanged lines