char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Sommisc: add /context-cap command for forcing short context on 1M modelsecb3f3b

main
8.3 KiB235 linesraw
1// Test fixtures: a harness over the faux provider, with small extensions to exercise it.
2
3import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
4import { type AssistantMessage, type Message, Type } from "@earendil-works/pi-ai";
5import { createModels } from "@earendil-works/pi-ai/models";
6import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
7import {
8  fauxAssistantMessage,
9  fauxProvider,
10  fauxToolCall,
11} from "@earendil-works/pi-ai/providers/faux";
12import { MemoryStorage } from "@earendil-works/pi-durable";
13import { encodeBase64 } from "@std/encoding/base64";
14import { fromFileUrl } from "@std/path";
15import * as aftercare from "@char/aftercare";
16import * as jsxRuntime from "@char/aftercare/jsx-runtime";
17import { defineExtension } from "@berry/api";
18import { HOST_GLOBAL } from "@berry/api/host.ts";
19import { until } from "@berry/util";
20import * as api from "@berry/api/web.ts";
21import { type Berry, openBerry } from "./berry.ts";
22import type { Environments } from "./environment.ts";
23
24export type Dir = { cwd: string };
25export type Cd = { type: "cd"; cwd: string };
26
27export const dir = (apply: (state: Dir, event: Cd) => Dir = (_, e) => ({ cwd: e.cwd })) =>
28  defineExtension<Dir, Cd>({
29    name: "dir",
30    description: "a working directory",
31    initial: () => ({ cwd: "/" }),
32    apply,
33    setup(ext) {
34      ext.configuration({
35        parameters: Type.Object({ cwd: Type.Optional(Type.String()) }),
36        values: ({ cwd }) => ({ cwd }),
37        events: ({ cwd }) => {
38          if (cwd !== undefined && !cwd.startsWith("/")) throw new Error(`not absolute: ${cwd}`);
39          return cwd === undefined ? [] : [{ type: "cd", cwd }];
40        },
41      });
42      ext.tool({
43        name: "cd",
44        description: "change directory",
45        parameters: Type.Object({ path: Type.String() }),
46        expose: "both",
47        async execute({ path }, ctx) {
48          await ctx.emit({ type: "cd", cwd: path });
49          return `now in ${ctx.state.cwd}`;
50        },
51      });
52      ext.tool({
53        name: "say",
54        description: "stream text as running output",
55        parameters: Type.Object({ text: Type.String() }),
56        execute: ({ text }, ctx) => {
57          for (const word of text.split(" ")) ctx.output(`${word}\n`);
58          return text.length;
59        },
60      });
61      ext.tool({
62        name: "shout",
63        description: "model-only",
64        parameters: Type.Object({}),
65        expose: "model",
66        execute: () => ({ word: "HEY" }),
67        text: ({ word }) => `${word}!`,
68      });
69      return (conv) => {
70        conv.prompt(() => `cwd: ${conv.state.cwd}`);
71        conv.tool({
72          name: "pwd",
73          description: "print the working directory",
74          parameters: Type.Object({}),
75          execute: (_, ctx) => ctx.state.cwd,
76        });
77        conv.command({
78          name: "go",
79          description: "change directory, as the user",
80          execute: async (path, ctx) => {
81            if (!path.startsWith("/")) throw new Error(`not absolute: ${path}`);
82            await ctx.emit({ type: "cd", cwd: path });
83            return path;
84          },
85        });
86      };
87    },
88  });
89
90/** A direct tool that holds its turn open until released. */
91export function gated() {
92  const gate = { entered: Promise.withResolvers<void>(), release: Promise.withResolvers<string>() };
93  const extension = defineExtension({
94    name: "gate",
95    initial: () => null,
96    setup(ext) {
97      ext.tool({
98        name: "hold",
99        description: "wait",
100        parameters: Type.Object({}),
101        expose: "model",
102        execute: () => {
103          gate.entered.resolve();
104          return gate.release.promise;
105        },
106      });
107    },
108  });
109  return { gate, extension };
110}
111
112/**
113 * A model tool, `block`, holding each call until `release` settles or the call is aborted, and a
114 * codemode tool, `ready`, that waits until one is held.
115 */
116export function blocking() {
117  const blocker = {
118    release: Promise.withResolvers<string>(),
119    running: 0,
120    /** Each started call's name, to the conversation it ran in. */
121    started: new Map<string, string>(),
122    cancelled: [] as string[],
123  };
124  const extension = defineExtension({
125    name: "blocking",
126    initial: () => null,
127    setup(ext) {
128      ext.tool({
129        name: "block",
130        description: "wait for release or cancellation",
131        parameters: Type.Object({ name: Type.Optional(Type.String()) }),
132        expose: "model",
133        execute({ name = "" }, ctx) {
134          blocker.started.set(name, ctx.tid);
135          blocker.running++;
136          return new Promise<string>((resolve, reject) => {
137            const abort = () => {
138              blocker.cancelled.push(name);
139              reject(ctx.signal.reason);
140            };
141            ctx.signal.addEventListener("abort", abort, { once: true });
142            void blocker.release.promise.then((answer) => {
143              ctx.signal.removeEventListener("abort", abort);
144              resolve(answer);
145            });
146          }).finally(() => blocker.running--);
147        },
148      });
149      ext.tool({
150        name: "ready",
151        description: "wait until a block is held",
152        parameters: Type.Object({}),
153        execute: () => until(() => blocker.running > 0).then(() => "ready"),
154      });
155    },
156  });
157  return Object.assign(blocker, { extension });
158}
159
160/** A harness with `dir` and `gate` installed and the faux model enabled; `await using` closes it. */
161export async function setup(
162  environments?: Environments,
163  provider?: Parameters<typeof fauxProvider>[0],
164) {
165  const faux = fauxProvider(provider);
166  const models = createModels();
167  models.setProvider(faux.provider);
168  const berry = await openBerry(new MemoryStorage(), models, undefined, environments);
169  await berry.install(dir());
170  const { gate, extension } = gated();
171  await berry.install(extension);
172  const model = { provider: "faux", modelId: faux.getModel().id };
173  await berry.enableModels([model]);
174  return { faux, berry, model, models, gate, [Symbol.asyncDispose]: () => berry.close() };
175}
176
177export const script = (code: string) =>
178  fauxAssistantMessage(fauxToolCall("codemode", { code }), { stopReason: "toolUse" });
179
180/**
181 * Answer one input with scripted steps, then "done"; resolves with the messages each request saw, once the
182 * conversation is idle.
183 */
184export async function turn(
185  { faux, berry }: { faux: ReturnType<typeof fauxProvider>; berry: Berry },
186  tid: string,
187  steps: readonly AssistantMessage[],
188): Promise<(readonly Message[])[]> {
189  const seen: (readonly Message[])[] = [];
190  faux.setResponses(
191    [...steps, fauxAssistantMessage("done")].map(
192      (step) => (ctx) => (seen.push(ctx.messages), step),
193    ),
194  );
195  const conversation = (await berry.conversation(tid))!;
196  await (await conversation.submit({ type: "input", content: "go" }, context)).wait(context);
197  await conversation.waitForIdle(context);
198  return seen;
199}
200
201export function lastToolResult(messages: readonly Message[]) {
202  const result = messages.findLast((m) => m.role === "toolResult");
203  if (result?.role !== "toolResult") throw new Error("no tool result");
204  return {
205    text: result.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"),
206    isError: result.isError,
207    details: result.details,
208  };
209}
210
211export const sectionsSeen = (messages: readonly Message[]) =>
212  messages.flatMap((m) => (m.role === "system" ? [m.sections ?? {}] : []));
213
214export const toolsOffered = (messages: readonly Message[]) =>
215  getCurrentTools(messages).map((tool) => tool.name);
216
217/** An extension's frontend as the app loads it: built on its own, over the app's instances of the shared modules. */
218export async function builtRenderers(name: string): Promise<api.Renderers> {
219  // vite is slow to load, and only these tests need it
220  const web = (await import("./web.ts")).extensionWeb();
221  const entry = fromFileUrl(new URL(`../extensions/${name}/web.tsx`, import.meta.url));
222  const js = await web.serve((await web.build(name, entry)).module)!.text();
223  const host = globalThis as Record<string, unknown>;
224  host[HOST_GLOBAL] = {
225    "@char/aftercare": aftercare,
226    "@char/aftercare/jsx-runtime": jsxRuntime,
227    "@berry/api/web.ts": api,
228  };
229  try {
230    const encoded = encodeBase64(new TextEncoder().encode(js));
231    return (await import(`data:text/javascript;base64,${encoded}`)).default;
232  } finally {
233    delete host[HOST_GLOBAL];
234  }
235}