OXYGENHUB

Drafts stream into the editor as the assistant writes them 119fd17d213f

A draft waited inside one request, which Cloudflare cuts after 100 s, so
the hub gave up at 90 s: Claude on a large proposal never answered in
time (#248). With JavaScript, Draft with NAME now posts to …/draft/stream,
which answers at once with server-sent events (part, then done or error;
a keep-alive every 15 s) while the draft is written in a task of its own,
and the description fills as the parts come. Without JavaScript the form
posts as before. Spec 13.4; the proposal test reads the stream and an
unknown assistant's error.

Closes #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 13:05 UTC
Signature
✔ signed by Claude Code
Hash
119fd17d213f23e85f97bae161e490a8bcd9ed22b75c1374c58063a24fa6f670
Depends on
3ea0b8af2d46 67be8ac00c24 7d2eb590051f 93be9ce4653e c08cec372c81 cc1cd197df1d d23464c0ac60 de505df5cf8d e71846aa07b9 ffb67a81188c
Depended on by
0bc95135954d a22e4aec2e28 c43e4f631d99 8f0efa0729b0 d366306dae7d 166f6e9bf4f1 c7067c7bda56 e97500b70f9a a7aa76ac52d7
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
20 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified assets/hub.js +59 −0
…
760 unchanged lines
761 761
    Array.prototype.forEach.call(marked, refresh);
762 762
  });
763 763
})();
764 +
765 +
/* Drafting with an assistant (oxygenhub#248): the draft is streamed into
766 +
   the description as it is written, so a long one is neither cut off by a
767 +
   proxy nor waited for in silence. Without this file the button posts the
768 +
   form and the page comes back with the draft. */
769 +
(function () {
770 +
  "use strict";
771 +
  if (!window.fetch || !window.TextDecoder || !window.URLSearchParams) return;
772 +
  document.querySelectorAll("button.agent-btn[data-stream]").forEach(function (btn) {
773 +
    btn.addEventListener("click", function (e) {
774 +
      var form = btn.form, area = form && form.querySelector('textarea[name="body"]');
775 +
      if (!form || !area) return;
776 +
      e.preventDefault();
777 +
      function get() { return area._mde ? area._mde.value() : area.value; }
778 +
      function show(t) { if (area._mde) area._mde.value(t); else area.value = t; }
779 +
      var before = get(), text = "";
780 +
      var box = (area._mde && area.parentNode.querySelector(".EasyMDEContainer")) || area;
781 +
      var note = form.querySelector(".draft-note");
782 +
      if (!note) { note = document.createElement("p"); note.className = "draft-note small mute"; box.parentNode.insertBefore(note, box.nextSibling); }
783 +
      note.textContent = btn.value + " is drafting the description…";
784 +
      var buttons = form.querySelectorAll("button.agent-btn");
785 +
      buttons.forEach(function (b) { b.disabled = true; });
786 +
      var data = new URLSearchParams(new FormData(form));
787 +
      data.set("assistant", btn.value);
788 +
      var finished = false;
789 +
      function end(message, keep) { finished = true; if (!keep) show(before); note.textContent = message; }
790 +
      fetch(btn.dataset.stream, { method: "POST", body: data, credentials: "same-origin" })
791 +
        .then(function (r) {
792 +
          if (!r.ok || !r.body) throw new Error("the hub answered " + r.status);
793 +
          var reader = r.body.getReader(), dec = new TextDecoder(), buf = "";
794 +
          function pump() {
795 +
            return reader.read().then(function (res) {
796 +
              if (res.done) { if (!finished) end(btn.value + " stopped before finishing.", text !== ""); return; }
797 +
              buf += dec.decode(res.value, { stream: true });
798 +
              var i;
799 +
              while ((i = buf.indexOf("\n\n")) >= 0) {
800 +
                var block = buf.slice(0, i), kind = "message", payload = "";
801 +
                buf = buf.slice(i + 2);
802 +
                block.split("\n").forEach(function (l) {
803 +
                  if (l.indexOf("event:") === 0) kind = l.slice(6).trim();
804 +
                  else if (l.indexOf("data:") === 0) payload += l.slice(5).trim();
805 +
                });
806 +
                if (!payload) continue;
807 +
                var v;
808 +
                try { v = JSON.parse(payload); } catch (_) { continue; }
809 +
                if (kind === "part") { text += v; show(text); }
810 +
                else if (kind === "done") { show(v); end("Drafted by " + btn.value + "; nothing is saved until you save the proposal.", true); return; }
811 +
                else if (kind === "error") { end(v, false); return; }
812 +
              }
813 +
              return pump();
814 +
            });
815 +
          }
816 +
          return pump();
817 +
        })
818 +
        .catch(function (err) { end(btn.value + " did not answer: " + err.message + ".", false); })
819 +
        .then(function () { buttons.forEach(function (b) { b.disabled = false; }); });
820 +
    });
821 +
  });
822 +
})();
modified crates/hub/src/assistants.rs +21 −5
…
947 unchanged lines
948 948
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
949 949
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
950 950
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
951 -
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await
951 +
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room), |_| {}).await
952 952
}
953 953
954 954
/// Ask with the context cut to fit `budget` characters, system prompt
…
7 unchanged lines
962 962
    budget: usize,
963 963
    wait: Duration,
964 964
    context: impl Fn(usize) -> String,
965 +
    mut part: impl FnMut(&str),
965 966
) -> Result<String, String> {
966 967
    let room = |b: usize| b.saturating_sub(system.chars().count());
967 -
    match ask(app, a, system, &context(room(budget)), wait).await {
968 +
    // A refusal for length comes before any part, so asking again starts
969 +
    // the answer afresh.
970 +
    match ask_streaming(app, a, system, &context(room(budget)), wait, &mut part).await {
968 971
        Err(e) if too_long(&e) => {
969 972
            let smaller = (budget / 3).max(1_000);
970 973
            tracing::info!("assistant {}: too long at {budget} characters ({e}); asking again at {smaller}", a.name);
971 -
            ask(app, a, system, &context(room(smaller)), wait).await.map_err(|e2| {
974 +
            ask_streaming(app, a, system, &context(room(smaller)), wait, &mut part).await.map_err(|e2| {
972 975
                format!(
973 976
                    "the proposal was too large for {}'s model at {budget} characters and again at {smaller} ({e2}): lower this assistant's context budget",
974 977
                    a.name
…
93 unchanged lines
1068 1071
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
1069 1072
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
1070 1073
    let system = summary_system_prompt(a, &app.config.site.name, &prompts);
1071 -
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await
1074 +
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room), |_| {}).await
1072 1075
}
1073 1076
1074 1077
// -------------------------------------------------------- drafting
…
29 unchanged lines
1104 1107
/// Ask `a` for a description, now; the answer goes into the editor,
1105 1108
/// nothing is saved. `Err` is for the person.
1106 1109
pub async fn draft_description(
1110 +
    app: &Shared,
1111 +
    row: &crate::db::repos::RepoRow,
1112 +
    by: &crate::db::accounts::Account,
1113 +
    a: &Assistant,
1114 +
    d: Draft<'_>,
1115 +
) -> Result<String, String> {
1116 +
    draft_description_streaming(app, row, by, a, d, |_| {}).await
1117 +
}
1118 +
1119 +
/// [`draft_description`], handing each part of the draft to `part` as it
1120 +
/// comes, for a page that shows it being written (oxygenhub#248).
1121 +
pub async fn draft_description_streaming(
1107 1122
    app: &Shared,
1108 1123
    row: &crate::db::repos::RepoRow,
1109 1124
    by: &crate::db::accounts::Account,
1110 1125
    a: &Assistant,
1111 1126
    d: Draft<'_>,
1127 +
    part: impl FnMut(&str),
1112 1128
) -> Result<String, String> {
1113 1129
    let Draft { slot, title, target, changes, diffs } = d;
1114 1130
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?;
…
3 unchanged lines
1118 1134
    let outcome = match prompts {
1119 1135
        Ok(prompts) => {
1120 1136
            let system = draft_system_prompt(a, &app.config.site.name, &prompts);
1121 -
            ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room)).await
1137 +
            ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room), part).await
1122 1138
        }
1123 1139
        Err(e) => Err(e),
1124 1140
    };
…
3 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
219 unchanged lines
220 220
        .route("/{owner}/{name}/proposals", get(proposals::list))
221 221
        .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create))
222 222
        .route("/{owner}/{name}/proposals/new/draft", post(proposals::new_draft))
223 +
        .route("/{owner}/{name}/proposals/new/draft/stream", post(proposals::new_draft_stream))
223 224
        .route("/{owner}/{name}/proposal/{number}", get(proposals::show))
224 225
        .route("/{owner}/{name}/proposal/{number}/comment", post(proposals::comment))
225 226
        .route("/{owner}/{name}/proposal/{number}/comment/{id}/resolve", post(proposals::resolve_comment))
…
1 unchanged lines
227 228
        .route("/{owner}/{name}/proposal/{number}/reviewers", post(proposals::reviewers))
228 229
        .route("/{owner}/{name}/proposal/{number}/assistant", post(proposals::assistant))
229 230
        .route("/{owner}/{name}/proposal/{number}/draft", post(proposals::draft))
231 +
        .route("/{owner}/{name}/proposal/{number}/draft/stream", post(proposals::draft_stream))
230 232
        .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit))
231 233
        .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state))
232 234
        .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land))
…
556 unchanged lines
modified crates/hub/src/web/proposals.rs +135 −0
…
1759 unchanged lines
1760 1760
        Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))),
1761 1761
    }
1762 1762
}
1763 +
1764 +
/// A draft being written, as server-sent events for the page: `part` with
1765 +
/// each piece of text, then `done` with the whole draft or `error` with
1766 +
/// why there is none (each a JSON string). The response starts at once, so
1767 +
/// a draft that takes minutes is not cut off by a proxy waiting for it
1768 +
/// (oxygenhub#248).
1769 +
type DraftEvents = axum::response::Sse<
1770 +
    axum::response::sse::KeepAliveStream<
1771 +
        tokio_stream::wrappers::ReceiverStream<Result<axum::response::sse::Event, std::convert::Infallible>>,
1772 +
    >,
1773 +
>;
1774 +
1775 +
fn draft_events(rx: tokio::sync::mpsc::Receiver<Result<axum::response::sse::Event, std::convert::Infallible>>) -> DraftEvents {
1776 +
    axum::response::Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx))
1777 +
        .keep_alive(axum::response::sse::KeepAlive::new().interval(std::time::Duration::from_secs(15)))
1778 +
}
1779 +
1780 +
fn draft_event(kind: &str, text: &str) -> Result<axum::response::sse::Event, std::convert::Infallible> {
1781 +
    Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(text).unwrap_or_default()))
1782 +
}
1783 +
1784 +
/// Only an error, for a draft that cannot start.
1785 +
fn draft_refused(why: String) -> DraftEvents {
1786 +
    let (tx, rx) = tokio::sync::mpsc::channel(1);
1787 +
    let _ = tx.try_send(draft_event("error", &why));
1788 +
    draft_events(rx)
1789 +
}
1790 +
1791 +
/// What a draft is written from, owned so a task of its own can have it.
1792 +
struct DraftJob {
1793 +
    row: db::repos::RepoRow,
1794 +
    by: accounts::Account,
1795 +
    a: crate::assistants::Assistant,
1796 +
    slot: i64,
1797 +
    title: String,
1798 +
    target: String,
1799 +
    changes: Vec<ChangeView>,
1800 +
    diffs: Vec<oxy_core::repo::FileDiff>,
1801 +
}
1802 +
1803 +
/// Write the draft in a task of its own, its parts to the page.
1804 +
fn draft_in_background(app: Shared, job: DraftJob) -> DraftEvents {
1805 +
    let (tx, rx) = tokio::sync::mpsc::channel(64);
1806 +
    tokio::spawn(async move {
1807 +
        let DraftJob { row, by, a, slot, title, target, changes, diffs } = job;
1808 +
        let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs };
1809 +
        let parts = tx.clone();
1810 +
        let result = crate::assistants::draft_description_streaming(&app, &row, &by, &a, d, move |p: &str| {
1811 +
            // A page that went away stops nothing: the draft finishes.
1812 +
            let _ = parts.try_send(draft_event("part", p));
1813 +
        })
1814 +
        .await;
1815 +
        let last = match result {
1816 +
            Ok(text) => draft_event("done", text.trim()),
1817 +
            Err(why) => draft_event("error", &format!("{} did not answer: {why}.", a.name)),
1818 +
        };
1819 +
        let _ = tx.send(last).await;
1820 +
    });
1821 +
    draft_events(rx)
1822 +
}
1823 +
1824 +
/// `POST /{owner}/{name}/proposal/{number}/draft/stream`: [`draft`], with
1825 +
/// the draft streamed to the page as it is written.
1826 +
pub async fn draft_stream(
1827 +
    State(app): State<Shared>,
1828 +
    viewer: Viewer,
1829 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1830 +
    Form(f): Form<DraftForm>,
1831 +
) -> Result<DraftEvents, Error> {
1832 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1833 +
    let me = require(&viewer, &shell)?;
1834 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1835 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1836 +
    super::writable(&row, &shell)?;
1837 +
    let p = pdb::get(&app.db, row.id, number)
1838 +
        .await
1839 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1840 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1841 +
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
1842 +
        return Err(Error::not_found(shell));
1843 +
    }
1844 +
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1845 +
        .await
1846 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1847 +
        .into_iter()
1848 +
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1849 +
    else {
1850 +
        return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim())));
1851 +
    };
1852 +
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
1853 +
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
1854 +
    let job = DraftJob {
1855 +
        row,
1856 +
        by: me.account.clone(),
1857 +
        a,
1858 +
        slot: p.id,
1859 +
        title,
1860 +
        target: p.target.clone(),
1861 +
        changes: loaded.changes,
1862 +
        diffs: loaded.diffs,
1863 +
    };
1864 +
    Ok(draft_in_background(app.clone(), job))
1865 +
}
1866 +
1867 +
/// `POST /{owner}/{name}/proposals/new/draft/stream`: [`new_draft`], with
1868 +
/// the draft streamed to the page as it is written.
1869 +
pub async fn new_draft_stream(
1870 +
    State(app): State<Shared>,
1871 +
    viewer: Viewer,
1872 +
    Path((owner, name)): Path<(String, String)>,
1873 +
    Form(f): Form<NewForm>,
1874 +
) -> Result<DraftEvents, Error> {
1875 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
1876 +
    let me = require(&viewer, &shell)?;
1877 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1878 +
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1879 +
    super::writable(&row, &shell)?;
1880 +
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1881 +
        .await
1882 +
        .unwrap_or_default()
1883 +
        .into_iter()
1884 +
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1885 +
    else {
1886 +
        return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim())));
1887 +
    };
1888 +
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
1889 +
        Ok(x) => x,
1890 +
        Err(e) => return Ok(draft_refused(e.to_string())),
1891 +
    };
1892 +
    if changes.is_empty() {
1893 +
        return Ok(draft_refused("Nothing to describe: the source adds nothing to the target.".into()));
1894 +
    }
1895 +
    let job = DraftJob { slot: -row.id, row, by: me.account.clone(), a, title: f.title.clone(), target: f.target.clone(), changes, diffs };
1896 +
    Ok(draft_in_background(app.clone(), job))
1897 +
}
modified crates/hub/tests/proposals.rs +14 −0
…
430 unchanged lines
431 431
    assert_eq!(seen.lock().unwrap().len(), 2);
432 432
    seen.lock().unwrap().clear();
433 433
434 +
    // The same as a stream for the page to show as it comes (#248): the
435 +
    // button says where, and the draft arrives in parts, then whole.
436 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
437 +
    assert!(page.contains("data-stream=\"/andy/demo/proposal/1/draft/stream\""), "{page}");
438 +
    let form = format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page));
439 +
    let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form).await;
440 +
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
441 +
    assert!(r.body.contains("event: part") && r.body.contains("event: done"), "{}", r.body);
442 +
    assert!(r.body.contains("Adds a third line."), "{}", r.body);
443 +
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "still nothing saved");
444 +
    let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form.replace("Claude", "Nobody")).await;
445 +
    assert!(r.body.contains("event: error") && r.body.contains("No assistant called Nobody"), "{}", r.body);
446 +
    seen.lock().unwrap().clear();
447 +
434 448
    // Andy approves; then Bob, the author, asks Claude.
435 449
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
436 450
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await;
…
377 unchanged lines
modified docs/spec.md +1 −1
…
569 unchanged lines
570 570
- **Adding one** (oxygenhub#109). The form offers providers (Anthropic, OpenAI, Google Gemini, DeepSeek, Mistral, Perplexity, Ollama) that fill the name, endpoint and API shape, and *Other* for an endpoint typed by hand. **List models** asks the endpoint's own list (`…/models` beside `…/chat/completions` or `…/messages`, with the token typed or, when editing, the one stored) and offers the chat models in the Model field; on save, a model the endpoint lists none of is refused with some it does offer. An endpoint without a list takes any name.
571 571
- **Secrets.** Tokens are sealed at rest with ChaCha20-Poly1305 under `keys/assistants.key` (mode 0600, made at first start beside the cookie key; each token carries its own random nonce), shown only as set or none, and never returned by the API. An empty token suits endpoints that need none. Each settings panel has TEST, a one-line exchange whose answer or error is shown. Each level's changes are in the audit log (`assistant.added`, `.updated`, `.enabled`, `.disabled`, `.deleted`, `.prompt`).
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 -
- **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.
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. With JavaScript the button posts to `…/draft/stream` instead, which answers at once with server-sent events (`part` with each piece of text as the assistant writes it, then `done` with the whole draft or `error` with why there is none, each a JSON string, and a keep-alive every 15 s), and the editor fills as the draft comes; without it the form posts and the page returns with the draft. Every assistant call is made as a stream, and its time limit is for silence (90 s for a draft, 300 s for a review) under a 15-minute cap, so a long answer that keeps coming is not cut off (oxygenhub#248).
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 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.
576 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.
…
184 unchanged lines
modified templates/proposal.html +1 −1
…
94 unchanged lines
95 95
        <div class="edit-row"><label for="etarget">Into bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}"></div>
96 96
        <textarea id="ebody" name="body" class="md" rows="10" aria-label="Description (Markdown)">{% if let Some(d) = draft %}{{ d }}{% else %}{{ p.body }}{% endif %}</textarea>
97 97
        {% if draft.is_some() %}<p class="small mute">Drafted by an assistant; nothing is saved until you update the proposal.</p>{% endif %}
98 -
        <div class="tl-actions"><a class="go" href="#description">Cancel</a> {% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposal/{{ p.number }}/draft" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before saving"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button> {% endfor %}<button type="submit" class="primary-btn">Update proposal</button></div>
98 +
        <div class="tl-actions"><a class="go" href="#description">Cancel</a> {% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposal/{{ p.number }}/draft" data-stream="{{ repo.url }}/proposal/{{ p.number }}/draft/stream" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before saving"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button> {% endfor %}<button type="submit" class="primary-btn">Update proposal</button></div>
99 99
      </form>
100 100
      {% endif %}
101 101
    </div>
…
152 unchanged lines
modified templates/proposal_new.html +1 −1
…
24 unchanged lines
25 25
  <input type="hidden" name="target" value="{{ target }}">
26 26
  <label for="title">Title</label><input type="text" id="title" name="title" required value="{% if !title.is_empty() %}{{ title }}{% else if preview.len() == 1 %}{{ preview[0].title }}{% endif %}">
27 27
  <label for="body">Description (Markdown)</label><textarea id="body" name="body" class="md" rows="8">{{ body }}</textarea>
28 -
  {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposals/new/draft" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before opening"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button>{% endfor %}</div>{% endif %}
28 +
  {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<button type="submit" class="agent-btn" formaction="{{ repo.url }}/proposals/new/draft" data-stream="{{ repo.url }}/proposals/new/draft/stream" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before opening"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Draft with {{ a }}</button>{% endfor %}</div>{% endif %}
29 29
  <div class="choice"><label><input type="checkbox" name="draft" value="1"> draft: not ready for review</label></div>
30 30
  <button type="submit">OPEN PROPOSAL</button>
31 31
</form>
…
4 unchanged lines