char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
d220151
main
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 { 6type ConversationId , 7type Cursor , 8type EntryRecord , 9MemoryStorage , 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 []> { 16const conversation = ( await berry . harness . conversation ( id as ConversationId , context )) ! ; 17const entries :EntryRecord [] = []; 18let cursor :Cursor | undefined ; 19do { 20const page = await conversation . entries ({ order :"ascending" }, 100 , cursor , context ); 21entries . push ( ...page . items ); 22cursor = page . next ; 23} while ( cursor !== undefined ); 24return 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 >>) { 29const byId = new Map ( tree . entries . map (( entry ) => [ entry . id , entry ])); 30for ( const branch of tree . branches ) { 31const path :TreeEntry [] = []; 32for ( let id = branch . tip ; id !== null ; id = byId . get ( id ) ! . parent ) path . unshift ( byId . get ( id ) ! ); 33assertEquals ( 34path . 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 () => { 41await usingt = await setup (); 42const { berry} = t ; 43const tid = await berry . create ({ extensions :{}, model :t . model }); 44const info = async () => ( await berry . harness . snapshot ( Conversations , context )) ! [ tid ]; 45await turn ( t , tid , [ script ( "1" )]); 46await turn ( t , tid , []); 47const first = ( await info ()). id ; 48const [ start , ...rest ] = await history ( berry , first ); 49const firstAnswer = rest . find (( entry ) => entry . kind === "pi.assistant" ) ! ; 50 51// a new first message, then a branch after the first answer 52await berry . branch ( tid , start . id ); 53await turn ( t , tid , []); 54await berry . branch ( tid , firstAnswer . id ); 55await turn ( t , tid , []); 56 57const tree = await conversationTree ( berry , tid ); 58assertEquals ( tree . branches . length , 3 ); 59assertEquals ( tree . head , tree . branches [ 2 ]. id ); 60assertEquals ( await berry . tidOf ( tree . head as ConversationId ), tid ); 61await assertPaths ( berry , tree ); 62assertEquals ( 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 67await berry . branch ( tid , rest . at ( - 1 ) ! . id ); 68assertEquals (( await info ()). head , undefined ); 69assertEquals (( 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 () => { 73await usingt = await setup (); 74const { berry} = t ; 75const tid = await berry . create ({ extensions :{}, model :t . model }); 76await turn ( t , tid , []); 77const parent = ( await berry . conversation ( tid )) ! ; 78const entries = await history ( berry , parent . id ); 79await berry . branch ( tid , entries [ 0 ]. id ); 80await turn ( t , tid , []); 81 82const fork = await parent . fork ( entries . at ( - 1 ) ! . id , { ownership :{ kind :"ownerless" } }, context ); 83const forkTid = await berry . tidOf ( fork . id ); 84await turn ( t , forkTid , []); 85const tree = await conversationTree ( berry , forkTid ); 86assertEquals ( tree . branches . map (( branch ) => branch . id ), [ fork . id ]); 87await assertPaths ( berry , tree ); 88}); 89 90Deno . test ( "regenerating an agent's message runs it again from just before it, keeping the first one" , async () => { 91await usingt = await setup (); 92const { berry} = t ; 93const tid = await berry . create ({ extensions :{}, model :t . model }); 94await turn ( t , tid , [ script ( "1" )]); 95const first = ( await berry . conversation ( tid )) ! . id ; 96const entries = await history ( berry , first ); 97const answer = entries . at ( - 1 ) ! ; 98const message = entries . find (( entry ) => entry . kind === "pi.user" ) ! ; 99 100t . faux . setResponses ([ fauxAssistantMessage ( "again" )]); 101await berry . regenerate ( tid , answer . id ); 102await ( await berry . conversation ( tid )) ! . waitForIdle ( context ); 103 104const tree = await conversationTree ( berry , tid ); 105await assertPaths ( berry , tree ); 106const byId = new Map ( tree . entries . map (( entry ) => [ entry . id , entry ])); 107const path = ( branch :number ) => { 108const entries :TreeEntry [] = []; 109let id = tree . branches . find (( of ) => of . id === branch ) ! . tip ; 110for (; id !== null ; id = byId . get ( id ) ! . parent ) entries . unshift ( byId . get ( id ) ! ); 111return entries . filter (( entry ) => entry . kind !== "pi.system" ); 112}; 113const was = path ( first ); 114const now = path ( tree . head ); 115assertEquals ( 116was . map (( entry ) => entry . kind ), 117[ "berry.start" , "pi.user" , "pi.assistant" , "pi.tool-result" , "pi.assistant" ], 118); 119assertEquals ( now . slice ( 0 , - 1 ), was . slice ( 0 , - 1 )); 120assertEquals ([ was . at ( - 1 ) ! . text , now . at ( - 1 ) ! . text ], [ "done" , "again" ]); 121await assertRejects (() => berry . regenerate ( tid , message . id ), Error , "not the agent's message" ); 122}); 123 124async function rewound ( provider ?:Parameters < typeof fauxProvider >[ 0 ]) { 125const faux = fauxProvider ( provider ); 126const models = createModels (); 127models . setProvider ( faux . provider ); 128const sessions :( string | undefined )[] = []; 129const payloads :Promise < unknown >[] = []; 130const original = models . streamSimple . bind ( models ); 131models . streamSimple = ( ...args ) => { 132const [ model , , options ] = args ; 133sessions . push ( options ?. sessionId ); 134// every provider exposes the request it would send through onPayload 135const next = options ?. onPayload ?.({ prompt_cache_key :"sentry" }, model ); 136if ( next !== undefined ) payloads . push ( Promise . resolve ( next )); 137return original ( ...args ); 138}; 139const berry = await openBerry ( new MemoryStorage (), models ); 140try { 141const model = { provider :"faux" , modelId :faux . getModel (). id }; 142await berry . enableModels ([ model ]); 143const tid = await berry . create ({ extensions :{}, model}); 144const harness = { faux, berry}; 145await turn ( harness , tid , []); 146const first = ( await berry . conversation ( tid )) ! ; 147const [ start ] = ( await first . entries ({ order :"ascending" }, 10 , undefined , context )). items ; 148await berry . branch ( tid , start . id ); 149await turn ( harness , tid , []); 150return { tid, sessions, payloads :await Promise . all ( payloads ) }; 151} finally { 152await berry . close (); 153} 154} 155 156Deno . test ( "a rewind keeps the provider session so the new branch starts on a warm prompt cache" , async () => { 157const { tid, sessions} = await rewound (); 158assert ( sessions . length > 1 ); 159assertEquals ( 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 () => { 163const { tid, sessions, payloads} = await rewound ({ api :"openai-codex-responses" }); 164assert ( new Set ( sessions ). size > 1 ); 165assert ( payloads . length > 0 ); 166for ( const payload of payloads ) { 167assertEquals (( payload as { prompt_cache_key :unknown }). prompt_cache_key , tid ); 168} 169}); 170 171Deno . test ( "tree entries preview their messages on one line" , async () => { 172await usingt = await setup (); 173const tid = await t . berry . create ({ extensions :{}, model :t . model }); 174await turn ( t , tid , [ script ( "throw new Error('no\\nway')" )]); 175const { entries} = await conversationTree ( t . berry , tid ); 176assertEquals ( 177entries . filter (( entry ) => entry . kind !== "pi.system" ). map (({ kind, text, tools, error}) => ({ 178 kind, 179text :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});