Merge v3-t50: subagents use the models service, every test module in the bundle script (T50)

# Conflicts:
#	host/js/package.json
#	host/js/subagents-test-bundle.js
This commit is contained in:
2026-11-07 13:51:34 +00:00
9 changed files with 112 additions and 53 deletions
+3
View File
@@ -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.
+2 -1
View File
@@ -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'}
+16 -4
View File
@@ -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)=>{
+20 -12
View File
@@ -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',
}
}
-17
View File
@@ -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)
}
+10 -10
View File
@@ -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))
+22
View File
@@ -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();
+21
View File
@@ -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);}
+18 -9
View File
@@ -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"),