From 830931ce9264274fd03d125dbac93e66f864b332 Mon Sep 17 00:00:00 2001 From: Adil Date: Thu, 29 Oct 2026 00:06:25 +0500 Subject: [PATCH] sigils: a conversation is found by the calls it was given, not by its words, and a call keeps claude's own id The OpenAI, Anthropic and AI SDK front doors found a conversation's sigil by a hash of its whole history. Clients compact and reshape history all the time: wryme drops older tool pairs, squashes a turn into one message, on the Responses API drops claude's words between tool rounds and replays them later, and puts a one-off note in its system prompt. Each made the hash miss, so the request began a new sigil and a new claude session, the whole conversation stitched in again. A request now finds its sid by the line its client names, X-Hum-Sid (on the AI SDK, providerOptions.hum.sid), then by the newest tool call id it holds that its caller was given, then by the words of its last two turns. Ids are unique and every client echoes them, since it must to answer; compaction leaves the last turns alone. A sigil continues only from where it last left off: from its newest call, which the request must still hold, and never one the server ran, which a client may not echo; or from its latest tail. A copy of a conversation taken earlier begins its own. No key reads the system prompt. claude-cli now tells claude a changed system prompt at the head of the turn it changes in, reading what a session was told from its transcript, since claude takes no system prompt but the one it is told. A call keeps the id its cell gave it. The nest asks the nurse for it, Nurse::call_id, knowing nothing of any cell; claude-cli reads Claude Code's tool use id from the call's _meta. A caller, the forager and claude's transcript now name a call alike, so a held transcript's tool results are matched by call and answer, then by tool and answer, and a late answer to a call claude already holds an answer for is stitched on as a call of its own. --- hives/anthropic-server/README.md | 23 +- hives/anthropic-server/src/conversation.ts | 139 ++++++++--- hives/anthropic-server/src/http.ts | 5 + hives/anthropic-server/src/messages.ts | 7 +- .../test/conversation.test.ts | 67 +++++- hives/claude-cli/src/graft.rs | 51 +++- hives/claude-cli/src/lib.rs | 42 +++- hives/claude-cli/src/stream.rs | 27 ++- hives/claude-cli/tests/graft_blooms.rs | 41 ++++ hives/openai-server/README.md | 39 +-- hives/openai-server/src/chat.ts | 9 +- hives/openai-server/src/conversation.ts | 146 +++++++---- hives/openai-server/src/growth.ts | 4 - hives/openai-server/src/http.ts | 5 + hives/openai-server/src/responses.ts | 18 +- hives/openai-server/test/claude.live.test.ts | 43 +++- hives/openai-server/test/conversation.test.ts | 227 ++++++++++++------ hives/openai-server/test/e2e.test.ts | 26 +- hives/openai-server/test/responses.test.ts | 2 +- hives/vercel-ai/README.md | 17 +- hives/vercel-ai/src/conversation.ts | 142 ++++++++--- hives/vercel-ai/src/provider.ts | 5 +- hives/vercel-ai/src/transform.ts | 4 +- hives/vercel-ai/test/conversation.test.ts | 52 +++- hum-nest/src/lib.rs | 8 +- hum-nest/src/mcp.rs | 49 +++- hum-nest/src/serve.rs | 11 +- 27 files changed, 870 insertions(+), 339 deletions(-) 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");