Merge branch 'v3' into v3-t85
This commit is contained in:
+2
-2
@@ -37,7 +37,7 @@ CARGO_TARGET_DIR=target/harness nix develop -c cargo test -p sausage --test harn
|
||||
|
||||
The lock file pins npm dependencies. The bundle command requires Nushell, GNU patch, and npm.
|
||||
It applies `patches/pi-durable-1.5.0.patch` before esbuild runs.
|
||||
This is Ultimate's patch at `7ec0b4f`, without the Node execution environment hunks (D65).
|
||||
This is Standing agent's patch at `7ec0b4f`, without the Node execution environment hunks (D65).
|
||||
The patch adds transactional `beforeInput`, input markers, scheduler hold/release, and the failed-run boundary fix.
|
||||
The step verifies an existing application before another bundle build. A mismatched or partially applied patch fails the command.
|
||||
`npm ci` restores the unpatched dependency. The next bundle command applies the patch again.
|
||||
@@ -85,5 +85,5 @@ The signal client sends `Input::Stop`. The harness closes its watches, credentia
|
||||
The host then stops its process services and HTTP listener.
|
||||
Console output still streams assistant messages, without replay of the historical transcript.
|
||||
|
||||
`harness_foundation.rs` runs the three Ultimate input tests and the scheduler phase-boundary test through bundled V8 modules.
|
||||
`harness_foundation.rs` runs the three Standing agent input tests and the scheduler phase-boundary test through bundled V8 modules.
|
||||
It also tests the channel with closed stdin, CLI EOF and SIGTERM, submission scans, and the patched bundle hooks.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** All 19 Ultimate pause cases, executed against world snapshot modules. */
|
||||
/** All 19 Standing agent pause cases, executed against world snapshot modules. */
|
||||
import {expect,it,until,setImmediate} from "./automations-test-support";
|
||||
import {fixture} from "./automations-pause-fixture";
|
||||
import {BACKGROUND_CONTEXT as context} from "@earendil-works/chord/context";
|
||||
@@ -245,9 +245,9 @@ it('pauses on the first rate-limited answer: the run waits in its retry, fails n
|
||||
},
|
||||
])
|
||||
const { root, harness } = await f.open()
|
||||
const ultimate = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
|
||||
const standing = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
|
||||
await harness.commit(async (tx) => {
|
||||
;(await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate = ultimate.id
|
||||
;(await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'] = standing.id
|
||||
}, context)
|
||||
const submission = await root.submit({ type: 'input', content: 'go' }, context)
|
||||
await until(async () => (await f.action('state')).limit !== undefined)
|
||||
@@ -263,7 +263,7 @@ it('pauses on the first rate-limited answer: the run waits in its retry, fails n
|
||||
expect(generation.record.state.status).not.toBe('terminal')
|
||||
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeDefined()
|
||||
const cards = async () =>
|
||||
(await ultimate.context(context)).entries.filter((entry) => entry.kind === 'pause.limit')
|
||||
(await standing.context(context)).entries.filter((entry) => entry.kind === 'pause.limit')
|
||||
await until(async () => (await cards()).length === 1)
|
||||
expect((await cards())[0].data).toMatchObject({ error: limit, conversationId: root.id })
|
||||
|
||||
@@ -420,9 +420,9 @@ it('plays a rate-limit pause by itself once, N minutes in, only when the user sw
|
||||
},
|
||||
])
|
||||
const { root, harness } = await f.open()
|
||||
const ultimate = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
|
||||
const standing = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
|
||||
await harness.commit(async (tx) => {
|
||||
;(await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate = ultimate.id
|
||||
;(await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'] = standing.id
|
||||
}, context)
|
||||
const submission = await root.submit({ type: 'input', content: 'go' }, context)
|
||||
await until(async () => (await f.action('state')).limit !== undefined)
|
||||
@@ -443,14 +443,14 @@ it('plays a rate-limit pause by itself once, N minutes in, only when the user sw
|
||||
expect(responses).toBe(2)
|
||||
// Once: the pause is over, so a second due timer finds nothing to play.
|
||||
expect(await f.autoPlay(since! + 31 * 60_000)).toBe(false)
|
||||
const played = (await ultimate.context(context)).entries.filter(
|
||||
const played = (await standing.context(context)).entries.filter(
|
||||
(entry) => entry.kind === 'pause.autoplay',
|
||||
)
|
||||
expect(played).toHaveLength(1)
|
||||
expect(played[0].data).toMatchObject({ since, at: since! + 30 * 60_000, minutes: 30 })
|
||||
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
|
||||
expect(notes[0].data).toMatchObject({ auto: true })
|
||||
expect(notes[0].model?.[0]?.content).toMatch(/continued after Ultimate played by itself\.$/)
|
||||
expect(notes[0].model?.[0]?.content).toMatch(/continued after Standing agent played by itself\.$/)
|
||||
// The setting stays for the next limit.
|
||||
expect((await f.action('state')).autoPlay).toBe(30)
|
||||
})
|
||||
@@ -495,7 +495,7 @@ const at = (kind: string, phase: string) => ({ kind, state: { checkpoint: { phas
|
||||
it('treats message deliveries and routes as passive waiters', () => {
|
||||
expect(passive(at('board.delivery', 'deliver'))).toBe(true)
|
||||
expect(passive(at('board.delivery', 'reply'))).toBe(true)
|
||||
expect(passive(at('ultimate.subagent-reporter', 'deliver'))).toBe(true)
|
||||
expect(passive(at('standing.subagent-reporter', 'deliver'))).toBe(true)
|
||||
expect(passive(at('board.route', 'observe'))).toBe(true)
|
||||
expect(passive(at('conversations.summary', 'generate'))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Pause cases use snapshot modules and isolated durable storage, as Ultimate's fixture does. */
|
||||
/** Pause cases use snapshot modules and isolated durable storage, as Standing agent's fixture does. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import { obj } from '@earendil-works/pi-schema'
|
||||
import { createModels } from '@earendil-works/pi-ai/models'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** The five Ultimate calendar cases execute inside deno_core's V8. */
|
||||
/** The five Standing agent calendar cases execute inside deno_core's V8. */
|
||||
import {describe, expect, it} from "./automations-test-support";
|
||||
const {describeSchedule,nextAt,parseSchedule}=await import(`${globalThis.automationBase}/automations/schedule.ts`);
|
||||
const time = Date.parse('2026-01-05T08:00:00Z')
|
||||
|
||||
+7579
-367
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
|
||||
/** Minimal assertions for the imported Standing agent cases, without Node or Vitest. */
|
||||
export const cases: {name:string;run:()=>any}[]=[]
|
||||
export const it=(name:string,run:()=>any)=>cases.push({name,run})
|
||||
export const describe=(_name:string,run:()=>any)=>run()
|
||||
|
||||
+7702
-1061
File diff suppressed because it is too large
Load Diff
+2467
-200
File diff suppressed because it is too large
Load Diff
+7037
-364
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { fauxProvider, fauxAssistantMessage } from '@earendil-works/pi-ai/provid
|
||||
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable'
|
||||
import { openStorage } from './sqlite'
|
||||
import { loadExtensions, fs } from './extensions'
|
||||
import { parse } from 'yaml'
|
||||
const assert = (ok:any,message:string)=>{if(!ok)throw new Error(message);Deno.core.ops.op_output(`ok - ${message}\n`)}
|
||||
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
|
||||
const proxyTemplate={...models.getModels('faux')[0],provider:'openai',baseUrl:'https://proxy.invalid/v1'}
|
||||
@@ -18,17 +19,35 @@ const loader=await loadExtensions(harness,registry,storage,models,context)
|
||||
try {
|
||||
assert(!loader.state().error,`loader: ${loader.state().error ?? 'loaded'}`)
|
||||
const {selection,conversations,State,metadata}=globalThis.core
|
||||
const config = parse(await fs.read('/etc/sausage/config.yaml'))
|
||||
config.standing='worker'
|
||||
config.roster=[{name:'worker',description:'Worker',model:'faux/faux-1',thinking:'low',tools:['read','write'],prompt:'Worker body'}]
|
||||
const save = ()=>fs.write('/etc/sausage/config.yaml',JSON.stringify(config))
|
||||
await save()
|
||||
const worker=await selection.start({agent:'worker',cwd:'/project/sub'},context)
|
||||
let agent=await worker.agent(context)
|
||||
assert(agent.model.modelId==='faux-1' && agent.thinkingLevel==='low','worker fast family and thinking')
|
||||
assert(agent.tools.map(t=>t.name).join(',')==='read,write','worker tools and project overlay')
|
||||
assert(agent.instructions.includes('Worker body') && agent.instructions.includes('Project body'),'definition body and overlay')
|
||||
assert(agent.model.modelId==='faux-1' && agent.thinkingLevel==='low','worker roster model and thinking')
|
||||
assert(agent.tools.map(t=>t.name).join(',')==='read,write','worker roster tools')
|
||||
assert(agent.instructions.indexOf('## Sausage and the world') < agent.instructions.indexOf('Worker body'),'global prompt precedes role prompt')
|
||||
const sections:any={}
|
||||
for(const section of agent.sections) sections[section.key]=await section.render({conversationId:worker.id,agent},context)
|
||||
assert(sections.project_context.includes('Root instructions') && sections.project_context.includes('Nested instructions'),'cwd and parent AGENTS.md')
|
||||
assert(!sections.project_context.includes('Outside instructions'),'instructions stop at repo root')
|
||||
assert(sections.skills.includes('builtin-skill') && sections.skills.includes('project-skill'),'builtin and project skills')
|
||||
assert(sections.cwd==='/project/sub' && sections.tools.includes('read'),'cwd and tools sections')
|
||||
const checkPrompt = (ctx: any, expected: string) => {
|
||||
const prompt=JSON.stringify(ctx.messages.filter((message: any)=>message.role==='system'))
|
||||
for (const route of ['whoami','conversations/ID/entries?after=N','events?after=N','models','extensions','credential-requests','/proc/processes','/proc/extensions','jaq']) assert(prompt.includes(route),`fresh system prompt includes ${route}`)
|
||||
assert(prompt.includes(expected),'system prompt uses current global prompt')
|
||||
assert(prompt.indexOf(expected)<prompt.indexOf('Worker body'),'system prompt places global prompt before role prompt')
|
||||
}
|
||||
faux.setResponses([(ctx)=>{checkPrompt(ctx,'## Sausage and the world');return fauxAssistantMessage('fresh')}])
|
||||
harness.resume()
|
||||
await (await worker.submit({type:'input',content:'fresh'},context)).wait(context)
|
||||
config.prompt='Edited global prompt\\n'+config.prompt
|
||||
await save()
|
||||
faux.setResponses([(ctx)=>{checkPrompt(ctx,'Edited global prompt');return fauxAssistantMessage('edited')}])
|
||||
await (await worker.submit({type:'input',content:'edited'},context)).wait(context)
|
||||
const seed=await harness.commit(tx=>tx.appendEntry(worker.id,{kind:'test.seed'}),context)
|
||||
const child=await worker.fork(seed.id,{ownership:{kind:'ownerless'}},context)
|
||||
const unrelated=await harness.createConversation({ownership:{kind:'ownerless'}},context)
|
||||
@@ -46,7 +65,7 @@ try {
|
||||
const receipt=await child.submit({type:'input',content:'resume'},context)
|
||||
assert(JSON.stringify([...await conversations.concluded(context)])===JSON.stringify([unrelated.id]),'input placement atomically resumes ancestors')
|
||||
harness.resume();await receipt.wait(context)
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'faux/faux-1',thinking:'high'}}))
|
||||
config.roster[0].thinking='high';await save()
|
||||
faux.setResponses([fauxAssistantMessage('next')])
|
||||
await (await worker.submit({type:'input',content:'next'},context)).wait(context)
|
||||
agent=await worker.agent(context)
|
||||
@@ -54,22 +73,22 @@ try {
|
||||
// A second faux model proves selection changes, not only the thinking level.
|
||||
const provider=models.getProvider('faux'), template=models.getModels('faux')[0]
|
||||
models.setProvider({...provider,getModels:()=>[template,{...template,id:'second'}]})
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'faux/second'}}))
|
||||
config.roster[0].model='faux/second';await save()
|
||||
faux.setResponses([fauxAssistantMessage('second')])
|
||||
await (await worker.submit({type:'input',content:'second'},context)).wait(context)
|
||||
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited family changes next model without restart')
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'openai/new'}}))
|
||||
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited roster changes next model without restart')
|
||||
config.roster[0].model='openai/new';await save()
|
||||
faux.setResponses([fauxAssistantMessage('proxy')])
|
||||
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
|
||||
assert(models.getModel('openai','new').baseUrl===proxyTemplate.baseUrl && models.getModel('openai','new').cost.input===0 && models.getProvider('openai').auth===proxyAuth,'new model retains proxy transport and auth')
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'anthropic/new'}}))
|
||||
config.roster[0].model='anthropic/new';await save()
|
||||
faux.setResponses([fauxAssistantMessage('anthropic')])
|
||||
await (await worker.submit({type:'input',content:'anthropic'},context)).wait(context)
|
||||
assert(models.getModel('anthropic','new').baseUrl==='https://proxy.invalid/apis/anthropic','new Anthropic model retains its own provider transport')
|
||||
globalThis.modelPin={provider:'faux',modelId:'faux-1'}
|
||||
faux.setResponses([fauxAssistantMessage('pin')])
|
||||
await (await worker.submit({type:'input',content:'pin'},context)).wait(context)
|
||||
assert((await worker.agent(context)).model.provider==='faux','models.pin overrides family selection')
|
||||
assert((await worker.agent(context)).model.provider==='faux','models.pin overrides roster selection')
|
||||
await harness.commit(tx=>conversations.setStanding(tx,worker.id,true),context)
|
||||
let refused=false;try{await harness.commit(tx=>conversations.concludeIn(tx,[worker.id]),context)}catch{refused=true}
|
||||
assert(refused,'standing conversation cannot conclude')
|
||||
|
||||
+7001
-356
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ const {Automations}=await import(`${base}/automations/server.ts`)
|
||||
const {ContextUsageDoc}=await import(`${base}/automations/triggers.ts`)
|
||||
const {Fired}=await import(`${base}/automations/refs.ts`)
|
||||
const {Messages}=await import(`${base}/messages/api.ts`)
|
||||
const primary=(await harness.conversation(await globalThis.dreamSubagents.standing('ultimate',context),context))!
|
||||
const primary=(await harness.conversation(await globalThis.dreamSubagents.standing('standing',context),context))!
|
||||
const entries=async()=> (await primary.context(context)).entries
|
||||
const ledger=async()=> (await harness.snapshot(Automations,context))?.automations??{}
|
||||
const requested=async()=> (await entries()).filter((e:any)=>e.kind==='dream.requested')
|
||||
|
||||
+7000
-355
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -42,7 +42,7 @@ Event emission updates the feed inside the same transaction.
|
||||
|
||||
Notices become `sausage.notice` events.
|
||||
The host records each distinct notice once per facet load.
|
||||
This task does not add Ultimate's build or supervisor startup events.
|
||||
This task does not add Standing agent's build or supervisor startup events.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
+7000
-355
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
// World extension snapshots, staged services, lifecycle, and last-good recovery.
|
||||
import * as durable from '@earendil-works/pi-durable';
|
||||
import * as yaml from 'yaml';
|
||||
import * as tools from '@earendil-works/pi-durable/tools';
|
||||
import * as ai from '@earendil-works/pi-ai';
|
||||
import * as modelApi from '@earendil-works/pi-ai/models';
|
||||
@@ -68,7 +69,7 @@ export async function git(cwd:string,...args:string[]) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
export function registerModules() {
|
||||
const modules = {'@earendil-works/sausage-events':events,'@earendil-works/pi-schema':schema,'@earendil-works/pi-durable':durable,'@earendil-works/pi-durable/tools':tools,'@earendil-works/pi-ai':ai,'@earendil-works/pi-ai/models':modelApi,'@earendil-works/chord':chord,'@earendil-works/chord/context':contextApi};
|
||||
const modules = {'yaml':yaml,'@earendil-works/sausage-events':events,'@earendil-works/pi-schema':schema,'@earendil-works/pi-durable':durable,'@earendil-works/pi-durable/tools':tools,'@earendil-works/pi-ai':ai,'@earendil-works/pi-ai/models':modelApi,'@earendil-works/chord':chord,'@earendil-works/chord/context':contextApi};
|
||||
globalThis.__sausage = {modules};
|
||||
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name,value])=>[name,Object.keys(value)])));
|
||||
}
|
||||
@@ -209,7 +210,12 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
|
||||
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`,`${repo}/.git/packed-refs`,`${repo}/settings.json`]);
|
||||
await enqueue();
|
||||
const pending = (async()=> {while(active){await ops.op_watch_next(rid);if(active)await enqueue();}})();
|
||||
return {reload:enqueue, state:()=>({...state}), async invoke(name:string,key:string,args:any,signal:AbortSignal,conversationId?:number) {
|
||||
return {reload:enqueue, state:()=>({...state}), async standing() {
|
||||
if (!providers.has('models') || !providers.has('subagents')) return undefined;
|
||||
const {standing:name}=await providers.get('models').discover(repo);
|
||||
const conversationId=await providers.get('subagents').standing(name,context);
|
||||
return {name,conversationId};
|
||||
}, async invoke(name:string,key:string,args:any,signal:AbortSignal,conversationId?:number) {
|
||||
const facet = facets.find(f=>f.extension.name===name);
|
||||
const action = facet?.actions?.[key];
|
||||
if (!action) throw Object.assign(new Error('Unknown user action'),{status:404});
|
||||
|
||||
@@ -20614,7 +20614,7 @@ async function openStorage(path2) {
|
||||
}
|
||||
|
||||
// input.ts
|
||||
async function serve(harness2, context, afterSubmit, api) {
|
||||
async function serve(harness2, context, afterSubmit, api, defaultConversation) {
|
||||
const pending = /* @__PURE__ */ new Set();
|
||||
const track2 = (work) => {
|
||||
const task = work.catch((error) => Deno.core.ops.op_output(`${String(error)}
|
||||
@@ -20637,8 +20637,9 @@ async function serve(harness2, context, afterSubmit, api) {
|
||||
track2(api.handle(input));
|
||||
continue;
|
||||
}
|
||||
const conversation = await harness2.conversation(input.conversation, context);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
|
||||
const id = defaultConversation ? await defaultConversation() : input.conversation;
|
||||
const conversation = await harness2.conversation(id, context);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${id}`);
|
||||
const submission2 = await conversation.submit({
|
||||
type: "input",
|
||||
content: input.content,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Ultimate's input-placement cases, scheduler hold, and the headless channel contract.
|
||||
// Standing agent's input-placement cases, scheduler hold, and the headless channel contract.
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
|
||||
+35
-35
@@ -1,4 +1,4 @@
|
||||
/** The seven Ultimate goal cases use overlaid documents and the loaded server modules. */
|
||||
/** The seven Standing agent goal cases use overlaid documents and the loaded server modules. */
|
||||
import type { ConversationId, GenerationHooks, Tx } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
|
||||
import { expect, it } from './goal-questions-test-support'
|
||||
@@ -6,7 +6,7 @@ const { reminderTask } = await import(`${globalThis.goalBase}/goal/reminder.ts`)
|
||||
const {default:goalExtension,act,actions,end,Goals,openGoal,start,TRIES} = await import(`${globalThis.goalBase}/goal/server.ts`)
|
||||
import type { Host } from './extensions.ts'
|
||||
|
||||
const ULTIMATE = 5 as ConversationId
|
||||
const STANDING = 5 as ConversationId
|
||||
const screenshot = { type: 'image', data: 'aGk=', mimeType: 'image/png' }
|
||||
|
||||
/** Like the harness's document overlay, a proxy at every level: structuredClone refuses it. */
|
||||
@@ -18,17 +18,17 @@ const overlay = <T extends object>(value: T): T =>
|
||||
},
|
||||
})
|
||||
|
||||
/** A transaction over overlaid documents and a few entries of @ultimate's conversation; @ultimate is conversation 5. */
|
||||
/** A transaction over overlaid documents and a few entries of @standing's conversation; @standing is conversation 5. */
|
||||
function fakeTx(
|
||||
newer: { id: number; conversationId: number; kind?: string; model: object[] }[] = [],
|
||||
) {
|
||||
const docs = new Map<string, object>()
|
||||
const entries = [
|
||||
...newer,
|
||||
{ id: 30, conversationId: ULTIMATE, model: [{ role: 'user', content: 'no images' }] },
|
||||
{ id: 30, conversationId: STANDING, model: [{ role: 'user', content: 'no images' }] },
|
||||
{
|
||||
id: 20,
|
||||
conversationId: ULTIMATE,
|
||||
conversationId: STANDING,
|
||||
model: [{ role: 'toolResult', content: [{ type: 'text', text: 'shot' }, screenshot] }],
|
||||
},
|
||||
{ id: 10, conversationId: 9, model: [{ role: 'user', content: [screenshot] }] },
|
||||
@@ -47,7 +47,7 @@ function fakeTx(
|
||||
if (!docs.has(key))
|
||||
docs.set(
|
||||
key,
|
||||
def.kind === 'ultimate.standing-agents' ? { ultimate: ULTIMATE } : def.initial(),
|
||||
def.kind === 'standing.standing-agents' ? { '$standing': STANDING } : def.initial(),
|
||||
)
|
||||
return overlay(docs.get(key)!)
|
||||
},
|
||||
@@ -120,27 +120,27 @@ it('keeps one goal open at a time and ends it once', async () => {
|
||||
|
||||
it('blocks a criterion after repeated failed tries and finishes with the outcome', async () => {
|
||||
const tx = fakeTx()
|
||||
await expect(act(tx, ULTIMATE, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
|
||||
await expect(act(tx, STANDING, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
|
||||
await start(tx, 'Goal', 0)
|
||||
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
|
||||
await expect(act(tx, ULTIMATE, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
|
||||
for (let i = 1; i < TRIES; i++) await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
|
||||
await act(tx, STANDING, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
|
||||
await expect(act(tx, STANDING, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
|
||||
for (let i = 1; i < TRIES; i++) await act(tx, STANDING, { action: 'failed', n: 1 }, 0)
|
||||
expect((await openGoal(tx))!.criteria[0]).toMatchObject({ status: 'open', tries: TRIES - 1 })
|
||||
const last = await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
|
||||
const last = await act(tx, STANDING, { action: 'failed', n: 1 }, 0)
|
||||
expect(last.answer).toContain('blocked')
|
||||
expect((await openGoal(tx))!.criteria[0].status).toBe('blocked')
|
||||
// Rewording keeps what was learned about an unchanged criterion.
|
||||
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
|
||||
await act(tx, STANDING, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
|
||||
expect((await openGoal(tx))!.criteria.map((item) => item.status)).toEqual(['blocked', 'open'])
|
||||
await expect(act(tx, ULTIMATE, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
|
||||
await expect(act(tx, STANDING, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
|
||||
const done = await act(
|
||||
tx,
|
||||
ULTIMATE,
|
||||
STANDING,
|
||||
{ action: 'finish', outcome: 'blocked', summary: 'Stuck' },
|
||||
4,
|
||||
)
|
||||
expect(done.ended).toMatchObject({
|
||||
ended: { by: 'ultimate', outcome: 'blocked', summary: 'Stuck' },
|
||||
ended: { by: 'standing', outcome: 'blocked', summary: 'Stuck' },
|
||||
})
|
||||
expect(await openGoal(tx)).toBeUndefined()
|
||||
})
|
||||
@@ -149,11 +149,11 @@ it('records decisions with the screenshots of its own conversation only', async
|
||||
const tx = fakeTx()
|
||||
await start(tx, 'Goal', 0)
|
||||
await expect(
|
||||
act(tx, ULTIMATE, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
|
||||
act(tx, STANDING, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
|
||||
).rejects.toThrow('reason')
|
||||
await act(
|
||||
tx,
|
||||
ULTIMATE,
|
||||
STANDING,
|
||||
{
|
||||
action: 'decide',
|
||||
question: 'Approve?',
|
||||
@@ -165,7 +165,7 @@ it('records decisions with the screenshots of its own conversation only', async
|
||||
1,
|
||||
)
|
||||
await expect(
|
||||
act(tx, ULTIMATE, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
|
||||
act(tx, STANDING, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
|
||||
).rejects.toThrow('has no images')
|
||||
expect((await openGoal(tx))!.decisions).toEqual([
|
||||
{ at: 1, question: 'Approve?', picked: 'Yes', reason: 'Meets 1', conversation: 7, images: 20 },
|
||||
@@ -173,17 +173,17 @@ it('records decisions with the screenshots of its own conversation only', async
|
||||
})
|
||||
|
||||
it('never takes an earlier turn\'s screenshot for images: "latest"', async () => {
|
||||
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: ULTIMATE, model: [] }])
|
||||
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: STANDING, model: [] }])
|
||||
await start(tx, 'Goal', 0)
|
||||
const decide = { action: 'decide', question: 'q', picked: 'p', reason: 'r' } as const
|
||||
await expect(act(tx, ULTIMATE, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
|
||||
await expect(act(tx, STANDING, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
|
||||
'no images in this turn',
|
||||
)
|
||||
await act(tx, ULTIMATE, { ...decide, images: 20 }, 2)
|
||||
await act(tx, STANDING, { ...decide, images: 20 }, 2)
|
||||
expect((await openGoal(tx))!.decisions).toMatchObject([{ images: 20 }])
|
||||
})
|
||||
|
||||
it("notes the open goal on @ultimate's runs only, and lets only @ultimate keep it", async () => {
|
||||
it("notes the open goal on @standing's runs only, and lets only @standing keep it", async () => {
|
||||
const tx = fakeTx()
|
||||
const extension = goalExtension({
|
||||
doc: () => undefined,
|
||||
@@ -193,10 +193,10 @@ it("notes the open goal on @ultimate's runs only, and lets only @ultimate keep i
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
const before = (conversationId: number, kind = 'pi.user') =>
|
||||
hooks.beforeInput({ kind }, { tx, conversationId } as never, BACKGROUND_CONTEXT)
|
||||
expect(await before(ULTIMATE)).toBeUndefined()
|
||||
expect(await before(STANDING)).toBeUndefined()
|
||||
await start(tx, 'Make it fast', 0)
|
||||
expect(await before(9)).toBeUndefined()
|
||||
const note = (await before(ULTIMATE))!.entries![0]
|
||||
const note = (await before(STANDING))!.entries![0]
|
||||
expect(note.kind).toBe('goal.note')
|
||||
expect(note.model![0].content).toContain(
|
||||
'Hands-off goal 1, granted by the user, who is away: Make it fast',
|
||||
@@ -209,19 +209,19 @@ it("notes the open goal on @ultimate's runs only, and lets only @ultimate keep i
|
||||
{ conversationId, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
undefined as never,
|
||||
)
|
||||
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only @ultimate')
|
||||
expect(await run(ULTIMATE, { action: 'criteria', criteria: ['Fast'] })).toEqual({
|
||||
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only the standing agent')
|
||||
expect(await run(STANDING, { action: 'criteria', criteria: ['Fast'] })).toEqual({
|
||||
content: [{ type: 'text', text: 'Goal 1 has 1 criteria.' }],
|
||||
})
|
||||
expect((await tx.doc(Goals, 1 as ConversationId)).goals[0].criteria).toHaveLength(1)
|
||||
|
||||
// Finishing ends the goal and creates the report's delivery in the same commit; the note stops.
|
||||
await run(ULTIMATE, { action: 'finish', outcome: 'met', summary: 'Done' })
|
||||
await run(STANDING, { action: 'finish', outcome: 'met', summary: 'Done' })
|
||||
expect(tx.tasks).toEqual([
|
||||
{ name: 'goal.deliver', input: expect.objectContaining({ to: ULTIMATE, kind: 'goal.report' }) },
|
||||
{ name: 'goal.deliver', input: expect.objectContaining({ to: STANDING, kind: 'goal.report' }) },
|
||||
{ name: 'goal.reminder', input: { goal: 1 } },
|
||||
])
|
||||
expect(await before(ULTIMATE)).toBeUndefined()
|
||||
expect(await before(STANDING)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a reminder while the goal is open: paused while waiting on the user, gone once ended', async () => {
|
||||
@@ -232,7 +232,7 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
|
||||
const run = (args: object) =>
|
||||
tool.execute(
|
||||
args as never,
|
||||
{ conversationId: ULTIMATE, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
{ conversationId: STANDING, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
undefined as never,
|
||||
)
|
||||
await actions.start({ text: 'Make it fast' }, { host } as never)
|
||||
@@ -240,7 +240,7 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
|
||||
// its key instead of making a second one, and pauses it.
|
||||
await service.create({
|
||||
name: 'Goal reminder · goal 1',
|
||||
subscribers: ['@ultimate'],
|
||||
subscribers: ['@standing'],
|
||||
key: 'goal.reminder:1',
|
||||
})
|
||||
;(await openGoal(tx))!.waiting = { reason: 'Which account?', at: 0 }
|
||||
@@ -249,7 +249,7 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
|
||||
{
|
||||
id: 'A1',
|
||||
name: 'Goal reminder · goal 1',
|
||||
subscribers: ['@ultimate'],
|
||||
subscribers: ['@standing'],
|
||||
key: 'goal.reminder:1',
|
||||
enabled: false,
|
||||
},
|
||||
@@ -261,10 +261,10 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
|
||||
// The user renamed it: it stays linked by its id.
|
||||
ledger.get('A1')!.name = 'Night check'
|
||||
|
||||
// Its fire tells @ultimate what to check; other inputs do not.
|
||||
// Its fire tells @standing what to check; other inputs do not.
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
const note = async (input: object) =>
|
||||
(await hooks.beforeInput(input as never, { tx, conversationId: ULTIMATE }, BACKGROUND_CONTEXT))!
|
||||
(await hooks.beforeInput(input as never, { tx, conversationId: STANDING }, BACKGROUND_CONTEXT))!
|
||||
.entries![0].model![0].content as string
|
||||
expect(await note({ kind: 'automations.fired', data: { id: 'A1' } })).toContain(
|
||||
'This is the goal reminder',
|
||||
@@ -315,7 +315,7 @@ it('grants nothing through a start card still queued after the user ended its go
|
||||
expect(
|
||||
await hooks.beforeInput(
|
||||
{ kind: 'goal.started' },
|
||||
{ tx, conversationId: ULTIMATE },
|
||||
{ tx, conversationId: STANDING },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toBeUndefined()
|
||||
|
||||
@@ -30,8 +30,8 @@ export async function fixture(clock: any = {}) {
|
||||
const settled=async()=>{await until(async()=>(await harness.commit((tx:any)=>tx.scanTasks({},1000),context)).items.every((t:any)=>t.state.status==='terminal'));await harness.waitForIdle(context)}
|
||||
const entries=async(id:number)=>(await harness.commit((tx:any)=>tx.scanEntries({conversationId:id},1000),context)).items.toSorted((a:any,b:any)=>a.id-b.id)
|
||||
const type=async(id:number,content:string)=>{const c=await harness.conversation(id,context);await(await c.submit({type:'input',content},context)).wait(context)}
|
||||
const ultimate=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id)).ultimate=c.id},context);return c.id}
|
||||
const standing=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id))['$standing']=c.id},context);return c.id}
|
||||
const ask=async(id:number,args:any)=>{await type(id,`SEND ${JSON.stringify({to:'user',...args})}`);await settled();const result=(await entries(id)).findLast((e:any)=>e.kind==='pi.tool-result')?.model?.[0];return {error:result?.isError?result.content.map((p:any)=>p.text).join(''):undefined,message:(await log()).messages.findLast((m:any)=>m.from===id&&m.to==='user')}}
|
||||
const action=(conversationId?:number)=>({host:globalThis.testHost,conversationId,signal:new AbortController().signal})
|
||||
return {base,faux,loader,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},ultimate,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
|
||||
return {base,faux,loader,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},standing,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
|
||||
}
|
||||
|
||||
+7613
-401
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
|
||||
/** Minimal assertions for the imported Standing agent cases, without Node or Vitest. */
|
||||
export const cases: {name:string;run:()=>any}[]=[]
|
||||
export const it=(name:string,run:()=>any)=>cases.push({name,run})
|
||||
export const describe=(_name:string,run:()=>any)=>run()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Ultimate goal and question-card cases execute in the world loader. */
|
||||
/** Standing agent goal and question-card cases execute in the world loader. */
|
||||
import {fixture} from './goal-questions-fixture'
|
||||
import {cases,cleanups} from './goal-questions-test-support'
|
||||
const initial=await fixture()
|
||||
|
||||
@@ -9,7 +9,7 @@ it('fires one goal reminder through automations',async()=>{
|
||||
const timer={at:now+delay,callback};timers.add(timer);return()=>timers.delete(timer)
|
||||
}})
|
||||
try {
|
||||
const id=await f.ultimate();const {harness}=f.opened
|
||||
const id=await f.standing();const {harness}=f.opened
|
||||
const goal=await import(/* @vite-ignore */ `${f.base}/goal/server.ts`)
|
||||
const {Automations}=await import(/* @vite-ignore */ `${f.base}/automations/server.ts`)
|
||||
await goal.actions.start({text:'Ship the goal'},{host:f.host})
|
||||
|
||||
+7012
-374
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,12 @@ const curl=async(path:string,body?:any)=>{
|
||||
assert(result.code===0,`curl failed: ${result.stderr}`);
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
const id=await f.ultimate();
|
||||
const id=await f.standing();
|
||||
const question=await f.ask(id,{text:'Choose.',questions:[{text:'Name',kind:'text',ref:'1'}]});
|
||||
assert(question.message,'Question fixture');
|
||||
const listed=await curl('/conversations');
|
||||
assert(listed.conversations.some((c:any)=>c.id===id && c.agent && !c.running),'Conversation list');
|
||||
assert(listed.standing.name==='standing'&&listed.standing.conversationId===id,'Conversation list identifies the configured standing conversation');
|
||||
const initial=await curl(`/conversations/${id}/entries`);
|
||||
const after=initial.entries.at(-1).id;
|
||||
const answer=await curl('/extensions/questions/actions/answer',{args:{messageId:question.message.id,answers:[{question:'Name',answer:'API choice'}]}});
|
||||
@@ -26,7 +27,7 @@ assert(!answer.error,JSON.stringify(answer));
|
||||
await f.settled();
|
||||
assert(JSON.stringify(await f.entries(id)).includes('API choice'),'Question answer reaches asker');
|
||||
assert((await curl('/models')).models.length>0,'Models');
|
||||
assert((await curl('/families')).families.default,'Families');
|
||||
assert((await curl('/families')).error,'The obsolete families route is absent');
|
||||
assert((await curl('/extensions')).good,'Extension status');
|
||||
assert(Array.isArray((await curl('/credential-requests')).requests),'Credential requests');
|
||||
assert((await curl('/unknown')).error,'Unknown route');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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 { Metadata } 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:()=>({})});
|
||||
@@ -51,6 +51,7 @@ export function harnessApi(harness:any, models:any, extensions:any, context:any)
|
||||
const reply=(status:number,body:any)=>{send(input.id,{status,body});send(input.id,null);};
|
||||
try {
|
||||
if(input.method==='GET' && path==='/conversations') {
|
||||
const standing=await extensions.standing?.() ?? null;
|
||||
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);
|
||||
@@ -64,17 +65,13 @@ export function harnessApi(harness:any, models:any, extensions:any, context:any)
|
||||
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;
|
||||
reply(200,{conversations,standing});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) {
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
// EOF and Stop drain submissions. Shutdown cancels invocations and preserves durable tasks for restart.
|
||||
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>, api?: any) {
|
||||
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>, api?: any, defaultConversation?: () => Promise<number>) {
|
||||
const pending = new Set<Promise<void>>();
|
||||
const track = (work: Promise<void>) => {
|
||||
const task = work.catch(error => Deno.core.ops.op_output(`${String(error)}\n`)).finally(() => pending.delete(task));
|
||||
@@ -22,8 +22,9 @@ export async function serve(harness: any, context: any, afterSubmit?: () => Prom
|
||||
track(api.handle(input));
|
||||
continue;
|
||||
}
|
||||
const conversation = await harness.conversation(input.conversation, context);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
|
||||
const id = defaultConversation ? await defaultConversation() : input.conversation;
|
||||
const conversation = await harness.conversation(id, context);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${id}`);
|
||||
const submission = await conversation.submit({
|
||||
type: 'input', content: input.content, requestId: input.requestId,
|
||||
}, context);
|
||||
|
||||
+13
-18
@@ -1,7 +1,7 @@
|
||||
// Durable root conversation and console. SQLite stays outside the world.
|
||||
// The standing conversation serves the console and chat. SQLite stays outside the world.
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { proxyModels } from './proxy-models';
|
||||
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { Harness, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from './environment';
|
||||
import { openStorage } from './sqlite';
|
||||
import { environment, conversationChains } from './environment';
|
||||
@@ -9,10 +9,8 @@ import { CredentialRequests, startRequests } from './credentials';
|
||||
import { serve } from './input';
|
||||
import { harnessApi } from './harness-api';
|
||||
import { loadExtensions } from './extensions';
|
||||
const proxy = Deno.core.ops.op_model_proxy();
|
||||
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
|
||||
const models = await proxyModels(proxy);
|
||||
const model = models.getModel('openai', 'gpt-6.1-sol');
|
||||
const proxy = await Deno.core.ops.op_model_proxy();
|
||||
const models = await proxyModels(proxy, () => Deno.core.ops.op_model_proxy());
|
||||
const registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CredentialRequests);
|
||||
@@ -22,14 +20,13 @@ const harness = await Harness.open(storage, {
|
||||
env: async (target: any) => environment(target.cwd ?? '/', await chain(target.conversationId)),
|
||||
}, context);
|
||||
const chain = conversationChains(harness, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/' } });
|
||||
// T32 stored this exact built-in list in the console root. Use registry defaults now.
|
||||
const storedAgent = await harness.snapshot(AgentDoc, root.id, context);
|
||||
if (Array.isArray(storedAgent?.extensions) && storedAgent.extensions.length === 2 &&
|
||||
[CodingTools, CredentialRequests].every(extension => storedAgent.extensions.includes(extension.name))) {
|
||||
await root.configure({ extensions: null }, context);
|
||||
}
|
||||
const watch = await root.watch(context);
|
||||
await harness.root(context, {agent:{cwd:'/src/sausage'}});
|
||||
const stopRequests = await startRequests(harness, context);
|
||||
const extensions = await loadExtensions(harness, registry, storage, models, context, [CodingTools, CredentialRequests]);
|
||||
const standing = await extensions.standing();
|
||||
if (!standing) throw new Error('The world needs the models and subagents extensions for its standing agent');
|
||||
const conversation = (await harness.conversation(standing.conversationId, context))!;
|
||||
const watch = await conversation.watch(context);
|
||||
const emitted = new Map<number, string>();
|
||||
let partial = '';
|
||||
const text = (message: any) => message?.content?.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('') ?? '';
|
||||
@@ -52,17 +49,15 @@ for (const entry of watch.value.entries) {
|
||||
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
|
||||
}
|
||||
if (globalThis.consoleEnabled) watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
|
||||
const stopRequests = await startRequests(harness, context);
|
||||
const extensions = await loadExtensions(harness, registry, storage, models, context, [CodingTools, CredentialRequests]);
|
||||
const api = harnessApi(harness, models, extensions, context);
|
||||
harness.resume();
|
||||
await serve(harness, context, globalThis.consoleEnabled ? async () => {
|
||||
await harness.waitForIdle(context);
|
||||
const final = await root.watch(context);
|
||||
const final = await conversation.watch(context);
|
||||
render(final.value);
|
||||
await final.stop();
|
||||
Deno.core.ops.op_output('\n');
|
||||
} : undefined, api);
|
||||
} : undefined, api, async () => (await extensions.standing())!.conversationId);
|
||||
await api.close();
|
||||
await extensions.close();
|
||||
await stopRequests();
|
||||
|
||||
+7001
-356
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ const {SubmissionLanded} = await import(`${base}/lineage/refs.ts`);
|
||||
const {Tally, abstract} = await import(`${base}/lineage/server.ts`);
|
||||
const {normalizeMemoryOp, parseMemoryOps, applyMemoryOps, readMemory, memorizeTask} = await import(`${base}/lineage/memory.ts`);
|
||||
const {Seen, mainOf} = await import(`${base}/lineage/catchup.ts`);
|
||||
const primaryId = await globalThis.subagentService.standing('ultimate', context);
|
||||
const primaryId = await globalThis.subagentService.standing('standing', context);
|
||||
const primary = (await harness.conversation(primaryId, context))!;
|
||||
const entries = async (conversation: any) => (await conversation.context(context)).entries;
|
||||
const replies = async () => (await entries(primary)).filter((e: any) => e.kind === 'board.reply');
|
||||
|
||||
+7012
-367
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -12,10 +12,10 @@ testReplay();
|
||||
if (globalThis.p3Mode !== 'unit') {
|
||||
const workerId = (transcript: any) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
|
||||
const rules: Rule[] = [
|
||||
{ id: 'ultimate-branch', role: 'ultimate', match: { kind: 'user', text: 'launch scenario' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', isolated: true, prompt: 'begin' } }] } },
|
||||
{ id: 'ultimate-probe', role: 'ultimate', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
|
||||
{ id: 'standing-branch', role: 'standing', match: { kind: 'user', text: 'launch scenario' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', isolated: true, prompt: 'begin' } }] } },
|
||||
{ id: 'standing-probe', role: 'standing', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
|
||||
{ id: 'coordinator-probe', role: 'coordinator', match: { kind: 'user', text: 'branch probe' }, answer: { text: 'branch complete' } },
|
||||
{ id: 'ultimate-receipt', role: 'ultimate', match: { text: '' }, answer: { text: 'Receipt noted.' } },
|
||||
{ id: 'standing-receipt', role: 'standing', match: { text: '' }, answer: { text: 'Receipt noted.' } },
|
||||
{ id: 'coordinator-echo', role: 'coordinator', match: { kind: 'user', text: 'Receipt noted.' }, answer: { text: 'Acknowledged.' } },
|
||||
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'worktree', task: 'build feature', background: true } }] } },
|
||||
{ id: 'start-reviewer', role: 'coordinator', match: { kind: 'user', text: 'worker complete' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'reviewer', isolate: 'none', task: 'review feature', background: true } }] } },
|
||||
@@ -32,7 +32,7 @@ if (globalThis.p3Mode !== 'unit') {
|
||||
models.setProvider(scriptedModel(rules, rule => Deno.core.ops.op_output(`scripted rule: ${rule}\n`)));
|
||||
const storage = await openStorage(globalThis.storagePath), registry = createRegistry();
|
||||
const harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() { return registry.snapshot().installed(); } } }, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:ultimate', cwd: '/src/demo' } });
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:standing', cwd: '/src/demo' } });
|
||||
// Stop after the durable lander commit records landed.sha, before its event/report commit.
|
||||
if (globalThis.p3Mode === 'kill-forward') {
|
||||
const install = registry.install.bind(registry);
|
||||
@@ -85,12 +85,12 @@ if (globalThis.p3Mode !== 'unit') {
|
||||
if (globalThis.p3Mode === 'load-all') {
|
||||
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load');
|
||||
} else {
|
||||
const ultimate = (await harness.conversation(await globalThis.p3Subagents.standing('ultimate', context), context))!;
|
||||
const standing = (await harness.conversation(await globalThis.p3Subagents.standing('standing', context), context))!;
|
||||
harness.resume();
|
||||
const probe = globalThis.p3Mode.startsWith('branch');
|
||||
if (globalThis.p3Mode === 'branch' || globalThis.p3Mode.startsWith('kill-')) {
|
||||
await (await ultimate.submit({ type: 'input', content: probe ? 'launch branch' : 'launch scenario', requestId: 'p3-launch' }, context)).wait(context);
|
||||
const entries = (await ultimate.context(context)).entries;
|
||||
await (await standing.submit({ type: 'input', content: probe ? 'launch branch' : 'launch scenario', requestId: 'p3-launch' }, context)).wait(context);
|
||||
const entries = (await standing.context(context)).entries;
|
||||
assert(!JSON.stringify(entries).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries)}`);
|
||||
}
|
||||
if (globalThis.p3Mode.startsWith('kill-')) await new Promise(() => {});
|
||||
@@ -101,12 +101,12 @@ if (globalThis.p3Mode !== 'unit') {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
const replies = async () => (await ultimate.context(context)).entries.filter((entry: any) => entry.kind === 'board.reply');
|
||||
const replies = async () => (await standing.context(context)).entries.filter((entry: any) => entry.kind === 'board.reply');
|
||||
await wait(async () => (await replies()).length > 0, 'No branch reply');
|
||||
const received = await replies();
|
||||
const coordinator = (await harness.conversation(received[0].data.from, context))!;
|
||||
assert((await coordinator.agent(context)).instructions === 'P3 role:coordinator', 'Branch must use coordinator definition');
|
||||
assert((await conversationChains(harness, context)(coordinator.id)).includes(String(ultimate.id)), 'D63 must include ultimate');
|
||||
assert((await coordinator.agent(context)).instructions.includes('P3 role:coordinator'), 'Branch must use coordinator definition');
|
||||
assert((await conversationChains(harness, context)(coordinator.id)).includes(String(standing.id)), 'D63 must include standing');
|
||||
if (!probe) {
|
||||
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).results).length === 1, 'No landing');
|
||||
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).queue.length === 0, 'Lander did not report');
|
||||
@@ -125,9 +125,9 @@ if (globalThis.p3Mode !== 'unit') {
|
||||
assert(!await fs.stat(worker.path).then(() => true, () => false), 'Released checkout must not exist');
|
||||
assert(Object.values(index.results).every((result: any) => result.outcome === 'landed'), `Landing failed: ${JSON.stringify(index)}`);
|
||||
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: 'event.workspaces.landed' }] })).length === 1, 'Expected one landed event');
|
||||
const ultimateEntries = (await ultimate.context(context)).entries;
|
||||
const standingEntries = (await standing.context(context)).entries;
|
||||
const landed = Object.values(index.results)[0] as any;
|
||||
const cards = ultimateEntries.filter((entry: any) => entry.kind === 'board.message' && entry.data.text.startsWith('[lander] Submission') && entry.data.text.includes(' landed as '));
|
||||
const cards = standingEntries.filter((entry: any) => entry.kind === 'board.message' && entry.data.text.startsWith('[lander] Submission') && entry.data.text.includes(' landed as '));
|
||||
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
|
||||
const repliesNow = await replies();
|
||||
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry: any) => entry.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
|
||||
|
||||
@@ -1697,7 +1697,7 @@ trap - EXIT
|
||||
printf 'P4_PHASE_2_OK
|
||||
'`,
|
||||
`set -e
|
||||
undo list --conversation 1 > /tmp/p4-undo.json
|
||||
undo list > /tmp/p4-undo.json
|
||||
box ls > /tmp/p4-boxes
|
||||
printf 'P4_PHASE_3_OK
|
||||
'`
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ box rm "$id"
|
||||
trap - EXIT
|
||||
printf 'P4_PHASE_2_OK\n'`,
|
||||
`set -e
|
||||
undo list --conversation 1 > /tmp/p4-undo.json
|
||||
undo list > /tmp/p4-undo.json
|
||||
box ls > /tmp/p4-boxes
|
||||
printf 'P4_PHASE_3_OK\n'`,
|
||||
];
|
||||
|
||||
Generated
+17
-1
@@ -7,7 +7,8 @@
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
"@earendil-works/pi-durable": "1.5.0"
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
@@ -1682,6 +1683,21 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild proxy-models-test.ts --bundle --format=esm --platform=browser --outfile=proxy-models-test-bundle.js" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
"@earendil-works/chord": "1.5.0"
|
||||
"scripts": {
|
||||
"bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild proxy-models-test.ts --bundle --format=esm --platform=browser --outfile=proxy-models-test-bundle.js"
|
||||
},
|
||||
"devDependencies": { "esbuild": "0.28.2" }
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
}
|
||||
}
|
||||
|
||||
+7012
-367
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -1,4 +1,4 @@
|
||||
/** The six Ultimate plan/todos cases, plus real-loader turns and branch reset. */
|
||||
/** The six Standing agent plan/todos cases, plus real-loader turns and branch reset. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
@@ -113,37 +113,37 @@ try {
|
||||
assert((await harness.snapshot(Started,branch.id,context)).started,'Branch started');
|
||||
|
||||
// Todos cases 1-4 retain the original small transaction fixture.
|
||||
const ULTIMATE=5;
|
||||
const fakeTx=()=>{const docs=new Map();return {doc:async({definition:def}:any,id:number)=>{const key=`${def.kind}:${id}`;if(!docs.has(key))docs.set(key,def.kind==='ultimate.standing-agents'?{ultimate:ULTIMATE}:def.initial());return docs.get(key);}};};
|
||||
const STANDING=5;
|
||||
const fakeTx=()=>{const docs=new Map();return {doc:async({definition:def}:any,id:number)=>{const key=`${def.kind}:${id}`;if(!docs.has(key))docs.set(key,def.kind==='standing.standing-agents'?{'$standing':STANDING}:def.initial());return docs.get(key);}};};
|
||||
const list=async(tx:any)=>(await tx.doc(Todos,1)).items;
|
||||
{
|
||||
const tx=fakeTx();equal((await add(tx,' Deploy fleet ','ultimate')).text,'Deploy fleet');
|
||||
const tx=fakeTx();equal((await add(tx,' Deploy fleet ','standing')).text,'Deploy fleet');
|
||||
assert((await add(tx,'Rotate the key','user')).id===2,'Sequential todo ids');
|
||||
await rejects(()=>add(tx,' ','user'),'nonempty');await rejects(()=>add(tx,'x','lander'),'owner');equal((await list(tx)).map((todo:any)=>todo.id),[1,2]);
|
||||
}
|
||||
{
|
||||
const tx=fakeTx();for(let i=0;i<DONE_LIMIT+2;i++)await add(tx,`t${i}`,'ultimate');
|
||||
const tx=fakeTx();for(let i=0;i<DONE_LIMIT+2;i++)await add(tx,`t${i}`,'standing');
|
||||
assert(typeof(await mark(tx,1,true)).done==='number','Tick');assert((await mark(tx,1,false)).done===undefined,'Reopen');
|
||||
for(const todo of (await list(tx)).slice(0,-1))todo.done=todo.id;
|
||||
await mark(tx,DONE_LIMIT+2,true);equal((await list(tx)).map((todo:any)=>todo.id),Array.from({length:DONE_LIMIT},(_,i)=>i+3));await rejects(()=>mark(tx,999,true),'No to-do 999');
|
||||
}
|
||||
{
|
||||
const tx=fakeTx(), content=async()=>(await noteFor(tx,ULTIMATE,0))?.entries[0].model[0].content;
|
||||
assert(await content()===undefined,'Empty note');await add(tx,'Mine','ultimate');await add(tx,'Theirs','user');
|
||||
const tx=fakeTx(), content=async()=>(await noteFor(tx,STANDING,0))?.entries[0].model[0].content;
|
||||
assert(await content()===undefined,'Empty note');await add(tx,'Mine','standing');await add(tx,'Theirs','user');
|
||||
equal(await content(),"Open to-dos (todo tool: done when finished):\nYours:\n- 1: Mine\nThe user's:\n- 2: Theirs");assert(await content()===undefined,'Unchanged note');
|
||||
await mark(tx,1,true);await mark(tx,2,true);equal(await content(),'No to-dos are open.');assert(await content()===undefined,'Unchanged empty note');
|
||||
}
|
||||
{
|
||||
const tx=fakeTx(), extension=todosModule.default(), tool=extension.tools[0];
|
||||
const run=(conversationId:number,args:any)=>tool.execute(args,{conversationId,commit:(fn:any)=>fn(tx)},context);
|
||||
await rejects(()=>run(9,{action:'list'}),'Only @ultimate');equal(await run(ULTIMATE,{action:'add',text:'Ship it',owner:'user'}),{output:[{type:'text',text:'Added 1 for user: Ship it'}]});
|
||||
equal(await run(ULTIMATE,{action:'list'}),{output:[{type:'text',text:"The user's:\n- 1: Ship it"}]});
|
||||
await rejects(()=>run(9,{action:'list'}),'Only the standing agent');equal(await run(STANDING,{action:'add',text:'Ship it',owner:'user'}),{output:[{type:'text',text:'Added 1 for user: Ship it'}]});
|
||||
equal(await run(STANDING,{action:'list'}),{output:[{type:'text',text:"The user's:\n- 1: Ship it"}]});
|
||||
const before=(conversationId:number)=>extension.hooks[0].handlers.beforeInput({}, {tx,conversationId},context);
|
||||
assert(await before(9)===undefined,'Nonstanding note');assert(await before(ULTIMATE)!==undefined,'Standing note');
|
||||
assert(await before(9)===undefined,'Nonstanding note');assert(await before(STANDING)!==undefined,'Standing note');
|
||||
}
|
||||
// The real world also calls todo through the loaded factory and places its note.
|
||||
await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id)).ultimate=root.id;},context);
|
||||
assert(text(await call({action:'add',text:'Real world',owner:'ultimate'},root.id,'todo')).includes('Added 1'),'Loaded todo tool');
|
||||
await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id))['$standing']=root.id;},context);
|
||||
assert(text(await call({action:'add',text:'Real world',owner:'standing'},root.id,'todo')).includes('Added 1'),'Loaded todo tool');
|
||||
assert(text(await call({action:'list'},root.id,'todo')).includes('Real world'),'Loaded todo list');
|
||||
assert((await root.context(context)).entries.some((entry:any)=>entry.kind==='todos.note'),'Real beforeInput note');
|
||||
await fs.write('/saved',JSON.stringify({plan:await harness.snapshot(Plan,root.id,context),index:await harness.snapshot(TaskIndex,root.id,context),todos:await harness.snapshot(Todos,root.id,context)}));
|
||||
|
||||
+4418
-432
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
/** Exercise both real pi-ai transports and their catalog prices, without provider SDK mocks. */
|
||||
import { proxyModels } from './proxy-models';
|
||||
const models = await proxyModels(globalThis.testProxy);
|
||||
import { fs } from './extensions';
|
||||
const readProxy=()=>Deno.core.ops.op_model_proxy();
|
||||
const models = await proxyModels(await readProxy(),readProxy);
|
||||
for (const [provider, id] of [['openai', 'gpt-6.1-sol'], ['anthropic', 'claude-opus-5-5']]) {
|
||||
const model = models.getModel(provider, id);
|
||||
if (model.cost.input !== 2 || model.cost.cacheWrite !== 3) throw new Error('catalog prices lost');
|
||||
@@ -15,3 +17,14 @@ for (const [provider, api] of [['openai', 'openai-responses'], ['anthropic', 'an
|
||||
const model = fallback.getModels(provider)[0];
|
||||
if (model.api !== api || model.cost.input !== 0) throw new Error('offline transport fallback failed');
|
||||
}
|
||||
await fs.write('/etc/sausage/config.yaml',JSON.stringify({proxy:{base_url:`${globalThis.testProxy.base_url}/edited`,api_key:'next-key'}}));
|
||||
const auth=await models.getAuth(models.getModel('openai','gpt-6.1-sol'));
|
||||
if(auth.auth.apiKey!=='next-key'||auth.auth.baseUrl!==`${globalThis.testProxy.base_url}/edited/v1`) throw new Error('Proxy edits did not reach the next request');
|
||||
await fs.write('/etc/sausage/config.yaml',JSON.stringify({proxy:{base_url:'',api_key:''}}));
|
||||
const empty=await proxyModels(await readProxy(),readProxy);
|
||||
let failure='';
|
||||
try {
|
||||
const result=await empty.completeSimple(empty.getModels('openai')[0],{messages:[{role:'user',content:'Missing key',timestamp:Date.now()}]});
|
||||
failure=result.errorMessage??'';
|
||||
} catch(error) {failure=String(error)}
|
||||
if(!failure.includes('/etc/sausage/config.yaml')) throw new Error(`Missing proxy settings must name the config file: ${failure}`);
|
||||
|
||||
+14
-9
@@ -3,15 +3,17 @@ import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
|
||||
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
|
||||
|
||||
export async function proxyModels(proxy: {base_url: string, api_key: string}) {
|
||||
export async function proxyModels(proxy: {base_url: string, api_key: string}, readProxy = async () => proxy) {
|
||||
const models = createModels();
|
||||
const base = proxy.base_url.replace(/\/$/, '');
|
||||
let catalog: any[] = [];
|
||||
try {
|
||||
const response = await fetch(`${base}/v1/models`, {
|
||||
headers: {Authorization: `Bearer ${proxy.api_key}`}, signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.ok) catalog = (await response.json()).data ?? [];
|
||||
if (proxy.base_url && proxy.api_key) {
|
||||
const response = await fetch(`${base}/v1/models`, {
|
||||
headers: {Authorization: `Bearer ${proxy.api_key}`}, signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.ok) catalog = (await response.json()).data ?? [];
|
||||
}
|
||||
} catch { /* An unavailable catalog must not prevent durable recovery. */ }
|
||||
for (const [provider, api, path, fallback] of [
|
||||
[openaiProvider(), 'openai-responses', '/v1/responses', 'gpt-6.1-sol'],
|
||||
@@ -37,10 +39,13 @@ export async function proxyModels(proxy: {base_url: string, api_key: string}) {
|
||||
};
|
||||
});
|
||||
models.setProvider({...provider, baseUrl: entries[0].baseUrl, getModels: () => entries as any,
|
||||
auth: {apiKey: {name: 'Sausage proxy', resolve: async () => ({
|
||||
auth: {apiKey: proxy.api_key, headers: {'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage'])}},
|
||||
source: 'llm-proxy',
|
||||
})}},
|
||||
auth: {apiKey: {name: 'Sausage proxy', resolve: async () => {
|
||||
const current = await readProxy();
|
||||
if (!current.base_url || !current.api_key) throw new Error('Set proxy.base_url and proxy.api_key in /etc/sausage/config.yaml before a model request');
|
||||
return {auth:{apiKey:current.api_key,
|
||||
baseUrl:current.base_url.replace(/\/$/,'') + entries[0].baseUrl.slice(base.length),
|
||||
headers:{'Shopify-Usage-Tag':JSON.stringify(['pi','sausage'])}},source:'/etc/sausage/config.yaml'};
|
||||
}}},
|
||||
});
|
||||
}
|
||||
return models;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
/** Ultimate server question cases, with a real world fixture. */
|
||||
/** Standing agent server question cases, with a real world fixture. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import {expect,it,describe,afterEach} from './goal-questions-test-support'
|
||||
import {fixture as openFixture} from './goal-questions-fixture'
|
||||
@@ -36,7 +36,7 @@ const decisions = [
|
||||
describe('questions on a message', () => {
|
||||
it('are stored as the tool validated them, only on reply-expected messages to the user', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const id = await f.standing()
|
||||
const sent = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
expect(sent.error).toBeUndefined()
|
||||
expect(sent.message).toMatchObject({ state: 'delivered', questions: decisions })
|
||||
@@ -55,7 +55,7 @@ describe('questions on a message', () => {
|
||||
|
||||
it('refuses questions the card could not answer, naming the question', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const id = await f.standing()
|
||||
const refused = async (questions: unknown, reason: string) => {
|
||||
const { error } = await f.ask(id, { text: 'Decide.', questions })
|
||||
expect(error).toContain(reason)
|
||||
@@ -118,7 +118,7 @@ describe('questions on a message', () => {
|
||||
|
||||
it('answers in the message numbering for every kind, with a note after the answers', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const id = await f.standing()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
expect(userActions).toEqual(['answer', 'dismiss'])
|
||||
await expect(
|
||||
@@ -166,7 +166,7 @@ describe('questions on a message', () => {
|
||||
|
||||
it('sends a note on its own, and free text for a message without questions', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const id = await f.standing()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
await expect(f.answer({ messageId: message!.id, answers: [], note: ' ' })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
@@ -193,9 +193,9 @@ describe('questions on a message', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps @ultimate's open message open until its card answers it, naming it by its questions", async () => {
|
||||
it("keeps @standing's open message open until its card answers it, naming it by its questions", async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const id = await f.standing()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
await f.type(
|
||||
f.opened.root.id,
|
||||
@@ -224,7 +224,7 @@ describe('questions on a message', () => {
|
||||
|
||||
it('dismisses one open question with a one-way note that starts no run, leaving the others open', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const id = await f.standing()
|
||||
const first = (await f.ask(id, { text: 'Keep it running?', questions: [decisions[2]] }))
|
||||
.message!
|
||||
const second = (await f.ask(id, { text: 'Which layout?' })).message!
|
||||
|
||||
+7029
-370
File diff suppressed because it is too large
Load Diff
+26
-14
@@ -5,6 +5,7 @@ import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-work
|
||||
import { Harness, createRegistry, defineExtension, defineTool, LiveDoc, InboxDoc } from '@earendil-works/pi-durable';
|
||||
import { obj } from '@earendil-works/pi-schema';
|
||||
import { loadExtensions, fs } from './extensions';
|
||||
import { parse } from 'yaml';
|
||||
import { conversationChains } from './environment';
|
||||
import { openStorage } from './sqlite';
|
||||
const assert = (value:any,message:string)=>{if(!value)throw new Error(message);};
|
||||
@@ -17,7 +18,7 @@ const gate=Promise.withResolvers<void>(), parentGate=Promise.withResolvers<void>
|
||||
const held=defineExtension({name:'hold',tools:[defineTool({name:'hold',description:'Wait for test gate',parameters:obj({}),replay:'safe',execute:async(_args,api,ctx)=>{holding.add(api.conversationId);await awaitWithContext(api.conversationId===root.id?parentGate.promise:gate.promise,ctx);return {output:[{type:'text',text:'released'}]};}})]});
|
||||
if(globalThis.subagentsMode==='kill') {
|
||||
const install=registry.install.bind(registry);
|
||||
registry.install=(extension:any)=>install(extension.name!=='subagents'?extension:{...extension,tasks:extension.tasks.map((task:any)=>task.definition.name!=='ultimate.subagent-reporter'?task:{definition:{...task.definition,phases:{...task.definition.phases,report:async(_task:any,_runtime:any,ctx:any)=>{await fs.write('/report-ready','yes');await awaitWithContext(new Promise(()=>{}),ctx);}}}})});
|
||||
registry.install=(extension:any)=>install(extension.name!=='subagents'?extension:{...extension,tasks:extension.tasks.map((task:any)=>task.definition.name!=='standing.subagent-reporter'?task:{definition:{...task.definition,phases:{...task.definition.phases,report:async(_task:any,_runtime:any,ctx:any)=>{await fs.write('/report-ready','yes');await awaitWithContext(new Promise(()=>{}),ctx);}}}})});
|
||||
}
|
||||
const route=(transcript:any):any=>{
|
||||
faux.appendResponses([route]);
|
||||
@@ -57,17 +58,18 @@ try {
|
||||
assert(await service.standing('scout',context)===standing,'Standing refresh must reuse its conversation');
|
||||
const standingRecord=await harness.commit((tx:any)=>tx.conversation(standing),context);
|
||||
assert(!standingRecord.owner,'Standing must be ownerless');
|
||||
const scoutFile=await fs.read('/src/sausage/agents/scout.md');
|
||||
await fs.write('/src/sausage/agents/scout.md',scoutFile.replace('Scout instructions','Updated instructions'));
|
||||
const config=parse(await fs.read('/etc/sausage/config.yaml'));
|
||||
const save=()=>fs.write('/etc/sausage/config.yaml',JSON.stringify(config));
|
||||
config.roster[0].prompt='Updated instructions';await save();
|
||||
await service.standing('scout',context);
|
||||
assert((await (await harness.conversation(standing,context))!.agent(context)).instructions==='Updated instructions','Standing refresh must reread instructions');
|
||||
assert((await (await harness.conversation(standing,context))!.agent(context)).instructions.includes('Updated instructions'),'Standing refresh must reread instructions');
|
||||
const answer=await call({action:'start',agent:'scout',task:'facts'});
|
||||
assert(text(answer)==='Returned model: faux/faux-1\nChild: facts',`Foreground answer: ${JSON.stringify(answer)}`);
|
||||
const [first]=await children();
|
||||
assert(JSON.stringify(await chain(first.conversationId))===JSON.stringify([String(first.conversationId),String(root.id)]),'Anchor ownership chain');
|
||||
const record=await harness.commit((tx:any)=>tx.conversation(first.conversationId),context);
|
||||
const anchor=await harness.getTask(record!.owner!.taskId,context);
|
||||
assert(anchor?.kind==='ultimate.subagent-anchor','Child owner must be anchor');
|
||||
assert(anchor?.kind==='standing.subagent-anchor','Child owner must be anchor');
|
||||
const child=(await harness.conversation(first.conversationId,context))!;
|
||||
assert(!(await child.agent(context)).tools.some((tool:any)=>tool.name==='subagent'),'Tool denials');
|
||||
assert((await root.agent(context)).tools.some((tool:any)=>tool.name==='subagent'),'Parent tools remain unchanged');
|
||||
@@ -106,26 +108,36 @@ try {
|
||||
assert(!(await harness.snapshot(LiveDoc,third.conversationId,context))?.run,'Stop must end child');
|
||||
assert((await reports()).length===2,'Stop must suppress aborted report');
|
||||
assert(text(await call({action:'start',agent:'scout',task:'no',isolate:'worktree'})).includes('requires the workspaces extension'),'Worktree seam');
|
||||
await fs.write('/src/sausage/agents/worker.md','---\nname: worker\ndescription: Worker\nfamily: fast\ntools: []\n---\nWorker body');
|
||||
await fs.mkdir('/src/sausage/.sausage');await fs.mkdir('/src/sausage/.sausage/agents');
|
||||
await fs.write('/src/sausage/.sausage/agents/worker.md','---\nname: worker\naddTools: [hold]\n---\nOverlay body');
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'faux/faux-1',thinking:'low'}}));
|
||||
config.roster.push({name:'worker',description:'Worker',model:'faux/faux-1',thinking:'low',tools:[],addTools:['hold'],prompt:'Worker body'});await save();
|
||||
await call({action:'start',agent:'worker',task:'family one'});
|
||||
const familyChild=(await children()).at(-1)!;
|
||||
const firstAgent=await (await harness.conversation(familyChild.conversationId,context))!.agent(context);
|
||||
assert(firstAgent.model?.modelId==='faux-1'&&firstAgent.thinkingLevel==='low','Worker must resolve fast family');
|
||||
assert(firstAgent.tools.map((tool:any)=>tool.name).join(',')==='hold','Project overlay must add a tool');
|
||||
assert(firstAgent.instructions.includes('Worker body')&&firstAgent.instructions.includes('Overlay body'),'Child must use overlaid instructions');
|
||||
assert(firstAgent.model?.modelId==='faux-1'&&firstAgent.thinkingLevel==='low','Worker must resolve roster model and thinking');
|
||||
assert(firstAgent.tools.map((tool:any)=>tool.name).join(',')==='hold','Roster addTools must add a tool');
|
||||
assert(firstAgent.instructions.includes('Worker body'),'Child must use roster instructions');
|
||||
const provider=models.getProvider('faux')!, template=models.getModels('faux')[0];
|
||||
models.setProvider({...provider,getModels:()=>[template,{...template,id:'second'}]});
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'faux/second',thinking:'high'}}));
|
||||
Object.assign(config.roster[1],{model:'faux/second',thinking:'high'});await save();
|
||||
await call({action:'start',agent:'worker',task:'family two'});
|
||||
const secondFamilyChild=(await children()).at(-1)!;
|
||||
const secondAgent=await (await harness.conversation(secondFamilyChild.conversationId,context))!.agent(context);
|
||||
assert(secondAgent.model?.modelId==='second'&&secondAgent.thinkingLevel==='high','Next child must use edited family without restart');
|
||||
assert(secondAgent.model?.modelId==='second'&&secondAgent.thinkingLevel==='high','Next child must use edited roster without restart');
|
||||
globalThis.childPin={provider:'faux',modelId:'faux-1'};
|
||||
await call({action:'start',agent:'worker',task:'pin'});
|
||||
const pinnedChild=(await children()).at(-1)!;
|
||||
assert((await (await harness.conversation(pinnedChild.conversationId,context))!.agent(context)).model?.modelId==='faux-1','models.pin must apply to subagents');
|
||||
globalThis.childPin=undefined;
|
||||
config.standing='renamed-scout';
|
||||
Object.assign(config.roster[0],{name:config.standing,model:'faux/second'});await save();
|
||||
assert(await service.standing(config.standing,context)===standing,'Rename must retain the standing conversation');
|
||||
faux.setResponses([(transcript:any)=>{
|
||||
const prompt=JSON.stringify(transcript.messages.filter((m:any)=>m.role==='system'));
|
||||
assert(prompt.includes('You are @renamed-scout.'),'Renamed standing prompt must use the configured name');
|
||||
return fauxAssistantMessage('I am @renamed-scout.');
|
||||
}]);
|
||||
const primary=(await harness.conversation(standing,context))!;
|
||||
await (await primary.submit({type:'input',content:'Who are you?'},context)).wait(context);
|
||||
assert((await primary.agent(context)).model?.modelId==='second','Standing model edit applies at its next run');
|
||||
assert(JSON.stringify((await primary.context(context)).entries).includes('I am @renamed-scout.'),'Same conversation answers with the new name');
|
||||
}
|
||||
} finally {gate.resolve();parentGate.resolve();await loader.close();await harness.close(context);}
|
||||
|
||||
+2467
-200
File diff suppressed because it is too large
Load Diff
+7004
-359
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,8 @@ const create = async (title: string) => harness.commit(async (tx: any) => {
|
||||
Object.assign(await tx.doc(Metadata, record.id), {title});
|
||||
return record.id;
|
||||
}, context);
|
||||
let primary = (await harness.snapshot(Standing, root.id, context)).ultimate;
|
||||
if (!primary) {primary = await create('@ultimate'); await harness.commit(async (tx: any) => {(await tx.doc(Standing, root.id)).ultimate = primary}, context)}
|
||||
let primary = (await harness.snapshot(Standing, root.id, context))['$standing'];
|
||||
if (!primary) {primary = await create('@standing'); await harness.commit(async (tx: any) => {(await tx.doc(Standing, root.id))['$standing'] = primary}, context)}
|
||||
const submit = async (id: number, text: string, requestId?: string) => (await (await harness.conversation(id, context))!.submit({type: 'input', content: text, requestId}, context)).wait(context);
|
||||
const requests = async (id: number) => (await storage.scanSubmissions({conversationId: id}, 100, undefined, context)).items;
|
||||
const retries = async (id: number) => (await requests(id)).filter((r: any) => r.requestId?.startsWith('watchdog-retry:'));
|
||||
@@ -70,7 +70,7 @@ try {
|
||||
await until(async () => (await reports(branch)).length === 1);
|
||||
await harness.waitForIdle(context);
|
||||
assert((await retries(branch)).length === 1, 'Expected one retry');
|
||||
assert((await reports(branch))[0].to === primary, 'The message must reach @ultimate');
|
||||
assert((await reports(branch))[0].to === primary, 'The message must reach @standing');
|
||||
assert((await reports(branch))[0].text.includes('After one automatic retry'), 'Report must describe its retry');
|
||||
await until(async () => (await service.failures(0)).length === 1);
|
||||
const history = await service.failures(0);
|
||||
|
||||
+7000
-355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
//! Fresh reads of the user-owned world configuration. Listener edits apply at host startup.
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const PATH: &str = "/etc/sausage/config.yaml";
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Proxy {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Listener {
|
||||
pub address: std::net::SocketAddr,
|
||||
pub origin: String,
|
||||
}
|
||||
impl Default for Listener {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address: "127.0.0.1:8090".parse().unwrap(),
|
||||
origin: "http://localhost:8090".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub proxy: Proxy,
|
||||
pub listener: Listener,
|
||||
}
|
||||
impl Config {
|
||||
pub async fn read(root: &Arc<dyn Layer>) -> anyhow::Result<Self> {
|
||||
let file = match root.open(PATH, Open::default()).await {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
serde_yaml_ng::from_slice(&file.read(0, 16 * 1024 * 1024).await?)
|
||||
.map_err(|error| anyhow::anyhow!("Cannot read {PATH}: {error}"))
|
||||
}
|
||||
}
|
||||
@@ -66,97 +66,18 @@ pub(super) fn op_credential_requests(state: &mut OpState) -> Result<serde_json::
|
||||
work().map_err(|_| JsErrorBox::generic("Cannot read credential requests"))
|
||||
}
|
||||
|
||||
pub(super) fn proxy(cred: &Cred) -> anyhow::Result<Option<Value>> {
|
||||
use crate::cred::{
|
||||
items::{Field, Item},
|
||||
types::{Caller, FieldType},
|
||||
};
|
||||
let caller = Caller {
|
||||
pid: 0,
|
||||
program: "host".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let item = cred
|
||||
.item_list()?
|
||||
.into_iter()
|
||||
.find(|item| item.name == "llm-proxy");
|
||||
let item = match item {
|
||||
Some(item) => item,
|
||||
None => {
|
||||
let (Ok(base), Ok(key)) = (
|
||||
std::env::var("PI_PROXY_BASE_URL"),
|
||||
std::env::var("PI_PROXY_API_KEY"),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut store = cred.access()?;
|
||||
let master = store.master.clone();
|
||||
let tx = store
|
||||
.db
|
||||
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
let mut item = crate::cred::items::insert_new(
|
||||
&tx,
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "llm-proxy".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: "export default (ctx) => ctx.request".into(),
|
||||
fields: vec![
|
||||
Field {
|
||||
field_id: String::new(),
|
||||
name: "base_url".into(),
|
||||
public: true,
|
||||
value: Some(base),
|
||||
needs_value: false,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
Field {
|
||||
field_id: String::new(),
|
||||
name: "api_key".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
&caller,
|
||||
Some("host bootstrap"),
|
||||
)?;
|
||||
let field = &item
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.name == "api_key")
|
||||
.unwrap()
|
||||
.field_id;
|
||||
let sealed =
|
||||
crate::cred::seal::seal(&master, &format!("{}|{field}", item.id), key.as_bytes())?;
|
||||
tx.execute(
|
||||
"INSERT INTO fields VALUES(?1,?2,?3)",
|
||||
rusqlite::params![item.id, field, sealed],
|
||||
)?;
|
||||
item.fields
|
||||
.iter_mut()
|
||||
.find(|f| f.name == "api_key")
|
||||
.unwrap()
|
||||
.needs_value = false;
|
||||
crate::cred::items::save(&tx, &item)?;
|
||||
tx.commit()?;
|
||||
item
|
||||
}
|
||||
};
|
||||
Ok(Some(cred.credential(&item, true)?))
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub(super) fn op_model_proxy(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
|
||||
proxy(state.borrow::<Arc<Cred>>())
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|_| JsErrorBox::generic("Cannot read llm-proxy credential"))
|
||||
pub(super) async fn op_model_proxy(
|
||||
state: Rc<RefCell<OpState>>,
|
||||
) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let root = state
|
||||
.borrow()
|
||||
.borrow::<super::environment::Environment>()
|
||||
.root
|
||||
.clone();
|
||||
let config = super::config::Config::read(&root)
|
||||
.await
|
||||
.map_err(|error| JsErrorBox::generic(error.to_string()))?;
|
||||
serde_json::to_value(config.proxy).map_err(|error| JsErrorBox::generic(error.to_string()))
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ impl ModuleLoader for Loader {
|
||||
referrer: &str,
|
||||
_: ResolutionKind,
|
||||
) -> Result<ModuleSpecifier, JsErrorBox> {
|
||||
if specifier.starts_with("@earendil-works/") {
|
||||
if specifier == "yaml" || specifier.starts_with("@earendil-works/") {
|
||||
return ModuleSpecifier::parse(&format!("sausage:{specifier}"))
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
|
||||
pub mod config;
|
||||
mod credentials;
|
||||
mod environment;
|
||||
mod http;
|
||||
@@ -157,7 +158,7 @@ pub async fn run_module_with_input(
|
||||
receiver: input::Receiver,
|
||||
source: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let proxy = credentials::proxy(&cred)?;
|
||||
let proxy = serde_json::to_value(config::Config::read(&world.root).await?.proxy)?;
|
||||
let loader = std::rc::Rc::new(loader::Loader {
|
||||
root: world.root.clone(),
|
||||
modules: Default::default(),
|
||||
@@ -239,10 +240,7 @@ pub async fn run_module_with_input(
|
||||
.await;
|
||||
result.map_err(|error| {
|
||||
let mut text = error.to_string();
|
||||
if let Some(key) = proxy
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("api_key"))
|
||||
.and_then(Value::as_str)
|
||||
if let Some(key) = proxy.get("api_key").and_then(Value::as_str)
|
||||
&& !key.is_empty()
|
||||
{
|
||||
text = text.replace(key, "[redacted]");
|
||||
|
||||
+31
-12
@@ -14,14 +14,14 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Start a world: the harness, its services and a console.
|
||||
Run {
|
||||
/// Create a world with default tools and /etc/sausage/config.yaml.
|
||||
Init {
|
||||
world: PathBuf,
|
||||
#[arg(long)]
|
||||
userland: Option<PathBuf>,
|
||||
#[command(flatten)]
|
||||
api: sausage::api::Options,
|
||||
},
|
||||
/// Start a world: the harness, its services and a console.
|
||||
Run { world: PathBuf },
|
||||
/// Run a shell in a world. With `-c`, run one command and exit with its status.
|
||||
Sh {
|
||||
world: PathBuf,
|
||||
@@ -39,14 +39,33 @@ async fn main() -> anyhow::Result<()> {
|
||||
sausage::process::configure_cache(cache)?;
|
||||
}
|
||||
match cli.command {
|
||||
Command::Run {
|
||||
world,
|
||||
userland,
|
||||
api,
|
||||
} => {
|
||||
let bundle =
|
||||
userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from));
|
||||
sausage::api::run_with_userland(&world, bundle.as_deref(), api).await
|
||||
Command::Init { world, userland } => {
|
||||
let bundle = userland
|
||||
.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Default tools unavailable. Set SAUSAGE_USERLAND or pass --userland."
|
||||
)
|
||||
})?;
|
||||
sausage::world::World::init(&world, &bundle).await?;
|
||||
Ok(())
|
||||
}
|
||||
Command::Run { world } => {
|
||||
anyhow::ensure!(
|
||||
world.join("world").is_dir(),
|
||||
"No world at {}. Run sausage init {} first.",
|
||||
world.display(),
|
||||
world.display()
|
||||
);
|
||||
let opened = sausage::world::World::open(&world)?;
|
||||
let config = sausage::harness::config::Config::read(&opened.root).await?;
|
||||
let origin = url::Url::parse(&config.listener.origin)?;
|
||||
let api = sausage::api::Options {
|
||||
api_listen: config.listener.address,
|
||||
api_rp_id: origin.host_str().unwrap_or("localhost").into(),
|
||||
api_origin: config.listener.origin,
|
||||
};
|
||||
sausage::api::run_with_userland(&world, None, api).await
|
||||
}
|
||||
Command::Sh {
|
||||
world,
|
||||
|
||||
+70
-1
@@ -3,7 +3,7 @@
|
||||
//! Layout of a world directory:
|
||||
//! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it.
|
||||
//! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it.
|
||||
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process};
|
||||
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Open, Process};
|
||||
use std::{
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
@@ -43,6 +43,75 @@ fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Initialize a fresh world and commit its plain bundled source on main.
|
||||
pub async fn init(dir: &Path, bundle: &Path) -> anyhow::Result<Self> {
|
||||
anyhow::ensure!(
|
||||
!dir.join("world").exists(),
|
||||
"World already exists: {}",
|
||||
dir.display()
|
||||
);
|
||||
let world = Self::open_with_userland(dir, Some(bundle))?;
|
||||
let path = crate::harness::config::PATH;
|
||||
let config = world.root.open(path, Open::default()).await?;
|
||||
let mut text = String::from_utf8(config.read(0, 16 * 1024 * 1024).await?)?;
|
||||
for (field, variable) in [
|
||||
("base_url", "PI_PROXY_BASE_URL"),
|
||||
("api_key", "PI_PROXY_API_KEY"),
|
||||
] {
|
||||
if let Ok(value) = std::env::var(variable) {
|
||||
text = text.replace(
|
||||
&format!("{field}: \"\""),
|
||||
&format!("{field}: {}", serde_json::to_string(&value)?),
|
||||
);
|
||||
}
|
||||
}
|
||||
let file = world
|
||||
.root
|
||||
.open(
|
||||
path,
|
||||
Open {
|
||||
write: true,
|
||||
truncate: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
file.write(0, text.into_bytes()).await?;
|
||||
let exec = crate::process::exec(Self {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
vec!["add", "."],
|
||||
vec!["commit", "--no-gpg-sign", "-m", "Initial world source"],
|
||||
] {
|
||||
let argv = [
|
||||
vec!["/bin/git.wasm".to_owned()],
|
||||
args.into_iter().map(str::to_owned).collect(),
|
||||
]
|
||||
.concat();
|
||||
let mut output = Vec::new();
|
||||
let code = exec
|
||||
.exec(
|
||||
crate::exec::Command::Argv(argv),
|
||||
crate::exec::Options {
|
||||
cwd: "/src/sausage".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, bytes| output.extend_from_slice(bytes),
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
code == 0,
|
||||
"Cannot initialize source repository: {}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts.
|
||||
pub fn open(dir: &Path) -> anyhow::Result<Self> {
|
||||
Self::open_with_userland(dir, None)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Native HTTP smoke test for host pages and the listener's CLI options.
|
||||
//! Host pages remain reachable. World config replaces listener flags on the CLI.
|
||||
use anyhow::Result;
|
||||
use sausage::{
|
||||
api::{Listener, Options},
|
||||
@@ -44,14 +44,16 @@ fn listener_serves_host_pages() -> Result<()> {
|
||||
.output()?;
|
||||
assert!(help.status.success());
|
||||
let help = std::str::from_utf8(&help.stdout)?;
|
||||
for option in [
|
||||
"--api-listen",
|
||||
"127.0.0.1:8090",
|
||||
"--api-rp-id",
|
||||
"--api-origin",
|
||||
"http://localhost:8090",
|
||||
] {
|
||||
assert!(help.contains(option));
|
||||
for option in ["--api-listen", "--api-rp-id", "--api-origin"] {
|
||||
assert!(!help.contains(option));
|
||||
}
|
||||
let missing = dir.path().join("missing-world");
|
||||
let run = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("run")
|
||||
.arg(&missing)
|
||||
.output()?;
|
||||
assert!(!run.status.success());
|
||||
assert!(std::str::from_utf8(&run.stderr)?.contains("sausage init"));
|
||||
assert!(!missing.join("world").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -79,10 +79,12 @@ fn setup(directory: &Path) -> Result<()> {
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
|
||||
&repo.join("extensions"),
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
crate::common::write_config(
|
||||
directory,
|
||||
"standing",
|
||||
serde_json::json!([
|
||||
{"name":"standing","description":"Primary","model":"faux/faux-1","prompt":"Primary agent"}
|
||||
]),
|
||||
)?;
|
||||
ensure!(
|
||||
Command::new("git")
|
||||
|
||||
+17
-1
@@ -8,6 +8,22 @@ use std::{
|
||||
};
|
||||
pub static BUILD: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Keep the real shared prompt in every roster fixture, with an ephemeral approval port.
|
||||
pub fn write_config(dir: &Path, standing: &str, roster: serde_json::Value) -> anyhow::Result<()> {
|
||||
let mut config: serde_json::Value =
|
||||
serde_yaml_ng::from_str(include_str!("../../../config/config.yaml"))?;
|
||||
config["standing"] = serde_json::json!(standing);
|
||||
config["roster"] = roster;
|
||||
config["listener"]["address"] = serde_json::json!("127.0.0.1:0");
|
||||
let folder = dir.join("world/etc/sausage");
|
||||
std::fs::create_dir_all(&folder)?;
|
||||
std::fs::write(
|
||||
folder.join("config.yaml"),
|
||||
serde_yaml_ng::to_string(&config)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
for entry in std::fs::read_dir(source)? {
|
||||
@@ -39,7 +55,7 @@ pub fn bundle_key(repo: &Path) -> anyhow::Result<String> {
|
||||
"host",
|
||||
"vfs",
|
||||
"userland",
|
||||
"agents",
|
||||
"config",
|
||||
"skills",
|
||||
"extensions",
|
||||
"Cargo.toml",
|
||||
|
||||
+67
-13
@@ -44,6 +44,70 @@ fn write(root: &Path, path: &str, text: &str) -> anyhow::Result<()> {
|
||||
std::fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let bundle = dir.path().join("bundle");
|
||||
let target = crate::common::build_git();
|
||||
std::fs::create_dir_all(bundle.join("bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
bundle.join("bin/git.wasm"),
|
||||
)?;
|
||||
write(&bundle, "src/sausage/README.md", "World source")?;
|
||||
write(
|
||||
&bundle,
|
||||
"etc/sausage/config.yaml",
|
||||
include_str!("../../../config/config.yaml"),
|
||||
)?;
|
||||
let destination = dir.path().join("new-world");
|
||||
let initialized = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("init")
|
||||
.arg(&destination)
|
||||
.arg("--userland")
|
||||
.arg(&bundle)
|
||||
.env("PI_PROXY_BASE_URL", "https://proxy.test")
|
||||
.env("PI_PROXY_API_KEY", "fixture-key")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()?;
|
||||
assert!(
|
||||
initialized.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&initialized.stderr)
|
||||
);
|
||||
let world = World::open(&destination)?;
|
||||
assert!(destination.join("world/bin/git").exists());
|
||||
let config = sausage::harness::config::Config::read(&world.root).await?;
|
||||
assert_eq!(config.listener.origin, "http://localhost:8090");
|
||||
assert_eq!(config.proxy.base_url, "https://proxy.test");
|
||||
assert_eq!(config.proxy.api_key, "fixture-key");
|
||||
let source = destination.join("world/src/sausage");
|
||||
for (args, expected) in [
|
||||
(vec!["symbolic-ref", "HEAD"], "refs/heads/main"),
|
||||
(vec!["rev-list", "--count", "HEAD"], "1"),
|
||||
] {
|
||||
let result = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&source)
|
||||
.output()?;
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8(result.stdout)?.trim(), expected);
|
||||
}
|
||||
assert!(
|
||||
World::init(&destination, &bundle)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("already exists")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn core_extensions() -> anyhow::Result<()> {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
@@ -66,14 +130,9 @@ async fn core_extensions() -> anyhow::Result<()> {
|
||||
)?;
|
||||
}
|
||||
write(
|
||||
&source,
|
||||
"agents/worker.md",
|
||||
"---\nname: worker\ndescription: Worker\nfamily: fast\ntools: [read]\n---\nWorker body",
|
||||
)?;
|
||||
write(
|
||||
&source,
|
||||
"agents/models.json",
|
||||
r#"{"default":{"model":"faux/faux-1"},"fast":{"model":"faux/faux-1","thinking":"low"}}"#,
|
||||
&disk,
|
||||
"etc/sausage/config.yaml",
|
||||
include_str!("../../../config/config.yaml"),
|
||||
)?;
|
||||
write(
|
||||
&source,
|
||||
@@ -93,11 +152,6 @@ export default host=>{host.implement({name:'models.pin'},{pin:async()=>globalThi
|
||||
write(&disk, "project/.git/HEAD", "ref: refs/heads/main")?;
|
||||
write(&disk, "project/AGENTS.md", "Root instructions")?;
|
||||
write(&disk, "project/sub/AGENTS.md", "Nested instructions")?;
|
||||
write(
|
||||
&disk,
|
||||
"project/.sausage/agents/worker.md",
|
||||
"---\nname: worker\naddTools: [write]\n---\nProject body",
|
||||
)?;
|
||||
write(
|
||||
&disk,
|
||||
"project/.sausage/skills/project/SKILL.md",
|
||||
|
||||
+17
-27
@@ -50,33 +50,23 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.dreamHost=host; globalThis.dreamService=host.need({name:'dream'}); globalThis.dreamSubagents=host.need({name:'subagents'}); globalThis.dreamAutomations=host.need({name:'automations'}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"},"fast":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
for role in ["ultimate", "coordinator", "worker", "reviewer"] {
|
||||
let denial = if role == "worker" || role == "reviewer" {
|
||||
"denyTools: [subagent]\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
std::fs::write(
|
||||
repo.join(format!("agents/{role}.md")),
|
||||
format!(
|
||||
"---\nname: {role}\ndescription: P3 {role}\nisolate: none\n{denial}{}---\nP3 role:{role}\n",
|
||||
if role == "ultimate" {
|
||||
"resident: true\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
)?;
|
||||
}
|
||||
std::fs::copy(
|
||||
source.join("agents/dream-pass.md"),
|
||||
repo.join("agents/dream-pass.md"),
|
||||
)?;
|
||||
let mut roster: Vec<serde_json::Value> = ["standing", "coordinator", "worker", "reviewer"].into_iter().map(|role| serde_json::json!({
|
||||
"name":role, "description":format!("P3 {role}"), "model":"faux/faux-1",
|
||||
"isolate":"none", "resident":role == "standing", "prompt":format!("P3 role:{role}"),
|
||||
"denyTools":if role == "worker" || role == "reviewer" {vec!["subagent"]} else {vec![]}
|
||||
})).collect();
|
||||
let defaults: serde_json::Value =
|
||||
serde_yaml_ng::from_str(include_str!("../../../config/config.yaml"))?;
|
||||
let mut pass = defaults["roster"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|entry| entry["name"] == "dream-pass")
|
||||
.unwrap()
|
||||
.clone();
|
||||
pass["model"] = serde_json::json!("faux/faux-1");
|
||||
roster.push(pass);
|
||||
crate::common::write_config(dir, "standing", serde_json::json!(roster))?;
|
||||
let demo = disk.join("src/demo");
|
||||
std::fs::create_dir_all(&demo)?;
|
||||
std::fs::write(demo.join("README.md"), "P3 demo\n")?;
|
||||
|
||||
@@ -146,7 +146,7 @@ async fn proxy_child() -> anyhow::Result<()> {
|
||||
};
|
||||
let dir = std::path::Path::new(&dir);
|
||||
let source = format!(
|
||||
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
|
||||
"globalThis.proxyOnly = true; const proxy = await Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy config');\n{}",
|
||||
include_str!("../../js/durable-test-bundle.js")
|
||||
);
|
||||
run_module(
|
||||
@@ -158,7 +158,7 @@ async fn proxy_child() -> anyhow::Result<()> {
|
||||
let error = run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
"throw new Error(Deno.core.ops.op_model_proxy().api_key)",
|
||||
"throw new Error((await Deno.core.ops.op_model_proxy()).api_key)",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -168,8 +168,14 @@ async fn proxy_child() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_bootstrap_once() -> anyhow::Result<()> {
|
||||
fn proxy_config_ignores_environment() -> anyhow::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
|
||||
let settings = dir.join("world/etc/sausage");
|
||||
std::fs::create_dir_all(&settings)?;
|
||||
std::fs::write(
|
||||
settings.join("config.yaml"),
|
||||
"proxy:\n base_url: https://proxy.invalid\n api_key: faux-private-token\n",
|
||||
)?;
|
||||
for start in 0..2 {
|
||||
let status = std::process::Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "durable::proxy_child", "--nocapture"])
|
||||
@@ -199,7 +205,7 @@ fn proxy_bootstrap_once() -> anyhow::Result<()> {
|
||||
.iter()
|
||||
.filter(|i| i.name == "llm-proxy")
|
||||
.count(),
|
||||
1
|
||||
0
|
||||
);
|
||||
for file in [
|
||||
"host/cred.sqlite",
|
||||
|
||||
@@ -26,21 +26,37 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/scout.md"),
|
||||
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/ultimate.md"),
|
||||
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"standing",
|
||||
serde_json::json!([
|
||||
{
|
||||
"name": "scout",
|
||||
"description": "Search",
|
||||
"resident": true,
|
||||
"denyTools": [
|
||||
"subagent"
|
||||
],
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Scout instructions"
|
||||
},
|
||||
{
|
||||
"name": "standing",
|
||||
"description": "Primary",
|
||||
"resident": true,
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Primary agent"
|
||||
},
|
||||
{
|
||||
"name": "coordinator",
|
||||
"description": "Coordinate",
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Coordinator agent"
|
||||
}
|
||||
]),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
@@ -76,21 +76,37 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/scout.md"),
|
||||
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/ultimate.md"),
|
||||
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"standing",
|
||||
serde_json::json!([
|
||||
{
|
||||
"name": "scout",
|
||||
"description": "Search",
|
||||
"resident": true,
|
||||
"denyTools": [
|
||||
"subagent"
|
||||
],
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Scout instructions"
|
||||
},
|
||||
{
|
||||
"name": "standing",
|
||||
"description": "Primary",
|
||||
"resident": true,
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Primary agent"
|
||||
},
|
||||
{
|
||||
"name": "coordinator",
|
||||
"description": "Coordinate",
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Coordinator agent"
|
||||
}
|
||||
]),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
@@ -114,12 +114,51 @@ fn channel_with_closed_stdin() -> anyhow::Result<()> {
|
||||
#[test]
|
||||
fn cli_stays_up_after_stdin_eof() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
World::open(dir.path())?;
|
||||
let disk = dir.path().join("world");
|
||||
let target = crate::common::build_git();
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
for binary in ["git", "tar"] {
|
||||
std::fs::copy(
|
||||
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
|
||||
disk.join(format!("bin/{binary}.wasm")),
|
||||
)?;
|
||||
}
|
||||
let repo = disk.join("src/sausage");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions");
|
||||
for extension in ["core", "models", "subagents"] {
|
||||
crate::common::copy_tree(
|
||||
&source.join(extension),
|
||||
&repo.join("extensions").join(extension),
|
||||
)?;
|
||||
}
|
||||
crate::common::write_config(
|
||||
dir.path(),
|
||||
"console",
|
||||
serde_json::json!([
|
||||
{"name":"console","description":"Standing console","model":"openai/gpt-6.1-sol","prompt":"Keep the user conversation"}
|
||||
]),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
vec!["add", "."],
|
||||
vec!["commit", "--no-gpg-sign", "-m", "EOF fixture"],
|
||||
] {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&repo)
|
||||
.env("GIT_AUTHOR_NAME", "Test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
}
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("run")
|
||||
.arg(dir.path())
|
||||
.args(["--api-listen", "127.0.0.1:0"])
|
||||
.env("PI_PROXY_BASE_URL", "https://proxy.invalid")
|
||||
.env("PI_PROXY_API_KEY", "faux-token")
|
||||
.env_remove("SAUSAGE_USERLAND")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()?;
|
||||
@@ -134,7 +173,7 @@ fn cli_stays_up_after_stdin_eof() -> anyhow::Result<()> {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
> 1
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
+31
-15
@@ -60,21 +60,37 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
source.join("extensions/core/agents.ts"),
|
||||
repo.join("extensions/core/agents.ts"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/scout.md"),
|
||||
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/ultimate.md"),
|
||||
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nmodel: faux/coordinator\nisolate: none\n---\nCoordinator agent\n",
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"standing",
|
||||
serde_json::json!([
|
||||
{
|
||||
"name": "scout",
|
||||
"description": "Search",
|
||||
"resident": true,
|
||||
"denyTools": [
|
||||
"subagent"
|
||||
],
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Scout instructions"
|
||||
},
|
||||
{
|
||||
"name": "standing",
|
||||
"description": "Primary",
|
||||
"resident": true,
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Primary agent"
|
||||
},
|
||||
{
|
||||
"name": "coordinator",
|
||||
"description": "Coordinate",
|
||||
"isolate": "none",
|
||||
"model": "faux/coordinator",
|
||||
"prompt": "Coordinator agent"
|
||||
}
|
||||
]),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
+8
-9
@@ -10,7 +10,7 @@ fn bundle() -> anyhow::Result<PathBuf> {
|
||||
let key = crate::common::bundle_key(&repo)?;
|
||||
let stamp = bundle.join(".source-key");
|
||||
if !bundle.join("bin/sh.wasm").exists()
|
||||
|| !bundle.join("src/sausage/agents/models.json").exists()
|
||||
|| !bundle.join("etc/sausage/config.yaml").exists()
|
||||
|| std::fs::read_to_string(&stamp).ok().as_deref() != Some(key.as_str())
|
||||
{
|
||||
if stamp.exists() {
|
||||
@@ -33,12 +33,11 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let bundle = bundle()?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
|
||||
let world = World::init(dir.path(), &bundle).await?;
|
||||
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
|
||||
let snapshot = dir.path().join("world/src/sausage");
|
||||
for path in [
|
||||
"agents/models.json",
|
||||
"agents/ultimate.md",
|
||||
"config/config.yaml",
|
||||
"skills/README.md",
|
||||
"extensions/README.md",
|
||||
] {
|
||||
@@ -58,11 +57,11 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
|
||||
};
|
||||
let message = git(&snapshot, &["log", "-1", "--format=%s"])?;
|
||||
let revision = message.strip_prefix("Bundle source ").unwrap();
|
||||
for path in ["agents", "skills", "extensions"] {
|
||||
assert_eq!(message, "Initial world source");
|
||||
for path in ["config", "skills", "extensions"] {
|
||||
assert_eq!(
|
||||
git(&snapshot, &["rev-parse", &format!("HEAD:{path}")])?,
|
||||
git(&repo, &["rev-parse", &format!("{revision}:{path}")])?,
|
||||
git(&repo, &["rev-parse", &format!("HEAD:{path}")])?,
|
||||
"bundled {path} must match the source commit"
|
||||
);
|
||||
}
|
||||
@@ -197,7 +196,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
);
|
||||
let history = String::from_utf8(history)?;
|
||||
assert_eq!(history.lines().count(), 1);
|
||||
assert!(history.contains("Bundle source "));
|
||||
assert!(history.contains("Initial world source"));
|
||||
let mut status = Vec::new();
|
||||
assert_eq!(
|
||||
exec.exec(
|
||||
@@ -312,7 +311,7 @@ impl sausage::exec::Exec for LoaderTiming {
|
||||
async fn bundled_extensions_load() -> anyhow::Result<()> {
|
||||
let bundle = bundle()?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
|
||||
let world = World::init(dir.path(), &bundle).await?;
|
||||
let root = world.root.clone();
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
|
||||
+6
-23
@@ -61,29 +61,12 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.p3Models=host.need({name:'models'}); globalThis.p3Subagents=host.need({name:'subagents'}); globalThis.p3Workspaces=host.need({name:'workspaces'}); globalThis.p3Host=host; return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
for role in ["ultimate", "coordinator", "worker", "reviewer"] {
|
||||
let denial = if role == "worker" || role == "reviewer" {
|
||||
"denyTools: [subagent]\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
std::fs::write(
|
||||
repo.join(format!("agents/{role}.md")),
|
||||
format!(
|
||||
"---\nname: {role}\ndescription: P3 {role}\nisolate: none\n{denial}{}---\nP3 role:{role}\n",
|
||||
if role == "ultimate" {
|
||||
"resident: true\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
)?;
|
||||
}
|
||||
let roster: Vec<serde_json::Value> = ["standing", "coordinator", "worker", "reviewer"].into_iter().map(|role| serde_json::json!({
|
||||
"name":role, "description":format!("P3 {role}"), "model":"faux/faux-1",
|
||||
"isolate":"none", "resident":role == "standing", "prompt":format!("P3 role:{role}"),
|
||||
"denyTools":if role == "worker" || role == "reviewer" {vec!["subagent"]} else {vec![]}
|
||||
})).collect();
|
||||
crate::common::write_config(dir, "standing", serde_json::json!(roster))?;
|
||||
std::fs::write(
|
||||
repo.join("settings.json"),
|
||||
r#"{"lander":{"checks":["sh -c ': > /check-running; until test -f /check-release; do :; done; test -f feature.txt'"]}}"#,
|
||||
|
||||
+15
-7
@@ -73,6 +73,19 @@ fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
|
||||
}
|
||||
World::open_with_userland(dir, Some(&bundle))?;
|
||||
let repo = dir.join("world/src/sausage");
|
||||
for extension in ["core", "models", "subagents"] {
|
||||
crate::common::copy_tree(
|
||||
&source.join("extensions").join(extension),
|
||||
&repo.join("extensions").join(extension),
|
||||
)?;
|
||||
}
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"console",
|
||||
serde_json::json!([
|
||||
{"name":"console","description":"P4 console","model":"openai/gpt-6.1-sol","prompt":"Build the requested programs","tools":["bash"]}
|
||||
]),
|
||||
)?;
|
||||
fs::create_dir_all(repo.join("extensions/p4"))?;
|
||||
fs::write(
|
||||
repo.join("extensions/p4/server.ts"),
|
||||
@@ -102,18 +115,13 @@ impl Drop for Running {
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
fn round(dir: &Path, bundle: &Path, number: usize) -> anyhow::Result<()> {
|
||||
fn round(dir: &Path, number: usize) -> anyhow::Result<()> {
|
||||
let stdout = dir.join(format!("round-{number}.stdout"));
|
||||
let stderr = dir.join(format!("round-{number}.stderr"));
|
||||
let mut child = Running(
|
||||
Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("run")
|
||||
.arg(dir)
|
||||
.arg("--userland")
|
||||
.arg(bundle)
|
||||
.args(["--api-listen", "127.0.0.1:0"])
|
||||
.env("PI_PROXY_BASE_URL", "https://proxy.invalid")
|
||||
.env("PI_PROXY_API_KEY", "faux-token")
|
||||
.env_remove("CREDENTIALS_DIRECTORY")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(fs::File::create(&stdout)?)
|
||||
@@ -229,7 +237,7 @@ fn agent_builds_rust_and_node_twice() -> anyhow::Result<()> {
|
||||
let bundle = prepare(dir.path())?;
|
||||
let mut first_hits = 0;
|
||||
for number in 1..=2 {
|
||||
let outcome = round(dir.path(), &bundle, number);
|
||||
let outcome = round(dir.path(), number);
|
||||
let cleanup = boxes_removed(dir.path(), &bundle);
|
||||
if outcome.is_err() {
|
||||
for stream in ["stdout", "stderr"] {
|
||||
|
||||
@@ -96,6 +96,12 @@ async fn provider_transport_and_prices() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
});
|
||||
let dir = tempfile::tempdir()?;
|
||||
let config = dir.path().join("world/etc/sausage");
|
||||
std::fs::create_dir_all(&config)?;
|
||||
std::fs::write(
|
||||
config.join("config.yaml"),
|
||||
serde_json::to_vec(&json!({"proxy":proxy}))?,
|
||||
)?;
|
||||
let source = format!(
|
||||
"globalThis.testProxy = {proxy};\n{}",
|
||||
include_str!("../../js/proxy-models-test-bundle.js")
|
||||
|
||||
@@ -51,13 +51,22 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
source.join("extensions/core/agents.ts"),
|
||||
repo.join("extensions/core/agents.ts"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/scout.md"),
|
||||
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"scout",
|
||||
serde_json::json!([
|
||||
{
|
||||
"name": "scout",
|
||||
"description": "Search",
|
||||
"resident": true,
|
||||
"denyTools": [
|
||||
"subagent"
|
||||
],
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Scout instructions"
|
||||
}
|
||||
]),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
+31
-15
@@ -62,21 +62,37 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
source.join("extensions/conversations/api.ts"),
|
||||
repo.join("extensions/conversations/api.ts"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/scout.md"),
|
||||
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/ultimate.md"),
|
||||
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"standing",
|
||||
serde_json::json!([
|
||||
{
|
||||
"name": "scout",
|
||||
"description": "Search",
|
||||
"resident": true,
|
||||
"denyTools": [
|
||||
"subagent"
|
||||
],
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Scout instructions"
|
||||
},
|
||||
{
|
||||
"name": "standing",
|
||||
"description": "Primary",
|
||||
"resident": true,
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Primary agent"
|
||||
},
|
||||
{
|
||||
"name": "coordinator",
|
||||
"description": "Coordinate",
|
||||
"isolate": "none",
|
||||
"model": "faux/faux-1",
|
||||
"prompt": "Coordinator agent"
|
||||
}
|
||||
]),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
@@ -70,14 +70,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.workspaceService=host.need({name:'workspaces'}); globalThis.workspaceHost=host; return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/worker.md"),
|
||||
"---\nname: worker\ndescription: Implement\nisolate: worktree\n---\nImplement\n",
|
||||
crate::common::write_config(
|
||||
dir,
|
||||
"worker",
|
||||
serde_json::json!([
|
||||
{"name":"worker","description":"Implement","model":"faux/faux-1","isolate":"worktree","prompt":"Implement"}
|
||||
]),
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("settings.json"),
|
||||
|
||||
Reference in New Issue
Block a user