diff --git a/extensions/models/README.md b/extensions/models/README.md index 4986bb0..664a0e5 100644 --- a/extensions/models/README.md +++ b/extensions/models/README.md @@ -3,6 +3,9 @@ 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`. +`discover(cwd)` exposes the same live definition resolver to subagents. +`configure(tx, id, {agent, inheritedTools, excludeTools?, model?, thinking?})` configures an existing child in its creation transaction. +It retains the parent's tool scope, explicit overrides, and exclusions for subsequent run boundaries. It applies the definition during creation and before each new run. Definitions without an explicit model follow their family, then the default family. diff --git a/extensions/models/api.ts b/extensions/models/api.ts index 56d96e9..7b031a8 100644 --- a/extensions/models/api.ts +++ b/extensions/models/api.ts @@ -3,5 +3,6 @@ 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. */ +/** discover(cwd) reads definitions. configure(tx, id, options) applies a definition to an existing child. + * start({agent, cwd, ownership?}, context) creates a definition-based conversation. */ export const ModelsApi = {name:'models'} diff --git a/extensions/models/server.ts b/extensions/models/server.ts index 2bbdf19..1b06731 100644 --- a/extensions/models/server.ts +++ b/extensions/models/server.ts @@ -45,21 +45,33 @@ export default function models(host: any) { 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 + const ref = pinned ?? profile.model ?? selected.model register(ref) state.model = ref - if (selected.thinking) state.thinkingLevel = selected.thinking + if (profile.thinking ?? selected.thinking) state.thinkingLevel = profile.thinking ?? 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 ?? [])])]} + if (definition.tools || Array.isArray(inherited)) state.tools = toolsFor(definition,inherited ?? []).filter(name=>!profile.excludeTools?.includes(name)) + else state.tools = {remove:[...new Set([...(inherited?.remove ?? []),...(definition.denyTools ?? []),...(profile.excludeTools ?? [])])]} } return ref } host.implement(ModelsApi, { + discover: read, + configure: async (tx: any, id: number, options: any) => { + const profile = await tx.doc(Profile,id) + profile.agent = options.agent + profile.inheritedTools = options.inheritedTools + profile.excludeTools = options.excludeTools ?? [] + if (options.model) profile.model = options.model + else delete profile.model + if (options.thinking) profile.thinking = options.thinking + else delete profile.thinking + return apply(tx,id) + }, 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)=>{ diff --git a/extensions/subagents/children.ts b/extensions/subagents/children.ts index 30f6952..6c6d94f 100644 --- a/extensions/subagents/children.ts +++ b/extensions/subagents/children.ts @@ -1,6 +1,6 @@ /** Child configuration and start events share the transaction that creates ownership. */ import { AgentDoc, configure } from '@earendil-works/pi-durable' -import { toolsFor } from '../core/agents.ts' +import { ModelsApi } from '../models/api.ts' import { Started } from './refs.ts' import { Profile } from './api.ts' export async function started(host: any, tx: any, conversation: any, agent: string, kind: 'child'|'standing'|'branch') { @@ -11,27 +11,35 @@ export async function started(host: any, tx: any, conversation: any, agent: stri ...(thinkingLevel ? {thinking:thinkingLevel} : {}), ...(parent === undefined ? {} : {parent})}) } export async function createChild(host: any, tx: any, options: any) { - const {parentAgent:parent, definition} = options + const {definition} = options const child = options.fork ? await tx.forkConversation(options.fork.conversationId, options.fork.at, {ownership:options.owner}) : await tx.createConversation({ownership:options.owner}) - await configure(tx, child.id, childConfiguration(options)) + await configureChild(host, tx, child.id, options) Object.assign(await tx.doc(host.metadata, child.id), {label:options.label, title:options.title}) if (definition) (await tx.doc(Profile, child.id)).definition = definition.name if (definition?.summaries === false) await host.want({name:'conversations'})?.setAutomaticSummaries(tx, child.id, false) return child } -/** Standing refreshes and new children apply the same definition and tool denials. */ -export function childConfiguration(options: any) { - const {parentAgent:parent, definition} = options - const inherited = parent.tools.map((tool: any) => tool.name) - const names = (definition ? toolsFor(definition, inherited) : inherited).filter((name: string) => !options.excludeTools?.includes(name)) +/** Standing refreshes and new children share the models service and persistent definition profile. */ +export async function configureChild(host: any, tx: any, id: number, options: any) { + await configure(tx, id, childConfiguration(options)) + if (options.definition) await host.need(ModelsApi).configure(tx, id, { + agent:options.definition.name, + inheritedTools:options.parentAgent.tools.map((tool: any)=>tool.name), + excludeTools:options.excludeTools, + model:options.model, + thinking:options.thinking, + }) +} +function childConfiguration(options: any) { + const {parentAgent:parent} = options return { - tools:parent.tools.filter((tool: any) => names.includes(tool.name)), + tools:parent.tools.filter((tool: any) => !options.excludeTools?.includes(tool.name)), extensions:parent.extensions.filter((extension: any) => !options.excludeExtensions?.includes(extension.name)), - instructions:definition?.instructions ?? parent.instructions ?? null, - model:options.model ?? definition?.runs?.model ?? parent.model ?? null, - thinkingLevel:options.thinking ?? definition?.runs?.thinking ?? definition?.thinking ?? parent.thinkingLevel ?? null, + instructions:parent.instructions ?? null, + model:options.model ?? parent.model ?? null, + thinkingLevel:options.thinking ?? parent.thinkingLevel ?? null, cwd:options.cwd ?? parent.cwd ?? '/src/sausage', } } diff --git a/extensions/subagents/definitions.ts b/extensions/subagents/definitions.ts deleted file mode 100644 index 37f1b97..0000000 --- a/extensions/subagents/definitions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Temporary world-file resolver until the models service supplies definitions. */ -import { discoverAgents, parseFamilies } from '../core/agents.ts' -export async function discover(host: any, cwd = '/src/sausage') { - const root = '/src/sausage' - const families = parseFamilies(await host.fs.read(`${root}/agents/models.json`)) - const models = host.models().getModels().map((model: any) => `${model.provider}/${model.id}`) - const files: {path:string; text:string}[] = [] - for (const directory of [`${root}/agents`, `${cwd}/.sausage/agents`]) { - let entries - try { entries = await host.fs.list(directory) } catch (error) { if ((error as any).code === 'not_found') continue; throw error } - for (const entry of entries) if (entry.kind === 'file' && entry.name.endsWith('.md')) { - const path = `${directory}/${entry.name}` - files.push({path, text:await host.fs.read(path)}) - } - } - return discoverAgents(files, root, cwd, families, models) -} diff --git a/extensions/subagents/server.ts b/extensions/subagents/server.ts index 3cbc908..25a80f1 100644 --- a/extensions/subagents/server.ts +++ b/extensions/subagents/server.ts @@ -3,11 +3,11 @@ import { BACKGROUND_CONTEXT, withCancel } from '@earendil-works/chord/context' import type { Context } from '@earendil-works/chord' import { bool, int, obj, oneOf, opt, str } from '@earendil-works/pi-schema' import type { AssistantMessage } from '@earendil-works/pi-ai' -import { AgentDoc, AssistantEntry, configure, ToolResultEntry, defineExtension, defineTask, defineTool, LiveDoc, InboxDoc, GenerationTask, hook, ROOT_CONVERSATION_ID, section } from '@earendil-works/pi-durable' +import { AgentDoc, AssistantEntry, ToolResultEntry, defineExtension, defineTask, defineTool, LiveDoc, InboxDoc, GenerationTask, hook, ROOT_CONVERSATION_ID, section } from '@earendil-works/pi-durable' import type { ConversationId, Cursor, EntryId, EntryRecord, TaskId, ToolExecutionApi, SubmissionRecord } from '@earendil-works/pi-durable' import { Subagents, Standing, Profile, Runs, SubagentsApi } from './api.ts' -import { createChild, childConfiguration, started } from './children.ts' -import { discover } from './definitions.ts' +import { createChild, configureChild, started } from './children.ts' +import { ModelsApi } from '../models/api.ts' export const docs = [Subagents, Standing, Profile, Runs] export const runsLabel = (runs: any) => runs ? `${runs.model.provider}/${runs.model.modelId}${runs.thinking ? ` ยท ${runs.thinking}` : ''}` : '' const onCommit = (harness: any, callback: any) => { @@ -298,15 +298,16 @@ async function waitInterruptibly( } export default function subagents(host: any) { + const models = host.need(ModelsApi) host.implement(SubagentsApi, { createChild: async (tx: any, options: any) => { const child = await createChild(host, tx, options) await started(host, tx, child, options.definition?.name ?? options.label, 'child') return child }, - residents: async () => (await discover(host)).definitions.filter((d: any) => d.resident).map(({name, description}: any) => ({name, description})), + residents: async () => (await models.discover('/src/sausage')).definitions.filter((d: any) => d.resident).map(({name, description}: any) => ({name, description})), standing: async (name: string, ctx: Context) => { - const found = await discover(host) + const found = await models.discover('/src/sausage') const definition = found.definitions.find((d: any) => d.name === name && d.resident) if (!definition) throw new Error(`Unknown standing agent @${name}`) const root = (await host.harness().conversation(ROOT_CONVERSATION_ID, ctx))! @@ -318,8 +319,7 @@ export default function subagents(host: any) { if (existing) { await lifecycle?.setStanding(tx, existing.id, true) await lifecycle?.setAutomaticSummaries(tx, existing.id, false) - const own = await tx.doc(AgentDoc, existing.id) - await configure(tx, existing.id, childConfiguration({definition, parentAgent:{...parentAgent, model:own.model ?? parentAgent.model, thinkingLevel:own.thinkingLevel ?? parentAgent.thinkingLevel}})) + await configureChild(host, tx, existing.id, {definition, parentAgent}) return existing.id } const child = await createChild(host, tx, {owner: {kind:'ownerless'}, definition, parentAgent, metadata:host.metadata, label:name, title:`@${name}`}) @@ -425,7 +425,7 @@ export default function subagents(host: any) { await recovery }) host.onOpen(async () => { - const found = await discover(host) + const found = await models.discover('/src/sausage') if (!found.errors.length) await host.harness().commit(async (tx: any) => { const index = await tx.doc(Standing, ROOT_CONVERSATION_ID) for (const name of Object.keys(index)) if (!found.definitions.some((d: any) => d.name === name && d.resident)) { @@ -540,7 +540,7 @@ export default function subagents(host: any) { ['subagent', 'branch', 'automation'].includes(tool.name), ) if (!usesDefinitions) return undefined - const discovered = await discover(host, input.agent.cwd) + const discovered = await models.discover(input.agent.cwd ?? '/src/sausage') const definitions = discovered.definitions .map((agent) => { const runs = runsLabel(agent.runs) @@ -614,7 +614,7 @@ export default function subagents(host: any) { const definitions = args.action === 'start' && !existingAction ? ( - await discover(host, parent.cwd) + await models.discover(parent.cwd ?? '/src/sausage') ).definitions : undefined const definition = definitions && (await spawnDefinition(args, definitions)) diff --git a/host/js/subagents-test-bundle.js b/host/js/subagents-test-bundle.js index 5892f5d..5904a8f 100644 --- a/host/js/subagents-test-bundle.js +++ b/host/js/subagents-test-bundle.js @@ -31809,6 +31809,28 @@ try { assert(!(await harness.snapshot(LiveDoc, third.conversationId, BACKGROUND_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" } })); + await call({ action: "start", agent: "worker", task: "family one" }); + const familyChild = (await children()).at(-1); + const firstAgent = await (await harness.conversation(familyChild.conversationId, BACKGROUND_CONTEXT)).agent(BACKGROUND_CONTEXT); + assert(firstAgent.model?.modelId === "faux-1" && firstAgent.thinkingLevel === "low", "Worker must resolve fast family"); + assert(firstAgent.tools.map((tool) => 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"); + const provider2 = models.getProvider("faux"), template = models.getModels("faux")[0]; + models.setProvider({ ...provider2, 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" } })); + await call({ action: "start", agent: "worker", task: "family two" }); + const secondFamilyChild = (await children()).at(-1); + const secondAgent = await (await harness.conversation(secondFamilyChild.conversationId, BACKGROUND_CONTEXT)).agent(BACKGROUND_CONTEXT); + assert(secondAgent.model?.modelId === "second" && secondAgent.thinkingLevel === "high", "Next child must use edited family 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, BACKGROUND_CONTEXT)).agent(BACKGROUND_CONTEXT)).model?.modelId === "faux-1", "models.pin must apply to subagents"); } } finally { gate.resolve(); diff --git a/host/js/subagents-test.ts b/host/js/subagents-test.ts index 90e4659..7cff4c2 100644 --- a/host/js/subagents-test.ts +++ b/host/js/subagents-test.ts @@ -106,5 +106,26 @@ 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'}})); + 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'); + 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'}})); + 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'); + 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'); } } finally {gate.resolve();parentGate.resolve();await loader.close();await harness.close(context);} diff --git a/host/tests/subagents.rs b/host/tests/subagents.rs index feaf363..12a24f9 100644 --- a/host/tests/subagents.rs +++ b/host/tests/subagents.rs @@ -40,22 +40,31 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { target.join("wasm32-wasip2/release/git.wasm"), disk.join("bin/git.wasm"), )?; - for directory in ["extensions/subagents", "extensions/core", "agents"] { + for directory in [ + "extensions/subagents", + "extensions/models", + "extensions/core", + "agents", + ] { std::fs::create_dir_all(repo.join(directory))?; } - for entry in std::fs::read_dir(source.join("extensions/subagents"))? { - let entry = entry?; - if entry.path().extension().is_some_and(|ext| ext == "ts") { - std::fs::copy( - entry.path(), - repo.join("extensions/subagents").join(entry.file_name()), - )?; + for extension in ["subagents", "models"] { + for entry in std::fs::read_dir(source.join("extensions").join(extension))? { + let entry = entry?; + if entry.path().extension().is_some_and(|ext| ext == "ts") { + std::fs::copy( + entry.path(), + repo.join("extensions") + .join(extension) + .join(entry.file_name()), + )?; + } } } std::fs::create_dir_all(repo.join("extensions/probe"))?; std::fs::write( repo.join("extensions/probe/server.ts"), - "export default host => { globalThis.subagentService = host.need({name:'subagents'}); return {name:'probe'}; }", + "export default host => { globalThis.subagentService = host.need({name:'subagents'}); host.implement({name:'models.pin'}, {pin:async()=>globalThis.childPin}); return {name:'probe'}; }", )?; std::fs::copy( source.join("extensions/core/agents.ts"),