Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions hives/anthropic-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
139 changes: 99 additions & 40 deletions hives/anthropic-server/src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();
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<string, string>();
readonly #byCall = new Map<string, string>();
readonly #byTail = new Map<string, string>();
readonly #keys = new Map<string, number>();
readonly #latest = new Map<string, { call?: string; tail?: string }>();
readonly #cap: number;
readonly #growing = new Set<string>();
readonly #forget: (sid: string) => void;
Expand All @@ -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);
}

Expand All @@ -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<string, string>, 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);
}
}
5 changes: 5 additions & 0 deletions hives/anthropic-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export async function readJson<T>(req: IncomingMessage): Promise<T> {
}
}

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<string, string> = {}): void {
if (res.headersSent) return;
res.writeHead(status, { "Content-Type": "application/json", ...headers });
Expand Down
7 changes: 4 additions & 3 deletions hives/anthropic-server/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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) {
Expand Down
67 changes: 54 additions & 13 deletions hives/anthropic-server/test/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down
Loading
Loading