char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somuse conversation session as prompt cache key for all providersd220151

main
8.0 KiB191 linesraw
1import { assert, assertEquals, assertRejects } from "@std/assert";
2import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
3import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
4import { createModels } from "@earendil-works/pi-ai/models";
5import {
6  type ConversationId,
7  type Cursor,
8  type EntryRecord,
9  MemoryStorage,
10} from "@earendil-works/pi-durable";
11import { type Berry, Conversations, openBerry } from "./berry.ts";
12import { script, setup, turn } from "./fixture.ts";
13import { conversationTree, type TreeEntry } from "./tree.ts";
14
15async function history(berry: Berry, id: number): Promise<EntryRecord[]> {
16  const conversation = (await berry.harness.conversation(id as ConversationId, context))!;
17  const entries: EntryRecord[] = [];
18  let cursor: Cursor | undefined;
19  do {
20    const page = await conversation.entries({ order: "ascending" }, 100, cursor, context);
21    entries.push(...page.items);
22    cursor = page.next;
23  } while (cursor !== undefined);
24  return entries;
25}
26
27/** The paths from each branch's tip back to the root, which must be its history. */
28async function assertPaths(berry: Berry, tree: Awaited<ReturnType<typeof conversationTree>>) {
29  const byId = new Map(tree.entries.map((entry) => [entry.id, entry]));
30  for (const branch of tree.branches) {
31    const path: TreeEntry[] = [];
32    for (let id = branch.tip; id !== null; id = byId.get(id)!.parent) path.unshift(byId.get(id)!);
33    assertEquals(
34      path.map((entry) => [entry.id, entry.kind]),
35      (await history(berry, branch.id)).map((entry) => [entry.id, entry.kind]),
36    );
37  }
38}
39
40Deno.test("a conversation's branches join into one tree, whose paths are their histories", async () => {
41  await using t = await setup();
42  const { berry } = t;
43  const tid = await berry.create({ extensions: {}, model: t.model });
44  const info = async () => (await berry.harness.snapshot(Conversations, context))![tid];
45  await turn(t, tid, [script("1")]);
46  await turn(t, tid, []);
47  const first = (await info()).id;
48  const [start, ...rest] = await history(berry, first);
49  const firstAnswer = rest.find((entry) => entry.kind === "pi.assistant")!;
50
51  // a new first message, then a branch after the first answer
52  await berry.branch(tid, start.id);
53  await turn(t, tid, []);
54  await berry.branch(tid, firstAnswer.id);
55  await turn(t, tid, []);
56
57  const tree = await conversationTree(berry, tid);
58  assertEquals(tree.branches.length, 3);
59  assertEquals(tree.head, tree.branches[2].id);
60  assertEquals(await berry.tidOf(tree.head as ConversationId), tid);
61  await assertPaths(berry, tree);
62  assertEquals(tree.entries.filter((entry) => entry.parent === null), [
63    { id: start.id, parent: null, branch: first, kind: "berry.start", text: "" },
64  ]);
65
66  // back to where it began, which needs no new branch
67  await berry.branch(tid, rest.at(-1)!.id);
68  assertEquals((await info()).head, undefined);
69  assertEquals((await conversationTree(berry, tid)).branches.length, 3);
70});
71
72Deno.test("a fork's tree holds the history it inherited, but none of its parent's other branches", async () => {
73  await using t = await setup();
74  const { berry } = t;
75  const tid = await berry.create({ extensions: {}, model: t.model });
76  await turn(t, tid, []);
77  const parent = (await berry.conversation(tid))!;
78  const entries = await history(berry, parent.id);
79  await berry.branch(tid, entries[0].id);
80  await turn(t, tid, []);
81
82  const fork = await parent.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context);
83  const forkTid = await berry.tidOf(fork.id);
84  await turn(t, forkTid, []);
85  const tree = await conversationTree(berry, forkTid);
86  assertEquals(tree.branches.map((branch) => branch.id), [fork.id]);
87  await assertPaths(berry, tree);
88});
89
90Deno.test("regenerating an agent's message runs it again from just before it, keeping the first one", async () => {
91  await using t = await setup();
92  const { berry } = t;
93  const tid = await berry.create({ extensions: {}, model: t.model });
94  await turn(t, tid, [script("1")]);
95  const first = (await berry.conversation(tid))!.id;
96  const entries = await history(berry, first);
97  const answer = entries.at(-1)!;
98  const message = entries.find((entry) => entry.kind === "pi.user")!;
99
100  t.faux.setResponses([fauxAssistantMessage("again")]);
101  await berry.regenerate(tid, answer.id);
102  await (await berry.conversation(tid))!.waitForIdle(context);
103
104  const tree = await conversationTree(berry, tid);
105  await assertPaths(berry, tree);
106  const byId = new Map(tree.entries.map((entry) => [entry.id, entry]));
107  const path = (branch: number) => {
108    const entries: TreeEntry[] = [];
109    let id = tree.branches.find((of) => of.id === branch)!.tip;
110    for (; id !== null; id = byId.get(id)!.parent) entries.unshift(byId.get(id)!);
111    return entries.filter((entry) => entry.kind !== "pi.system");
112  };
113  const was = path(first);
114  const now = path(tree.head);
115  assertEquals(
116    was.map((entry) => entry.kind),
117    ["berry.start", "pi.user", "pi.assistant", "pi.tool-result", "pi.assistant"],
118  );
119  assertEquals(now.slice(0, -1), was.slice(0, -1));
120  assertEquals([was.at(-1)!.text, now.at(-1)!.text], ["done", "again"]);
121  await assertRejects(() => berry.regenerate(tid, message.id), Error, "not the agent's message");
122});
123
124async function rewound(provider?: Parameters<typeof fauxProvider>[0]) {
125  const faux = fauxProvider(provider);
126  const models = createModels();
127  models.setProvider(faux.provider);
128  const sessions: (string | undefined)[] = [];
129  const payloads: Promise<unknown>[] = [];
130  const original = models.streamSimple.bind(models);
131  models.streamSimple = (...args) => {
132    const [model, , options] = args;
133    sessions.push(options?.sessionId);
134    // every provider exposes the request it would send through onPayload
135    const next = options?.onPayload?.({ prompt_cache_key: "sentry" }, model);
136    if (next !== undefined) payloads.push(Promise.resolve(next));
137    return original(...args);
138  };
139  const berry = await openBerry(new MemoryStorage(), models);
140  try {
141    const model = { provider: "faux", modelId: faux.getModel().id };
142    await berry.enableModels([model]);
143    const tid = await berry.create({ extensions: {}, model });
144    const harness = { faux, berry };
145    await turn(harness, tid, []);
146    const first = (await berry.conversation(tid))!;
147    const [start] = (await first.entries({ order: "ascending" }, 10, undefined, context)).items;
148    await berry.branch(tid, start.id);
149    await turn(harness, tid, []);
150    return { tid, sessions, payloads: await Promise.all(payloads) };
151  } finally {
152    await berry.close();
153  }
154}
155
156Deno.test("a rewind keeps the provider session so the new branch starts on a warm prompt cache", async () => {
157  const { tid, sessions } = await rewound();
158  assert(sessions.length > 1);
159  assertEquals(new Set(sessions), new Set([tid]));
160});
161
162Deno.test("codex keeps a session per branch, and its body carries the conversation's cache key", async () => {
163  const { tid, sessions, payloads } = await rewound({ api: "openai-codex-responses" });
164  assert(new Set(sessions).size > 1);
165  assert(payloads.length > 0);
166  for (const payload of payloads) {
167    assertEquals((payload as { prompt_cache_key: unknown }).prompt_cache_key, tid);
168  }
169});
170
171Deno.test("tree entries preview their messages on one line", async () => {
172  await using t = await setup();
173  const tid = await t.berry.create({ extensions: {}, model: t.model });
174  await turn(t, tid, [script("throw new Error('no\\nway')")]);
175  const { entries } = await conversationTree(t.berry, tid);
176  assertEquals(
177    entries.filter((entry) => entry.kind !== "pi.system").map(({ kind, text, tools, error }) => ({
178      kind,
179      text: text.split(" ").slice(0, 2).join(" "),
180      tools,
181      error,
182    })),
183    [
184      { kind: "berry.start", text: "", tools: undefined, error: undefined },
185      { kind: "pi.user", text: "go", tools: undefined, error: undefined },
186      { kind: "pi.assistant", text: "", tools: ["codemode"], error: undefined },
187      { kind: "pi.tool-result", text: "Script failed", tools: ["codemode"], error: true },
188      { kind: "pi.assistant", text: "done", tools: undefined, error: undefined },
189    ],
190  );
191});