Files
sausage/host/js/harness-api.ts
T

111 lines
6.7 KiB
TypeScript

// The world chat API runs in the same harness as turns and extension actions.
import { AgentDoc, LiveDoc, defineDoc } from '@earendil-works/pi-durable';
import { Metadata, fs } from './extensions';
import { eventLog } from './events';
const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({ids:[]})});
const Started = defineDoc({kind:'board.started',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})});
const fail = (status:number,message:string):never => { throw Object.assign(new Error(message),{status}); };
const integer = (value:any, fallback?:number) => {
if (value === null && fallback !== undefined) return fallback;
const n=Number(value);
if (!Number.isSafeInteger(n) || n<0) fail(400,'Invalid cursor or conversation');
return n;
};
export function harnessApi(harness:any, models:any, extensions:any, context:any) {
const streams = new Map<string,()=>Promise<void>>();
const log = eventLog(()=>harness).eventLog;
const send = (id:string,value:any) => Deno.core.ops.op_harness_reply(id,value);
async function entries(conversation:any,after:number) {
const found:any[]=[];
let cursor:any;
do {
const page=await conversation.entries({minEntryId:after+1,order:'ascending'},256,cursor,context);
found.push(...page.items); cursor=page.next;
} while(cursor);
return found;
}
async function follow(input:any, read:(after:number)=>Promise<any[]>, cursor:number) {
// Observe before the snapshot. Serialize scans to close the snapshot/subscription race.
let active=true, queue=Promise.resolve();
const scan=()=>queue=queue.then(async()=>{
if (!active) return;
for (;;) {
const page=await read(cursor);
if(!active || !page.length) return;
for (const entry of page) {
cursor=entry.id; send(input.id,entry);
}
}
});
const observation=harness.observeCommits({advance:(publication:any)=>{if(publication.changes.some((change:any)=>change.type==='entry'))void scan();},end:()=>{void stop();}});
const stop=async()=>{if(!active)return;active=false;observation.detach();await queue;send(input.id,null);streams.delete(input.id);};
streams.set(input.id,stop);
send(input.id,{status:200,stream:true});
void Deno.core.ops.op_harness_closed(input.id).then(stop);
await scan();
}
async function handle(input:any) {
const url=new URL(input.path,'http://host.sausage');
const path=url.pathname.slice('/harness'.length);
const data=input.body??{};
const reply=(status:number,body:any)=>{send(input.id,{status,body});send(input.id,null);};
try {
if(input.method==='GET' && path==='/conversations') {
const records=await harness.commit(async(tx:any)=>{
const all:any[]=[];let cursor:any;
do {const page=await tx.scanConversations({},256,cursor);all.push(...page.items);cursor=page.next;}while(cursor);
return all;
},context);
const concluded=new Set((await harness.snapshot(Concluded,context))?.ids??[]);
const conversations=await Promise.all(records.map(async(record:any)=>{
const agent=await harness.snapshot(AgentDoc,record.id,context);
const metadata=await harness.snapshot(Metadata,record.id,context);
const live=await harness.snapshot(LiveDoc,record.id,context);
const started:any=await harness.snapshot(Started,record.id,context);
return {...record,title:metadata?.title??'',agent,starter:started?.by??null,concluded:concluded.has(record.id),running:!!live?.run};
}));
reply(200,{conversations});return;
}
if(input.method==='GET' && path==='/events') {
const cursor=integer(url.searchParams.get('after'),0);
await follow(input,async(after)=>log.recent({since:after,order:'ascending',limit:256}),cursor);return;
}
if(input.method==='GET' && path==='/extensions') {reply(200,extensions.state());return;}
if(input.method==='GET' && path==='/families') {
let families={};try{families=JSON.parse(await fs.read('/src/sausage/agents/models.json'));}catch(error){if((error as any).code!=='not_found')throw error;}
reply(200,{families});return;
}
if(input.method==='GET' && path==='/models') {reply(200,{models:models.getAllModels().map(({id,name,provider,contextWindow,maxTokens,reasoning,cost}:any)=>({id,name,provider,contextWindow,maxTokens,reasoning,cost}))});return;}
const action=/^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path);
if(input.method==='POST' && action) {
if(data.conversationId!==undefined && (!Number.isSafeInteger(data.conversationId)||data.conversationId<1)) fail(400,'Invalid conversation id');
const controller=new AbortController();
void Deno.core.ops.op_harness_closed(input.id).then(()=>controller.abort());
reply(200,{result:await extensions.invoke(decodeURIComponent(action[1]),decodeURIComponent(action[2]),data.args??{},controller.signal,data.conversationId)});return;
}
const match=/^\/conversations\/(\d+)\/(entries|stream|submit|stop)$/.exec(path);
if(!match) fail(404,'Unknown harness route');
const conversation=await harness.conversation(integer(match![1]),context);
if(!conversation) fail(404,'Unknown conversation');
const after=integer(url.searchParams.get('after'),0);
if(input.method==='GET' && match![2]==='entries') {reply(200,{entries:await entries(conversation,after)});return;}
if(input.method==='GET' && match![2]==='stream') {await follow(input,(cursor)=>entries(conversation,cursor),after);return;}
if(input.method==='POST' && match![2]==='submit') {
if(typeof data.requestId!=='string'||!data.requestId||typeof data.content!=='string'||!data.content.trim()) fail(400,'Expected content and requestId');
const submission=await conversation.submit({type:'input',content:data.content,requestId:data.requestId},context);
reply(202,{submissionId:submission.id});return;
}
if(input.method==='POST' && match![2]==='stop') {
for (;;) {
const own=(await harness.inspect(context)).tasks.filter(({record}:any)=>record.conversationId===conversation.id&&!record.owner&&!record.background);
if(!own.length) break;
for(const {record} of own) {await harness.abortTask(record.id,context);await harness.waitForTask(record.id,context);}
}
reply(200,{ok:true});return;
}
fail(404,'Unknown harness route');
} catch(error) {reply((error as any).status??500,{error:(error as Error).message});}
}
return {handle,async close(){await Promise.all([...streams.values()].map(stop=>stop()));}};
}