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