diff --git a/hives/anthropic-server/README.md b/hives/anthropic-server/README.md index b526cd5e..1053fd8c 100644 --- a/hives/anthropic-server/README.md +++ b/hives/anthropic-server/README.md @@ -92,14 +92,21 @@ images and documents, tools of any type but custom, a thinking config other than a budget, `cache_control`, `citations`, `metadata`, `service_tier` and `container` are all left out. -**The sigil.** A conversation keeps one sigil while it only grows, so a -nurse whose cell grew every bloom of the graft grows on from its own, and -any other nurse grows from the graft. The sid a request continues is the -one its conversation was last grown in, for its tenant; a conversation no -one has seen, one whose history was edited, or one already growing gets -a sigil of its own. The trace that finds it is the tenant, the system -prompt, the words of every turn and the id of every tool use. The server -keeps the 4096 latest, and sends `cleanup` for a sigil it forgets. +**The sigil.** A conversation keeps one sigil while it grows, so a nurse +whose cell grew every bloom of the graft grows on from its own, and any +other nurse grows from the graft. A client may name its line with an +`X-Hum-Sid` header, kept apart from every other tenant's. Otherwise the +sigil is found by the newest tool use or result id the conversation +holds that its tenant was given, or, with no tools, by the words of its +last two turns. The whole history is not compared: clients compact it +all the time, dropping old tool pairs, trimming, or sending a turn back +without the words said between its tool uses, while a tool use's id +comes back as it was given. A client that edits an older turn keeps its +sigil, but a sigil goes on only from where it last left off, so a copy +of the conversation taken before then gets one of its own; one already +growing gets a sigil of its own, and tenants never share one. The server +keeps 4096 ids and 4096 tails, and sends `cleanup` for a sigil no key +finds any more. **The answer.** A request streams only when its body says `"stream": true`. A request without it gets one message: its text, diff --git a/hives/anthropic-server/src/conversation.ts b/hives/anthropic-server/src/conversation.ts index 48ce4c28..3f76aa9e 100644 --- a/hives/anthropic-server/src/conversation.ts +++ b/hives/anthropic-server/src/conversation.ts @@ -18,13 +18,19 @@ // with tool results or with the assistant's own words asks for a bloom to // grow on: the prompt has no content, and every bloom is its graft. // -// A conversation keeps one sigil while it only grows. The sid a request -// continues is the one its blooms so far were last grown in, for this -// tenant; 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 tenant, the system prompt, the words of every turn and -// the ids of every tool use, which a client sends back as it was given -// them. +// A conversation keeps one sigil while it grows, for this tenant; tenants +// never share one, and one already growing elsewhere gets a sigil of its +// own. A client may name its line with `X-Hum-Sid`. Otherwise the sigil is +// found by the newest tool use or result id the conversation holds that +// this tenant was given, or by the words of its last two turns, and not by +// the whole history: a client compacts and reshapes it all the time, +// dropping old tool pairs, trimming, or sending a turn back without the +// words said between its tool uses, while a tool use's id is echoed as it +// was given, since its result needs it, and the newest turns are left +// alone. A client that edits an older turn keeps its sigil. A sigil goes +// on only from where it last left off, so a copy of the conversation taken +// before then starts its own. A conversation that ends in tool results is +// found by its last two turns before them too. import { createHash, randomUUID } from "node:crypto"; import { PetalKind, type Bloom, type Grain, type Petal } from "../../../thrum-clients/ts/index.ts"; @@ -193,35 +199,47 @@ function squeeze(s: string): string { return s.replace(/\s+/g, " ").trim(); } -export function trace(label: string, system: string | undefined, blooms: Bloom[]): string { - const h = createHash("sha256"); - h.update(`${label}\0${squeeze(system ?? "")}\0`); - for (const b of blooms) { - h.update("U"); +function unspaced(s: string): string { + return s.replace(/\s+/g, ""); +} + +function tailOf(label: string, blooms: Bloom[]): string | undefined { + let said = ""; + let worded = false; + for (const b of blooms.slice(-2)) { + let user = ""; for (const p of b.content ?? []) { - 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 = ""; - 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`); - } + if (p.kind === PetalKind.text) user += `${squeeze(p.text ?? "")}\0`; + else if (p.kind === PetalKind.pollen) user += `p${p.grain?.kind ?? ""}\0`; } - if (squeeze(words)) h.update(`t${squeeze(words)}\0`); + const words = unspaced((b.grew ?? []).filter((p) => p.kind === PetalKind.text).map((p) => p.text ?? "").join("")); + worded ||= words.length > 0 || (b.content ?? []).some((p) => p.kind === PetalKind.text && squeeze(p.text ?? "").length > 0); + said += `U${user}G${words}\0`; + } + return worded ? createHash("sha256").update(`${label}\0${said}`).digest("hex") : undefined; +} + +function callIds(blooms: Bloom[]): string[] { + return blooms.flatMap((b) => (b.grew ?? []).filter((p) => (p.kind === PetalKind.tendril || p.kind === PetalKind.grasp) && p.callId).map((p) => p.callId as string)); +} + +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 ?? ""); } - return h.digest("hex"); + 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 #byCall = new Map(); + readonly #byTail = new Map(); + readonly #keys = new Map(); + readonly #latest = new Map(); readonly #cap: number; readonly #growing = new Set(); readonly #forget: (sid: string) => void; @@ -235,9 +253,20 @@ export class Sigils { return label === "default" ? randomUUID() : `${label}:${randomUUID()}`; } - continuing(label: string, system: string | undefined, blooms: Bloom[]): string { + named(label: string, name: string): string { + const line = createHash("sha256").update(`${label}\0${name}`).digest("hex").slice(0, 32); + const sid = label === "default" ? `line-${line}` : `${label}:line-${line}`; + return this.#growing.has(sid) ? this.fresh(label) : sid; + } + + continuing(label: string, blooms: Bloom[]): string { if (blooms.length === 0) return this.fresh(label); - const sid = this.#byTrace.get(trace(label, system, blooms)); + const ids = callIds(blooms); + const byCall = [...ids].reverse().map((id) => this.#byCall.get(`${label}\0${id}`)).find((found) => found !== undefined); + const newest = byCall === undefined ? undefined : this.#latest.get(byCall)?.call; + const sid = (newest !== undefined && ids.includes(newest) ? byCall : undefined) ?? [blooms, beforeAnswers(blooms, false), beforeAnswers(blooms, true)] + .map((b) => this.#leftOff(tailOf(label, b))) + .find((found) => found !== undefined); return sid && !this.#growing.has(sid) ? sid : this.fresh(label); } @@ -253,14 +282,44 @@ export class Sigils { this.#growing.delete(sid); } - remember(label: string, system: string | undefined, blooms: Bloom[], sid: string): void { - const key = trace(label, system, blooms); - this.#byTrace.delete(key); - this.#byTrace.set(key, sid); - while (this.#byTrace.size > this.#cap) { - const [oldest, gone] = this.#byTrace.entries().next().value as [string, string]; - this.#byTrace.delete(oldest); - if (![...this.#byTrace.values()].includes(gone) && !this.#growing.has(gone)) this.#forget(gone); + remember(label: string, blooms: Bloom[], sid: string): void { + const ids = callIds(blooms); + for (const id of ids) this.#keep(this.#byCall, `${label}\0${id}`, sid); + const tail = tailOf(label, blooms); + if (tail !== undefined) this.#keep(this.#byTail, tail, sid); + this.#latest.set(sid, { call: ids[ids.length - 1], tail }); + for (const map of [this.#byCall, this.#byTail]) { + while (map.size > this.#cap) { + const [oldest, gone] = map.entries().next().value as [string, string]; + map.delete(oldest); + this.#drop(gone); + } + } + } + + #leftOff(key: string | undefined): string | undefined { + const sid = key === undefined ? undefined : this.#byTail.get(key); + return sid !== undefined && this.#latest.get(sid)?.tail === key ? sid : undefined; + } + + #keep(map: Map, key: string, sid: string): void { + const was = map.get(key); + map.delete(key); + map.set(key, sid); + if (was === sid) return; + this.#keys.set(sid, (this.#keys.get(sid) ?? 0) + 1); + if (was !== undefined) this.#drop(was); + } + + #drop(sid: string): void { + const left = (this.#keys.get(sid) ?? 1) - 1; + if (left > 0) { + this.#keys.set(sid, left); + return; } + this.#keys.delete(sid); + if (this.#growing.has(sid)) return; + this.#forget(sid); + this.#latest.delete(sid); } } diff --git a/hives/anthropic-server/src/http.ts b/hives/anthropic-server/src/http.ts index 408afc09..722d1b11 100644 --- a/hives/anthropic-server/src/http.ts +++ b/hives/anthropic-server/src/http.ts @@ -66,6 +66,11 @@ export async function readJson(req: IncomingMessage): Promise { } } +export function lineOf(req: IncomingMessage): string | undefined { + const named = req.headers["x-hum-sid"]; + return typeof named === "string" && named.trim().length > 0 ? named.trim() : undefined; +} + export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record = {}): void { if (res.headersSent) return; res.writeHead(status, { "Content-Type": "application/json", ...headers }); diff --git a/hives/anthropic-server/src/messages.ts b/hives/anthropic-server/src/messages.ts index b7f613e0..7e7d10ce 100644 --- a/hives/anthropic-server/src/messages.ts +++ b/hives/anthropic-server/src/messages.ts @@ -20,7 +20,7 @@ import type { Bloom, Petal, PromptBody, Tone } from "../../../thrum-clients/ts/i import { answers, fromMessages, opening, type Message, type Sigils } from "./conversation.ts"; import type { Forager } from "./forager.ts"; import { grewOf, grow, type Block, type Grown, type Listener, type Usage } from "./growth.ts"; -import { bad, gone, HttpError, readJson, sendJson, Sse } from "./http.ts"; +import { bad, gone, HttpError, lineOf, readJson, sendJson, Sse } from "./http.ts"; import { tendrilsOf, toolsFromAnthropic } from "./tools.ts"; export interface Ctx { @@ -81,7 +81,8 @@ export async function messages(ctx: Ctx, tenant: string, req: IncomingMessage, r const given = answers(said); const held = given.length > 0 ? ctx.forager.heldSid(given.map((a) => a.callId)) : undefined; const { graft, content } = opening(said); - const sid = held !== undefined && !ctx.sigils.growing(held) ? held : ctx.sigils.continuing(tenant, said.system, graft); + const line = lineOf(req); + const sid = held !== undefined && !ctx.sigils.growing(held) ? held : line !== undefined ? ctx.sigils.named(tenant, line) : ctx.sigils.continuing(tenant, graft); const continuing = sid === held; const stream = body.stream === true; ctx.audit({ endpoint: "messages", tenant, model, sid, stream }); @@ -116,7 +117,7 @@ export async function messages(ctx: Ctx, tenant: string, req: IncomingMessage, r 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(tenant, said.system, after, sid); + ctx.sigils.remember(tenant, after, sid); ctx.track(tenant, grown.usage); if (sse) { diff --git a/hives/anthropic-server/test/conversation.test.ts b/hives/anthropic-server/test/conversation.test.ts index fd8f99dc..f6bfb714 100644 --- a/hives/anthropic-server/test/conversation.test.ts +++ b/hives/anthropic-server/test/conversation.test.ts @@ -2,7 +2,7 @@ // and the sigil it keeps. import { describe, expect, test } from "bun:test"; -import { answers, fromMessages, opening, Sigils, systemOf, trace } from "../src/conversation.ts"; +import { answers, fromMessages, opening, Sigils, systemOf } from "../src/conversation.ts"; import { stopReasonOf, usageOf } from "../src/growth.ts"; describe("fromMessages", () => { @@ -80,27 +80,68 @@ describe("fromMessages", () => { }); describe("a conversation keeps its sigil", () => { - test("the trace reads what a client sends back, whitespace and thinking aside", () => { - const a = fromMessages(undefined, [{ role: "user", content: "hi" }, { role: "assistant", content: "hello there" }]); - const b = fromMessages(undefined, [{ role: "user", content: "hi" }, { role: "assistant", content: [{ type: "thinking", thinking: "x", signature: "s" }, { type: "text", text: "hello there\n" }] }]); - expect(trace("default", undefined, a.blooms)).toBe(trace("default", undefined, b.blooms)); - expect(trace("default", undefined, a.blooms)).not.toBe(trace("acme", undefined, a.blooms)); + const use = (id: string) => ({ type: "tool_use", id, name: "zsh", input: {} }); + const result = (id: string, content: string) => ({ type: "tool_result", tool_use_id: id, content }); + const stepped = [ + { role: "user", content: "check it" }, + { role: "assistant", content: [{ type: "text", text: "I'll look." }, use("t1")] }, + { role: "user", content: [result("t1", "a")] }, + { role: "assistant", content: [{ type: "text", text: "Then this." }, use("t2")] }, + { role: "user", content: [result("t2", "b")] }, + { role: "assistant", content: "Done." }, + ]; + const grown = () => { + const sigils = new Sigils(() => {}); + sigils.remember("default", fromMessages(undefined, stepped as never).blooms, "s-1"); + return sigils; + }; + const asked = (system: unknown, messages: unknown[]) => opening(fromMessages(system as never, [...messages, { role: "user", content: "and now?" }] as never)).graft; + + test("though old tool pairs are dropped, words are missing between tool uses, or the system prompt changes", () => { + const dropped = [stepped[0], { role: "assistant", content: [{ type: "text", text: "I'll look. Then this." }, use("t2")] }, stepped[4], stepped[5]]; + expect(grown().continuing("default", asked(undefined, dropped))).toBe("s-1"); + const bare = [stepped[0], { role: "assistant", content: [use("t1")] }, stepped[2], { role: "assistant", content: [use("t2")] }, stepped[4], stepped[5]]; + expect(grown().continuing("default", asked(undefined, bare))).toBe("s-1"); + expect(grown().continuing("default", asked("[bookkeeping] deem it", stepped))).toBe("s-1"); + }); + + test("with no tools, by its last two turns, whitespace and thinking aside", () => { + const sigils = new Sigils(() => {}); + sigils.remember("default", fromMessages(undefined, [{ role: "user", content: "hi" }, { role: "assistant", content: "hello there" }]).blooms, "s-1"); + const thought = [{ role: "user", content: "hi" }, { role: "assistant", content: [{ type: "thinking", thinking: "x", signature: "s" }, { type: "text", text: "hello there\n" }] }]; + expect(sigils.continuing("default", asked(undefined, thought))).toBe("s-1"); + const delivered = [...thought, { role: "assistant", content: [use("note_1")] }, { role: "user", content: [result("note_1", "a note")] }]; + expect(sigils.continuing("default", opening(fromMessages(undefined, delivered as never)).graft)).toBe("s-1"); + }); + + test("only from where it last left off: an earlier copy starts its own", () => { + const copy = [stepped[0], stepped[1], stepped[2], { role: "assistant", content: "Looks fine." }]; + expect(grown().continuing("default", asked(undefined, copy))).not.toBe("s-1"); + const sigils = new Sigils(() => {}); + const chat = [{ role: "user", content: "hi" }, { role: "assistant", content: "Hello!" }, { role: "user", content: "joke" }, { role: "assistant", content: "Why did the bee..." }]; + sigils.remember("default", fromMessages(undefined, chat as never).blooms, "s-1"); + sigils.remember("default", fromMessages(undefined, [...chat, { role: "user", content: "another" }, { role: "assistant", content: "A wasp..." }] as never).blooms, "s-1"); + expect(sigils.continuing("default", asked(undefined, chat))).not.toBe("s-1"); }); - test("a sigil is continued only while it is not growing, and only by its tenant", () => { + test("never for another tenant, nor while it is growing, and a line a client names is its own", () => { const forgot: string[] = []; const sigils = new Sigils((sid) => forgot.push(sid), 1); const blooms = fromMessages(undefined, [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }]).blooms; - expect(sigils.continuing("default", undefined, [])).not.toBe(sigils.continuing("default", undefined, [])); + expect(sigils.continuing("default", [])).not.toBe(sigils.continuing("default", [])); expect(sigils.fresh("acme")).toStartWith("acme:"); - sigils.remember("default", undefined, blooms, "s-1"); - expect(sigils.continuing("default", undefined, blooms)).toBe("s-1"); - expect(sigils.continuing("acme", undefined, blooms)).not.toBe("s-1"); + sigils.remember("default", blooms, "s-1"); + expect(sigils.continuing("default", blooms)).toBe("s-1"); + expect(sigils.continuing("acme", blooms)).not.toBe("s-1"); sigils.begin("s-1"); - expect(sigils.continuing("default", undefined, blooms)).not.toBe("s-1"); + expect(sigils.continuing("default", blooms)).not.toBe("s-1"); sigils.end("s-1"); - sigils.remember("default", undefined, [...blooms, ...blooms], "s-2"); + sigils.remember("default", [...blooms, ...blooms], "s-2"); expect(forgot).toEqual(["s-1"]); + const mine = sigils.named("acme", "window-1"); + expect(mine).toStartWith("acme:"); + expect(sigils.named("acme", "window-1")).toBe(mine); + expect(sigils.named("other", "window-1")).not.toBe(mine); }); }); diff --git a/hives/claude-cli/src/graft.rs b/hives/claude-cli/src/graft.rs index 5ab383c6..0ff38444 100644 --- a/hives/claude-cli/src/graft.rs +++ b/hives/claude-cli/src/graft.rs @@ -13,10 +13,12 @@ //! 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. +//! Tool results a held transcript lacks are those it holds no answer like: +//! the same call answered alike, or, for a call the nest named before it +//! kept claude's ids, the same tool answered alike. A transcript may hold +//! answers a client never saw, a tool the server ran or a call the nest +//! gave up on; a late answer to a call claude already holds an answer for +//! is stitched on as a call of its own, since a call takes one answer. use anyhow::{Context, Result}; use serde_json::{json, Value}; @@ -347,9 +349,19 @@ fn late_results(history: &[Value], entries: &[Value]) -> Vec { .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() + let mut held: Vec<(&str, &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"))))) + .filter_map(|p| { + let id = p.get("tool_use_id")?.as_str()?; + Some((id, *named.get(id)?, answer_of(p.get("content")))) + }) + .collect(); + let used: HashSet<&str> = entries + .iter() + .filter_map(message_content) + .flatten() + .filter(|p| kind(p, "tool_use")) + .filter_map(|p| p.get("id")?.as_str()) .collect(); let calls: Vec<&Value> = parts_of(turn, "assistant", "tool-call").collect(); let mut late: Vec<(Value, Value)> = Vec::new(); @@ -358,7 +370,8 @@ fn late_results(history: &[Value], entries: &[Value]) -> Vec { 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) { + let same_call = held.iter().position(|(i, _, a)| Some(*i) == id && *a == answer); + match same_call.or_else(|| held.iter().position(|(_, n, a)| *n == name && *a == answer)) { Some(i) => { held.swap_remove(i); } @@ -368,10 +381,34 @@ fn late_results(history: &[Value], entries: &[Value]) -> Vec { if late.is_empty() { return Vec::new(); } + for (call, result) in late.iter_mut() { + if id_of_call(call).is_some_and(|id| used.contains(id)) { + let fresh = format!("toolu_hum_{}", random_uuid_v4().replace('-', "")); + call["toolCallId"] = json!(fresh); + result["toolCallId"] = json!(fresh); + } + } let (answered, results): (Vec, Vec) = late.into_iter().unzip(); vec![json!({ "role": "assistant", "content": answered }), json!({ "role": "tool", "content": results })] } +fn id_of_call(call: &Value) -> Option<&str> { + call.get("toolCallId").and_then(Value::as_str) +} + +pub fn told(path: &Path) -> Option { + let entries = read_entries(path); + entries + .iter() + .rev() + .filter(|e| entry_type(e) == "user") + .filter_map(message_content) + .flat_map(|c| c.iter().rev()) + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .find_map(crate::instructed) + .map(str::to_owned) +} + /// 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( diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index 7c6fe4ec..f8a84eac 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -26,10 +26,15 @@ //! //! claude signed in with a subscription takes no system prompt of a //! caller's: `--system-prompt` and `--append-system-prompt` never reach the -//! model. So a session's system prompt is told once, at the head of its -//! first user turn, [`instructions`] wrapping it: in the turn fed to a new -//! session, or in the first turn of a graft stitched into one. A session -//! whose transcript already holds its first turn is not told again. +//! model. So a session's system prompt is told at the head of a user +//! turn, [`instructions`] wrapping it: the first, fed to a new session or +//! stitched in with its graft, and again the turn it changes in. A cell +//! raised on a session begun before reads what it was told from its +//! transcript. +//! +//! Claude Code names each MCP call by its own tool use id, in the call's +//! `_meta`, and the nest keeps it ([`hum_nest::Nurse::call_id`]), so a +//! caller and claude's transcript name a call alike. //! //! How the bloom grows reaches claude where its CLI can take it: a //! `trellis` is its `--json-schema`, `"json"` being any object, and a @@ -207,8 +212,14 @@ pub fn build_argv(spec: &Egg, session: Session<'_>) -> Vec { argv } +const TOLD_AFTER: &str = "\n\nThose are the instructions for this conversation, as its system prompt, in place of any given before."; + pub fn instructions(system: &str) -> String { - format!("\n{system}\n\nThose are the instructions for this whole conversation, as its system prompt.") + format!("\n{system}{TOLD_AFTER}") +} + +pub fn instructed(text: &str) -> Option<&str> { + text.strip_prefix("\n")?.strip_suffix(TOLD_AFTER) } pub fn build_env(spec: &Egg) -> Vec<(String, String)> { @@ -236,6 +247,9 @@ impl Nurse for ClaudeCliNurse { fn ephemeral(&self) -> bool { false } fn propensity(&self) -> Propensity { Propensity::StatefulSession } fn grows_on(&self) -> bool { true } + fn call_id(&self, call: &Value) -> Option { + call.get("_meta")?.get("claudecode/toolUseId")?.as_str().map(str::to_owned) + } async fn curate(&self, sid: &hum_identity::HumId, cwd: &str) -> Result { let derived = session_of(sid); @@ -273,7 +287,7 @@ impl Nurse for ClaudeCliNurse { let path = graft::session_path(&cwd, &derived); let foreign = spec.prompt.resume.clone().filter(|r| !r.is_empty()); let begun = foreign.is_some() || path.exists(); - let mut preamble = spec.prompt.system_prompt.as_deref().filter(|s| !s.is_empty() && !begun).map(instructions); + let mut told = if begun { graft::told(&graft::session_path(&cwd, foreign.as_deref().unwrap_or(&derived))) } else { None }; if foreign.is_none() && let Some(blooms) = spec.prompt.graft.as_ref().filter(|g| !g.is_empty()) { @@ -281,8 +295,11 @@ impl Nurse for ClaudeCliNurse { graft::create_claude_session(&cwd, &derived)?; } let mut prior = graft::prior_of(blooms); - if preamble.as_deref().is_some_and(|text| graft::tell(&mut prior, text)) { - preamble = None; + if !begun + && let Some(system) = spec.prompt.system_prompt.as_deref().filter(|s| !s.is_empty()) + && graft::tell(&mut prior, &instructions(system)) + { + told = Some(system.to_owned()); } let stitched = graft::graft(&prior, &path, &derived, &cwd, None)?; trace!(sid = %spec.sid, blooms = blooms.len(), grafted = stitched.grafted, "nurse.graft.stitched"); @@ -319,7 +336,7 @@ impl Nurse for ClaudeCliNurse { tokio::spawn(async move { while let Some(tone) = rx_in.recv().await { - let Some(line) = stream::heard(&tone, &mut preamble) else { continue }; + let Some(line) = stream::heard(&tone, &mut told) else { continue }; let mut buf = line.into_bytes(); buf.push(b'\n'); if let Err(e) = stdin.write_all(&buf).await { @@ -403,6 +420,13 @@ mod tests { .unwrap() } + #[test] + fn a_call_keeps_claudes_own_id() { + let call = serde_json::json!({ "name": "bash", "arguments": {}, "_meta": { "claudecode/toolUseId": "toolu_01AB" } }); + assert_eq!(ClaudeCliNurse.call_id(&call).as_deref(), Some("toolu_01AB")); + assert_eq!(ClaudeCliNurse.call_id(&serde_json::json!({ "name": "bash" })), None); + } + #[test] fn claude_grows_on_only_from_tool_results() { let mut prompt = thrum_core::PromptBody { diff --git a/hives/claude-cli/src/stream.rs b/hives/claude-cli/src/stream.rs index e34f7082..f02df158 100644 --- a/hives/claude-cli/src/stream.rs +++ b/hives/claude-cli/src/stream.rs @@ -60,10 +60,11 @@ pub fn said(petals: &[Petal]) -> Vec { 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 { +pub fn heard(tone: &Value, told: &mut Option) -> Option { match tone.get("chi").and_then(Value::as_str)? { "prompt" => { let body: PromptBody = serde_json::from_value(tone.clone()).ok()?; + let system = body.system_prompt.filter(|s| !s.is_empty()); let petals = body.content.filter(|c| !c.is_empty()).unwrap_or_else(|| { body.text.into_iter().map(|t| Petal { kind: PetalKind::Text.as_str().into(), text: Some(t), ..Petal::default() }).collect() }); @@ -71,8 +72,11 @@ pub fn heard(tone: &Value, preamble: &mut Option) -> Option { 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 })); + if let Some(system) = system + && told.as_deref() != Some(system.as_str()) + { + content.insert(0, json!({ "type": "text", "text": crate::instructions(&system) })); + *told = Some(system); } Some(json!({ "type": "user", "message": { "role": "user", "content": content } }).to_string()) } @@ -204,22 +208,25 @@ mod tests { } #[test] - fn a_prompt_is_heard_as_a_user_message_told_its_system_prompt_once() { - let prompt = json!({ - "chi": "prompt", "sid": "s", + fn a_prompt_is_heard_as_a_user_message_told_its_system_prompt_when_it_changes() { + let prompt = |system: &str| json!({ + "chi": "prompt", "sid": "s", "systemPrompt": system, "content": [ { "kind": "text", "text": "what is this?" }, { "kind": "pollen", "grain": { "kind": "image", "mediaType": "image/png", "data": "AAAA" } }, ], }); - let mut preamble = Some("be brief".to_string()); - let first: Value = serde_json::from_str(&heard(&prompt, &mut preamble).unwrap()).unwrap(); + let mut told = None; + let first: Value = serde_json::from_str(&heard(&prompt("be brief"), &mut told).unwrap()).unwrap(); let parts = first["message"]["content"].as_array().unwrap(); - assert_eq!(parts[0]["text"], "be brief"); + assert_eq!(crate::instructed(parts[0]["text"].as_str().unwrap()), Some("be brief")); assert_eq!(parts[1]["text"], "what is this?"); assert_eq!(parts[2]["source"]["media_type"], "image/png"); - let again: Value = serde_json::from_str(&heard(&prompt, &mut preamble).unwrap()).unwrap(); + let again: Value = serde_json::from_str(&heard(&prompt("be brief"), &mut told).unwrap()).unwrap(); assert_eq!(again["message"]["content"][0]["text"], "what is this?", "told once"); + let changed: Value = serde_json::from_str(&heard(&prompt("be terse"), &mut told).unwrap()).unwrap(); + assert_eq!(crate::instructed(changed["message"]["content"][0]["text"].as_str().unwrap()), Some("be terse")); + assert_eq!(told.as_deref(), Some("be terse")); } #[test] diff --git a/hives/claude-cli/tests/graft_blooms.rs b/hives/claude-cli/tests/graft_blooms.rs index a7eadc81..14123322 100644 --- a/hives/claude-cli/tests/graft_blooms.rs +++ b/hives/claude-cli/tests/graft_blooms.rs @@ -232,3 +232,44 @@ fn an_answer_that_came_after_the_nest_gave_up_is_stitched_on() { let lines = lines_of(&path); assert_eq!(lines[before + 1]["message"]["content"][0]["content"], "job 17 finished (exit 0)"); } + +#[test] +fn a_late_answer_to_a_call_claude_holds_an_answer_for_is_stitched_as_its_own_call() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000007"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + let gave_up = json!({"kind": "grasp", "callId": "toolu_9", "result": "MCP error -32000: never came", "isError": true}); + let call = json!({"kind": "tendril", "callId": "toolu_9", "name": "zsh_check", "args": {"id": 17}}); + graft(&prior_of(&held_with(vec![call.clone(), gave_up.clone()])), &path, session, &cwd, None).unwrap(); + let before = lines_of(&path).len(); + + let again = graft(&prior_of(&held_with(vec![call.clone(), gave_up])), &path, session, &cwd, None).unwrap(); + assert_eq!(again.grafted, 0, "the same call answered alike is held"); + + let answered = json!({"kind": "grasp", "callId": "toolu_9", "result": "job 17 finished"}); + let late = graft(&prior_of(&held_with(vec![call, answered])), &path, session, &cwd, None).unwrap(); + assert_eq!(late.grafted, 1); + let lines = lines_of(&path); + let id = lines[before]["message"]["content"][0]["id"].as_str().unwrap().to_owned(); + assert_ne!(id, "toolu_9", "a call takes one answer, so the late one is a call of its own"); + assert_eq!(lines[before + 1]["message"]["content"][0]["tool_use_id"], id); + assert_eq!(lines[before + 1]["message"]["content"][0]["content"], "job 17 finished"); +} + +#[test] +fn a_transcript_says_which_system_prompt_it_was_told_last() { + let (dir, _guard) = home(); + let cwd = dir.path().join("nest"); + std::fs::create_dir_all(&cwd).unwrap(); + let session = "00000000-0000-4000-8000-000000000008"; + create_claude_session(&cwd, session).unwrap(); + let path = session_path(&cwd, session); + assert_eq!(claude_cli::graft::told(&path), None); + let mut prior = prior_of(&blooms()); + assert!(tell(&mut prior, &claude_cli::instructions("be brief"))); + graft(&prior, &path, session, &cwd, None).unwrap(); + assert_eq!(claude_cli::graft::told(&path).as_deref(), Some("be brief")); +} diff --git a/hives/openai-server/README.md b/hives/openai-server/README.md index 47139e72..7c0bac00 100644 --- a/hives/openai-server/README.md +++ b/hives/openai-server/README.md @@ -69,24 +69,27 @@ Every route answers without its `/v1` too. A request for words may leave `model` out, and any nurse with room grows it. **Conversations.** OpenAI's APIs send the whole conversation every time; -hum keeps it in a sigil. A request's earlier turns travel as the prompt's -`graft`, so any nurse can grow it, and its last turn as the prompt. A -conversation that ends with tool answers or with the assistant's own -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 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. +hum keeps it in a sigil. A request's earlier turns travel as the +prompt's `graft`, so any nurse can grow it, and its last turn as the +prompt. A conversation that ends with tool answers or with the +assistant's own 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 grows, so a nurse that keeps sessions picks up where +it was. A client may name its line with an `X-Hum-Sid` header, kept +apart from every other caller's. Otherwise the sigil is found by the +newest tool call or answer id the conversation holds that this caller +was given, or, with no tools, by the words of its last two turns. The +whole history is not compared: clients compact it all the time, dropping +old tool pairs, trimming, sending a turn back as one message or without +the words said between its calls, while a call's id comes back as it was +given. A caller that edits an older turn keeps its sigil, but a sigil +goes on only from where it last left off, so a copy of the conversation +taken before then gets one of its own; a conversation already growing +gets one of its own, and callers on different keys never share one. A +conversation that ends in tool answers is also found by the turns 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`, diff --git a/hives/openai-server/src/chat.ts b/hives/openai-server/src/chat.ts index 29f4d5cf..944478ce 100644 --- a/hives/openai-server/src/chat.ts +++ b/hives/openai-server/src/chat.ts @@ -15,9 +15,9 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { answers, fromChat, opening, type ChatMessage } from "./conversation.ts"; -import { grow, mcpCallOf, servedOf, usageOf, type Grown, type Listener } from "./growth.ts"; +import { grow, mcpCallOf, 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 { bad, gone, HttpError, lineOf, readJson, sendJson, Sse } from "./http.ts"; import { toolsFromOpenAI } from "./tools.ts"; interface Body { @@ -61,7 +61,8 @@ 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, said.served); + const line = lineOf(req); + const sid = held !== undefined && continuing ? held : line !== undefined ? ctx.sigils.named(rq.label, line) : ctx.sigils.continuing(rq.label, graft); const open = continuing ? { answers: given } : { @@ -126,7 +127,7 @@ 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 ?? []), ...grown.grew]; - ctx.sigils.remember(rq.label, said.system, after, sid, servedOf(said, grown)); + ctx.sigils.remember(rq.label, after, sid, grown.served.map((s) => s.callId)); const used = usageOf(grown.nectar); ctx.usage.track(who.key, used.prompt, used.completion); diff --git a/hives/openai-server/src/conversation.ts b/hives/openai-server/src/conversation.ts index 452cf677..332d17d6 100644 --- a/hives/openai-server/src/conversation.ts +++ b/hives/openai-server/src/conversation.ts @@ -11,19 +11,21 @@ // with tool answers or with the assistant's own words asks for a bloom to // grow on: the prompt has no content, and every bloom is its graft. // -// A conversation keeps one sigil while it only grows. The sid a request -// continues is the one its blooms so far were last grown in, by this -// 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 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. +// A conversation keeps one sigil while it grows, by this caller; strangers +// never share one, and one already growing elsewhere gets a sigil of its +// own. A client may name its line with `X-Hum-Sid`. Otherwise the sigil is +// found by the newest tool call or answer id the conversation holds that +// this caller was given, or by the words of its last two turns, and not by +// the whole history: a client compacts and reshapes it all the time, +// dropping old tool pairs, trimming, sending a turn back as one message or +// without the words said between its calls, while a call's id is echoed as +// it was given, since the answer needs it, and the newest turns are left +// alone. A client that edits an older turn keeps its sigil. A sigil goes +// on only from where it last left off, so a copy of the conversation taken +// before then starts its own. A conversation that ends in answers is found +// by its last two turns 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"; @@ -34,7 +36,6 @@ export interface Said { system?: string; blooms: Bloom[]; tail: Tail; - served: Set; } export interface Answer { @@ -111,8 +112,7 @@ 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); +function servedPetals(id: string, name: string, args: unknown, output: unknown, error: unknown): Petal[] { return [ { kind: PetalKind.tendril, callId: id, name, args: argsOf(args) }, { kind: PetalKind.grasp, callId: id, result: resultOf(output ?? error), isError: Boolean(error) }, @@ -123,7 +123,6 @@ class Reader { system: string[] = []; blooms: Bloom[] = []; tail: Tail = "none"; - served = new Set(); #open(): Bloom { let last = this.blooms[this.blooms.length - 1]; @@ -149,7 +148,7 @@ 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, served: this.served }; + return { system: system || undefined, blooms: this.blooms, tail: this.tail }; } } @@ -189,7 +188,7 @@ export function fromChat(messages: ChatMessage[]): Said { 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(...servedPetals(c.id ?? "", c.name ?? "", c.arguments, c.output, c.error)); } petals.push(...partsOf(m.content)); for (const c of m.tool_calls ?? []) { @@ -220,7 +219,6 @@ 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 : []; @@ -244,7 +242,7 @@ export function fromResponses(input: unknown, instructions?: string, before?: Sa break; } case "mcp_call": - r.grew(servedPetals(r.served, item.id ?? "", item.name ?? "", item.arguments, item.output, item.error), "assistant"); + r.grew(servedPetals(item.id ?? "", item.name ?? "", item.arguments, item.output, item.error), "assistant"); break; } } @@ -277,27 +275,24 @@ 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) { - h.update("U"); +function tailOf(label: string, blooms: Bloom[]): string | undefined { + let said = ""; + let worded = false; + for (const b of blooms.slice(-2)) { + let user = ""; for (const p of b.content ?? []) { - 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`); + if (p.kind === PetalKind.text) user += `${squeeze(p.text ?? "")}\0`; + else if (p.kind === PetalKind.pollen) user += `p${p.grain?.kind ?? ""}\0`; } - let words = ""; - let calls = ""; - let answers = ""; - for (const p of b.grew ?? []) { - 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`; - } - h.update(`G${unspaced(words)}\0c${calls}r${answers}`); + const words = unspaced((b.grew ?? []).filter((p) => p.kind === PetalKind.text).map((p) => p.text ?? "").join("")); + worded ||= words.length > 0 || (b.content ?? []).some((p) => p.kind === PetalKind.text && squeeze(p.text ?? "").length > 0); + said += `U${user}G${words}\0`; } - return h.digest("hex"); + return worded ? createHash("sha256").update(`${label}\0${said}`).digest("hex") : undefined; +} + +function callIds(blooms: Bloom[]): string[] { + return blooms.flatMap((b) => (b.grew ?? []).filter((p) => (p.kind === PetalKind.tendril || p.kind === PetalKind.grasp) && p.callId).map((p) => p.callId as string)); } function beforeAnswers(blooms: Bloom[], withTheirCalls: boolean): Bloom[] { @@ -313,7 +308,11 @@ function beforeAnswers(blooms: Bloom[], withTheirCalls: boolean): Bloom[] { } export class Sigils { - readonly #byTrace = new Map(); + readonly #byCall = new Map(); + readonly #byTail = new Map(); + readonly #keys = new Map(); + readonly #latest = new Map(); + readonly #ran = new Set(); readonly #cap: number; readonly #growing = new Set(); readonly #owner = new Map(); @@ -328,10 +327,18 @@ export class Sigils { return `oai-${randomUUID()}`; } - continuing(label: string, system: string | undefined, blooms: Bloom[], served?: Set): string { + named(label: string, name: string): string { + const sid = `oai-${createHash("sha256").update(`${label}\0${name}`).digest("hex").slice(0, 32)}`; + return this.#growing.has(sid) ? this.fresh() : sid; + } + + continuing(label: string, blooms: Bloom[]): string { if (blooms.length === 0) return this.fresh(); - const sid = [blooms, beforeAnswers(blooms, false), beforeAnswers(blooms, true)] - .map((b) => this.#byTrace.get(trace(label, system, b, served))) + const ids = callIds(blooms); + const byCall = [...ids].reverse().map((id) => this.#byCall.get(`${label}\0${id}`)).find((found) => found !== undefined); + const newest = byCall === undefined ? undefined : this.#latest.get(byCall)?.call; + const sid = (newest !== undefined && ids.includes(newest) ? byCall : undefined) ?? [blooms, beforeAnswers(blooms, false), beforeAnswers(blooms, true)] + .map((b) => this.#leftOff(tailOf(label, b))) .find((found) => found !== undefined); return sid && !this.#growing.has(sid) ? sid : this.fresh(); } @@ -348,18 +355,51 @@ export class Sigils { this.#growing.delete(sid); } - 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); + remember(label: string, blooms: Bloom[], sid: string, ran: string[] = []): void { + for (const id of ran) { + this.#ran.delete(`${label}\0${id}`); + this.#ran.add(`${label}\0${id}`); + } + while (this.#ran.size > this.#cap) this.#ran.delete(this.#ran.values().next().value as string); this.#owner.set(sid, label); - while (this.#byTrace.size > this.#cap) { - const [oldest, gone] = this.#byTrace.entries().next().value as [string, string]; - this.#byTrace.delete(oldest); - if (![...this.#byTrace.values()].includes(gone) && !this.#growing.has(gone)) { - this.#forget(gone, this.#owner.get(gone) ?? ""); - this.#owner.delete(gone); + const ids = callIds(blooms); + for (const id of ids) this.#keep(this.#byCall, `${label}\0${id}`, sid); + const tail = tailOf(label, blooms); + if (tail !== undefined) this.#keep(this.#byTail, tail, sid); + this.#latest.set(sid, { call: [...ids].reverse().find((id) => !this.#ran.has(`${label}\0${id}`)), tail }); + for (const map of [this.#byCall, this.#byTail]) { + while (map.size > this.#cap) { + const [oldest, gone] = map.entries().next().value as [string, string]; + map.delete(oldest); + this.#drop(gone); } } } + + #leftOff(key: string | undefined): string | undefined { + const sid = key === undefined ? undefined : this.#byTail.get(key); + return sid !== undefined && this.#latest.get(sid)?.tail === key ? sid : undefined; + } + + #keep(map: Map, key: string, sid: string): void { + const was = map.get(key); + map.delete(key); + map.set(key, sid); + if (was === sid) return; + this.#keys.set(sid, (this.#keys.get(sid) ?? 0) + 1); + if (was !== undefined) this.#drop(was); + } + + #drop(sid: string): void { + const left = (this.#keys.get(sid) ?? 1) - 1; + if (left > 0) { + this.#keys.set(sid, left); + return; + } + this.#keys.delete(sid); + if (this.#growing.has(sid)) return; + this.#forget(sid, this.#owner.get(sid) ?? ""); + this.#owner.delete(sid); + this.#latest.delete(sid); + } } diff --git a/hives/openai-server/src/growth.ts b/hives/openai-server/src/growth.ts index a20dcaf4..475fafdc 100644 --- a/hives/openai-server/src/growth.ts +++ b/hives/openai-server/src/growth.ts @@ -218,10 +218,6 @@ export function grow( }); } -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, diff --git a/hives/openai-server/src/http.ts b/hives/openai-server/src/http.ts index 892d58fa..7cc06b9a 100644 --- a/hives/openai-server/src/http.ts +++ b/hives/openai-server/src/http.ts @@ -161,6 +161,11 @@ export function cors(req: IncomingMessage, res: ServerResponse, allowed: string[ return true; } +export function lineOf(req: IncomingMessage): string | undefined { + const named = req.headers["x-hum-sid"]; + return typeof named === "string" && named.trim().length > 0 ? named.trim() : undefined; +} + export function bearerOf(req: IncomingMessage): string { const auth = req.headers.authorization; if (typeof auth === "string") { diff --git a/hives/openai-server/src/responses.ts b/hives/openai-server/src/responses.ts index c583b3ee..75c0f99b 100644 --- a/hives/openai-server/src/responses.ts +++ b/hives/openai-server/src/responses.ts @@ -20,9 +20,9 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { answers, fromResponses, opening, type Said } from "./conversation.ts"; -import { grow, mcpCallOf, servedOf, usageOf, type Grown, type Listener } from "./growth.ts"; +import { grow, mcpCallOf, 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 { bad, gone, HttpError, lineOf, readJson, sendJson, Sse } from "./http.ts"; import { toolsFromOpenAI } from "./tools.ts"; interface Body { @@ -128,7 +128,14 @@ 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, said.served); + const line = lineOf(req); + const sid = held !== undefined && continuing + ? held + : before?.sid && !ctx.sigils.growing(before.sid) + ? before.sid + : line !== undefined + ? ctx.sigils.named(rq.label, line) + : ctx.sigils.continuing(rq.label, graft); const format = body.text?.format; const open = continuing ? { answers: given } @@ -276,8 +283,7 @@ 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 ?? []), ...grown.grew]; - const served = servedOf(said, grown); - ctx.sigils.remember(rq.label, said.system, after, sid, served); + ctx.sigils.remember(rq.label, after, sid, grown.served.map((s) => s.callId)); const used = usageOf(grown.nectar); ctx.usage.track(who.key, used.prompt, used.completion); @@ -293,7 +299,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", served }, response: final }, conversation); + if (body.store !== false) kept.put(id, { label: rq.label, sid, said: { ...said, blooms: after, tail: "assistant" }, 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 8d2f746d..ab8cf0f2 100644 --- a/hives/openai-server/test/claude.live.test.ts +++ b/hives/openai-server/test/claude.live.test.ts @@ -11,17 +11,19 @@ // stopped, and only the nurse has the person's user, as claude's sign-in // asks. // -// What it holds to: answers streamed and not, with usage; a conversation -// 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 +// What it holds to: answers streamed and not, with usage; a changed system +// prompt told to claude on the same sigil; a line the caller names keeping +// unrelated requests on one sigil; a conversation 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, +// each call by claude's own id; 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. +// 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. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; @@ -58,6 +60,7 @@ async function untilAnswered(messages: OpenAI.ChatCompletionMessageParam[], tool messages = [...messages, m]; for (const c of m.tool_calls) { if (c.type !== "function") continue; + expect(c.id).toMatch(/^toolu_/); const city = String(JSON.parse(c.function.arguments || "{}").city ?? "").toLowerCase(); asked.push(`${c.function.name}:${city}`); messages.push({ role: "tool", tool_call_id: c.id, content: forecast[city] ?? "unknown city" }); @@ -120,6 +123,26 @@ function nest(): string { expect(total).toBeGreaterThan(0); }, SLOW); + test("a changed system prompt reaches claude, and the conversation stays on its sigil", async () => { + const ask = (fruit: string, messages: OpenAI.ChatCompletionMessageParam[]) => + ai.chat.completions.create({ model: MODEL, messages: [{ role: "system", content: `End every reply with the single word ${fruit}, in capitals.` }, ...messages] }); + const first: OpenAI.ChatCompletionMessageParam[] = [{ role: "user", content: `Say hello in three words. ${tag()}` }]; + const a = await ask("KIWI", first); + expect(a.choices[0].message.content?.trim()).toMatch(/KIWI\W*$/); + const before = nurseLog.length; + const b = await ask("MANGO", [...first, { role: "assistant", content: a.choices[0].message.content ?? "" }, { role: "user", content: "Now say goodbye in three words." }]); + expect(b.choices[0].message.content?.trim()).toMatch(/MANGO\W*$/); + expect(nurseLog.slice(before)).toContain("nurse.cell.reused"); + }, SLOW); + + test("a line the caller names keeps unrelated requests on one sigil", async () => { + const line = { headers: { "X-Hum-Sid": `live-${tag()}` } }; + const word = `PLUM-${tag()}`; + await ai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: `Remember this codeword: ${word}. Reply only with OK.` }] }, line); + const r = await ai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: "What codeword did I ask you to remember? Reply with only the codeword." }] }, line); + expect(r.choices[0].message.content).toContain(word); + }, SLOW); + test("a conversation is remembered turn to turn, on one sigil", async () => { const word = `TANGERINE-${tag()}`; const first: OpenAI.ChatCompletionMessageParam[] = [{ role: "user", content: `Remember this codeword: ${word}. Reply only with OK.` }]; diff --git a/hives/openai-server/test/conversation.test.ts b/hives/openai-server/test/conversation.test.ts index 4d97d978..6a172082 100644 --- a/hives/openai-server/test/conversation.test.ts +++ b/hives/openai-server/test/conversation.test.ts @@ -1,7 +1,7 @@ // An OpenAI conversation read as blooms, and what a request opens with. import { describe, expect, test } from "bun:test"; -import { answers, fromChat, fromResponses, opening, Sigils, trace } from "../src/conversation.ts"; +import { answers, fromChat, fromResponses, opening, Sigils } from "../src/conversation.ts"; describe("fromChat", () => { test("each user turn opens a bloom, and what the assistant did grows in it", () => { @@ -57,117 +57,188 @@ describe("fromResponses", () => { }); }); -describe("a conversation keeps its sigil", () => { - test("the trace reads what a client sends back, whitespace aside", () => { - const a = fromChat([{ role: "user", content: "hi" }, { role: "assistant", content: "hello there" }]); - const b = fromChat([{ role: "user", content: "hi" }, { role: "assistant", content: "hello there\n" }]); - expect(trace("key-a", undefined, a.blooms)).toBe(trace("key-a", undefined, b.blooms)); - 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", () => { +describe("a tool the server ran", () => { + test("is read back from a chat message and from items, as what grew", () => { 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([ + const grew = [ { 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" }, - ]); + ]; + expect(echoed.blooms[0].grew).toEqual(grew); 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); + expect(items.blooms[0].grew).toEqual(grew); }); +}); - 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([ +const call = (id: string, name = "zsh") => ({ id, type: "function", function: { name, arguments: "{}" } }); + +describe("a conversation keeps its sigil", () => { + const stepped = [ + { 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 grown = () => { + const sigils = new Sigils(() => {}); + sigils.remember("key-a", fromChat(stepped).blooms, "oai-1"); + return sigils; + }; + const next = { role: "user", content: "and now?" }; + const asked = (messages: Array>) => opening(fromChat(messages as never)).graft; + + test("though old tool pairs are dropped", () => { + const kept = [ { 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: "assistant", content: "I'll look.Then this.", tool_calls: [call("c2")] }, { role: "tool", tool_call_id: "c2", content: "b" }, { role: "assistant", content: "Done." }, - ]); - const kept = fromChat([ + next, + ]; + expect(grown().continuing("key-a", asked(kept))).toBe("oai-1"); + }); + + test("though a turn comes back as one message, its words run together", () => { + const squashed = [ { role: "user", content: "check it" }, - { role: "assistant", content: "I'll look.Then this.\n\nDone.", tool_calls: [call("c1"), call("c2")] }, + { role: "assistant", content: "I'll look.\nThen 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([ + next, + ]; + expect(grown().continuing("key-a", asked(squashed))).toBe("oai-1"); + }); + + test("though the words said between calls are missing in one request and back in the next", () => { + const sigils = new Sigils(() => {}); + const midTurn = 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: "assistant", content: null, tool_calls: [call("c1")] }, { role: "tool", tool_call_id: "c1", content: "a" }, + { role: "assistant", content: null, tool_calls: [call("c2")] }, + { role: "tool", tool_call_id: "c2", content: "b" }, ]); - expect(trace("key-a", undefined, reordered.blooms)).not.toBe(trace("key-a", undefined, stepped.blooms)); - const answered = fromChat([ + sigils.remember("key-a", [{ ...midTurn.blooms[0], grew: [...(midTurn.blooms[0].grew ?? []), { kind: "text", text: "Done." }] }], "oai-1"); + expect(sigils.continuing("key-a", asked([...stepped, next]))).toBe("oai-1"); + }); + + test("though the system prompt changes", () => { + const told = fromChat([{ role: "system", content: "[bookkeeping] deem it" }, ...stepped, next] as never); + expect(told.system).toBe("[bookkeeping] deem it"); + expect(grown().continuing("key-a", opening(told).graft)).toBe("oai-1"); + }); + + test("though a call and its answer are made up to hand the model news", () => { + const delivered = [...stepped, { role: "assistant", content: null, tool_calls: [call("check_17", "check")] }, { role: "tool", tool_call_id: "check_17", content: "job 17 finished" }]; + expect(grown().continuing("key-a", asked(delivered))).toBe("oai-1"); + }); + + test("with no tools, by its last two turns, older turns compacted away", () => { + const sigils = new Sigils(() => {}); + const chat = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello!" }, + { role: "user", content: "tell me a joke" }, + { role: "assistant", content: "Why did the bee..." }, + { role: "user", content: "another" }, + { role: "assistant", content: "A wasp walks in..." }, + ]; + sigils.remember("key-a", fromChat(chat).blooms, "oai-1"); + expect(sigils.continuing("key-a", asked([...chat, next]))).toBe("oai-1"); + expect(sigils.continuing("key-a", asked([{ role: "system", content: "[summary] we said hi" }, ...chat.slice(2), next]))).toBe("oai-1"); + const delivered = [...chat, { role: "assistant", content: null, tool_calls: [call("note_1", "note")] }, { role: "tool", tool_call_id: "note_1", content: "a note" }]; + expect(sigils.continuing("key-a", asked(delivered))).toBe("oai-1"); + expect(sigils.continuing("key-a", asked([...chat.slice(0, 5), { role: "assistant", content: "A hornet walks in..." }, next]))).not.toBe("oai-1"); + }); + + test("though the calls of a squashed turn come back in another order", () => { + const reordered = [ { 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: "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: "check_17", content: "job 17 finished" }, - ]); - expect(trace("key-a", undefined, answered.blooms)).not.toBe(trace("key-a", undefined, stepped.blooms)); + { role: "tool", tool_call_id: "c1", content: "a" }, + next, + ]; + expect(grown().continuing("key-a", asked(reordered))).toBe("oai-1"); }); - test("a conversation ending in answers finds the sigil it had before them", () => { + test("though its last call was one the server ran, and the client dropped it and reshaped the words", () => { + const lookup = { id: "m1", name: "lookup", arguments: "{}", output: "found bees", error: null }; + const turn = [...stepped, { role: "user", content: "find bees" }, { role: "assistant", content: "Looking.\nFound bees.", mcp_calls: [lookup] }]; + const back = [...stepped, { role: "user", content: "find bees" }, { role: "assistant", content: "Found bees." }, next]; 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"); + sigils.remember("key-a", fromChat(turn as never).blooms, "oai-1", ["m1"]); + expect(sigils.continuing("key-a", asked(back))).toBe("oai-1"); + const unaware = new Sigils(() => {}); + unaware.remember("key-a", fromChat(turn as never).blooms, "oai-1"); + expect(unaware.continuing("key-a", asked(back))).not.toBe("oai-1"); + }); - 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("only from where it last left off: a copy taken before its newest call starts its own", () => { + const copy = [ + { 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: "Looks fine." }, + next, + ]; + expect(grown().continuing("key-a", asked(copy))).not.toBe("oai-1"); }); - 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); - const blooms = fromChat([{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }]).blooms; - expect(sigils.continuing("key-a", undefined, [])).not.toBe(sigils.continuing("key-a", undefined, [])); - sigils.remember("key-a", undefined, blooms, "oai-1"); - expect(sigils.continuing("key-a", undefined, blooms)).toBe("oai-1"); - expect(sigils.continuing("key-b", undefined, blooms)).not.toBe("oai-1"); + test("only from where it last left off: a tool-less copy taken before its latest turn starts its own", () => { + const sigils = new Sigils(() => {}); + const chat = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello!" }, + { role: "user", content: "tell me a joke" }, + { role: "assistant", content: "Why did the bee..." }, + { role: "user", content: "another" }, + { role: "assistant", content: "A wasp walks in..." }, + ]; + sigils.remember("key-a", fromChat(chat.slice(0, 4)).blooms, "oai-1"); + expect(sigils.continuing("key-a", asked([...chat.slice(0, 4), next]))).toBe("oai-1"); + sigils.remember("key-a", fromChat(chat).blooms, "oai-1"); + expect(sigils.continuing("key-a", asked([...chat.slice(0, 4), next]))).not.toBe("oai-1"); + expect(sigils.continuing("key-a", asked([...chat, next]))).toBe("oai-1"); + }); + + test("never for a stranger, nor while it is growing", () => { + const sigils = grown(); + expect(sigils.continuing("key-b", asked([...stepped, next]))).not.toBe("oai-1"); sigils.begin("oai-1"); - expect(sigils.continuing("key-a", undefined, blooms)).not.toBe("oai-1"); + expect(sigils.continuing("key-a", asked([...stepped, next]))).not.toBe("oai-1"); sigils.end("oai-1"); - sigils.remember("key-a", undefined, [...blooms, ...blooms], "oai-2"); + expect(sigils.continuing("key-a", asked([...stepped, next]))).toBe("oai-1"); + expect(sigils.continuing("key-a", [])).not.toBe(sigils.continuing("key-a", [])); + }); + + test("a line a client names is its own, apart from every other caller's", () => { + const sigils = new Sigils(() => {}); + const mine = sigils.named("key-a", "window-1"); + expect(sigils.named("key-a", "window-1")).toBe(mine); + expect(sigils.named("key-b", "window-1")).not.toBe(mine); + sigils.begin(mine); + expect(sigils.named("key-a", "window-1")).not.toBe(mine); + }); + + test("a sigil no key finds any more is forgotten", () => { + const forgot: string[] = []; + const sigils = new Sigils((sid) => forgot.push(sid), 2); + sigils.remember("key-a", fromChat([{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }]).blooms, "oai-1"); + sigils.remember("key-a", fromChat([{ role: "user", content: "yo" }, { role: "assistant", content: "hey" }]).blooms, "oai-2"); + expect(forgot).toEqual([]); + sigils.remember("key-a", fromChat([{ role: "user", content: "sup" }, { role: "assistant", content: "hi" }]).blooms, "oai-3"); expect(forgot).toEqual(["oai-1"]); }); }); diff --git a/hives/openai-server/test/e2e.test.ts b/hives/openai-server/test/e2e.test.ts index c3b6d7e4..dc2cfef6 100644 --- a/hives/openai-server/test/e2e.test.ts +++ b/hives/openai-server/test/e2e.test.ts @@ -4,13 +4,14 @@ // // Each finding of the hive's audit is pinned here: a request without // `stream` gets JSON; usage streams only when asked; a turn carries its -// 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 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. +// history once, as messages; a line the caller names is one sigil; +// 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 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"; @@ -89,6 +90,17 @@ describe("chat completions", () => { expect(sent[3].content).toBe(`second ${t}`); }); + test("a line the caller names with X-Hum-Sid is one sigil, whatever the messages say", async () => { + const t = tag(); + const line = { headers: { "X-Hum-Sid": `window-${t}` } }; + const n = (await heard()).length; + await ai.chat.completions.create({ model: "gpt-lab", messages: [{ role: "user", content: `one ${t}` }] }, line); + await ai.chat.completions.create({ model: "gpt-lab", messages: [{ role: "user", content: `two ${t}` }] }, line); + const sent = (await heardSince(n))[1].body.messages as Array<{ role: string; content: string }>; + expect(sent.map((m) => m.role)).toEqual(["user", "assistant", "user"]); + expect(sent[0].content).toBe(`one ${t}`); + }); + test("strangers who open alike share nothing, and both are answered", async () => { const n = (await heard()).length; const [a, b] = await Promise.all([ diff --git a/hives/openai-server/test/responses.test.ts b/hives/openai-server/test/responses.test.ts index f13b1c61..57521201 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", served: new Set() }, response: {} }); +const kept = (label: string, sid: string): Kept => ({ label, sid, said: { blooms: [], tail: "assistant" }, response: {} }); describe("the Responses store", () => { test("a conversation follows its latest response, for its own key", () => { diff --git a/hives/vercel-ai/README.md b/hives/vercel-ai/README.md index c758e8f2..92e9c0cd 100644 --- a/hives/vercel-ai/README.md +++ b/hives/vercel-ai/README.md @@ -113,12 +113,17 @@ conversation that ends with tool results, or with the assistant's own words, sends no content, and every bloom is the graft, for the nurse to grow on from. -A conversation keeps one sigil while it only grows: a call whose earlier -blooms are the ones this bee last grew on a sigil prompts on that sigil -again, so a nurse whose cell grew them grows on from its own. A -conversation the bee has not seen, one whose history was edited, or one -already growing gets a sigil of its own, `vai-`. The match reads -the system prompt, the words of each turn and the ids of each tool call. +A conversation keeps one sigil while it grows, so a nurse whose cell +grew its blooms grows on from its own. A call may name its line with +`providerOptions: { hum: { sid } }`. Otherwise the sigil is found by the +newest tool call or result id the conversation holds that this bee was +given, or, with no tools, by the words of its last two turns. The whole +history is not compared: an app compacts it, dropping old tool pairs or +trimming, while a call's id comes back as it was given. An app that +edits an older turn keeps its sigil, but a sigil goes on only from where +it last left off, so a copy of the conversation taken before then gets +one of its own; a conversation the bee has not seen, or one already +growing, gets a sigil of its own, `vai-`. ## Tools diff --git a/hives/vercel-ai/src/conversation.ts b/hives/vercel-ai/src/conversation.ts index e89c5b8d..2bfbc6ca 100644 --- a/hives/vercel-ai/src/conversation.ts +++ b/hives/vercel-ai/src/conversation.ts @@ -12,12 +12,18 @@ // grow on: the prompt has no content, and every bloom is its graft. The // results of the tool messages it ends with are its answers. // -// A conversation keeps one sigil while it only grows. The sid a call -// continues is the one its blooms so far were last grown in, by this bee; -// 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 system prompt, the words of every turn and the ids of every -// tool call, which the SDK sends back as it was given them. +// A conversation keeps one sigil while it grows, for this bee; one already +// growing elsewhere gets a sigil of its own. A call may name its line with +// `providerOptions.hum.sid`. Otherwise the sigil is found by the newest +// tool call or result id the conversation holds that this bee was given, +// or by the words of its last two turns, and not by the whole history: an +// app compacts and reshapes it, dropping old tool pairs or trimming, while +// a call's id is echoed as it was given, since its result needs it, and +// the newest turns are left alone. An app that edits an older turn keeps +// its sigil. A sigil goes on only from where it last left off, so a copy +// of the conversation taken before then starts its own. A conversation +// that ends in tool results is found by its last two turns before them +// too. import { createHash, randomUUID } from "node:crypto"; import type { LanguageModelV3FilePart, LanguageModelV3Prompt, LanguageModelV3ToolResultOutput } from "@ai-sdk/provider"; @@ -144,35 +150,48 @@ function squeeze(s: string): string { return s.replace(/\s+/g, " ").trim(); } -export function trace(system: string | undefined, blooms: Bloom[]): string { - const h = createHash("sha256"); - h.update(`${squeeze(system ?? "")}\0`); - for (const b of blooms) { - h.update("U"); +function unspaced(s: string): string { + return s.replace(/\s+/g, ""); +} + +function tailOf(blooms: Bloom[]): string | undefined { + let said = ""; + let worded = false; + for (const b of blooms.slice(-2)) { + let user = ""; for (const p of b.content ?? []) { - 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 = ""; - 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`); - } + if (p.kind === PetalKind.text) user += `${squeeze(p.text ?? "")}\0`; + else if (p.kind === PetalKind.pollen) user += `p${p.grain?.kind ?? ""}\0`; } - if (squeeze(words)) h.update(`t${squeeze(words)}\0`); + const words = unspaced((b.grew ?? []).filter((p) => p.kind === PetalKind.text).map((p) => p.text ?? "").join("")); + worded ||= words.length > 0 || (b.content ?? []).some((p) => p.kind === PetalKind.text && squeeze(p.text ?? "").length > 0); + said += `U${user}G${words}\0`; } - return h.digest("hex"); + return worded ? createHash("sha256").update(said).digest("hex") : undefined; +} + +function callIds(blooms: Bloom[]): string[] { + return blooms.flatMap((b) => (b.grew ?? []).filter((p) => (p.kind === PetalKind.tendril || p.kind === PetalKind.grasp) && p.callId).map((p) => p.callId as string)); +} + +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 #byCall = new Map(); + readonly #byTail = new Map(); + readonly #keys = new Map(); + readonly #latest = new Map(); + readonly #ran = new Set(); readonly #cap: number; readonly #growing = new Set(); readonly #forget: (sid: string) => void; @@ -186,9 +205,19 @@ export class Sigils { return `vai-${randomUUID()}`; } - continuing(system: string | undefined, blooms: Bloom[]): string { + named(name: string): string { + const sid = `vai-line-${createHash("sha256").update(name).digest("hex").slice(0, 32)}`; + return this.#growing.has(sid) ? this.fresh() : sid; + } + + continuing(blooms: Bloom[]): string { if (blooms.length === 0) return this.fresh(); - const sid = this.#byTrace.get(trace(system, blooms)); + const ids = callIds(blooms); + const byCall = [...ids].reverse().map((id) => this.#byCall.get(id)).find((found) => found !== undefined); + const newest = byCall === undefined ? undefined : this.#latest.get(byCall)?.call; + const sid = (newest !== undefined && ids.includes(newest) ? byCall : undefined) ?? [blooms, beforeAnswers(blooms, false), beforeAnswers(blooms, true)] + .map((b) => this.#leftOff(tailOf(b))) + .find((found) => found !== undefined); return sid && !this.#growing.has(sid) ? sid : this.fresh(); } @@ -204,14 +233,49 @@ export class Sigils { this.#growing.delete(sid); } - remember(system: string | undefined, blooms: Bloom[], sid: string): void { - const key = trace(system, blooms); - this.#byTrace.delete(key); - this.#byTrace.set(key, sid); - while (this.#byTrace.size > this.#cap) { - const [oldest, gone] = this.#byTrace.entries().next().value as [string, string]; - this.#byTrace.delete(oldest); - if (![...this.#byTrace.values()].includes(gone) && !this.#growing.has(gone)) this.#forget(gone); + remember(blooms: Bloom[], sid: string, ran: string[] = []): void { + for (const id of ran) { + this.#ran.delete(id); + this.#ran.add(id); + } + while (this.#ran.size > this.#cap) this.#ran.delete(this.#ran.values().next().value as string); + const ids = callIds(blooms); + for (const id of ids) this.#keep(this.#byCall, id, sid); + const tail = tailOf(blooms); + if (tail !== undefined) this.#keep(this.#byTail, tail, sid); + this.#latest.set(sid, { call: [...ids].reverse().find((id) => !this.#ran.has(id)), tail }); + for (const map of [this.#byCall, this.#byTail]) { + while (map.size > this.#cap) { + const [oldest, gone] = map.entries().next().value as [string, string]; + map.delete(oldest); + this.#drop(gone); + } + } + } + + #leftOff(key: string | undefined): string | undefined { + const sid = key === undefined ? undefined : this.#byTail.get(key); + return sid !== undefined && this.#latest.get(sid)?.tail === key ? sid : undefined; + } + + #keep(map: Map, key: string, sid: string): void { + const was = map.get(key); + map.delete(key); + map.set(key, sid); + if (was === sid) return; + this.#keys.set(sid, (this.#keys.get(sid) ?? 0) + 1); + if (was !== undefined) this.#drop(was); + } + + #drop(sid: string): void { + const left = (this.#keys.get(sid) ?? 1) - 1; + if (left > 0) { + this.#keys.set(sid, left); + return; } + this.#keys.delete(sid); + if (this.#growing.has(sid)) return; + this.#forget(sid); + this.#latest.delete(sid); } } diff --git a/hives/vercel-ai/src/provider.ts b/hives/vercel-ai/src/provider.ts index 7d988920..89ba3d24 100644 --- a/hives/vercel-ai/src/provider.ts +++ b/hives/vercel-ai/src/provider.ts @@ -87,7 +87,8 @@ export class HumModel implements LanguageModelV3 { const held = given.length > 0 ? forager.heldSid(given.map((a) => a.callId)) : undefined; const continuing = held !== undefined && !forager.sigils.growing(held); const { graft, content } = opening(said); - const sid = continuing ? held : forager.sigils.continuing(said.system, graft); + const line = opts.providerOptions?.hum?.sid; + const sid = continuing ? held : typeof line === "string" && line.length > 0 ? forager.sigils.named(line) : forager.sigils.continuing(graft); const tools = toolsOf(opts.tools); const own = new Set(tools?.map((t) => t.name)); const translator = new HumTranslator(own, new Set(given.map((a) => a.callId))); @@ -107,7 +108,7 @@ export class HumModel implements LanguageModelV3 { opts.abortSignal?.removeEventListener("abort", cancel); forager.unfollow(sid); forager.sigils.end(sid); - if (grown) forager.sigils.remember(said.system, after(), sid); + if (grown) forager.sigils.remember(after(), sid, translator.served); for (const part of parts) { try { controller.enqueue(part); } catch {} } diff --git a/hives/vercel-ai/src/transform.ts b/hives/vercel-ai/src/transform.ts index c0c5e4d4..87e6c9e3 100644 --- a/hives/vercel-ai/src/transform.ts +++ b/hives/vercel-ai/src/transform.ts @@ -14,7 +14,7 @@ // by an attendant or a builder, is told whole by tendril-info and becomes a // provider-executed tool-call and its tool-result; tendril-info for a reach // the call itself answers, or reached, is old news. What grew is kept as -// petals, in order, for the conversation's trace. One translator serves one +// petals, in order, for the conversation's sigil. One translator serves one // doStream call, so no end is ever emitted without its start. Anything else // (perf-mark, pulse, breath, echo, drone-retrofit, …) is out-of-band // signalling this pure provider ignores. @@ -75,6 +75,7 @@ type Block = { kind: string; id: string; petal?: Petal }; export class HumTranslator { readonly grew: Petal[] = []; + readonly served: string[] = []; private blocks = new Map(); private started = new Set(); private called = new Set(); @@ -122,6 +123,7 @@ export class HumTranslator { } yield { type: "tool-call", toolCallId: msg.callId, toolName, input: inputOf(msg.args), ...served }; yield { type: "tool-result", toolCallId: msg.callId, toolName, result: (msg.result ?? "") as NonNullable, ...(msg.isError ? { isError: true } : {}), dynamic: true }; + this.served.push(msg.callId); this.grew.push( { kind: PetalKind.tendril, callId: msg.callId, name: toolName, args: msg.args ?? {} }, { kind: PetalKind.grasp, callId: msg.callId, result: msg.result ?? "", ...(msg.isError ? { isError: true } : {}) }, diff --git a/hives/vercel-ai/test/conversation.test.ts b/hives/vercel-ai/test/conversation.test.ts index 80f826d5..5015f3e6 100644 --- a/hives/vercel-ai/test/conversation.test.ts +++ b/hives/vercel-ai/test/conversation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; -import { fromPrompt, opening, Sigils, trace } from "../src/conversation.ts"; +import { fromPrompt, opening, Sigils } from "../src/conversation.ts"; const loop: LanguageModelV3Prompt = [ { role: "system", content: "be terse" }, @@ -75,25 +75,51 @@ describe("fromPrompt", () => { }); describe("sigils", () => { - test("a conversation that only grows keeps its sigil, and one edited or growing gets its own", () => { + test("a conversation keeps its sigil by its call ids, though the system prompt changes or old pairs are dropped", () => { const forgot: string[] = []; const sigils = new Sigils((sid) => forgot.push(sid), 2); const said = fromPrompt(loop); const sid = sigils.fresh(); - sigils.remember(said.system, said.blooms, sid); - expect(sigils.continuing(said.system, said.blooms)).toBe(sid); - expect(sigils.continuing("other words", said.blooms)).not.toBe(sid); + sigils.remember(said.blooms, sid); + expect(sigils.continuing(said.blooms)).toBe(sid); + expect(sigils.continuing(fromPrompt([{ role: "system", content: "other words" }, ...loop.slice(2)]).blooms)).toBe(sid); sigils.begin(sid); - expect(sigils.continuing(said.system, said.blooms)).not.toBe(sid); + expect(sigils.continuing(said.blooms)).not.toBe(sid); sigils.end(sid); - sigils.remember(undefined, [{ content: [{ kind: "text", text: "b" }] }], "b"); - sigils.remember(undefined, [{ content: [{ kind: "text", text: "c" }] }], "c"); - expect(forgot).toEqual([sid]); + sigils.remember([{ content: [{ kind: "text", text: "b" }] }], "b"); + sigils.remember([{ content: [{ kind: "text", text: "c" }] }], "c"); + sigils.remember([{ content: [{ kind: "text", text: "d" }] }], "d"); + expect(forgot).toContain("b"); }); - test("reasoning and spacing leave the trace alone", () => { - const a = trace("s", [{ content: [{ kind: "text", text: "hi there" }], grew: [{ kind: "sepal", text: "x" }, { kind: "text", text: "yo" }] }]); - const b = trace(" s ", [{ content: [{ kind: "text", text: "hi there" }], grew: [{ kind: "text", text: "yo " }] }]); - expect(a).toBe(b); + test("with no tools, by its last two turns, reasoning and spacing aside", () => { + const sigils = new Sigils(() => {}); + sigils.remember([{ content: [{ kind: "text", text: "hi there" }], grew: [{ kind: "sepal", text: "x" }, { kind: "text", text: "yo" }] }], "s-1"); + expect(sigils.continuing([{ content: [{ kind: "text", text: "hi there" }], grew: [{ kind: "text", text: "yo " }] }])).toBe("s-1"); + expect(sigils.continuing([{ content: [{ kind: "text", text: "hi there" }], grew: [{ kind: "text", text: "yo " }, { kind: "tendril", callId: "n1", name: "note" }, { kind: "grasp", callId: "n1", result: "news" }] }])).toBe("s-1"); + expect(sigils.continuing([{ content: [{ kind: "text", text: "hi there" }], grew: [{ kind: "text", text: "hey" }] }])).not.toBe("s-1"); + }); + + test("only from where it last left off: an earlier copy starts its own", () => { + const sigils = new Sigils(() => {}); + const turn = (id: string) => [{ kind: "tendril", callId: id, name: "f" }, { kind: "grasp", callId: id, result: "r" }]; + sigils.remember([{ content: [{ kind: "text", text: "go" }], grew: [...turn("t1"), ...turn("t2"), { kind: "text", text: "done" }] }], "s-1"); + expect(sigils.continuing([{ content: [{ kind: "text", text: "go" }], grew: [...turn("t2"), ...turn("t1"), { kind: "text", text: "done" }] }])).toBe("s-1"); + expect(sigils.continuing([{ content: [{ kind: "text", text: "go" }], grew: [...turn("t1"), { kind: "text", text: "fine" }] }])).not.toBe("s-1"); + const one = { content: [{ kind: "text", text: "hi" }], grew: [{ kind: "text", text: "hello" }] }; + const two = { content: [{ kind: "text", text: "joke" }], grew: [{ kind: "text", text: "bee" }] }; + sigils.remember([one], "s-2"); + sigils.remember([one, two], "s-2"); + expect(sigils.continuing([one])).not.toBe("s-2"); + expect(sigils.continuing([one, two])).toBe("s-2"); + }); + + test("a line an app names is one sigil", () => { + const sigils = new Sigils(() => {}); + const mine = sigils.named("window-1"); + expect(sigils.named("window-1")).toBe(mine); + expect(sigils.named("window-2")).not.toBe(mine); + sigils.begin(mine); + expect(sigils.named("window-1")).not.toBe(mine); }); }); diff --git a/hum-nest/src/lib.rs b/hum-nest/src/lib.rs index 1bbfdc7b..3c772b72 100644 --- a/hum-nest/src/lib.rs +++ b/hum-nest/src/lib.rs @@ -24,7 +24,9 @@ //! grafted on it among the rest, so a cell that did not grow them grows //! from them. A nurse that can grow a bloom on from its graft, with //! no new words, says so with [`Nurse::grows_on`]; any other is never -//! handed such a prompt. +//! handed such a prompt. A nurse whose cell names its own tool calls reads +//! that name with [`Nurse::call_id`], since only the nurse knows its cell's +//! MCP client. //! //! The drone (humd's sentinel)-facing [`RegexClassifier`](suspicion_regex::RegexClassifier) //! also lives here (patterns for chat-LLM context-loss detection). @@ -178,6 +180,10 @@ pub trait Nurse: Send + Sync { false } + fn call_id(&self, _call: &Value) -> Option { + None + } + async fn curate(&self, _sid: &HumId, _cwd: &str) -> Result { Ok(CurateReport::default()) } diff --git a/hum-nest/src/mcp.rs b/hum-nest/src/mcp.rs index a06a001c..fe5415cf 100644 --- a/hum-nest/src/mcp.rs +++ b/hum-nest/src/mcp.rs @@ -9,6 +9,10 @@ //! the call's result, while a `chi:"tendril-info"` tells the forager the //! whole of it. MCP stops at the nest: past it, a tool call is a tendril. //! +//! A call keeps the id its cell gave it, when the nurse can read one from +//! the call (`Nurse::call_id`), so a caller, the forager and the cell's +//! own record all name a call alike; otherwise the nest names it. +//! //! A tool with no name is dropped, and one with no usable schema is given //! an empty object, because a cell's MCP client rejects the whole list //! over a single `inputSchema: null`. `parameters`, the older spelling of @@ -130,18 +134,22 @@ impl Response { } } +pub type CallNamer = Arc Option + Send + Sync>; + pub struct Bridge { offered: Mutex>>, pending: Mutex>>, reach: Arc, + named: CallNamer, } impl Bridge { - pub fn new(sigils: NonZeroUsize, reach: Arc) -> Arc { + pub fn new(sigils: NonZeroUsize, reach: Arc, named: CallNamer) -> Arc { Arc::new(Self { offered: Mutex::new(LruCache::new(sigils)), pending: Mutex::new(HashMap::new()), reach, + named, }) } @@ -206,7 +214,9 @@ async fn call(bridge: &Bridge, sid: &str, id: Option, params: Value) -> R return Response::err(id, -32602, "Missing tool name"); } let args = params.get("arguments").cloned().unwrap_or_else(|| json!({})); - let call_id = thrum_core::rid(); + let call_id = (bridge.named)(¶ms) + .filter(|given| !given.is_empty() && !bridge.pending.lock().contains_key(given)) + .unwrap_or_else(thrum_core::rid); let (tx, rx) = oneshot::channel::(); bridge.pending.lock().insert(call_id.clone(), tx); (bridge.reach)(json!({ @@ -249,7 +259,20 @@ mod tests { } fn bridge(shipped: Arc>>) -> Arc { - Bridge::new(NonZeroUsize::new(4).unwrap(), Arc::new(move |t| shipped.lock().push(t))) + Bridge::new(NonZeroUsize::new(4).unwrap(), Arc::new(move |t| shipped.lock().push(t)), Arc::new(|_| None)) + } + + async fn first_reach(shipped: &Arc>>) -> Value { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(t) = shipped.lock().first().cloned() { + return t; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap() } async fn post(addr: SocketAddr, path: &str, body: Value) -> Value { @@ -322,6 +345,26 @@ mod tests { assert_eq!(info["isError"], true); } + #[tokio::test] + async fn a_call_keeps_the_id_its_cell_gave_it() { + let shipped = Arc::new(Mutex::new(Vec::new())); + let sink = shipped.clone(); + let bridge = Bridge::new( + NonZeroUsize::new(4).unwrap(), + Arc::new(move |t| sink.lock().push(t)), + Arc::new(|call: &Value| call.pointer("/_meta/cellCallId").and_then(Value::as_str).map(str::to_owned)), + ); + bridge.offer("s", vec![def("bash")]); + let addr = serve(bridge.clone()).await.unwrap(); + let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "bash", "arguments": {}, "_meta": {"cellCallId": "cell_7"}}}); + let answer = tokio::spawn(async move { post(addr, "/s/s", call).await }); + let reach = first_reach(&shipped).await; + assert_eq!(reach["callId"], "cell_7"); + assert!(bridge.resolve("cell_7", json!({"chi": "tendril-result", "callId": "cell_7", "output": "ok"}))); + assert_eq!(answer.await.unwrap()["result"]["content"][0]["text"], "ok"); + } + #[test] fn a_tool_is_read_as_its_cell_can_take_it() { let tools = ToolDef::all_in(Some(&json!([ diff --git a/hum-nest/src/serve.rs b/hum-nest/src/serve.rs index 2a77abfb..2427d64b 100644 --- a/hum-nest/src/serve.rs +++ b/hum-nest/src/serve.rs @@ -107,9 +107,14 @@ pub async fn serve_nurse(nurse: Arc, advert: NurseAdvert) let pool = NonZeroUsize::new(nest.max_cells()).unwrap_or(NonZeroUsize::MIN); let sigils = pool.saturating_mul(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN)); let write_for_bridge = write_half.clone(); - let bridge = Bridge::new(sigils, Arc::new(move |tone: Value| { - write_for_bridge.send(tone); - })); + let namer = nurse.clone(); + let bridge = Bridge::new( + sigils, + Arc::new(move |tone: Value| { + write_for_bridge.send(tone); + }), + Arc::new(move |call: &Value| namer.call_id(call)), + ); let mcp_addr = mcp::serve(bridge.clone()).await.context("serve the nest's mcp")?; let mcp_url = format!("http://{}", mcp_addr); info!(%mcp_url, "nurse.mcp.up");