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')
|
||||
}),
|
||||
]})
|
||||
}
|
||||
Reference in New Issue
Block a user