diff --git a/hives/claude-cli/src/graft.rs b/hives/claude-cli/src/graft.rs index 674929d1..5ab383c6 100644 --- a/hives/claude-cli/src/graft.rs +++ b/hives/claude-cli/src/graft.rs @@ -12,10 +12,15 @@ //! and tendrils of the cell as assistant turns and each grasp as the tool //! result that answered them. A tendril is named as the cell knew it, a //! tool of the nest's `hum` MCP server. +//! +//! Tool results a held transcript lacks are found by the tool and what it +//! answered, not by their ids: claude names its own calls by its ids, and +//! a client knows them by the nest's. A transcript may hold answers a +//! client never saw, a tool the server ran or a call the nest gave up on. use anyhow::{Context, Result}; use serde_json::{json, Value}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -159,6 +164,14 @@ pub fn last_uuid(path: &Path) -> Option { None } +pub fn ends_in_grasps(blooms: &[thrum_core::Bloom]) -> bool { + blooms + .last() + .and_then(|b| b.grew.as_ref()) + .and_then(|grew| grew.iter().rev().find(|p| p.kind != thrum_core::PetalKind::Sepal.as_str())) + .is_some_and(|p| p.kind == thrum_core::PetalKind::Grasp.as_str()) +} + pub fn tell(prior: &mut [Value], instructions: &str) -> bool { let Some(first) = prior.iter_mut().find(|m| role_of(m) == "user") else { return false }; match first.get_mut("content") { @@ -253,18 +266,18 @@ fn count_turns(messages: &[Value]) -> usize { turns } -/// Count completed turns in JSONL: user-with-text followed by an assistant -/// before another user. +fn opens_turn(entry: &Value) -> bool { + entry_type(entry) == "user" + && message_content(entry).is_some_and(|c| { + c.iter().any(|p| p.get("type").and_then(Value::as_str) == Some("text")) + && !c.iter().any(|p| p.get("text").and_then(Value::as_str) == Some(crate::stream::CONTINUE)) + }) +} + fn count_jsonl_turns(entries: &[Value]) -> usize { let mut turns = 0; for i in 0..entries.len() { - if entry_type(&entries[i]) != "user" { - continue; - } - let has_text = message_content(&entries[i]) - .map(|c| c.iter().any(|p| p.get("type").and_then(Value::as_str) == Some("text"))) - .unwrap_or(false); - if !has_text { + if !opens_turn(&entries[i]) { continue; } for j in (i + 1)..entries.len() { @@ -306,6 +319,59 @@ fn skip_turns(messages: &[Value], n: usize) -> usize { messages.len() } +fn parts_of<'a>(messages: &'a [Value], role: &'a str, kind: &'a str) -> impl Iterator { + messages + .iter() + .filter(move |m| role_of(m) == role) + .filter_map(|m| m.get("content").and_then(Value::as_array)) + .flatten() + .filter(move |p| p.get("type").and_then(Value::as_str) == Some(kind)) +} + +fn answer_of(content: Option<&Value>) -> String { + match content { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => parts.iter().filter_map(|p| p.get("text").and_then(Value::as_str)).collect(), + Some(other) => other.to_string(), + None => String::new(), + } +} + +fn late_results(history: &[Value], entries: &[Value]) -> Vec { + let start = history.iter().rposition(|m| role_of(m) == "user").map_or(0, |i| i + 1); + let turn = &history[start..]; + let opened = entries.iter().rposition(opens_turn).map_or(0, |i| i + 1); + let held_parts = || entries[opened..].iter().filter_map(message_content).flatten(); + let kind = |p: &Value, k: &str| p.get("type").and_then(Value::as_str) == Some(k); + let named: HashMap<&str, &str> = held_parts() + .filter(|p| kind(p, "tool_use")) + .filter_map(|p| Some((p.get("id")?.as_str()?, p.get("name")?.as_str()?))) + .collect(); + let mut held: Vec<(&str, String)> = held_parts() + .filter(|p| kind(p, "tool_result")) + .filter_map(|p| Some((*named.get(p.get("tool_use_id")?.as_str()?)?, answer_of(p.get("content"))))) + .collect(); + let calls: Vec<&Value> = parts_of(turn, "assistant", "tool-call").collect(); + let mut late: Vec<(Value, Value)> = Vec::new(); + for result in parts_of(turn, "tool", "tool-result") { + let id = result.get("toolCallId").and_then(Value::as_str); + let Some(call) = calls.iter().find(|c| id.is_some() && c.get("toolCallId").and_then(Value::as_str) == id) else { continue }; + let name = call.get("toolName").and_then(Value::as_str).unwrap_or(""); + let answer = answer_of(result.get("result")); + match held.iter().position(|(n, a)| *n == name && *a == answer) { + Some(i) => { + held.swap_remove(i); + } + None => late.push(((*call).clone(), result.clone())), + } + } + if late.is_empty() { + return Vec::new(); + } + let (answered, results): (Vec, Vec) = late.into_iter().unzip(); + vec![json!({ "role": "assistant", "content": answered }), json!({ "role": "tool", "content": results })] +} + /// Stitch a nestler's prior petals into the JSONL. UUID-anchored, /// count-based — does not hash text. Returns the new tail uuid. pub fn graft( @@ -343,6 +409,14 @@ pub fn graft( .any(|e| e.get("uuid").and_then(Value::as_str) == Some(anchor)) }); + if j_users == p_users { + let late = late_results(&history, &existing); + if !late.is_empty() { + trace!(j_users, "graft.late-results"); + let count = append_from_prompt(jsonl_path, session_id, &late, cwd)?; + return Ok(GraftResult { grafted: count, last_petal: last_uuid(jsonl_path) }); + } + } if anchored && j_users >= p_users { trace!(anchor = ?last_synced, j_users, p_users, "graft.synced"); return Ok(GraftResult { grafted: 0, last_petal: last_synced.map(str::to_owned) }); diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index a596ce1b..7c6fe4ec 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -19,7 +19,10 @@ //! //! A prompt's graft is stitched into the session's transcript before the //! cell resumes it, so a cell that did not grow those blooms grows on as if -//! it had; a transcript that already holds them is left as it is. +//! it had, and a transcript is given only what it lacks. +//! +//! claude cannot be handed words to carry on, nor a `scion` to grow +//! toward, so a prompt with no words grows on only from tool results. //! //! claude signed in with a subscription takes no system prompt of a //! caller's: `--system-prompt` and `--append-system-prompt` never reach the @@ -106,6 +109,27 @@ pub const SESSION_NS: Uuid = Uuid::from_bytes([ 0x68, 0x75, 0x6d, 0x2d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2d, 0x6e, 0x73, 0x00, 0x01, ]); +fn refused(code: &str, message: &str) -> Cell { + let (feed, mut fed) = mpsc::channel::(1); + tokio::spawn(async move { while fed.recv().await.is_some() {} }); + let (tx_evt, rx_evt) = mpsc::channel::(1); + let _ = tx_evt.try_send(serde_json::json!({ "chi": "error", "code": code, "message": message })); + let (exit, emerged) = tokio::sync::oneshot::channel(); + let _ = exit.send(0); + Cell { + mark: None, + feed, + mmm: std::sync::Arc::new(Mutex::new(rx_evt)), + emerged, + ephemeral: false, + silence: Default::default(), + } +} + +pub fn grows_on_from(prompt: &thrum_core::PromptBody) -> bool { + prompt.scion.is_none() && graft::ends_in_grasps(prompt.graft.as_deref().unwrap_or(&[])) +} + pub struct ClaudeCliNurse; impl Default for ClaudeCliNurse { @@ -211,6 +235,7 @@ pub fn build_env(spec: &Egg) -> Vec<(String, String)> { impl Nurse for ClaudeCliNurse { fn ephemeral(&self) -> bool { false } fn propensity(&self) -> Propensity { Propensity::StatefulSession } + fn grows_on(&self) -> bool { true } async fn curate(&self, sid: &hum_identity::HumId, cwd: &str) -> Result { let derived = session_of(sid); @@ -238,6 +263,11 @@ impl Nurse for ClaudeCliNurse { } async fn raise(&self, spec: Egg) -> Result { + let worded = spec.prompt.content.as_ref().is_some_and(|c| !c.is_empty()) + || spec.prompt.text.as_ref().is_some_and(|t| !t.is_empty()); + if !worded && !grows_on_from(&spec.prompt) { + return Ok(refused("cannot-grow-on", "claude grows on only from tool results, and this graft does not end in them")); + } let derived = session_of(&spec.sid); let cwd = std::fs::canonicalize(&spec.cwd).unwrap_or_else(|_| spec.cwd.clone().into()); let path = graft::session_path(&cwd, &derived); @@ -365,6 +395,41 @@ mod tests { Egg::new(hum_identity::HumId::mint(), "claude-haiku-4-5", "/tmp") } + fn graft_ending(last: serde_json::Value) -> Vec { + serde_json::from_value(serde_json::json!([{ + "content": [{ "kind": "text", "text": "build it" }], + "grew": [{ "kind": "tendril", "callId": "c-1", "name": "bash", "args": {} }, last], + }])) + .unwrap() + } + + #[test] + fn claude_grows_on_only_from_tool_results() { + let mut prompt = thrum_core::PromptBody { + graft: Some(graft_ending(serde_json::json!({ "kind": "grasp", "callId": "c-1", "result": "ok" }))), + ..Default::default() + }; + assert!(grows_on_from(&prompt)); + prompt.scion = Some("}".into()); + assert!(!grows_on_from(&prompt), "a scion is not met"); + prompt.scion = None; + prompt.graft = Some(graft_ending(serde_json::json!({ "kind": "text", "text": "Buil" }))); + assert!(!grows_on_from(&prompt), "words are not continued"); + assert!(ClaudeCliNurse.grows_on()); + } + + #[tokio::test] + async fn a_prompt_it_cannot_grow_on_is_refused_without_a_claude() { + let mut spec = egg(); + spec.prompt.graft = Some(graft_ending(serde_json::json!({ "kind": "text", "text": "Buil" }))); + let cell = ClaudeCliNurse.raise(spec).await.unwrap(); + let tone = cell.mmm.lock().await.recv().await.unwrap(); + assert_eq!(tone["chi"], "error"); + assert_eq!(tone["code"], "cannot-grow-on"); + assert!(cell.mmm.lock().await.recv().await.is_none(), "and nothing else"); + assert_eq!(cell.emerged.await.unwrap(), 0); + } + #[test] fn argv_includes_basics() { let argv = build_argv(&egg(), Session::Begin("s")); diff --git a/hives/claude-cli/src/stream.rs b/hives/claude-cli/src/stream.rs index 5ab44b56..e34f7082 100644 --- a/hives/claude-cli/src/stream.rs +++ b/hives/claude-cli/src/stream.rs @@ -7,6 +7,10 @@ //! head; an answer to a tool claude called outside its sigil's tools as //! that tool's result; a cancel as claude's own cancel request. //! +//! claude takes a turn only from a user message, and resuming a transcript +//! that ends in tool results it first answers them with an empty "No +//! response requested." of its own, which [`CONTINUE`] disowns. +//! //! [`Grower`] turns claude's events into tones. Its init is a `cell-ready` //! pulse. Each content block unfurls as one petal by its index, text as //! text and thinking as a sepal sealed by its signature; a tool use unfurls @@ -54,6 +58,8 @@ pub fn said(petals: &[Petal]) -> Vec { .collect() } +pub const CONTINUE: &str = "Tool results came back after your turn ended. If a \"No response requested.\" sits between them and this line, the session added it when it resumed; you did not say it. Continue from where you left off."; + pub fn heard(tone: &Value, preamble: &mut Option) -> Option { match tone.get("chi").and_then(Value::as_str)? { "prompt" => { @@ -62,6 +68,9 @@ pub fn heard(tone: &Value, preamble: &mut Option) -> Option { body.text.into_iter().map(|t| Petal { kind: PetalKind::Text.as_str().into(), text: Some(t), ..Petal::default() }).collect() }); let mut content = said(&petals); + if content.is_empty() { + content.push(json!({ "type": "text", "text": CONTINUE })); + } if let Some(text) = preamble.take() { content.insert(0, json!({ "type": "text", "text": text })); } @@ -213,6 +222,14 @@ mod tests { assert_eq!(again["message"]["content"][0]["text"], "what is this?", "told once"); } + #[test] + fn a_prompt_with_no_words_is_heard_as_the_continue_line() { + let mut none = None; + let on: Value = serde_json::from_str(&heard(&json!({ "chi": "prompt", "sid": "s", "graft": [] }), &mut none).unwrap()).unwrap(); + assert_eq!(on["type"], "user"); + assert_eq!(on["message"]["content"], json!([{ "type": "text", "text": CONTINUE }])); + } + #[test] fn an_answer_and_a_cancel_are_heard_as_claude_says_them() { let mut none = None; diff --git a/hives/claude-cli/tests/graft_blooms.rs b/hives/claude-cli/tests/graft_blooms.rs index df7107d7..a7eadc81 100644 --- a/hives/claude-cli/tests/graft_blooms.rs +++ b/hives/claude-cli/tests/graft_blooms.rs @@ -3,7 +3,8 @@ //! Each bloom becomes a user turn of its words, then the cell's words and //! tendrils as assistant turns and each grasp as the tool result that //! answered them, the tendril named as the cell knew it over MCP. A -//! transcript that already holds the graft's blooms is left as it is. +//! transcript that already holds the graft's blooms is given only what it +//! lacks. use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -84,3 +85,150 @@ fn a_graft_is_stitched_once() { let again = graft(&prior_of(&blooms()), &path, session, &cwd, None).unwrap(); assert_eq!(again.grafted, 0, "a transcript that holds the blooms is left as it is"); } + +fn lines_of(path: &std::path::Path) -> Vec { + std::fs::read_to_string(path).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect() +} + +fn delivered(mut b: Vec) -> Vec { + let last = b.last_mut().unwrap().grew.get_or_insert_with(Vec::new); + last.push(petal(json!({"kind": "tendril", "callId": "check_17", "name": "zsh_check", "args": {"id": 17}}))); + last.push(petal(json!({"kind": "grasp", "callId": "check_17", "result": "job 17 finished (exit 0)"}))); + b +} + +#[test] +fn a_graft_ends_in_grasps_thinking_aside() { + use claude_cli::graft::ends_in_grasps; + assert!(!ends_in_grasps(&blooms()), "it ends in words"); + assert!(ends_in_grasps(&delivered(blooms()))); + let mut thought = delivered(blooms()); + thought.last_mut().unwrap().grew.as_mut().unwrap().push(petal(json!({"kind": "sepal", "text": "hm"}))); + assert!(ends_in_grasps(&thought), "thinking aside"); + assert!(!ends_in_grasps(&[])); +} + +#[test] +fn a_graft_ending_in_tool_results_keeps_them_last() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000002"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + + graft(&prior_of(&delivered(blooms())), &path, session, &cwd, None).unwrap(); + let lines = lines_of(&path); + let last = lines.last().unwrap(); + assert_eq!(last["type"], "user"); + assert_eq!(last["message"]["content"][0]["type"], "tool_result"); + assert_eq!(last["message"]["content"][0]["tool_use_id"], "check_17"); +} + +#[test] +fn tool_results_a_held_transcript_lacks_are_stitched_on() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000003"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + + graft(&prior_of(&blooms()), &path, session, &cwd, None).unwrap(); + let before = lines_of(&path).len(); + let late = graft(&prior_of(&delivered(blooms())), &path, session, &cwd, None).unwrap(); + assert_eq!(late.grafted, 1, "the late call is written"); + let lines = lines_of(&path); + assert_eq!(lines.len(), before + 2, "only the late call and its result"); + assert_eq!(lines[before]["message"]["content"][0]["type"], "tool_use"); + assert_eq!(lines[before]["message"]["content"][0]["id"], "check_17"); + assert_eq!(lines[before + 1]["message"]["content"][0]["tool_use_id"], "check_17"); + + let again = graft(&prior_of(&delivered(blooms())), &path, session, &cwd, None).unwrap(); + assert_eq!(again.grafted, 0, "held results are not stitched twice"); + assert_eq!(lines_of(&path).len(), before + 2); +} + +#[test] +fn the_continue_line_opens_no_turn() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000004"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + + graft(&prior_of(&delivered(blooms())), &path, session, &cwd, None).unwrap(); + let mut file = std::fs::OpenOptions::new().append(true).open(&path).unwrap(); + use std::io::Write; + for entry in [ + json!({"type": "assistant", "uuid": "s-1", "message": {"role": "assistant", "model": "", "content": [{"type": "text", "text": "No response requested."}]}}), + json!({"type": "user", "uuid": "s-2", "message": {"role": "user", "content": claude_cli::stream::CONTINUE}}), + json!({"type": "assistant", "uuid": "s-3", "message": {"role": "assistant", "content": [{"type": "text", "text": "Job 17 is done."}]}}), + ] { + writeln!(file, "{entry}").unwrap(); + } + let before = lines_of(&path).len(); + let mut grown = delivered(blooms()); + grown.last_mut().unwrap().grew.as_mut().unwrap().push(petal(json!({"kind": "text", "text": "Job 17 is done."}))); + let held = graft(&prior_of(&grown), &path, session, &cwd, None).unwrap(); + assert_eq!(held.grafted, 0, "the transcript holds every turn"); + assert_eq!(lines_of(&path).len(), before); + + grown.push(Bloom { + content: Some(vec![petal(json!({"kind": "text", "text": "and now?"}))]), + grew: Some(vec![petal(json!({"kind": "text", "text": "Now this."}))]), + }); + let next = graft(&prior_of(&grown), &path, session, &cwd, None).unwrap(); + assert_eq!(next.grafted, 1, "a turn grown elsewhere is still stitched"); + assert_eq!(lines_of(&path).last().unwrap()["message"]["content"][0]["text"], "Now this."); +} + +fn held_with(extra: Vec) -> Vec { + let mut b = blooms(); + b.last_mut().unwrap().grew.as_mut().unwrap().extend(extra.into_iter().map(petal)); + b +} + +#[test] +fn a_delivery_is_stitched_on_though_the_transcript_holds_answers_the_client_never_saw() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000005"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + + graft(&prior_of(&held_with(vec![ + json!({"kind": "tendril", "callId": "toolu_lookup", "name": "lookup", "args": {"q": "paris"}}), + json!({"kind": "grasp", "callId": "toolu_lookup", "result": "18C"}), + ])), &path, session, &cwd, None).unwrap(); + let before = lines_of(&path).len(); + + let late = graft(&prior_of(&delivered(blooms())), &path, session, &cwd, None).unwrap(); + assert_eq!(late.grafted, 1, "the server's own call does not hide the delivery"); + let lines = lines_of(&path); + assert_eq!(lines.len(), before + 2); + assert_eq!(lines[before + 1]["message"]["content"][0]["tool_use_id"], "check_17"); +} + +#[test] +fn an_answer_that_came_after_the_nest_gave_up_is_stitched_on() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000006"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + + graft(&prior_of(&held_with(vec![ + json!({"kind": "tendril", "callId": "toolu_9", "name": "zsh_check", "args": {"id": 17}}), + json!({"kind": "grasp", "callId": "toolu_9", "result": "MCP error -32000: tendril-result for callId r-9 never came", "isError": true}), + ])), &path, session, &cwd, None).unwrap(); + let before = lines_of(&path).len(); + + let late = graft(&prior_of(&delivered(blooms())), &path, session, &cwd, None).unwrap(); + assert_eq!(late.grafted, 1); + let lines = lines_of(&path); + assert_eq!(lines[before + 1]["message"]["content"][0]["content"], "job 17 finished (exit 0)"); +} diff --git a/hives/ollama-server/README.md b/hives/ollama-server/README.md index 1917f059..4eae9060 100644 --- a/hives/ollama-server/README.md +++ b/hives/ollama-server/README.md @@ -219,9 +219,10 @@ A bloom grows through Ollama's `/api/chat`: `cancel` wilts it `cancel`. - **Growing on.** A prompt with no words whose graft is plain grown text goes to `/api/generate` instead, as that text raw, or with the prompt's - `scion` as its `suffix`. A graft that holds a conversation cannot grow - on that way, and is refused with `cannot-grow-on`; a prompt with - neither words nor a graft, with `empty-prompt`. + `scion` as its `suffix`. A conversation that ends in tool answers goes + to `/api/chat` as it stands. Any other conversation is refused with + `cannot-grow-on`; a prompt with neither words nor a graft, with + `empty-prompt`. - **Errors.** `ollama_connection_error` when Ollama cannot be reached, `ollama_api_error` when it answers with an error, `ollama_stream_ended` when its reply ends before it is done, and diff --git a/hives/ollama-server/src/nurse/ollama.rs b/hives/ollama-server/src/nurse/ollama.rs index 9d124717..ddd91b0c 100644 --- a/hives/ollama-server/src/nurse/ollama.rs +++ b/hives/ollama-server/src/nurse/ollama.rs @@ -13,8 +13,9 @@ //! A prompt with no words grows on from its graft through `/api/generate`: //! a graft that is plain text, grown by no one's prompt, is the text to //! grow on from as it stands, and a `scion` is the suffix the growth meets. -//! A graft that holds a conversation cannot grow on that way, and is -//! refused with `cannot-grow-on`. +//! A conversation that ends in tool answers grows on through `/api/chat` +//! as it stands. `/api/chat` cannot carry on the model's own words, so any +//! other conversation is refused with `cannot-grow-on`. //! //! A cell lives for its sigil: it grows each prompt the nest feeds it, in //! turn, each from that prompt's own graft, until the nest stills it. A @@ -155,6 +156,23 @@ pub fn plain_text(graft: &[Bloom]) -> Option { Some(graft.iter().map(|b| words(b.grew.as_deref().unwrap_or(&[]))).collect::>().join("")) } +pub fn ends_in_grasps(graft: &[Bloom]) -> bool { + graft + .last() + .and_then(|b| b.grew.as_deref()) + .and_then(|grew| grew.iter().rev().find(|p| !is(p, PetalKind::Sepal))) + .is_some_and(|p| is(p, PetalKind::Grasp)) +} + +fn worded(prompt: &PromptBody) -> bool { + prompt.content.as_deref().is_some_and(|c| !c.is_empty()) || prompt.text.as_deref().is_some_and(|t| !t.is_empty()) +} + +pub fn grows_on_from(prompt: &PromptBody) -> bool { + let graft = prompt.graft.as_deref().unwrap_or(&[]); + plain_text(graft).is_some() || (prompt.scion.is_none() && ends_in_grasps(graft)) +} + pub fn options(prompt: &PromptBody, ctx: u64) -> Value { let mut o = Map::new(); o.insert("num_ctx".into(), json!(ctx)); @@ -185,8 +203,9 @@ pub fn request(model: &str, system: Option<&str>, prompt: &PromptBody, ctx: u64) let graft = prompt.graft.as_deref().unwrap_or(&[]); let content = prompt.content.as_deref().unwrap_or(&[]); let opts = options(prompt, ctx); - if content.is_empty() && prompt.text.as_deref().is_none_or(str::is_empty) { - let text = plain_text(graft).unwrap_or_default(); + if !worded(prompt) + && let Some(text) = plain_text(graft) + { let mut body = json!({ "model": model, "prompt": text, "stream": true, "options": opts }); match prompt.scion.as_ref() { Some(scion) => body["suffix"] = json!(scion), @@ -194,10 +213,9 @@ pub fn request(model: &str, system: Option<&str>, prompt: &PromptBody, ctx: u64) } return ("/api/generate", body); } - let content: Vec = if content.is_empty() { - vec![Petal { kind: PetalKind::Text.as_str().into(), text: prompt.text.clone(), ..Petal::default() }] - } else { - content.to_vec() + let content: Vec = match prompt.text.as_ref().filter(|t| content.is_empty() && !t.is_empty()) { + Some(text) => vec![Petal { kind: PetalKind::Text.as_str().into(), text: Some(text.clone()), ..Petal::default() }], + None => content.to_vec(), }; let mut body = json!({ "model": model, @@ -315,10 +333,8 @@ struct Grower { impl Grower { async fn grow(&self, prompt: &PromptBody, tx: &mpsc::Sender, stilled: &CancellationToken) { let model = prompt.model_id.as_deref().filter(|m| !m.is_empty()).unwrap_or(&self.model); - let words_given = prompt.content.as_deref().is_some_and(|c| !c.is_empty()) - || prompt.text.as_deref().is_some_and(|t| !t.is_empty()); - if !words_given && plain_text(prompt.graft.as_deref().unwrap_or(&[])).is_none() { - let _ = tx.send(fail("cannot-grow-on", "ollama grows on only from plain text, and this graft holds a conversation".into())).await; + if !worded(prompt) && !grows_on_from(prompt) { + let _ = tx.send(fail("cannot-grow-on", "ollama grows on only from plain text or tool results, and this graft ends in neither".into())).await; return; } let (path, body) = request(model, prompt.system_prompt.as_deref(), prompt, self.ctx); @@ -458,6 +474,42 @@ mod tests { assert!(plain_text(&graft).is_none()); } + fn delivered(last: Value) -> PromptBody { + PromptBody { + graft: Some(vec![Bloom { + content: Some(vec![petal(json!({"kind": "text", "text": "start the build"}))]), + grew: Some(vec![ + petal(json!({"kind": "text", "text": "Started job 17."})), + petal(json!({"kind": "tendril", "callId": "check_17", "name": "check", "args": {"id": 17}})), + petal(json!({"kind": "grasp", "callId": "check_17", "result": "job 17 finished"})), + petal(last), + ]), + }]), + ..PromptBody::default() + } + } + + #[test] + fn a_conversation_ending_in_tool_answers_grows_on_through_chat() { + let prompt = delivered(json!({"kind": "sepal", "text": "hm"})); + assert!(grows_on_from(&prompt), "thinking aside"); + let (path, body) = request("m", Some("be brief"), &prompt, 4096); + assert_eq!(path, "/api/chat"); + let messages = body["messages"].as_array().unwrap(); + let roles: Vec<&str> = messages.iter().map(|m| m["role"].as_str().unwrap()).collect(); + assert_eq!(roles, ["system", "user", "assistant", "tool"]); + assert_eq!(messages[2]["tool_calls"][0]["function"]["name"], "check"); + assert_eq!(messages[3]["content"], "job 17 finished"); + } + + #[test] + fn a_conversation_ending_in_words_or_meeting_a_scion_is_not_grown_on() { + assert!(!grows_on_from(&delivered(json!({"kind": "text", "text": "Job 17 is"})))); + let scion = PromptBody { scion: Some("done".into()), ..delivered(json!({"kind": "sepal", "text": "hm"})) }; + assert!(!grows_on_from(&scion)); + assert!(!grows_on_from(&PromptBody::default())); + } + async fn ollama() -> String { use axum::{routing::post, Json, Router}; let app = Router::new().route("/api/chat", post(|Json(body): Json| async move { diff --git a/hives/openai-server/README.md b/hives/openai-server/README.md index f87742a6..47139e72 100644 --- a/hives/openai-server/README.md +++ b/hives/openai-server/README.md @@ -76,9 +76,17 @@ words asks for a bloom that grows on: the prompt has no words, and the whole conversation is its graft. A conversation stays on one sigil while it only grows, so a nurse that keeps sessions picks up where it was. The sigil is found by the caller's key, the system prompt, the words of every -turn and the id of every tool call; a conversation no one has seen, one -whose history was edited, or one already growing gets a sigil of its -own, so callers on different keys never share one. +turn and the id of every tool call and answer; a conversation no one has +seen, one whose history was edited, or one already growing gets a sigil +of its own, so callers on different keys never share one. A turn's +words, calls and answers are each read in order but apart from one +another, its words whitespace aside, so a caller that keeps a turn as one +message, all its words and then all its calls, finds the sigil it grew +on in steps. A tool the server ran is not read, so a caller finds its +sigil whether it sends that call back or not. A conversation that ends +in tool answers also finds the sigil it had before them, so an answer +that came after its call was let go, or a call and answer a caller made +up to hand the model news, grows on in the session that came before. **Settings.** `temperature`, `top_p`, the token cap, `stop`, `seed`, the JSON shape (`response_format`, `text.format`), `tool_choice`, @@ -93,23 +101,25 @@ it for, by their exact names, in the chat shape or the Responses one; a tool of any other type, one only OpenAI runs, is left out. Calls come back as `tool_calls`, or `function_call` items, several at once when the model makes several, and the caller's answers, on the same key, carry the -bloom on. The server holds each call for thirty minutes, and keeps the -key's link open while it holds one. A call it lets go unanswered, as the -thirty minutes pass or as the key's link ends, cancels the bloom that +bloom on. The server holds each call for ten minutes, less than the nest +waits for it, and keeps the key's link open while it holds one. A call +it lets go unanswered, as the ten minutes pass or as the key's link +ends, cancels the bloom that reached for it, unless a prompt has since opened another bloom on the same sigil; a link that ends on its own leaves the cancel to the key's next one. An answer that comes after the server has let its call go, -past those thirty minutes or across a restart, opens a bloom that grows +past those ten minutes or across a restart, opens a bloom that grows on from the whole conversation, which a nurse that cannot grow on from a graft refuses with a 400. A tool an attendant serves runs there, and an attendant on this humd takes a name even from the caller's own tool, so `GET /v1/models` lists these under each model's `tools` for a client to keep its own names -apart. Chat completions has no place for a result the caller did not -produce, so such a call stays out of it and the caller sees what the -model said after; a response shows it as an `mcp_call` item, output and -all. The hive's prompts name no `cwd` and its foragers do not build, so +apart. A response shows such a call as an `mcp_call` item, output and +all. Chat completions has no place for it, so it rides on the assistant +message as `mcp_calls`, each shaped as that item, streamed as a delta +like `tool_calls`; a caller that sends the message back sends them too, +and they are read as what grew. The hive's prompts name no `cwd` and its foragers do not build, so its blooms build nowhere: a reach for `bash` comes back unserved, unless the caller offered a tool named `bash`, which reaches the caller like any other. diff --git a/hives/openai-server/src/chat.ts b/hives/openai-server/src/chat.ts index eb456af5..29f4d5cf 100644 --- a/hives/openai-server/src/chat.ts +++ b/hives/openai-server/src/chat.ts @@ -5,16 +5,17 @@ // answers. The bloom's words stream as `content` deltas and its thinking // as `reasoning_content`; a tool the bloom reaches the caller for streams // as `tool_calls` and finishes the turn with `finish_reason: "tool_calls"`. -// A tool the server ran never shows here: chat completions has no place -// for a result the caller did not produce, so the caller sees only what -// the model said after it. Without `stream` the same arrives as one -// `chat.completion`. Usage comes in a last chunk only when -// `stream_options.include_usage` asks for it, and always without stream. +// Chat completions has no place for a tool the server ran, so it rides on +// the message as `mcp_calls`, each shaped as the Responses API's +// `mcp_call` item, and is read back when the caller sends it. Without +// `stream` the same arrives as one `chat.completion`. Usage comes in a +// last chunk only when `stream_options.include_usage` asks for it, and +// always without stream. import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { answers, fromChat, opening, type ChatMessage } from "./conversation.ts"; -import { grow, grewOf, usageOf, type Grown, type Listener } from "./growth.ts"; +import { grow, mcpCallOf, servedOf, usageOf, type Grown, type Listener } from "./growth.ts"; import { growthOf, modelFor, prompt, type Caller, type Ctx } from "./hive.ts"; import { bad, gone, HttpError, readJson, sendJson, Sse } from "./http.ts"; import { toolsFromOpenAI } from "./tools.ts"; @@ -60,7 +61,7 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser const held = given.length > 0 ? rq.heldSid(given.map((a) => a.callId)) : undefined; const continuing = held !== undefined && !ctx.sigils.growing(held); const { graft, content } = opening(said); - const sid = held !== undefined && continuing ? held : ctx.sigils.continuing(rq.label, said.system, graft); + const sid = held !== undefined && continuing ? held : ctx.sigils.continuing(rq.label, said.system, graft, said.served); const open = continuing ? { answers: given } : { @@ -94,6 +95,7 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser choices: [{ index: 0, delta, logprobs: null, finish_reason: finish }], }); let spoke = false; + let ran = 0; const speak = (delta: Record) => { if (!sse) return; if (!spoke) { @@ -106,6 +108,7 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser text: (d) => speak({ content: d }), thought: (d) => speak({ reasoning_content: d }), call: (c) => speak({ tool_calls: [{ index: c.index, id: c.id, type: "function", function: { name: c.name, arguments: c.args } }] }), + served: (s) => speak({ mcp_calls: [{ index: ran++, ...mcpCallOf(s) }] }), }; ctx.sigils.begin(sid); @@ -122,8 +125,8 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser const after = said.blooms.map((b) => ({ content: b.content, grew: [...(b.grew ?? [])] })); const last = after[after.length - 1]; - if (last) last.grew = [...(last.grew ?? []), ...grewOf(grown)]; - ctx.sigils.remember(rq.label, said.system, after, sid); + if (last) last.grew = [...(last.grew ?? []), ...grown.grew]; + ctx.sigils.remember(rq.label, said.system, after, sid, servedOf(said, grown)); const used = usageOf(grown.nectar); ctx.usage.track(who.key, used.prompt, used.completion); @@ -157,6 +160,7 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser content: grown.text.length > 0 ? grown.text : grown.calls.length > 0 ? null : "", refusal: null, ...(grown.thought ? { reasoning_content: grown.thought } : {}), + ...(grown.served.length > 0 ? { mcp_calls: grown.served.map(mcpCallOf) } : {}), ...(grown.calls.length > 0 ? { tool_calls: grown.calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) } : {}), diff --git a/hives/openai-server/src/conversation.ts b/hives/openai-server/src/conversation.ts index 0f8686c8..452cf677 100644 --- a/hives/openai-server/src/conversation.ts +++ b/hives/openai-server/src/conversation.ts @@ -16,8 +16,14 @@ // caller; a conversation no one has seen, one whose history was edited, // or one already growing elsewhere gets a sigil of its own. The trace that // finds it is the caller's label, the system prompt, the words of every -// turn and the ids of every tool call, which a client sends back as it -// was given them. +// turn and the ids of every tool call and answer, which a client sends +// back as it was given them, though it may send a turn back as one +// message, its words run together and then all its calls, where the turn +// grew them in steps between. A tool the server ran is left out of the +// trace, since a client may or may not send it back. A conversation that +// ends in answers finds the sigil it had before them too: answers that +// came after their call was let go, or a call and its answer a client +// made up to hand the model news. import { createHash, randomUUID } from "node:crypto"; import { PetalKind, type Bloom, type Grain, type Petal } from "../../../thrum-clients/ts/index.ts"; @@ -28,6 +34,7 @@ export interface Said { system?: string; blooms: Bloom[]; tail: Tail; + served: Set; } export interface Answer { @@ -104,10 +111,19 @@ function partsOf(content: unknown): Petal[] { return out; } +function servedPetals(served: Set, id: string, name: string, args: unknown, output: unknown, error: unknown): Petal[] { + served.add(id); + return [ + { kind: PetalKind.tendril, callId: id, name, args: argsOf(args) }, + { kind: PetalKind.grasp, callId: id, result: resultOf(output ?? error), isError: Boolean(error) }, + ]; +} + class Reader { system: string[] = []; blooms: Bloom[] = []; tail: Tail = "none"; + served = new Set(); #open(): Bloom { let last = this.blooms[this.blooms.length - 1]; @@ -133,10 +149,18 @@ class Reader { done(): Said { const system = this.system.filter((s) => s.length > 0).join("\n\n"); - return { system: system || undefined, blooms: this.blooms, tail: this.tail }; + return { system: system || undefined, blooms: this.blooms, tail: this.tail, served: this.served }; } } +export interface McpCall { + id?: string; + name?: string; + arguments?: unknown; + output?: unknown; + error?: unknown; +} + export interface ChatMessage { role: string; content?: unknown; @@ -146,6 +170,7 @@ export interface ChatMessage { tool_call_id?: string; reasoning_content?: unknown; reasoning?: unknown; + mcp_calls?: McpCall[]; } export function fromChat(messages: ChatMessage[]): Said { @@ -163,6 +188,9 @@ export function fromChat(messages: ChatMessage[]): Said { const petals: Petal[] = []; const thought = typeof m.reasoning_content === "string" ? m.reasoning_content : typeof m.reasoning === "string" ? m.reasoning : ""; if (thought) petals.push({ kind: PetalKind.sepal, text: thought }); + for (const c of Array.isArray(m.mcp_calls) ? m.mcp_calls : []) { + petals.push(...servedPetals(r.served, c.id ?? "", c.name ?? "", c.arguments, c.output, c.error)); + } petals.push(...partsOf(m.content)); for (const c of m.tool_calls ?? []) { petals.push({ kind: PetalKind.tendril, callId: c.id ?? "", name: c.function?.name ?? "", args: argsOf(c.function?.arguments) }); @@ -192,6 +220,7 @@ export function fromResponses(input: unknown, instructions?: string, before?: Sa if (before.system) r.system.push(before.system); r.blooms = before.blooms.map((b) => ({ content: [...(b.content ?? [])], grew: [...(b.grew ?? [])] })); r.tail = before.tail; + r.served = new Set(before.served); } if (typeof instructions === "string" && instructions.length > 0) r.system = [instructions]; const items: ResponsesItem[] = typeof input === "string" ? [{ type: "message", role: "user", content: input }] : Array.isArray(input) ? input : []; @@ -215,10 +244,7 @@ export function fromResponses(input: unknown, instructions?: string, before?: Sa break; } case "mcp_call": - r.grew([ - { kind: PetalKind.tendril, callId: item.id ?? "", name: item.name ?? "", args: argsOf(item.arguments) }, - { kind: PetalKind.grasp, callId: item.id ?? "", result: resultOf(item.output ?? item.error), isError: Boolean(item.error) }, - ], "assistant"); + r.grew(servedPetals(r.served, item.id ?? "", item.name ?? "", item.arguments, item.output, item.error), "assistant"); break; } } @@ -247,7 +273,11 @@ function squeeze(s: string): string { return s.replace(/\s+/g, " ").trim(); } -export function trace(label: string, system: string | undefined, blooms: Bloom[]): string { +function unspaced(s: string): string { + return s.replace(/\s+/g, ""); +} + +export function trace(label: string, system: string | undefined, blooms: Bloom[], served: Set = new Set()): string { const h = createHash("sha256"); h.update(`${label}\0${squeeze(system ?? "")}\0`); for (const b of blooms) { @@ -256,24 +286,32 @@ export function trace(label: string, system: string | undefined, blooms: Bloom[] if (p.kind === PetalKind.text) h.update(`t${squeeze(p.text ?? "")}\0`); else if (p.kind === PetalKind.pollen) h.update(`p${p.grain?.kind ?? ""}\0`); } - h.update("G"); let words = ""; + let calls = ""; + let answers = ""; for (const p of b.grew ?? []) { - if (p.kind === PetalKind.text) { - words += p.text ?? ""; - continue; - } - if (p.kind === PetalKind.tendril || p.kind === PetalKind.grasp) { - if (squeeze(words)) h.update(`t${squeeze(words)}\0`); - words = ""; - h.update(`${p.kind === PetalKind.tendril ? "c" : "r"}${p.callId ?? ""}\0`); - } + const ran = served.has(p.callId ?? ""); + if (p.kind === PetalKind.text) words += p.text ?? ""; + else if (p.kind === PetalKind.tendril && !ran) calls += `${p.callId ?? ""}\0`; + else if (p.kind === PetalKind.grasp && !ran) answers += `${p.callId ?? ""}\0`; } - if (squeeze(words)) h.update(`t${squeeze(words)}\0`); + h.update(`G${unspaced(words)}\0c${calls}r${answers}`); } return h.digest("hex"); } +function beforeAnswers(blooms: Bloom[], withTheirCalls: boolean): Bloom[] { + const last = blooms[blooms.length - 1]; + const grew = [...(last?.grew ?? [])]; + const answered = new Set(); + while (grew.length > 0 && (grew[grew.length - 1].kind === PetalKind.grasp || grew[grew.length - 1].kind === PetalKind.sepal)) { + const p = grew.pop()!; + if (p.kind === PetalKind.grasp) answered.add(p.callId ?? ""); + } + const kept = withTheirCalls ? grew.filter((p) => !(p.kind === PetalKind.tendril && answered.has(p.callId ?? ""))) : grew; + return [...blooms.slice(0, -1), { ...last, grew: kept }]; +} + export class Sigils { readonly #byTrace = new Map(); readonly #cap: number; @@ -290,9 +328,11 @@ export class Sigils { return `oai-${randomUUID()}`; } - continuing(label: string, system: string | undefined, blooms: Bloom[]): string { + continuing(label: string, system: string | undefined, blooms: Bloom[], served?: Set): string { if (blooms.length === 0) return this.fresh(); - const sid = this.#byTrace.get(trace(label, system, blooms)); + const sid = [blooms, beforeAnswers(blooms, false), beforeAnswers(blooms, true)] + .map((b) => this.#byTrace.get(trace(label, system, b, served))) + .find((found) => found !== undefined); return sid && !this.#growing.has(sid) ? sid : this.fresh(); } @@ -308,8 +348,8 @@ export class Sigils { this.#growing.delete(sid); } - remember(label: string, system: string | undefined, blooms: Bloom[], sid: string): void { - const key = trace(label, system, blooms); + remember(label: string, system: string | undefined, blooms: Bloom[], sid: string, served?: Set): void { + const key = trace(label, system, blooms, served); this.#byTrace.delete(key); this.#byTrace.set(key, sid); this.#owner.set(sid, label); diff --git a/hives/openai-server/src/growth.ts b/hives/openai-server/src/growth.ts index 38111643..a20dcaf4 100644 --- a/hives/openai-server/src/growth.ts +++ b/hives/openai-server/src/growth.ts @@ -53,6 +53,7 @@ export interface Grown { thought: string; calls: Call[]; served: Served[]; + grew: Petal[]; nectar?: Nectar; } @@ -118,7 +119,7 @@ export function grow( on: Listener = {}, ): Promise { return new Promise((resolve, reject) => { - const grown: Grown = { finish: "stop", text: "", thought: "", calls: [], served: [] }; + const grown: Grown = { finish: "stop", text: "", thought: "", calls: [], served: [], grew: [] }; const asked = new Set(); let quiet: NodeJS.Timeout | undefined; let settled = false; @@ -152,6 +153,9 @@ export function grow( if (t.closed || !t.text) return; if (t.kind === "text") { grown.text += t.text; + const last = grown.grew[grown.grew.length - 1]; + if (last?.kind === "text") last.text = (last.text ?? "") + t.text; + else grown.grew.push({ kind: "text", text: t.text }); on.text?.(t.text); } else if (t.kind === "sepal") { grown.thought += t.text; @@ -164,6 +168,7 @@ export function grow( asked.add(t.callId); const call: Call = { index: grown.calls.length, id: t.callId, name: t.name, args: json(t.args) }; grown.calls.push(call); + grown.grew.push({ kind: "tendril", callId: call.id, name: call.name, args: call.args }); rq.hold(sid, t.callId); on.call?.(call); if (quiet) clearTimeout(quiet); @@ -183,6 +188,10 @@ export function grow( isError: t.isError === true, }; grown.served.push(served); + grown.grew.push( + { kind: "tendril", callId: served.callId, name: served.name, args: served.args }, + { kind: "grasp", callId: served.callId, result: served.result, isError: served.isError }, + ); on.served?.(served); return; } @@ -209,9 +218,19 @@ export function grow( }); } -export function grewOf(grown: Grown): Petal[] { - const petals: Petal[] = []; - if (grown.text) petals.push({ kind: "text", text: grown.text }); - for (const c of grown.calls) petals.push({ kind: "tendril", callId: c.id, name: c.name, args: c.args }); - return petals; +export function servedOf(said: { served: Set }, grown: Grown): Set { + return new Set([...said.served, ...grown.served.map((s) => s.callId)]); +} + +export function mcpCallOf(s: Served): Record { + return { + id: s.callId, + type: "mcp_call", + status: s.isError ? "failed" : "completed", + server_label: "hum", + name: s.name, + arguments: s.args, + output: s.result, + error: s.isError ? s.result : null, + }; } diff --git a/hives/openai-server/src/nurse.ts b/hives/openai-server/src/nurse.ts index ff616df5..a897de3b 100644 --- a/hives/openai-server/src/nurse.ts +++ b/hives/openai-server/src/nurse.ts @@ -1,14 +1,17 @@ // The nurse half: blooms grown by the OpenAI-compatible API upstream. // // The nurse keeps each sigil's conversation as OpenAI messages, its own -// replies among them. A prompt whose graft holds blooms the nurse did not -// grow, or a sigil it has never seen, starts the conversation over from -// the graft. The prompt's words, images, audio and files are the next user -// message, and how the bloom grows becomes the request's: `temperature`, -// `top_p`, `max_completion_tokens`, `stop`, `seed`, `response_format`, -// `tool_choice`, `parallel_tool_calls`, `reasoning_effort`, and the -// penalties under this hive's `ext`. The prompt's tools are the request's -// function tools. +// replies among them, since they hold the calls the server ran that a +// chat client may never send back. A prompt whose graft holds blooms the +// nurse did not grow, or a sigil it has never seen, starts the +// conversation over from the graft. Tool answers a caller adds to the last +// bloom join the kept conversation by call id, which the caller was given +// as the upstream gave it. The prompt's words, images, audio and files +// are the next user message, and how the bloom grows becomes the +// request's: `temperature`, `top_p`, `max_completion_tokens`, `stop`, +// `seed`, `response_format`, `tool_choice`, `parallel_tool_calls`, +// `reasoning_effort`, and the penalties under this hive's `ext`. The +// prompt's tools are the request's function tools. // // The reply streams back as petals: its words as text, its reasoning as a // sepal. Tool calls close its petals as tendrils and reach, every one, and @@ -97,6 +100,17 @@ export function messagesOf(graft: Bloom[]): Message[] { return out; } +export function lateAnswers(known: Message[], bloom: Bloom | undefined): Message[] { + const answered = new Set(known.filter((m) => m.role === "tool").map((m) => m.tool_call_id)); + const called = new Set(known.flatMap((m) => (m.tool_calls ?? []).map((c: Message) => c.id))); + const grew = bloom?.grew ?? []; + const callable = (id: string) => called.has(id) || grew.some((p) => p.kind === PetalKind.tendril && p.callId === id); + const late = new Set(grew.filter((p) => p.kind === PetalKind.grasp && !answered.has(p.callId) && callable(p.callId ?? "")).map((p) => p.callId)); + const out: Message[] = []; + grown(grew.filter((p) => late.has(p.callId) && (p.kind === PetalKind.grasp || (p.kind === PetalKind.tendril && !called.has(p.callId)))), out); + return out; +} + export function plainText(graft: Bloom[]): string | undefined { if (graft.length === 0) return undefined; const plain = graft.every((b) => (b.content ?? []).length === 0 && (b.grew ?? []).every((p) => p.kind === PetalKind.text)); @@ -183,6 +197,7 @@ export class Nurse { #sigil(sid: string, graft: Bloom[]): Sigil { const known = this.#sigils.get(sid); if (known && (graft.length === 0 || known.grown === graft.length)) { + known.messages.push(...lateAnswers(known.messages, graft[graft.length - 1])); this.#sigils.delete(sid); this.#sigils.set(sid, known); return known; diff --git a/hives/openai-server/src/requester.ts b/hives/openai-server/src/requester.ts index 19e9991f..12f0a4a0 100644 --- a/hives/openai-server/src/requester.ts +++ b/hives/openai-server/src/requester.ts @@ -8,16 +8,20 @@ // away mid-bloom. // // A reach for one of its caller's tools is held by callId until the caller -// answers it, for thirty minutes at most. A hold let go unanswered, as it -// lapses or as the link closes, cancels its bloom, unless a prompt has -// since opened another on its sid; the cancel's wilt is never taken for a -// later bloom's. A link that ends on its own leaves its cancels to the -// key's next link. A reach for a sid whose bloom has already gone back to -// the caller cannot be put to the caller any more, so it is answered at -// once with an error asking the cell to call again. A reach the requester -// sends itself, a scent, waits for its result by callId. A requester's -// link closes once it has been idle ten minutes, following no bloom and -// holding no call, and opens again on its next request. +// answers it, for ten minutes at most: the nest gives up on a reach a +// little after the longest `bash` may run, ten minutes, and an answer held +// past that would reach a call no one waits on any more. An answer that +// comes once its hold is gone grows the bloom on from its graft instead. A +// hold let go unanswered, as it lapses or as the link closes, cancels its +// bloom, unless a prompt has since opened another on its sid; the cancel's +// wilt is never taken for a later bloom's. A link that ends on its own +// leaves its cancels to the key's next link. A reach for a sid whose bloom +// has already gone back to the caller cannot be put to the caller any +// more, so it is answered at once with an error asking the cell to call +// again. A reach the requester sends itself, a scent, waits for its result +// by callId. A requester's link closes once it has been idle ten minutes, +// following no bloom and holding no call, and opens again on its next +// request. import { randomUUID } from "node:crypto"; import { Bee, Chi, Role, type Hello, type Model, type Tone } from "../../../thrum-clients/ts/index.ts"; @@ -37,7 +41,7 @@ export const HELLO: Hello = { const IDLE_MS = 10 * 60_000; const READY_MS = 10_000; -const HELD_MS = 30 * 60_000; +const HELD_MS = 10 * 60_000; export interface Reach { models: Model[]; diff --git a/hives/openai-server/src/responses.ts b/hives/openai-server/src/responses.ts index 2626b7f1..c583b3ee 100644 --- a/hives/openai-server/src/responses.ts +++ b/hives/openai-server/src/responses.ts @@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { answers, fromResponses, opening, type Said } from "./conversation.ts"; -import { grow, grewOf, usageOf, type Grown, type Listener } from "./growth.ts"; +import { grow, mcpCallOf, servedOf, usageOf, type Grown, type Listener } from "./growth.ts"; import { growthOf, modelFor, prompt, type Caller, type Ctx } from "./hive.ts"; import { bad, gone, HttpError, readJson, sendJson, Sse } from "./http.ts"; import { toolsFromOpenAI } from "./tools.ts"; @@ -128,7 +128,7 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res const held = given.length > 0 ? rq.heldSid(given.map((a) => a.callId)) : undefined; const continuing = held !== undefined && !ctx.sigils.growing(held); const { graft, content } = opening(said); - const sid = held !== undefined && continuing ? held : before?.sid && !ctx.sigils.growing(before.sid) ? before.sid : ctx.sigils.continuing(rq.label, said.system, graft); + const sid = held !== undefined && continuing ? held : before?.sid && !ctx.sigils.growing(before.sid) ? before.sid : ctx.sigils.continuing(rq.label, said.system, graft, said.served); const format = body.text?.format; const open = continuing ? { answers: given } @@ -246,17 +246,7 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res }); }, served: (s) => { - const item = { - id: s.callId || `mcp_${short()}`, - type: "mcp_call", - status: s.isError ? "failed" : "completed", - server_label: "hum", - name: s.name, - arguments: s.args, - output: s.result, - error: s.isError ? s.result : null, - }; - whole(item, () => {}); + whole({ ...mcpCallOf(s), id: s.callId || `mcp_${short()}` }, () => {}); }, }; @@ -285,8 +275,9 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res const after = said.blooms.map((b) => ({ content: b.content, grew: [...(b.grew ?? [])] })); const last = after[after.length - 1]; - if (last) last.grew = [...(last.grew ?? []), ...grewOf(grown)]; - ctx.sigils.remember(rq.label, said.system, after, sid); + if (last) last.grew = [...(last.grew ?? []), ...grown.grew]; + const served = servedOf(said, grown); + ctx.sigils.remember(rq.label, said.system, after, sid, served); const used = usageOf(grown.nectar); ctx.usage.track(who.key, used.prompt, used.completion); @@ -302,7 +293,7 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res usage, ...(incomplete ? { incomplete_details: { reason: "max_output_tokens" } } : {}), }); - if (body.store !== false) kept.put(id, { label: rq.label, sid, said: { ...said, blooms: after, tail: "assistant" }, response: final }, conversation); + if (body.store !== false) kept.put(id, { label: rq.label, sid, said: { ...said, blooms: after, tail: "assistant", served }, response: final }, conversation); if (sse) { emit(incomplete ? "response.incomplete" : "response.completed", { response: final }); diff --git a/hives/openai-server/test/claude.live.test.ts b/hives/openai-server/test/claude.live.test.ts index 03034e39..8d2f746d 100644 --- a/hives/openai-server/test/claude.live.test.ts +++ b/hives/openai-server/test/claude.live.test.ts @@ -12,10 +12,13 @@ // asks. // // What it holds to: answers streamed and not, with usage; a conversation -// remembered turn to turn on one sigil; a conversation the nurse never saw, -// tool calls and all, grafted so claude knows it; a caller's tools, one or -// several, answered and carried on; a tool the server runs, hidden from -// chat completions and shown in a response; a JSON shape and a system +// remembered turn to turn on one sigil, even sent back with a turn's steps +// kept as one message; a conversation the nurse never saw, tool calls and +// all, grafted so claude knows it; a caller's tools, one or several, +// answered and carried on; a tool result the caller made up answered, as +// claude grows on from it, even in a turn where the server ran a tool +// the caller never saw; a tool the server runs, riding on a chat message +// and shown in a response; a JSON shape and a system // prompt honoured; an image seen; the Responses API's function calls; a // hang-up that stops claude; two conversations at once kept apart; and // what claude cannot do refused plainly. @@ -174,10 +177,80 @@ function nest(): string { expect(text).toContain("18"); }, SLOW); - test("a tool the server runs stays out of chat completions, and shows in a response", async () => { + test("a turn sent back as one message, its words and then its calls, stays on its sigil", async () => { + let messages: OpenAI.ChatCompletionMessageParam[] = [{ role: "user", content: "What is the weather in Paris? Use the get_weather tool, then tell me the temperature." }]; + const said: string[] = []; + const calls: OpenAI.ChatCompletionMessageToolCall[] = []; + const answers: OpenAI.ChatCompletionToolMessageParam[] = []; + for (let round = 0; round < 5; round++) { + const r = await ai.chat.completions.create({ model: MODEL, tools: [weather], messages }); + const m = r.choices[0].message; + said.push(m.content ?? ""); + if (!m.tool_calls || m.tool_calls.length === 0) break; + messages = [...messages, m]; + for (const c of m.tool_calls) { + if (c.type !== "function") continue; + calls.push(c); + const city = String(JSON.parse(c.function.arguments || "{}").city ?? "").toLowerCase(); + const answer = { role: "tool" as const, tool_call_id: c.id, content: forecast[city] ?? "unknown city" }; + answers.push(answer); + messages.push(answer); + } + } + expect(calls.length).toBeGreaterThan(0); + const before = nurseLog.length; + const r = await ai.chat.completions.create({ + model: MODEL, + tools: [weather], + messages: [ + messages[0], + { role: "assistant", content: said.join(""), tool_calls: calls }, + ...answers, + { role: "user", content: "Which city did you just look up? Reply with only its name, and do not call any tool." }, + ], + }); + expect(r.choices[0].message.content?.toLowerCase()).toContain("paris"); + expect(nurseLog.slice(before)).toContain("nurse.cell.reused"); + }, SLOW); + + test("a tool result the caller made up is answered, claude growing on from it", async () => { + const r = await ai.chat.completions.create({ + model: MODEL, + messages: [ + { role: "user", content: "Start the build in the background, and tell me how it went once it finishes." }, + { role: "assistant", content: "Started it as job 17; I'll report back when it finishes." }, + { role: "assistant", content: null, tool_calls: [{ id: "check_17", type: "function", function: { name: "check", arguments: '{"id":17}' } }] }, + { role: "tool", tool_call_id: "check_17", content: "job 17 finished (exit 0): build ok, 160 tests pass" }, + ], + }); + expect(r.choices[0].finish_reason).toBe("stop"); + expect(r.choices[0].message.content).toContain("160"); + }, SLOW); + + test("a delivery reaches claude though the server ran a tool in that turn the caller never saw", async () => { + const ask: OpenAI.ChatCompletionMessageParam = { role: "user", content: "Call the lookup tool with q set to wasps, tell me what it returned, then start the build in the background and say you'll report back." }; + const first = await ai.chat.completions.create({ model: MODEL, messages: [ask] }); + expect(first.choices[0].message.content).toContain("found wasps"); + const before = nurseLog.length; + const r = await ai.chat.completions.create({ + model: MODEL, + messages: [ + ask, + { role: "assistant", content: first.choices[0].message.content }, + { role: "assistant", content: null, tool_calls: [{ id: "check_42", type: "function", function: { name: "check", arguments: '{"id":42}' } }] }, + { role: "tool", tool_call_id: "check_42", content: "job 42 finished (exit 0): build ok, 271 tests pass" }, + ], + }); + expect(r.choices[0].message.content).toContain("271"); + expect(nurseLog.slice(before)).toContain("graft.late-results"); + }, SLOW); + + test("a tool the server runs rides on a chat message, and shows in a response", async () => { const chat = await ai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: "Call the lookup tool with q set to bees, then tell me exactly what it returned." }] }); - expect(chat.choices[0].message.tool_calls).toBeUndefined(); - expect(chat.choices[0].message.content).toContain("found bees"); + const m = chat.choices[0].message as OpenAI.ChatCompletionMessage & { mcp_calls?: Array<{ name: string; output: string }> }; + expect(m.tool_calls).toBeUndefined(); + expect(m.content).toContain("found bees"); + expect(m.mcp_calls?.map((c) => c.output)).toEqual(["found bees"]); const r = await ai.responses.create({ model: MODEL, input: "Call the lookup tool with q set to honey, then tell me exactly what it returned." }); const served = r.output.find((o) => o.type === "mcp_call") as { name: string; output: string } | undefined; expect(served?.name).toBe("lookup"); diff --git a/hives/openai-server/test/conversation.test.ts b/hives/openai-server/test/conversation.test.ts index 91294b67..4d97d978 100644 --- a/hives/openai-server/test/conversation.test.ts +++ b/hives/openai-server/test/conversation.test.ts @@ -65,6 +65,97 @@ describe("a conversation keeps its sigil", () => { expect(trace("key-a", undefined, a.blooms)).not.toBe(trace("key-b", undefined, a.blooms)); }); + test("a tool the server ran is read back from chat and from items, and the trace finds the sigil either way", () => { + const lookup = { id: "mcp-1", name: "lookup", arguments: '{"q":"bees"}', output: "found bees", error: null }; + const echoed = fromChat([ + { role: "user", content: "find bees" }, + { role: "assistant", content: "after tools: found bees", mcp_calls: [lookup] }, + ]); + expect(echoed.blooms[0].grew).toEqual([ + { kind: "tendril", callId: "mcp-1", name: "lookup", args: { q: "bees" } }, + { kind: "grasp", callId: "mcp-1", result: "found bees", isError: false }, + { kind: "text", text: "after tools: found bees" }, + ]); + expect([...echoed.served]).toEqual(["mcp-1"]); + const dropped = fromChat([ + { role: "user", content: "find bees" }, + { role: "assistant", content: "after tools: found bees" }, + ]); + const items = fromResponses([ + { role: "user", content: "find bees" }, + { type: "mcp_call", ...lookup }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "after tools: found bees" }] }, + ]); + const kept = trace("key-a", undefined, echoed.blooms, echoed.served); + expect(trace("key-a", undefined, dropped.blooms, dropped.served)).toBe(kept); + expect(trace("key-a", undefined, items.blooms, items.served)).toBe(kept); + }); + + test("the trace reads a turn kept as one message as the steps it grew in", () => { + const call = (id: string) => ({ id, type: "function", function: { name: "zsh", arguments: "{}" } }); + const stepped = fromChat([ + { role: "user", content: "check it" }, + { role: "assistant", content: "I'll look.", tool_calls: [call("c1")] }, + { role: "tool", tool_call_id: "c1", content: "a" }, + { role: "assistant", content: "Then this.", tool_calls: [call("c2")] }, + { role: "tool", tool_call_id: "c2", content: "b" }, + { role: "assistant", content: "Done." }, + ]); + const kept = fromChat([ + { role: "user", content: "check it" }, + { role: "assistant", content: "I'll look.Then this.\n\nDone.", tool_calls: [call("c1"), call("c2")] }, + { role: "tool", tool_call_id: "c1", content: "a" }, + { role: "tool", tool_call_id: "c2", content: "b" }, + ]); + expect(trace("key-a", undefined, kept.blooms)).toBe(trace("key-a", undefined, stepped.blooms)); + const reordered = fromChat([ + { role: "user", content: "check it" }, + { role: "assistant", content: "I'll look.Then this.Done.", tool_calls: [call("c2"), call("c1")] }, + { role: "tool", tool_call_id: "c2", content: "b" }, + { role: "tool", tool_call_id: "c1", content: "a" }, + ]); + expect(trace("key-a", undefined, reordered.blooms)).not.toBe(trace("key-a", undefined, stepped.blooms)); + const answered = fromChat([ + { role: "user", content: "check it" }, + { role: "assistant", content: "I'll look.Then this.Done.", tool_calls: [call("c1"), call("c2"), call("check_17")] }, + { role: "tool", tool_call_id: "c1", content: "a" }, + { role: "tool", tool_call_id: "c2", content: "b" }, + { role: "tool", tool_call_id: "check_17", content: "job 17 finished" }, + ]); + expect(trace("key-a", undefined, answered.blooms)).not.toBe(trace("key-a", undefined, stepped.blooms)); + }); + + test("a conversation ending in answers finds the sigil it had before them", () => { + const sigils = new Sigils(() => {}); + const call = (id: string) => ({ id, type: "function", function: { name: "check", arguments: "{}" } }); + const grown = fromChat([{ role: "user", content: "start the build" }, { role: "assistant", content: "Started job 17.", tool_calls: [call("c1")] }]); + sigils.remember("key-a", undefined, grown.blooms, "oai-1"); + const late = fromChat([ + { role: "user", content: "start the build" }, + { role: "assistant", content: "Started job 17.", tool_calls: [call("c1")] }, + { role: "tool", tool_call_id: "c1", content: "running" }, + ]); + expect(sigils.continuing("key-a", undefined, late.blooms)).toBe("oai-1"); + + const told = fromChat([{ role: "user", content: "start the build" }, { role: "assistant", content: "Started job 17." }]); + sigils.remember("key-a", undefined, told.blooms, "oai-2"); + const delivered = fromChat([ + { role: "user", content: "start the build" }, + { role: "assistant", content: "Started job 17." }, + { role: "assistant", content: null, tool_calls: [call("check_17")] }, + { role: "tool", tool_call_id: "check_17", content: "job 17 finished" }, + ]); + expect(sigils.continuing("key-a", undefined, delivered.blooms)).toBe("oai-2"); + + const edited = fromChat([ + { role: "user", content: "start the build" }, + { role: "assistant", content: "Started job 18." }, + { role: "assistant", content: null, tool_calls: [call("check_17")] }, + { role: "tool", tool_call_id: "check_17", content: "job 17 finished" }, + ]); + expect(sigils.continuing("key-a", undefined, edited.blooms)).not.toBe("oai-2"); + }); + test("a sigil is continued only while it is not growing, and only by its caller", () => { const forgot: string[] = []; const sigils = new Sigils((sid) => forgot.push(sid), 1); diff --git a/hives/openai-server/test/e2e.test.ts b/hives/openai-server/test/e2e.test.ts index dfcada25..c3b6d7e4 100644 --- a/hives/openai-server/test/e2e.test.ts +++ b/hives/openai-server/test/e2e.test.ts @@ -7,10 +7,10 @@ // history once, as messages; strangers never share a conversation; every // sampling setting and image reaches the model; a caller's tools keep // their names, come back one turn at a time or several at once, and their -// answers reach the model; a tool the server runs stays out of chat -// completions and shows in a response; failures carry their status; a -// caller that hangs up stops the model; and the Responses API, legacy -// completions, embeddings and keys all hold. +// answers reach the model; a tool the server runs rides on a chat +// message as `mcp_calls` and shows in a response; failures carry their +// status; a caller that hangs up stops the model; and the Responses API, +// legacy completions, embeddings and keys all hold. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import OpenAI from "openai"; @@ -164,10 +164,25 @@ describe("chat completions", () => { expect((await heardSince(n))[0].body.messages.map((m: { role: string }) => m.role)).toEqual(["user", "assistant", "tool"]); }); - test("a tool the server runs stays out of chat completions", async () => { - const r = await ai.chat.completions.create({ model: "gpt-lab", messages: [{ role: "user", content: `LOOKUP ${tag()}` }] }); - expect(r.choices[0].message.tool_calls).toBeUndefined(); - expect(r.choices[0].message.content).toBe("after tools: found bees"); + test("a tool the server runs rides on the message as mcp_calls, and is read back", async () => { + const t = tag(); + const r = await ai.chat.completions.create({ model: "gpt-lab", messages: [{ role: "user", content: `LOOKUP ${t}` }] }); + const m = r.choices[0].message as OpenAI.ChatCompletionMessage & { mcp_calls?: Array<{ type: string; name: string; output: string }> }; + expect(m.tool_calls).toBeUndefined(); + expect(m.content).toBe("after tools: found bees"); + expect(m.mcp_calls?.map((c) => [c.type, c.name, c.output])).toEqual([["mcp_call", "lookup", "found bees"]]); + + const n = (await heard()).length; + await ai.chat.completions.create({ model: "gpt-lab", messages: [{ role: "user", content: `LOOKUP ${t}` }, m, { role: "user", content: `next ${t}` }] }); + const sent = (await heardSince(n))[0].body.messages as Array<{ role: string; content: string }>; + expect(sent.filter((x) => x.role === "tool").map((x) => x.content)).toEqual(["found bees"]); + + const streamed = await ai.chat.completions.create({ model: "gpt-lab", stream: true, messages: [{ role: "user", content: `LOOKUP ${tag()}` }] }); + const rode: string[] = []; + for await (const c of streamed) { + for (const call of (c.choices[0]?.delta as { mcp_calls?: Array<{ name: string }> } | undefined)?.mcp_calls ?? []) rode.push(call.name); + } + expect(rode).toEqual(["lookup"]); }); test("failures carry their status", async () => { diff --git a/hives/openai-server/test/nurse.test.ts b/hives/openai-server/test/nurse.test.ts index bb4192fb..274c8ebc 100644 --- a/hives/openai-server/test/nurse.test.ts +++ b/hives/openai-server/test/nurse.test.ts @@ -1,9 +1,9 @@ -// The nurse half's reading of a prompt: its graft as OpenAI messages, a -// plain-text graft to grow on from, and how the bloom grows as the -// request's settings. +// The nurse half's reading of a prompt: its graft as OpenAI messages, tool +// answers a caller added to a conversation the nurse kept, a plain-text +// graft to grow on from, and how the bloom grows as the request's settings. import { describe, expect, test } from "bun:test"; -import { askOf, messagesOf, plainText } from "../src/nurse.ts"; +import { askOf, lateAnswers, messagesOf, plainText } from "../src/nurse.ts"; describe("the nurse half", () => { test("a graft is the conversation, tool calls and answers in their places", () => { @@ -23,6 +23,37 @@ describe("the nurse half", () => { ]); }); + test("tool answers a caller added to the last bloom join the kept conversation, once", () => { + const kept = [ + { role: "user", content: "Start the build" }, + { role: "assistant", content: "Started it as job 17." }, + ]; + const delivered = { + content: [{ kind: "text", text: "Start the build" }], + grew: [ + { kind: "text", text: "Started it as job 17." }, + { kind: "tendril", callId: "check_17", name: "check", args: { id: 17 } }, + { kind: "grasp", callId: "check_17", result: "job 17 finished" }, + ], + }; + const late = lateAnswers(kept, delivered); + expect(late).toEqual([ + { role: "assistant", content: null, tool_calls: [{ id: "check_17", type: "function", function: { name: "check", arguments: '{"id":17}' } }] }, + { role: "tool", tool_call_id: "check_17", content: "job 17 finished" }, + ]); + expect(lateAnswers([...kept, ...late], delivered)).toEqual([]); + }); + + test("a late answer to the nurse's own call joins the kept call, which is not said twice", () => { + const kept = [ + { role: "user", content: "read x" }, + { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "readFile", arguments: "{}" } }] }, + ]; + const answered = { grew: [{ kind: "tendril", callId: "call_1", name: "readFile", args: {} }, { kind: "grasp", callId: "call_1", result: "X" }] }; + expect(lateAnswers(kept, answered)).toEqual([{ role: "tool", tool_call_id: "call_1", content: "X" }]); + expect(lateAnswers(kept, { grew: [{ kind: "grasp", callId: "nobody", result: "?" }] })).toEqual([]); + }); + test("only a graft no one prompted is plain text to grow on from", () => { expect(plainText([{ grew: [{ kind: "text", text: "def f():" }] }])).toBe("def f():"); expect(plainText([{ content: [{ kind: "text", text: "hi" }], grew: [{ kind: "text", text: "hello" }] }])).toBeUndefined(); diff --git a/hives/openai-server/test/requester.test.ts b/hives/openai-server/test/requester.test.ts index 3e6a2d5c..045ff559 100644 --- a/hives/openai-server/test/requester.test.ts +++ b/hives/openai-server/test/requester.test.ts @@ -1,5 +1,5 @@ // A caller's bee holding the tools its bloom reached it for: its link -// stays open while it holds one, a hold lasts thirty minutes, and a hold +// stays open while it holds one, a hold lasts ten minutes, and a hold // let go unanswered cancels its bloom. The requester here never reaches a // humd; what it sends is caught, and what it hears is played to it. @@ -32,20 +32,21 @@ describe("a requester holding a call", () => { test("is not idle while the hold is live, and is once it lapses", () => { const rq = new Requester("key-held", nowhere); const at = Date.now(); + rq.lastUsed = at - 5 * MINUTE; rq.hold("oai-1", "call_1"); - expect(rq.idle(at + 11 * MINUTE)).toBe(false); - expect(rq.idle(at + 29 * MINUTE)).toBe(false); - expect(rq.idle(at + 31 * MINUTE)).toBe(true); + expect(rq.idle(at + 6 * MINUTE)).toBe(false); + expect(rq.idle(at + 9 * MINUTE)).toBe(false); + expect(rq.idle(at + 11 * MINUTE)).toBe(true); rq.close(); }); - test("resumes the bloom for an answer within thirty minutes, and not after", () => { + test("resumes the bloom for an answer within ten minutes, and not after", () => { const rq = new Requester("key-late", nowhere); const at = Date.now(); rq.hold("oai-2", "call_2"); - setSystemTime(new Date(at + 29 * MINUTE)); + setSystemTime(new Date(at + 9 * MINUTE)); expect(rq.heldSid(["call_2"])).toBe("oai-2"); - setSystemTime(new Date(at + 31 * MINUTE)); + setSystemTime(new Date(at + 11 * MINUTE)); expect(rq.heldSid(["call_2"])).toBeUndefined(); rq.close(); }); @@ -57,7 +58,7 @@ describe("a requester holding a call", () => { rq.release(["call_3"]); expect(rq.heldSid(["call_3"])).toBeUndefined(); expect(rq.idle(at + 11 * MINUTE)).toBe(true); - rq.lapse(at + 31 * MINUTE); + rq.lapse(at + 11 * MINUTE); rq.close(); expect(cancels()).toEqual([]); }); @@ -69,10 +70,10 @@ describe("a hold let go unanswered", () => { const at = Date.now(); rq.hold("oai-4", "call_a"); rq.hold("oai-4", "call_b"); - rq.lapse(at + 11 * MINUTE); + rq.lapse(at + 9 * MINUTE); expect(cancels()).toEqual([]); - rq.lapse(at + 31 * MINUTE); - rq.lapse(at + 32 * MINUTE); + rq.lapse(at + 11 * MINUTE); + rq.lapse(at + 12 * MINUTE); expect(cancels()).toEqual(["oai-4"]); rq.close(); expect(cancels()).toEqual(["oai-4"]); @@ -92,7 +93,7 @@ describe("a hold let go unanswered", () => { rq.send(prompt("oai-6", undefined, { content: [] })); rq.hold("oai-7", "call_7"); rq.follow("oai-7", () => {}); - rq.lapse(at + 31 * MINUTE); + rq.lapse(at + 11 * MINUTE); expect(cancels()).toEqual([]); rq.close(); }); @@ -101,7 +102,7 @@ describe("a hold let go unanswered", () => { const { rq, hear, cancels } = caught("key-stilled"); const at = Date.now(); rq.hold("oai-8", "call_8"); - rq.lapse(at + 31 * MINUTE); + rq.lapse(at + 11 * MINUTE); expect(cancels()).toEqual(["oai-8"]); const got: string[] = []; rq.follow("oai-8", (t) => void got.push(`${t.chi}${t.chi === "wilt" ? `:${t.reason}` : ""}`)); @@ -121,7 +122,7 @@ describe("a bloom's holds", () => { hear({ chi: "tendril-reach", sid: "oai-9", callId: "call_9", name: "readFile", args: {} }); expect((await grown).finish).toBe("tool_calls"); expect(rq.heldSid(["call_9"])).toBe("oai-9"); - rq.lapse(at + 31 * MINUTE); + rq.lapse(at + 11 * MINUTE); expect(cancels()).toEqual(["oai-9"]); rq.close(); }); @@ -134,7 +135,7 @@ describe("a bloom's holds", () => { hear({ chi: "error", sid: "oai-10", code: "upstream_error", message: "broke" }); await expect(grown).rejects.toThrow("broke"); expect(rq.heldSid(["call_10"])).toBeUndefined(); - rq.lapse(at + 31 * MINUTE); + rq.lapse(at + 11 * MINUTE); rq.close(); expect(cancels()).toEqual([]); }); diff --git a/hives/openai-server/test/responses.test.ts b/hives/openai-server/test/responses.test.ts index 57521201..f13b1c61 100644 --- a/hives/openai-server/test/responses.test.ts +++ b/hives/openai-server/test/responses.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"; import { Shelf, type Kept } from "../src/responses.ts"; -const kept = (label: string, sid: string): Kept => ({ label, sid, said: { blooms: [], tail: "assistant" }, response: {} }); +const kept = (label: string, sid: string): Kept => ({ label, sid, said: { blooms: [], tail: "assistant", served: new Set() }, response: {} }); describe("the Responses store", () => { test("a conversation follows its latest response, for its own key", () => {