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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user