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 |