char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
ecb3f3b
main
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 { 8fauxAssistantMessage , 9fauxProvider , 10fauxToolCall , 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 })) => 28defineExtension < Dir , Cd >({ 29name :"dir" , 30description :"a working directory" , 31initial :() => ({ cwd :"/" }), 32 apply, 33setup ( ext ) { 34ext . configuration ({ 35parameters :Type . Object ({ cwd :Type . Optional ( Type . String ()) }), 36values :({ cwd}) => ({ cwd}), 37events :({ cwd}) => { 38if ( cwd !== undefined && ! cwd . startsWith ( "/" )) throw new Error ( `not absolute: ${ cwd } ` ); 39return cwd === undefined ?[] :[{ type :"cd" , cwd}]; 40}, 41}); 42ext . tool ({ 43name :"cd" , 44description :"change directory" , 45parameters :Type . Object ({ path :Type . String () }), 46expose :"both" , 47async execute ({ path}, ctx ) { 48await ctx . emit ({ type :"cd" , cwd :path }); 49return `now in ${ ctx . state . cwd } ` ; 50}, 51}); 52ext . tool ({ 53name :"say" , 54description :"stream text as running output" , 55parameters :Type . Object ({ text :Type . String () }), 56execute :({ text}, ctx ) => { 57for ( const word of text . split ( " " )) ctx . output ( ` ${ word } \n` ); 58return text . length ; 59}, 60}); 61ext . tool ({ 62name :"shout" , 63description :"model-only" , 64parameters :Type . Object ({}), 65expose :"model" , 66execute :() => ({ word :"HEY" }), 67text :({ word}) => ` ${ word } !` , 68}); 69return ( conv ) => { 70conv . prompt (() => `cwd: ${ conv . state . cwd } ` ); 71conv . tool ({ 72name :"pwd" , 73description :"print the working directory" , 74parameters :Type . Object ({}), 75execute :( _ , ctx ) => ctx . state . cwd , 76}); 77conv . command ({ 78name :"go" , 79description :"change directory, as the user" , 80execute :async ( path , ctx ) => { 81if ( ! path . startsWith ( "/" )) throw new Error ( `not absolute: ${ path } ` ); 82await ctx . emit ({ type :"cd" , cwd :path }); 83return path ; 84}, 85}); 86}; 87}, 88}); 89 90/** A direct tool that holds its turn open until released. */ 91export function gated () { 92const gate = { entered :Promise . withResolvers < void >(), release :Promise . withResolvers < string >() }; 93const extension = defineExtension ({ 94name :"gate" , 95initial :() => null , 96setup ( ext ) { 97ext . tool ({ 98name :"hold" , 99description :"wait" , 100parameters :Type . Object ({}), 101expose :"model" , 102execute :() => { 103gate . entered . resolve (); 104return gate . release . promise ; 105}, 106}); 107}, 108}); 109return { 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 () { 117const blocker = { 118release :Promise . withResolvers < string >(), 119running :0 , 120/** Each started call's name, to the conversation it ran in. */ 121started :new Map < string , string >(), 122cancelled :[] as string [], 123}; 124const extension = defineExtension ({ 125name :"blocking" , 126initial :() => null , 127setup ( ext ) { 128ext . tool ({ 129name :"block" , 130description :"wait for release or cancellation" , 131parameters :Type . Object ({ name :Type . Optional ( Type . String ()) }), 132expose :"model" , 133execute ({ name= "" }, ctx ) { 134blocker . started . set ( name , ctx . tid ); 135blocker . running ++ ; 136return new Promise < string >(( resolve , reject ) => { 137const abort = () => { 138blocker . cancelled . push ( name ); 139reject ( ctx . signal . reason ); 140}; 141ctx . signal . addEventListener ( "abort" , abort , { once :true }); 142void blocker . release . promise . then (( answer ) => { 143ctx . signal . removeEventListener ( "abort" , abort ); 144resolve ( answer ); 145}); 146}). finally (() => blocker . running -- ); 147}, 148}); 149ext . tool ({ 150name :"ready" , 151description :"wait until a block is held" , 152parameters :Type . Object ({}), 153execute :() => until (() => blocker . running > 0 ). then (() => "ready" ), 154}); 155}, 156}); 157return 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 ( 162environments ?:Environments , 163provider ?:Parameters < typeof fauxProvider >[ 0 ], 164) { 165const faux = fauxProvider ( provider ); 166const models = createModels (); 167models . setProvider ( faux . provider ); 168const berry = await openBerry ( new MemoryStorage (), models , undefined , environments ); 169await berry . install ( dir ()); 170const { gate, extension} = gated (); 171await berry . install ( extension ); 172const model = { provider :"faux" , modelId :faux . getModel (). id }; 173await berry . enableModels ([ model ]); 174return { faux, berry, model, models, gate, [ Symbol . asyncDispose ] :() => berry . close () }; 175} 176 177export const script = ( code :string ) => 178fauxAssistantMessage ( 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 }, 186tid :string , 187steps :readonly AssistantMessage [], 188) :Promise <( readonly Message [])[]> { 189const seen :( readonly Message [])[] = []; 190faux . setResponses ( 191[ ...steps , fauxAssistantMessage ( "done" )]. map ( 192( step ) => ( ctx ) => ( seen . push ( ctx . messages ), step ), 193), 194); 195const conversation = ( await berry . conversation ( tid )) ! ; 196await ( await conversation . submit ({ type :"input" , content :"go" }, context )). wait ( context ); 197await conversation . waitForIdle ( context ); 198return seen ; 199} 200 201export function lastToolResult ( messages :readonly Message []) { 202const result = messages . findLast (( m ) => m . role === "toolResult" ); 203if ( result ?. role !== "toolResult" ) throw new Error ( "no tool result" ); 204return { 205text :result . content . flatMap (( block ) => ( block . type === "text" ?[ block . text ] :[])). join ( "\n" ), 206isError :result . isError , 207details :result . details , 208}; 209} 210 211export const sectionsSeen = ( messages :readonly Message []) => 212messages . flatMap (( m ) => ( m . role === "system" ?[ m . sections ?? {}] :[])); 213 214export const toolsOffered = ( messages :readonly Message []) => 215getCurrentTools ( 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 220const web = ( await import ( "./web.ts" )). extensionWeb (); 221const entry = fromFileUrl ( new URL ( `../extensions/ ${ name } /web.tsx` , import . meta. url )); 222const js = await web . serve (( await web . build ( name , entry )). module ) ! . text (); 223const host = globalThis as Record < string , unknown >; 224host [ HOST_GLOBAL ] = { 225"@char/aftercare" :aftercare , 226"@char/aftercare/jsx-runtime" :jsxRuntime , 227"@berry/api/web.ts" :api , 228}; 229try { 230const encoded = encodeBase64 ( new TextEncoder (). encode ( js )); 231return ( await import ( `data:text/javascript;base64, ${ encoded } ` )). default ; 232} finally { 233delete host [ HOST_GLOBAL ]; 234} 235}