char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd a /compact command to misc2c01ad9

main
16.6 KiB423 linesraw
1// Run one extension without a harness: state folds through its reducer, arguments are validated like the runtime does.
2
3import { homedir } from "node:os";
4import { resolve } from "node:path";
5import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
6import { type Provider, validateToolArguments } from "@earendil-works/pi-ai";
7import type { FileError, FileInfo, Result } from "@earendil-works/pi-durable/env";
8import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
9import {
10  connectHere,
11  directory,
12  type Environment,
13  localEnvironment,
14  spawnHere,
15} from "./environment.ts";
16import { ptyHere } from "./pty.ts";
17import {
18  type Channel,
19  type ChannelHandler,
20  type ChannelMessage,
21  type Command,
22  type CompactionCap,
23  type Configuration,
24  configurationEvents,
25  type ContextHook,
26  type Extension,
27  type ExtensionApi,
28  type Handler,
29  handlerArguments,
30  type JsonValue,
31  type PayloadHook,
32  type PromptProse,
33  type PromptRender,
34  type SectionRender,
35  tightestCap,
36  type Tool,
37  type ToolContext,
38} from "./mod.ts";
39
40export function testExtension<S extends JsonValue, E extends JsonValue>(
41  extension: Extension<S, E>,
42  {
43    events = [],
44    tid = "3mxd6epszps5d",
45    env = localEnvironment(homedir()),
46    auth = () => Promise.resolve(undefined),
47  }: {
48    events?: readonly E[];
49    auth?: ExtensionApi<S, E>["auth"];
50    tid?: string;
51    /** Where its files and commands are; `cd` moves its `cwd`. Default: this machine's home. */
52    env?: Environment;
53  } = {},
54) {
55  let state = events.reduce((s, e) => extension.apply?.(s, e) ?? s, extension.initial());
56  const messages: Parameters<ContextHook>[0][number][] = [];
57  const emit = (event: E, options?: { content: string }) => {
58    state = extension.apply?.(state, event) ?? state;
59    if (options) messages.push({ role: "user", content: options.content, timestamp: Date.now() });
60    return Promise.resolve();
61  };
62  const providers: Provider[] = [];
63  const compactions: (string | undefined)[] = [];
64  const compactionGetters: (() => CompactionCap | undefined)[] = [];
65  const statics: Tool<S, E>[] = [];
66  const handlers: Handler<S>[] = [];
67  const channels: ChannelHandler<S>[] = [];
68  const disposers: (() => void | Promise<void>)[] = [];
69  const sections: SectionRender<S>[] = [];
70  let configuration: Configuration<S, E> | undefined;
71  let prose: PromptProse | undefined;
72  const contribute = extension.setup({
73    auth,
74    provider: (provider) => void providers.push(provider),
75    tool: (tool) => void statics.push(tool as Tool<S, E>),
76    handle: (handler) => void handlers.push(handler as unknown as Handler<S>),
77    channel: (handler) => void channels.push(handler as unknown as ChannelHandler<S>),
78    prompt: (_name, render) => void sections.push(render),
79    promptProse: (given) => void (prose = given),
80    compactionCap: (getter) => void compactionGetters.push(getter),
81    configuration: (given) => void (configuration = given as unknown as Configuration<S, E>),
82    onDispose: (dispose) => void disposers.push(dispose),
83  });
84
85  function contribution(currentEnv = env) {
86    const tools: Tool<S, E>[] = [...statics];
87    const prompts: PromptRender[] = [];
88    const payloadHooks: PayloadHook[] = [];
89    const contextHooks: ContextHook[] = [];
90    const commands: Command<S, E>[] = [];
91    contribute?.({
92      tid,
93      state,
94      env: currentEnv,
95      tool: (tool) => void tools.push(tool as Tool<S, E>),
96      prompt: (render) => void prompts.push(render),
97      onPayload: (hook) => void payloadHooks.push(hook),
98      onContext: (hook) => void contextHooks.push(hook),
99      command: (command) => void commands.push(command),
100    });
101    return { tools, prompts, commands, contextHooks, payloadHooks };
102  }
103
104  /** Call a tool as codemode would: validated arguments, the value returned as is. */
105  async function call(
106    name: string,
107    args: JsonValue,
108    options: {
109      signal?: AbortSignal;
110      /** As though the conversation were on another machine for this one call. */
111      env?: Environment;
112      output?: (chunk: string) => void;
113      progress?: (details: JsonValue) => void;
114    } = {},
115  ): Promise<JsonValue | undefined> {
116    const { tools } = contribution(options.env ?? env);
117    const tool = tools.findLast((tool) => tool.name === name);
118    if (tool === undefined || (tool.enabled && !await tool.enabled({ env: options.env ?? env }))) {
119      throw new Error(`No tool named ${name}`);
120    }
121    const validated = validateToolArguments(tool, {
122      type: "toolCall",
123      id: "test",
124      name,
125      arguments: args as never,
126    });
127    const ctx: ToolContext<S, E> = {
128      tid,
129      get state() {
130        return state;
131      },
132      env: options.env ?? env,
133      signal: options.signal ?? new AbortController().signal,
134      emit,
135      output: (chunk) => options.output?.(chunk),
136      progress: (details) => options.progress?.(details),
137    };
138    return await (tool as Tool<S, E>).execute(validated, ctx);
139  }
140
141  return {
142    get state() {
143      return state;
144    },
145    get messages(): Parameters<ContextHook>[0] {
146      return structuredClone(messages);
147    },
148    /** The instructions of each manual compaction a command started, in order; `undefined` for none given. */
149    get compactions(): readonly (string | undefined)[] {
150      return [...compactions];
151    },
152    /** The tightest compaction cap this load reports, as the harness would resolve it. */
153    get compactionCap(): CompactionCap | undefined {
154      const seen = compactionGetters.map((get) => {
155        try {
156          return get();
157        } catch {
158          return undefined;
159        }
160      });
161      return tightestCap(seen);
162    },
163    env,
164    providers: providers as readonly Provider[],
165    call,
166    async context(
167      messages: Parameters<ContextHook>[0],
168      model: Parameters<ContextHook>[1],
169    ): Promise<Parameters<ContextHook>[0]> {
170      messages = structuredClone(messages);
171      for (const hook of contribution().contextHooks) {
172        const next = await hook(messages, model);
173        if (next !== undefined) messages = next;
174      }
175      return messages;
176    },
177    async payload(payload: unknown, model: Parameters<PayloadHook>[1]): Promise<unknown> {
178      for (const hook of contribution().payloadHooks) {
179        const next = await hook(payload, model);
180        if (next !== undefined) payload = next;
181      }
182      return payload;
183    },
184    /** Ask a handler as the extension's frontend would: validated arguments, the value returned as is. */
185    async request(
186      name: string,
187      args?: JsonValue,
188      options: { signal?: AbortSignal; env?: Environment } = {},
189    ): Promise<JsonValue | undefined> {
190      const handler = handlers.findLast((handler) => handler.name === name);
191      if (handler === undefined) throw new Error(`No handler named ${name}`);
192      return await handler.execute(handlerArguments(handler.parameters, args), {
193        tid,
194        state,
195        env: options.env ?? env,
196        signal: options.signal ?? new AbortController().signal,
197      });
198    },
199    /** Open a channel as the extension's frontend would, and get the frontend's end of it. */
200    connect(name: string, args?: JsonValue, options: { env?: Environment } = {}): Channel {
201      const handler = channels.findLast((handler) => handler.name === name);
202      if (handler === undefined) throw new Error(`No channel named ${name}`);
203      const { server, client, signal, fail } = channelPair();
204      // a throw, even while validating, refuses the channel rather than this call
205      void Promise.resolve().then(() =>
206        handler.open(handlerArguments(handler.parameters, args), server, {
207          tid,
208          state,
209          env: options.env ?? env,
210          signal,
211        })
212      ).catch(fail);
213      return client;
214    },
215    /** Run a command as the user; its events apply at once, as they do in an idle conversation. */
216    async command(name: string, args = ""): Promise<JsonValue | undefined> {
217      const { commands } = contribution();
218      const command = commands.findLast((command) => command.name === name);
219      if (command === undefined || (command.enabled && !await command.enabled({ env }))) {
220        throw new Error(`No command /${name}`);
221      }
222      return await command.execute(args, {
223        tid,
224        state,
225        env,
226        emit,
227        async cd(path) {
228          env.cwd = await directory(env, path, BACKGROUND_CONTEXT);
229          return env.cwd;
230        },
231        compact: (instructions) => {
232          compactions.push(instructions);
233          return Promise.resolve();
234        },
235      });
236    },
237    /** The events a conversation started with these values would begin with. */
238    async seed(values: JsonValue = {}): Promise<readonly E[]> {
239      if (!configuration) throw new Error(`${extension.name} has no configuration`);
240      return await configurationEvents(configuration, values, extension.initial());
241    },
242    get configuration() {
243      return configuration?.values(state);
244    },
245    /** Apply a configuration patch through the extension's events, as in an idle conversation. */
246    async configure(values: Record<string, JsonValue>): Promise<void> {
247      if (!configuration) throw new Error(`${extension.name} has no configuration`);
248      for (const key of Object.keys(values)) {
249        if (!Object.hasOwn(configuration.parameters.properties, key)) {
250          throw new Error(`unknown configuration field: ${key}`);
251        }
252      }
253      const events = await configurationEvents(configuration, {
254        ...configuration.values(state),
255        ...values,
256      }, state);
257      for (const event of events) await emit(event);
258    },
259    async prompt(): Promise<string> {
260      // the harness replaces `$USER` at render; the helper keeps the default as entered
261      const texts = await Promise.all([
262        ...(prose && prose.default.trim() ? [prose.default] : []),
263        ...contribution().prompts.map((render) => render({ env })),
264        ...sections.map((render) => render({ tid, state, env })),
265      ]);
266      return texts.filter((text) => text !== undefined).join("\n\n");
267    },
268    async dispose() {
269      for (const dispose of disposers) await dispose();
270    },
271  };
272}
273
274/** Two ends of a channel in memory, as a websocket would join them; what crosses is copied, as it would be. */
275function channelPair() {
276  const closed = new AbortController();
277  const end = () => {
278    let controller!: ReadableStreamDefaultController<ChannelMessage>;
279    return {
280      messages: new ReadableStream<ChannelMessage>({ start: (c) => void (controller = c) }),
281      controller,
282    };
283  };
284  const [a, b] = [end(), end()];
285  const close = () => {
286    if (closed.signal.aborted) return;
287    closed.abort();
288    for (const { controller } of [a, b]) controller.close();
289  };
290  const channel = (own: typeof a, other: typeof a): Channel => ({
291    send: (message) =>
292      void (!closed.signal.aborted && other.controller.enqueue(structuredClone(message))),
293    messages: own.messages,
294    close,
295  });
296  return {
297    server: channel(a, b),
298    client: channel(b, a),
299    signal: closed.signal,
300    /** Refuse the channel, as the server would: the frontend's messages fail with why. */
301    fail(error: unknown) {
302      if (closed.signal.aborted) return;
303      closed.abort();
304      a.controller.close();
305      b.controller.error(error);
306    },
307  };
308}
309
310/**
311 * An environment whose `/` is `root` on this machine, so a test can tell the files an extension reaches through it
312 * from this machine's own: a path that bypasses it names nothing. Commands still run here, from the mapped `cwd`.
313 */
314export function rootedEnvironment(
315  root: string,
316  { cwd = "/", home = "/home", variables = {} }: {
317    cwd?: string;
318    home?: string;
319    variables?: Record<string, string>;
320  } = {},
321): Environment {
322  const inside = (path: string) => {
323    const expanded = path === "~" ? home : path.startsWith("~/") ? home + path.slice(1) : path;
324    return expanded.startsWith("/") ? root + expanded : expanded;
325  };
326  const outside = (path: string) =>
327    path === root ? "/" : path.startsWith(`${root}/`) ? path.slice(root.length) : path;
328  const mapped = <T, E>(result: Result<T, E>, map: (value: T) => T): Result<T, E> =>
329    result.ok ? { ok: true, value: map(result.value) } : result;
330  const info = (file: FileInfo) => ({ ...file, path: outside(file.path) });
331  const inner = new NodeExecutionEnv({ cwd: inside(cwd) });
332  const temporary = async (prefix: string | undefined): Promise<Result<string, FileError>> => {
333    const created = await inner.createDir(`${root}/tmp`, { recursive: true }, BACKGROUND_CONTEXT);
334    if (!created.ok) return created;
335    const dir = await Deno.makeTempDir({ dir: `${root}/tmp`, prefix: prefix ?? "tmp-" });
336    return { ok: true, value: outside(dir) };
337  };
338  return {
339    id: `rooted:${root}`,
340    name: "rooted",
341    platform: "linux",
342    home,
343    variables,
344    get cwd() {
345      return outside(inner.cwd);
346    },
347    set cwd(path: string) {
348      inner.cwd = inside(path);
349    },
350    absolutePath: async (path, context) =>
351      mapped(await inner.absolutePath(inside(path), context), outside),
352    joinPath: (parts, context) => inner.joinPath(parts, context),
353    readTextFile: (path, context) => inner.readTextFile(inside(path), context),
354    openTextLineReader: (path, context) => inner.openTextLineReader(inside(path), context),
355    readTextLines: (path, options, context) => inner.readTextLines(inside(path), options, context),
356    readBinaryFile: (path, context) => inner.readBinaryFile(inside(path), context),
357    openBinaryReader: async (path, options, context) =>
358      mapped(await inner.openBinaryReader(inside(path), options, context), (reader) => ({
359        ...reader,
360        info: async (context) => mapped(await reader.info(context), info),
361        read: (offset, length, context) => reader.read(offset, length, context),
362        scanLines: (options, context) => reader.scanLines(options, context),
363        close: (context) => reader.close(context),
364      })),
365    writeFile: (path, content, context) => inner.writeFile(inside(path), content, context),
366    appendFile: (path, content, context) => inner.appendFile(inside(path), content, context),
367    truncateFile: (path, size, context) => inner.truncateFile(inside(path), size, context),
368    flushFile: (path, context) => inner.flushFile(inside(path), context),
369    renameFile: (from, to, context) => inner.renameFile(inside(from), inside(to), context),
370    fileInfo: async (path, context) => mapped(await inner.fileInfo(inside(path), context), info),
371    listDir: async (path, context) =>
372      mapped(await inner.listDir(inside(path), context), (files) => files.map(info)),
373    openDirReader: async (path, context) =>
374      mapped(await inner.openDirReader(inside(path), context), (reader) => ({
375        next: async (count, context) =>
376          mapped(await reader.next(count, context), (page) => ({
377            ...page,
378            entries: page.entries.map(info),
379          })),
380        close: (context) => reader.close(context),
381      })),
382    watch: (targets, onChange, context) =>
383      inner.watch(
384        targets.map((target) => ({ ...target, path: inside(target.path) })),
385        (change) => onChange("paths" in change ? { paths: change.paths.map(outside) } : change),
386        context,
387      ),
388    canonicalPath: async (path, context) =>
389      mapped(await inner.canonicalPath(inside(path), context), outside),
390    exists: (path, context) => inner.exists(inside(path), context),
391    createDir: (path, options, context) => inner.createDir(inside(path), options, context),
392    remove: (path, options, context) => inner.remove(inside(path), options, context),
393    createTempDir: (prefix) => temporary(prefix),
394    async createTempFile(options, context) {
395      const dir = await temporary("tmp-");
396      if (!dir.ok) return dir;
397      const path = `${dir.value}/${options?.prefix ?? ""}file${options?.suffix ?? ""}`;
398      const written = await inner.writeFile(inside(path), "", context);
399      return written.ok ? { ok: true, value: path } : written;
400    },
401    exec: async (command, options, context) =>
402      await inner.exec(
403        command,
404        options?.cwd === undefined ? options : { ...options, cwd: inside(options.cwd) },
405        context,
406      ),
407    cleanup: (context) => inner.cleanup(context),
408    spawn: (argv, options, context) =>
409      Promise.resolve(spawnHere(argv, {
410        ...options,
411        cwd: options.cwd === undefined ? inner.cwd : resolve(inner.cwd, inside(options.cwd)),
412      }, context)),
413    connect: (hostname, port, context) => connectHere(hostname, port, context),
414    fetch: (input, init) => fetch(input, init),
415    pty: (argv, options, context) =>
416      new Promise((done) =>
417        done(ptyHere(argv, {
418          ...options,
419          cwd: options.cwd === undefined ? inner.cwd : resolve(inner.cwd, inside(options.cwd)),
420        }, context))
421      ),
422  };
423}