Merge v3-t45: core world extensions models, prompt, conversations, definitions (T45, P3-g, P3-e)
# Conflicts: # host/js/package.json
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Conversation lifecycle
|
||||
|
||||
The `conversations` service exposes metadata subjects and the concluded set.
|
||||
`concludeIn(tx, ids)` refuses unknown, owned, root, and standing conversations.
|
||||
`setStanding`, `setAutomaticSummaries`, and `generatedTitle` update durable conversation state.
|
||||
`generatedTitle` only marks a matching metadata title that no earlier call marked.
|
||||
|
||||
The `beforeInput` hook resumes the recipient and its owner/fork ancestors.
|
||||
This change shares the transaction that places the input.
|
||||
A write without an input does not resume a conversation.
|
||||
Unrelated concluded conversations remain concluded.
|
||||
|
||||
Summaries do not run in this subset.
|
||||
`summaryModel()` returns undefined and `requestSummary()` does nothing.
|
||||
The durable automatic-summary preference remains available for the later summary port.
|
||||
|
||||
`host/tests/core_ext.rs` verifies the lifecycle through the real snapshot loader.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Durable conversation lifecycle state and the summary seam for later ports. */
|
||||
import { defineDoc } from '@earendil-works/pi-durable'
|
||||
export const ConversationsApi = {name:'conversations'}
|
||||
export const State = defineDoc({kind:'conversations.state',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false})
|
||||
export const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',initial:()=>({ids:[]}),checkpointWhen:()=>false})
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Conversation metadata and lifecycle. Summaries remain disabled until the summary port supplies them. */
|
||||
import { defineExtension, GenerationTask, hook, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import { ConversationsApi, State, Concluded } from './api.ts'
|
||||
export default function conversations(host: any) {
|
||||
const concludedIn = async (tx: any) => new Set<number>((await tx.doc(Concluded)).ids)
|
||||
host.implement(ConversationsApi, {
|
||||
concludedIn,
|
||||
concluded: async (context: any) => host.harness().commit(concludedIn,context),
|
||||
concludeIn: async (tx: any, ids: number[]) => {
|
||||
for (const id of ids) {
|
||||
const record = await tx.conversation(id)
|
||||
if (!record) throw new Error(`Unknown conversation ${id}`)
|
||||
if (record.owner) throw new Error(`Conversation ${id} is owned`)
|
||||
if (id === ROOT_CONVERSATION_ID || (await tx.doc(State,id)).standing) throw new Error(`Conversation ${id} is fixed`)
|
||||
}
|
||||
const state = await tx.doc(Concluded)
|
||||
state.ids = [...new Set([...state.ids,...ids])]
|
||||
},
|
||||
setStanding: async (tx: any, id: number, standing: boolean) => { (await tx.doc(State,id)).standing = standing },
|
||||
setAutomaticSummaries: async (tx: any, id: number, enabled: boolean) => { (await tx.doc(State,id)).automaticSummaries = enabled },
|
||||
generatedTitle: async (tx: any, id: number, title: string) => {
|
||||
const state = await tx.doc(State,id)
|
||||
if (state.generatedTitle === undefined && (await tx.doc(host.metadata,id)).title === title) state.generatedTitle = title
|
||||
},
|
||||
subject: async (id: number, context: any) => {
|
||||
const metadata = await host.harness().snapshot(host.metadata,id,context)
|
||||
return metadata?.title ? {title:metadata.title} : {}
|
||||
},
|
||||
// Later summaries can supply these without changing lifecycle callers.
|
||||
summaryModel: async () => undefined,
|
||||
requestSummary: async () => {},
|
||||
})
|
||||
return defineExtension({name:'conversations',hooks:[hook(GenerationTask,{beforeInput:async (_input,{tx,conversationId})=>{
|
||||
const ancestors = new Set<number>()
|
||||
for (let id = conversationId; id !== undefined && !ancestors.has(id);) {
|
||||
ancestors.add(id)
|
||||
const record = await tx.conversation(id)
|
||||
id = record?.owner?.conversationId ?? record?.parent?.conversationId
|
||||
}
|
||||
const state = await tx.doc(Concluded)
|
||||
state.ids = state.ids.filter(id=>!ancestors.has(id))
|
||||
}})]})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Model selection
|
||||
|
||||
The `models` service starts a conversation with `start({agent, cwd, ownership?}, context)`.
|
||||
The `agent` field names a built-in definition or a project overlay.
|
||||
The service stores that name in `models.profile`.
|
||||
It applies the definition during creation and before each new run.
|
||||
Definitions without an explicit model follow their family, then the default family.
|
||||
|
||||
Each resolution reads `/src/sausage/agents/models.json` and the definition files through `host.fs`.
|
||||
A file edit applies at the next run boundary without a restart.
|
||||
The project definitions live under `<repo>/.sausage/agents/`.
|
||||
The existing T37 parser controls validation, overlays, model suffixes, and tool denials.
|
||||
|
||||
The optional `models.pin` service supplies `pin(tx, conversationId)` for an external model override.
|
||||
Without that provider, the definition or default family selects the model.
|
||||
No login fallback runs.
|
||||
|
||||
Existing registered models retain their transport.
|
||||
New logical model references reuse the host's OpenAI-compatible proxy provider and its auth resolver.
|
||||
The extension never reads a credential or registers a direct provider SDK.
|
||||
|
||||
`host/tests/core_ext.rs` tests these services through the snapshot loader.
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Model selection and definition-based conversation creation for coordination extensions. */
|
||||
import { defineDoc } from '@earendil-works/pi-durable'
|
||||
export const Profile = defineDoc({kind:'models.profile',version:1,scope:'conversation',history:'latest',fork:'current',initial:()=>({}),checkpointWhen:()=>false})
|
||||
/** Optional external pin provider. The service returns a logical provider/modelId reference. */
|
||||
export const Pin = {name:'models.pin'}
|
||||
/** start({agent, cwd, ownership?}, context) creates a definition-based conversation. */
|
||||
export const ModelsApi = {name:'models'}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** World paths and optional files shared by the core extensions. */
|
||||
export async function optional(fs: any, path: string): Promise<string | undefined> {
|
||||
try { return await fs.read(path) } catch (error) { if (error.code === 'not_found') return undefined; throw error }
|
||||
}
|
||||
export const parent = (path: string) => path.replace(/\/+$/, '').replace(/\/[^/]+$/, '') || '/'
|
||||
export async function repoRoot(fs: any, cwd: string): Promise<string> {
|
||||
for (let at = cwd; ; at = parent(at)) {
|
||||
try { await fs.stat(`${at}/.git`); return at } catch (error) { if (error.code !== 'not_found') throw error }
|
||||
if (at === '/') return cwd
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Resolve world families and definitions at each run boundary. All real requests retain the host proxy transport. */
|
||||
import { AgentDoc, defineExtension, GenerationTask, hook } from '@earendil-works/pi-durable'
|
||||
import { definitionRuns, discoverAgents, parseDefinition, parseOverlay, parseFamilies, parseFrontmatter, toolsFor } from '../core/agents.ts'
|
||||
import { ModelsApi, Pin, Profile } from './api.ts'
|
||||
import { repoRoot } from './files.ts'
|
||||
export const order = -200
|
||||
export default function models(host: any) {
|
||||
const read = async (cwd: string) => {
|
||||
const families = parseFamilies(await host.fs.read('/src/sausage/agents/models.json'))
|
||||
const root = await repoRoot(host.fs, cwd)
|
||||
const files = []
|
||||
for (const folder of ['/src/sausage/agents', `${root}/.sausage/agents`]) {
|
||||
let entries
|
||||
try { entries = await host.fs.list(folder) } catch (error) { if (error.code !== 'not_found') throw error; entries = [] }
|
||||
for (const entry of entries) if (entry.kind === 'file' && entry.name.endsWith('.md')) {
|
||||
const path = `${folder}/${entry.name}`
|
||||
files.push({path, text:await host.fs.read(path)})
|
||||
}
|
||||
}
|
||||
const refs = Object.values(families).map(spec => spec.model.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ''))
|
||||
// Explicit pins can name a proxy model outside the families.
|
||||
for (const file of files) {
|
||||
try {
|
||||
const parsed = 'addTools' in parseFrontmatter(file.text).frontmatter ? parseOverlay(file.text,file.path).settings : parseDefinition(file.text,file.path)
|
||||
if (parsed.model) refs.push(parsed.model.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ''))
|
||||
} catch { /* discoverAgents records invalid files without replacing valid definitions. */ }
|
||||
}
|
||||
const found = discoverAgents(files, '/src/sausage', root, families, refs)
|
||||
return {families, ...found}
|
||||
}
|
||||
const transport = host.models().getProvider('openai')
|
||||
const template = host.models().getModels('openai')[0]
|
||||
const register = (ref: any) => {
|
||||
const store = host.models()
|
||||
if (store.getModel(ref.provider, ref.modelId)) return
|
||||
if (!transport || !template) throw new Error(`No proxy transport for ${ref.provider}/${ref.modelId}`)
|
||||
const prior = store.getModels(ref.provider)
|
||||
store.setProvider({...transport, id:ref.provider, getModels:()=>[...prior, {...template, provider:ref.provider, id:ref.modelId, name:ref.modelId}]})
|
||||
}
|
||||
const apply = async (tx: any, id: number) => {
|
||||
const state = await tx.doc(AgentDoc, id)
|
||||
const profile = await tx.doc(Profile, id)
|
||||
const config = await read(state.cwd ?? '/')
|
||||
const definition = profile.agent ? config.definitions.find(d => d.name === profile.agent) : undefined
|
||||
if (profile.agent && !definition) throw new Error(`Unknown agent ${profile.agent}: ${JSON.stringify(config.errors)}`)
|
||||
const selected = definition?.runs ?? definitionRuns({} as any,config.families,Object.values(config.families).map(spec=>spec.model.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, '')))
|
||||
const pinned = await host.want(Pin)?.pin(tx, id)
|
||||
const ref = pinned ?? selected.model
|
||||
register(ref)
|
||||
state.model = ref
|
||||
if (selected.thinking) state.thinkingLevel = selected.thinking
|
||||
else delete state.thinkingLevel
|
||||
if (definition) {
|
||||
state.instructions = definition.instructions
|
||||
if (profile.inheritedTools === undefined) profile.inheritedTools = state.tools ?? null
|
||||
const inherited = profile.inheritedTools
|
||||
if (definition.tools || Array.isArray(inherited)) state.tools = toolsFor(definition,inherited ?? [])
|
||||
else state.tools = {remove:[...new Set([...(inherited?.remove ?? []),...(definition.denyTools ?? [])])]}
|
||||
}
|
||||
return ref
|
||||
}
|
||||
host.implement(ModelsApi, {
|
||||
resolve: apply,
|
||||
start: async ({agent, cwd, ownership = {kind:'ownerless'}}: any, context: any) => {
|
||||
const conversation = await host.harness().createConversation({ownership, agent:{cwd}, init:async (tx: any, id: number)=>{
|
||||
(await tx.doc(Profile,id)).agent = agent
|
||||
await apply(tx,id)
|
||||
}}, context)
|
||||
return conversation
|
||||
},
|
||||
})
|
||||
return defineExtension({name:'models', hooks:[hook(GenerationTask,{beforeInput:async (_input, {tx,conversationId})=>{await apply(tx,conversationId)}})]})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# System context
|
||||
|
||||
The prompt extension supplies `project_context`, `skills`, `tools`, and `cwd` sections.
|
||||
The effective environment cwd takes precedence over the configured agent cwd.
|
||||
Project instructions include each AGENTS.md from the repository root to that cwd, in that order.
|
||||
Outside a repository, the extension reads the cwd's AGENTS.md only.
|
||||
|
||||
Skills come from `/src/sausage/skills/*/SKILL.md` and `<repo>/.sausage/skills/*/SKILL.md`.
|
||||
The prompt lists each skill's name, description, and path.
|
||||
A project skill replaces a built-in skill with the same name.
|
||||
The tools section lists the tools that the resolved agent can use.
|
||||
No Ultimate build note enters the prompt.
|
||||
|
||||
`host/tests/core_ext.rs` tests these sections through the real snapshot loader.
|
||||
@@ -0,0 +1,37 @@
|
||||
/** System context from the effective cwd, project instructions, and discoverable world skills. */
|
||||
import { defineExtension, section } from '@earendil-works/pi-durable'
|
||||
import { parseFrontmatter } from '../core/agents.ts'
|
||||
import { optional, parent, repoRoot } from '../models/files.ts'
|
||||
export const order = -100
|
||||
export default function prompt(host: any) {
|
||||
const cwd = (input: any) => input.env?.cwd ?? input.agent.cwd ?? '/'
|
||||
return defineExtension({name:'prompt',sections:[
|
||||
section('cwd', input=>cwd(input)),
|
||||
section('tools', input=>(input.agent.tools ?? []).map(tool=>`- ${tool.name}: ${tool.description}`).join('\n')),
|
||||
section('project_context', async input=>{
|
||||
const current = cwd(input), root = await repoRoot(host.fs,current), paths = []
|
||||
for (let at = current; ; at = parent(at)) { paths.unshift(at); if(at === root || at === '/') break }
|
||||
const parts = []
|
||||
for (const path of paths) {
|
||||
const file = `${path === '/' ? '' : path}/AGENTS.md`, text = await optional(host.fs,file)
|
||||
if (text !== undefined) parts.push(`<project_instructions path="${file}">\n${text}\n</project_instructions>`)
|
||||
}
|
||||
return parts.join('\n\n')
|
||||
}),
|
||||
section('skills', async input=>{
|
||||
const root = await repoRoot(host.fs,cwd(input)), skills = new Map()
|
||||
for (const folder of ['/src/sausage/skills',`${root}/.sausage/skills`]) {
|
||||
let entries
|
||||
try { entries = await host.fs.list(folder) } catch(error) { if(error.code !== 'not_found') throw error; entries = [] }
|
||||
for (const entry of entries.toSorted((a,b)=>a.name.localeCompare(b.name))) if(entry.kind === 'directory') {
|
||||
const path = `${folder}/${entry.name}/SKILL.md`, text = await optional(host.fs,path)
|
||||
if(text === undefined) continue
|
||||
const {frontmatter} = parseFrontmatter(text)
|
||||
if(typeof frontmatter.name !== 'string' || typeof frontmatter.description !== 'string') throw new Error(`Invalid skill ${path}`)
|
||||
skills.set(frontmatter.name, `- ${frontmatter.name}: ${frontmatter.description}\n Path: ${path}`)
|
||||
}
|
||||
}
|
||||
return [...skills.values()].join('\n')
|
||||
}),
|
||||
]})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
/** Core extensions run through the real snapshot loader with a faux model. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import { createModels } from '@earendil-works/pi-ai/models'
|
||||
import { fauxProvider, fauxAssistantMessage } from '@earendil-works/pi-ai/providers/faux'
|
||||
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable'
|
||||
import { openStorage } from './sqlite'
|
||||
import { loadExtensions, fs } from './extensions'
|
||||
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'}
|
||||
models.setProvider({...faux.provider,id:'openai',getModels:()=>[proxyTemplate]})
|
||||
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)
|
||||
await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
|
||||
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 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')
|
||||
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 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)
|
||||
await harness.commit(async tx=>{
|
||||
await conversations.concludeIn(tx,[worker.id,child.id,unrelated.id])
|
||||
await conversations.setAutomaticSummaries(tx,worker.id,false)
|
||||
;(await tx.doc(metadata,worker.id)).title='Generated'
|
||||
await conversations.generatedTitle(tx,worker.id,'Generated')
|
||||
},context)
|
||||
assert((await harness.snapshot(State,worker.id,context)).automaticSummaries===false,'automatic summary seam')
|
||||
assert((await harness.snapshot(State,worker.id,context)).generatedTitle==='Generated','generated title')
|
||||
await child.submit({type:'write',entry:{kind:'test.note'}},context)
|
||||
assert((await conversations.concluded(context)).size===3,'write does not resume')
|
||||
faux.setResponses([fauxAssistantMessage('done')])
|
||||
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'}}))
|
||||
faux.setResponses([fauxAssistantMessage('next')])
|
||||
await (await worker.submit({type:'input',content:'next'},context)).wait(context)
|
||||
agent=await worker.agent(context)
|
||||
assert(agent.thinkingLevel==='high','model config edit applies next turn')
|
||||
// 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'}}))
|
||||
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:'logical/new'}}))
|
||||
faux.setResponses([fauxAssistantMessage('proxy')])
|
||||
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
|
||||
assert(models.getModel('logical','new').baseUrl===proxyTemplate.baseUrl && models.getProvider('logical').auth===models.getProvider('openai').auth,'new model retains proxy transport and auth')
|
||||
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')
|
||||
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')
|
||||
} finally {await loader.close();await harness.close(context)}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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" },
|
||||
"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" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Core world extensions execute through the snapshot loader and the actual VFS.
|
||||
use sausage::{
|
||||
exec::{Command, Exec, Options, Stream},
|
||||
world::World,
|
||||
};
|
||||
use std::{path::Path, sync::Arc};
|
||||
const BUNDLE: &str = include_str!("../js/core-ext-test-bundle.js");
|
||||
struct Author(Arc<dyn Exec>);
|
||||
#[async_trait::async_trait]
|
||||
impl Exec for Author {
|
||||
async fn exec(
|
||||
&self,
|
||||
command: Command,
|
||||
mut options: Options,
|
||||
output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
|
||||
) -> anyhow::Result<i32> {
|
||||
for (key, value) in [
|
||||
("GIT_AUTHOR_NAME", "Test"),
|
||||
("GIT_AUTHOR_EMAIL", "test@example.com"),
|
||||
("GIT_COMMITTER_NAME", "Test"),
|
||||
("GIT_COMMITTER_EMAIL", "test@example.com"),
|
||||
] {
|
||||
options.env.push((key.into(), value.into()));
|
||||
}
|
||||
self.0.exec(command, options, output).await
|
||||
}
|
||||
}
|
||||
fn copy_tree(from: &Path, to: &Path) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let dest = to.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &dest)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), dest)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn write(root: &Path, path: &str, text: &str) -> anyhow::Result<()> {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
std::fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn core_extensions() -> anyhow::Result<()> {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let userland = repo.join("userland");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
|
||||
if !userland.join("cwd-init/init.o").exists() {
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "cwd-init/build.nu"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "git", "--manifest-path"])
|
||||
.arg(userland.join("Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open(dir.path())?;
|
||||
let disk = dir.path().join("world");
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
disk.join("bin/git.wasm"),
|
||||
)?;
|
||||
let source = disk.join("src/sausage");
|
||||
for name in ["core", "models", "prompt", "conversations"] {
|
||||
copy_tree(
|
||||
&repo.join("extensions").join(name),
|
||||
&source.join("extensions").join(name),
|
||||
)?;
|
||||
}
|
||||
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"}}"#,
|
||||
)?;
|
||||
write(
|
||||
&source,
|
||||
"skills/builtin/SKILL.md",
|
||||
"---\nname: builtin-skill\ndescription: Builtin skill\n---\nInstructions",
|
||||
)?;
|
||||
write(
|
||||
&source,
|
||||
"extensions/fixture/server.ts",
|
||||
r#"import {defineExtension,defineTool} from '@earendil-works/pi-durable';
|
||||
import {obj} from '@earendil-works/pi-schema';
|
||||
import {State} from '../conversations/api.ts';
|
||||
export const order=500;
|
||||
export default host=>{host.implement({name:'models.pin'},{pin:async()=>globalThis.modelPin});host.onOpen(async()=>{globalThis.core={selection:host.need({name:'models'}),conversations:host.need({name:'conversations'}),State,metadata:host.metadata}});return defineExtension({name:'fixture',tools:['read','write'].map(name=>defineTool({name,description:name,parameters:obj({}),execute:async()=>({output:[]})}))})};"#,
|
||||
)?;
|
||||
write(&disk, "AGENTS.md", "Outside instructions")?;
|
||||
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",
|
||||
"---\nname: project-skill\ndescription: Project skill\n---\nInstructions",
|
||||
)?;
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.arg(&source)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&source)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"--no-gpg-sign",
|
||||
"-m",
|
||||
"core fixtures"
|
||||
])
|
||||
.current_dir(&source)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
let exec: Arc<dyn Exec> = Arc::new(Author(sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?));
|
||||
sausage::harness::run_module(world, exec, BUNDLE).await
|
||||
}
|
||||
Reference in New Issue
Block a user