859 lines
38 KiB
TypeScript
859 lines
38 KiB
TypeScript
/** Durable child coordination. Reporter delivery and report are separate, replay-safe phases. */
|
|
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, 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, MainAgent, Profile, Runs, SubagentsApi } from './api.ts'
|
|
import { createChild, configureChild, started } from './children.ts'
|
|
import { ModelsApi } from '../models/api.ts'
|
|
export const docs = [Subagents, MainAgent, Profile, Runs]
|
|
export const runsLabel = (runs: any) => runs ? `${runs.model.provider}/${runs.model.modelId}${runs.thinking ? ` · ${runs.thinking}` : ''}` : ''
|
|
const onCommit = (harness: any, callback: any) => {
|
|
const observation = harness.observeCommits({advance: callback, end: () => {}})
|
|
return () => observation.detach()
|
|
}
|
|
const Anchor = defineTask<null, { phase: 'done' }, null>({
|
|
name: 'main-agent.subagent-anchor',
|
|
version: 1,
|
|
initial: () => ({ phase: 'done' }),
|
|
phases: {
|
|
done: (_task, runtime, context) =>
|
|
runtime.commit(
|
|
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
|
context,
|
|
),
|
|
},
|
|
abort: (_task, runtime, context) =>
|
|
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context),
|
|
})
|
|
const textOf = (message: AssistantMessage | undefined) =>
|
|
(message?.content ?? []).flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('')
|
|
// A report is keyed by its answer, independently of the tool call that may return it.
|
|
const answerRequest = (answer: EntryId) => `subagent-answer:${answer}`
|
|
/** An aborted or failed turn keeps its partial message, often only an empty thinking block: not an answer. */
|
|
const answered = (message: AssistantMessage | undefined) =>
|
|
message !== undefined &&
|
|
message.stopReason !== 'aborted' &&
|
|
message.stopReason !== 'error' &&
|
|
textOf(message).trim() !== ''
|
|
const unanswered = (message: AssistantMessage | undefined) =>
|
|
`ended its turn without an answer${message?.stopReason === 'aborted' || message?.stopReason === 'error' ? ` (${message.stopReason})` : ''}`
|
|
const answerText = (message: AssistantMessage | undefined) =>
|
|
`Returned model: ${message?.provider && message?.model ? `${message.provider}/${message.model}` : 'unknown'}\n${textOf(message)}`
|
|
const reportText = (id: ConversationId, agent: string, message: AssistantMessage | undefined) =>
|
|
answered(message)
|
|
? `[subagent ${id} (${agent}) answered] ${answerText(message)}`
|
|
: `[subagent ${id} failed: ${unanswered(message)}]`
|
|
/** What a foreground start, message or wait returns for a child's answer entry. */
|
|
const replyText = (id: ConversationId, message: AssistantMessage | undefined) =>
|
|
answered(message)
|
|
? answerText(message)
|
|
: `Subagent ${id} ${unanswered(message)}. Message it to continue.`
|
|
function createReporter() {
|
|
return defineTask<
|
|
{
|
|
conversationId: ConversationId
|
|
text: string
|
|
steer: boolean
|
|
sourceEntry?: EntryId
|
|
answer?: EntryId
|
|
},
|
|
{ phase: 'deliver' } | { phase: 'report'; report?: string; answer?: EntryId },
|
|
null
|
|
>({
|
|
name: 'main-agent.subagent-reporter',
|
|
version: 1,
|
|
initial: (input) =>
|
|
input.answer === undefined
|
|
? { phase: 'deliver' }
|
|
: { phase: 'report', report: input.text, answer: input.answer },
|
|
phases: {
|
|
deliver: async (task, runtime, context) => {
|
|
try {
|
|
const child = (await runtime.conversation(task.input.conversationId, context))!
|
|
const content = task.input.text
|
|
const settled = await (
|
|
await child.submit(
|
|
{
|
|
type: 'input',
|
|
content,
|
|
entry: {
|
|
kind: 'subagents.instruction',
|
|
data: {
|
|
sender: runtime.conversationId,
|
|
kind: 'instruction',
|
|
agent: (await runtime.snapshot(Subagents, runtime.conversationId, context))!
|
|
.agents[String(child.id)].agent,
|
|
body: content,
|
|
...(task.input.sourceEntry ? { entry: task.input.sourceEntry } : {}),
|
|
},
|
|
},
|
|
whenBusy: task.input.steer ? 'steer' : 'followUp',
|
|
requestId: `subagent:${task.id}`,
|
|
},
|
|
context,
|
|
)
|
|
).wait(context)
|
|
await runtime.commit(async (tx) => {
|
|
const state = await tx.doc(Subagents, runtime.conversationId)
|
|
const action = Object.values(state.actions).find(
|
|
(candidate) => candidate.reporter === task.id,
|
|
)!
|
|
action.status = settled.status
|
|
const agent = state.agents[String(child.id)]
|
|
if (settled.status === 'done' && settled.type === 'input') {
|
|
action.answer = settled.answer
|
|
if (agent.reported.includes(settled.answer))
|
|
return { status: 'running', checkpoint: { phase: 'report' } }
|
|
agent.reported.push(settled.answer)
|
|
const answer = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0] as
|
|
| AssistantMessage
|
|
| undefined
|
|
return {
|
|
status: 'running',
|
|
checkpoint: {
|
|
phase: 'report',
|
|
report: reportText(child.id, agent.agent, answer),
|
|
answer: settled.answer,
|
|
},
|
|
}
|
|
}
|
|
// Provider failures belong to the watchdog, which marks the action `given-up` once it reports
|
|
// instead of retrying. Either way recover() delivers the answer of a later watchdog retry (or
|
|
// manual continuation).
|
|
if (
|
|
settled.status === 'unanswered' &&
|
|
(settled.reason === 'model_error' || settled.reason === 'no_model')
|
|
) {
|
|
action.status = 'failed'
|
|
return { status: 'running', checkpoint: { phase: 'report' } }
|
|
}
|
|
if (settled.status === 'unanswered' && settled.reason !== 'aborted') {
|
|
const failure = settled.reason
|
|
return { status: 'running', checkpoint: { phase: 'report', report: failure } }
|
|
}
|
|
// A parent stop aborts this task before the input, so this abort came from the child's
|
|
// own conversation: whatever answers its next input there still belongs to the parent.
|
|
if (settled.status === 'unanswered') action.status = 'interrupted'
|
|
return { status: 'running', checkpoint: { phase: 'report' } }
|
|
}, context)
|
|
} catch (error) {
|
|
if (runtime.signal.aborted) throw error
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
await runtime.commit(async (tx) => {
|
|
const state = await tx.doc(Subagents, runtime.conversationId)
|
|
const action = Object.values(state.actions).find(
|
|
(candidate) => candidate.reporter === task.id,
|
|
)!
|
|
action.status = 'failed'
|
|
return {
|
|
status: 'running',
|
|
checkpoint: {
|
|
phase: 'report',
|
|
report: message,
|
|
},
|
|
}
|
|
}, context)
|
|
}
|
|
},
|
|
report: async (task, runtime, context) => {
|
|
const { report, answer } = task.state.checkpoint
|
|
if (report !== undefined) {
|
|
const parent = (await runtime.conversation(runtime.conversationId, context))!
|
|
const agent = (await runtime.snapshot(Subagents, runtime.conversationId, context))!
|
|
.agents[String(task.input.conversationId)].agent
|
|
let message: AssistantMessage | undefined
|
|
if (answer !== undefined)
|
|
await runtime.commit(async (tx) => {
|
|
message = (await tx.entry(answer))?.model?.[0] as AssistantMessage | undefined
|
|
}, context)
|
|
const failed = !answered(message)
|
|
const body =
|
|
answer === undefined
|
|
? `Failed: ${report}`
|
|
: failed
|
|
? `Failed: ${unanswered(message)}`
|
|
: answerText(message)
|
|
await parent.submit(
|
|
{
|
|
type: 'input',
|
|
content:
|
|
answer === undefined
|
|
? `[subagent ${task.input.conversationId} failed: ${report}]`
|
|
: report,
|
|
entry: {
|
|
kind: 'subagents.report',
|
|
data: {
|
|
sender: task.input.conversationId,
|
|
kind: 'report',
|
|
agent,
|
|
failed,
|
|
body,
|
|
...(answer ? { entry: answer } : {}),
|
|
},
|
|
},
|
|
whenBusy: 'steer',
|
|
requestId:
|
|
answer === undefined ? `subagent-report:${task.id}` : answerRequest(answer),
|
|
},
|
|
context,
|
|
)
|
|
}
|
|
await runtime.commit(
|
|
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
|
context,
|
|
)
|
|
},
|
|
},
|
|
abort: (task, runtime, context) =>
|
|
runtime.commit(async (tx) => {
|
|
const state = await tx.doc(Subagents, runtime.conversationId)
|
|
const action = Object.values(state.actions).find(
|
|
(candidate) => candidate.reporter === task.id,
|
|
)!
|
|
action.status = 'unanswered'
|
|
return { status: 'terminal', outcome: { status: 'aborted' } }
|
|
}, context),
|
|
})
|
|
}
|
|
|
|
/** Details name the child and whether it answers in the background, for the call's renderer. */
|
|
const details = (
|
|
conversationId: ConversationId,
|
|
background: boolean,
|
|
delivery?: { yielded?: true; answerId?: EntryId },
|
|
) => ({
|
|
conversationId,
|
|
...(background ? { background } : {}),
|
|
...delivery,
|
|
})
|
|
const reply = (text: string, info?: ReturnType<typeof details>) => ({
|
|
output: [{ type: 'text' as const, text }],
|
|
...(info === undefined ? {} : { details: info }),
|
|
})
|
|
|
|
/** Stop only these local waits; child and reporter ownership never changes. */
|
|
async function waitInterruptibly(
|
|
api: ToolExecutionApi,
|
|
id: ConversationId,
|
|
reporters: TaskId[],
|
|
idle: boolean,
|
|
context: Context,
|
|
) {
|
|
await api.commit(async (tx) => {
|
|
await tx.doc(InboxDoc, api.conversationId)
|
|
}, context)
|
|
const local = withCancel(context)
|
|
const inbox = (await api.watchDoc(InboxDoc, api.conversationId, local.context))!
|
|
let wake!: () => void
|
|
const steering = new Promise<void>((resolve) => {
|
|
wake = resolve
|
|
})
|
|
const inspect = (value: typeof inbox.value) => {
|
|
if (value?.items.some((item) => item.mode === 'steer')) wake()
|
|
}
|
|
inbox.start(async (call) => {
|
|
if (call.kind === 'value') inspect(call.value)
|
|
})
|
|
inspect(inbox.value)
|
|
const child = (await api.conversation(id, context))!
|
|
const ready = async () => {
|
|
if (idle && (await api.snapshot(LiveDoc, id, context))?.run !== undefined) return false
|
|
for (const reporter of reporters) {
|
|
const task = await api.getTask(reporter, context)
|
|
if (
|
|
task?.state.status !== 'terminal' &&
|
|
!(
|
|
task?.state.status === 'running' &&
|
|
(task.state.checkpoint as { phase?: string } | null)?.phase === 'report'
|
|
)
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
const completion = async () => {
|
|
for (const reporter of reporters) await api.waitForTask(reporter, local.context)
|
|
if (idle) await child.waitForIdle(local.context)
|
|
}
|
|
try {
|
|
const result = await Promise.race([
|
|
completion().then(() => true),
|
|
steering.then(async () => {
|
|
// Our reporter may have just queued the steer. Prefer its completed answer.
|
|
if (await ready()) {
|
|
await completion()
|
|
return true
|
|
}
|
|
return false
|
|
}),
|
|
])
|
|
return result
|
|
} finally {
|
|
local.cancel()
|
|
await inbox.stop()
|
|
}
|
|
}
|
|
|
|
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 models.discover('/src/sausage')).definitions.filter((d: any) => d.resident).map(({name, description}: any) => ({name, description})),
|
|
mainAgent: async (name: string, ctx: Context) => {
|
|
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 main agent @${name}`)
|
|
const root = (await host.harness().conversation(ROOT_CONVERSATION_ID, ctx))!
|
|
const parentAgent = await root.agent(ctx)
|
|
return host.harness().commit(async (tx: any) => {
|
|
const index = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
|
|
const primary = name === found.main_agent_name
|
|
const key = primary ? '$main-agent' : name
|
|
const existing = index[key] && await tx.conversation(index[key])
|
|
const lifecycle = host.want({name:'conversations'})
|
|
if (existing) {
|
|
await lifecycle?.setMainAgent(tx, existing.id, true)
|
|
await lifecycle?.setAutomaticSummaries(tx, existing.id, false)
|
|
if (primary) for (const alias of Object.keys(index)) if (alias !== '$main-agent' && index[alias] === existing.id) delete index[alias]
|
|
index[name] = existing.id
|
|
const metadata = await tx.doc(host.metadata, existing.id)
|
|
if (metadata.label !== name) Object.assign(metadata, {label:name, title:`@${name}`})
|
|
await configureChild(host, tx, existing.id, {definition, parentAgent, mainAgent:primary})
|
|
return existing.id
|
|
}
|
|
const child = await createChild(host, tx, {owner: {kind:'ownerless'}, definition, parentAgent, metadata:host.metadata, label:name, title:`@${name}`, mainAgent:primary})
|
|
index[key] = child.id
|
|
index[name] = child.id
|
|
await lifecycle?.setMainAgent(tx, child.id, true)
|
|
await lifecycle?.setAutomaticSummaries(tx, child.id, false)
|
|
await started(host, tx, child, name, 'mainAgent')
|
|
return child.id
|
|
}, ctx)
|
|
},
|
|
})
|
|
const spawnDefinition = async (args: any, definitions: any[]) => {
|
|
const definition = definitions.find(d => d.name === args.agent)
|
|
if (!definition) throw new Error(`Unknown agent: ${args.agent}. Available definitions: ${definitions.map(d => d.name).join(', ')}`)
|
|
if ((args.isolate ?? definition.isolate ?? 'none') === 'worktree' && !host.want({name:'workspaces'})) throw new Error('worktree isolation requires the workspaces extension (P3-k)')
|
|
return definition
|
|
}
|
|
const Reporter = createReporter()
|
|
let unsubscribe: (() => void) | undefined
|
|
let recovery = Promise.resolve()
|
|
const recover = async (submission: SubmissionRecord) => {
|
|
if (
|
|
submission.type !== 'input' ||
|
|
submission.status !== 'done' ||
|
|
submission.requestId?.startsWith('subagent:')
|
|
)
|
|
return
|
|
await host.harness().commit(async (tx) => {
|
|
const child = await tx.conversation(submission.conversationId)
|
|
const parent = child?.owner?.conversationId
|
|
if (parent === undefined) return
|
|
const state = await tx.doc(Subagents, parent)
|
|
const agent = state.agents[String(submission.conversationId)]
|
|
if (!agent || agent.reported.includes(submission.answer)) return
|
|
const childActions = Object.values(state.actions).filter(
|
|
(candidate) => candidate.conversationId === submission.conversationId,
|
|
)
|
|
// A one-way message from the parent, or its reply to the child's own question, has no
|
|
// reporter and no reply: without this, the answer it starts never reaches the parent,
|
|
// which stays idle. A pending reporter whose input was answered in the same run delivers
|
|
// that answer itself.
|
|
const input = await tx.entry(submission.entry)
|
|
const message = input?.data as { from?: unknown; reply?: unknown } | undefined
|
|
let messaged =
|
|
message?.from === parent &&
|
|
((input?.kind === 'board.message' && message.reply === false) ||
|
|
input?.kind === 'board.reply')
|
|
for (const action of childActions) {
|
|
if (!messaged || action.status !== undefined) continue
|
|
const sibling = await tx.submissionByRequest(
|
|
submission.conversationId,
|
|
`subagent:${action.reporter}`,
|
|
)
|
|
if (sibling?.status === 'done' && sibling.answer === submission.answer) messaged = false
|
|
}
|
|
// Watchdog continuations also answer failed one-way messages, which have no reporter action.
|
|
if (!messaged && !submission.requestId?.startsWith('watchdog-retry:')) {
|
|
const action = childActions.at(-1)
|
|
const interrupted = action?.status === 'interrupted'
|
|
if (
|
|
!action ||
|
|
(!['unanswered', 'failed', 'given-up'].includes(action.status ?? '') && !interrupted)
|
|
)
|
|
return
|
|
const failed = await tx.submissionByRequest(
|
|
submission.conversationId,
|
|
`subagent:${action.reporter}`,
|
|
)
|
|
// IDs share one allocation order; an input withdrawn before placement has no entry.
|
|
if (
|
|
failed?.status !== 'unanswered' ||
|
|
(failed.reason === 'aborted') !== interrupted ||
|
|
(failed.entry === undefined && !interrupted) ||
|
|
submission.entry <= (failed.entry ?? failed.id)
|
|
)
|
|
return
|
|
}
|
|
const answer = (await tx.entry(AssistantEntry, submission.answer))?.model?.[0] as
|
|
| AssistantMessage
|
|
| undefined
|
|
const reporter = await tx.createTask(
|
|
Reporter,
|
|
{
|
|
conversationId: submission.conversationId,
|
|
text: reportText(submission.conversationId, agent.agent, answer),
|
|
steer: false,
|
|
answer: submission.answer,
|
|
},
|
|
{ conversationId: parent, ownership: { kind: 'conversation' }, background: true },
|
|
)
|
|
agent.reported.push(submission.answer)
|
|
state.actions[`subagent-answer:${submission.answer}`] = {
|
|
conversationId: submission.conversationId,
|
|
reporter,
|
|
answer: submission.answer,
|
|
status: 'done',
|
|
}
|
|
}, BACKGROUND_CONTEXT)
|
|
}
|
|
host.onClose(async () => {
|
|
unsubscribe?.()
|
|
unsubscribe = undefined
|
|
await recovery
|
|
})
|
|
host.onOpen(async () => {
|
|
const found = await models.discover('/src/sausage')
|
|
if (!found.errors.length) await host.harness().commit(async (tx: any) => {
|
|
const index = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
|
|
for (const name of Object.keys(index)) if (name !== '$main-agent' && !found.definitions.some((d: any) => d.name === name && d.resident)) {
|
|
const id = index[name]
|
|
delete index[name]
|
|
if (id !== index['$main-agent'] && await tx.conversation(id)) {
|
|
delete (await tx.doc(host.metadata, id)).label
|
|
await host.want({name:'conversations'})?.setMainAgent(tx, id, false)
|
|
}
|
|
}
|
|
}, BACKGROUND_CONTEXT)
|
|
unsubscribe?.()
|
|
// Not a reactor: subagent answers settle as Pi submission records, not domain events.
|
|
unsubscribe = onCommit(host.harness(), (publication) => {
|
|
for (const change of publication.changes) {
|
|
if (change.type !== 'submission') continue
|
|
recovery = recovery
|
|
.then(() => recover(change.value))
|
|
.catch((error: unknown) => {
|
|
host.notice(`Subagent answer recovery failed: ${String(error)}`)
|
|
})
|
|
}
|
|
})
|
|
let cursor: Cursor | undefined
|
|
do {
|
|
const page = await host.storage.scanSubmissions({ status: 'done' }, 500, cursor, BACKGROUND_CONTEXT)
|
|
for (const submission of page.items) await recover(submission)
|
|
cursor = page.next
|
|
} while (cursor)
|
|
})
|
|
return defineExtension({
|
|
name: 'subagents',
|
|
tasks: [Anchor, Reporter],
|
|
hooks: [
|
|
hook(GenerationTask, {
|
|
afterTools: async (_assistant, results, api, context) => {
|
|
// Tool settlement is not a receipt. Only committed outer results may acknowledge.
|
|
await host.harness().commit(async (tx) => {
|
|
const answers = new Set<EntryId>()
|
|
for (const id of results) {
|
|
const message = (await tx.entry(ToolResultEntry, id))?.model?.[0]
|
|
if (message?.role !== 'toolResult' || message.isError) continue
|
|
const info = message.details as
|
|
| { answerId?: EntryId; subagentAnswers?: EntryId[] }
|
|
| undefined
|
|
if (message.toolName === 'subagent' && info?.answerId !== undefined)
|
|
answers.add(info.answerId)
|
|
if (message.toolName === 'codemode')
|
|
for (const answer of info?.subagentAnswers ?? []) answers.add(answer)
|
|
}
|
|
if (!answers.size) return
|
|
const submissions = []
|
|
for (const answer of answers)
|
|
submissions.push({
|
|
answer,
|
|
submission: await tx.submissionByRequest(api.conversationId, answerRequest(answer)),
|
|
})
|
|
const queued = submissions.flatMap(({ submission }) =>
|
|
submission?.status === 'queued' ? [submission.id] : [],
|
|
)
|
|
const inbox = await tx.doc(InboxDoc, api.conversationId)
|
|
inbox.items = inbox.items.filter((item) => !queued.includes(item.id))
|
|
for (const { answer, submission } of submissions) {
|
|
// A reporter not yet submitted must also see this receipt through request deduplication.
|
|
if (!submission)
|
|
await tx.createSubmission({
|
|
conversationId: api.conversationId,
|
|
requestId: answerRequest(answer),
|
|
type: 'input',
|
|
status: 'unanswered',
|
|
reason: 'subagent-tool-delivered',
|
|
})
|
|
else if (submission.status === 'queued')
|
|
tx.settleSubmission(submission.id, {
|
|
status: 'unanswered',
|
|
reason: 'subagent-tool-delivered',
|
|
})
|
|
}
|
|
}, context)
|
|
},
|
|
}),
|
|
],
|
|
sections: [
|
|
section('subagent_state', async (input, context) => {
|
|
const state = await input.read.snapshot(Subagents, input.conversationId, context)
|
|
const running: string[] = []
|
|
const pending = new Set<ConversationId>()
|
|
for (const action of Object.values(state?.actions ?? {}))
|
|
if (
|
|
action.status === undefined &&
|
|
(await host.harness().getTask(action.reporter, context))?.state.status !== 'terminal'
|
|
)
|
|
pending.add(action.conversationId)
|
|
const queued: string[] = []
|
|
for (const agent of Object.values(state?.agents ?? {})) {
|
|
const label = `${agent.conversationId} (${agent.agent})`
|
|
if (
|
|
(await input.read.snapshot(LiveDoc, agent.conversationId, context))?.run !== undefined
|
|
)
|
|
running.push(label)
|
|
else if (pending.has(agent.conversationId)) queued.push(label)
|
|
}
|
|
if (!running.length && !queued.length) return undefined
|
|
return [
|
|
...(running.length ? [`Still running subagents: ${running.join(', ')}.`] : []),
|
|
...(queued.length ? [`Pending subagent inputs: ${queued.join(', ')}.`] : []),
|
|
'A yielded wait is not a child answer. Use message, stop, or wait with these IDs; parent stop does not stop them. Queued inputs are delivered only at the normal boundary; do not act on them early.',
|
|
].join(' ')
|
|
}),
|
|
section('subagents', async (input) => {
|
|
const usesDefinitions = input.agent.tools.some((tool) =>
|
|
['subagent', 'branch', 'automation'].includes(tool.name),
|
|
)
|
|
if (!usesDefinitions) return undefined
|
|
const discovered = await models.discover(input.agent.cwd ?? '/src/sausage')
|
|
const definitions = discovered.definitions
|
|
.map((agent) => {
|
|
const runs = runsLabel(agent.runs)
|
|
return `${agent.name}${runs ? ` (${runs})` : ''}: ${agent.description}`
|
|
})
|
|
.join('\n')
|
|
return `Available definitions:\n${definitions}`
|
|
}),
|
|
],
|
|
tools: [
|
|
defineTool({
|
|
name: 'subagent',
|
|
description:
|
|
'Delegate to a named definition. start returns an answer or a background child ID. message steers a busy child unless steer is false. wait returns its latest answer. Foreground waits yield on parent steering. stop ends current child work. list shows your children. Parent stop leaves children independent. fork inherits history before this call. Worktree isolation gives the child a checkout. land fast-forwards your checkout and releases the child checkout. discard removes a clean checkout and retains its branch. Background answers steer the parent exactly once.',
|
|
// Call-id receipts deduplicate starts/messages; memoized targets and guards protect stop/workspace replays.
|
|
replay: 'safe',
|
|
parameters: obj({
|
|
action: oneOf(['start', 'message', 'wait', 'stop', 'list', 'land', 'discard']),
|
|
agent: opt(
|
|
str({
|
|
description:
|
|
'For start: an Available definitions name.',
|
|
}),
|
|
),
|
|
label: opt(
|
|
str({
|
|
description:
|
|
'For start: an optional label for the child.',
|
|
}),
|
|
),
|
|
task: opt(str()),
|
|
background: opt(bool()),
|
|
fork: opt(bool()),
|
|
id: opt(int({ minimum: 1 })),
|
|
text: opt(str()),
|
|
steer: opt(bool()),
|
|
isolate: opt(oneOf(['none', 'worktree'])),
|
|
}),
|
|
execute: async (args, api, context) => {
|
|
const cached = await api.memo<ReturnType<typeof reply>>('result', context)
|
|
if (cached) return cached
|
|
const finish = (text: string, info?: ReturnType<typeof details>) =>
|
|
api.memo('result', reply(text, info), context)
|
|
const index = (await api.snapshot(Subagents, api.conversationId, context)) ?? {
|
|
agents: {},
|
|
actions: {},
|
|
}
|
|
if (args.action === 'list') {
|
|
const rows = await Promise.all(
|
|
Object.values(index.agents).map(async (agent) => ({
|
|
...agent,
|
|
busy:
|
|
(await api.snapshot(LiveDoc, agent.conversationId, context))?.run !== undefined,
|
|
})),
|
|
)
|
|
return finish(JSON.stringify(rows.map(({ reported: _reported, ...row }) => row)))
|
|
}
|
|
if (
|
|
args.action !== 'start' &&
|
|
(!args.id || !Object.hasOwn(index.agents, String(args.id)))
|
|
)
|
|
throw new Error('id must identify a child of this conversation')
|
|
if (args.action === 'land' || args.action === 'discard') {
|
|
const workspaces = host.need({name:'workspaces'})
|
|
const result = await workspaces[args.action](args.id, context)
|
|
if (args.action === 'land') await workspaces.release([args.id], context)
|
|
return finish(result.text)
|
|
}
|
|
if (args.action === 'start' || args.action === 'message') {
|
|
const text = args.action === 'start' ? args.task : args.text
|
|
if (!text?.trim())
|
|
throw new Error(
|
|
`${args.action} requires ${args.action === 'start' ? 'task' : 'text'}`,
|
|
)
|
|
const parent = await api.agent(context)
|
|
const existingAction = index.actions[api.callId]
|
|
const definitions =
|
|
args.action === 'start' && !existingAction
|
|
? (
|
|
await models.discover(parent.cwd ?? '/src/sausage')
|
|
).definitions
|
|
: undefined
|
|
const definition = definitions && (await spawnDefinition(args, definitions))
|
|
if (definition?.tools?.some((name) => !parent.tools.some((tool) => tool.name === name)))
|
|
throw new Error('Definition tools must be a subset of parent tools')
|
|
const label = definition && (args.label ?? definition.name)
|
|
const task = await api.getTask(api.taskId, context)
|
|
// Pi upstream: @earendil-works/pi-durable@1.5.0 src/harness/tool.ts — internal ToolTaskInput.assistant locates the calling assistant. Drop when ToolExecutionApi exposes the calling assistant entry ID.
|
|
const assistant = (task?.input as { assistant: EntryId } | undefined)?.assistant
|
|
let forkAt: EntryId | undefined
|
|
if (definition && args.fork) {
|
|
const conversation = (await host.harness().conversation(api.conversationId, context))!
|
|
const { entries } = await conversation.context(context)
|
|
forkAt = entries[entries.findIndex((entry) => entry.id === assistant) - 1]?.id
|
|
if (forkAt === undefined) throw new Error('No parent history before this call')
|
|
}
|
|
|
|
const workspaceChain = definition && (args.isolate ?? definition.isolate ?? 'none') === 'worktree'
|
|
? await host.need({name:'workspaces'}).chain(api.conversationId, context) : []
|
|
const action = await api.commit(async (tx) => {
|
|
const state = await tx.doc(Subagents, api.conversationId)
|
|
if (state.actions[api.callId]) return { ...state.actions[api.callId] }
|
|
let conversationId = args.id as ConversationId
|
|
if (definition) {
|
|
const owner = await tx.createTask(Anchor, null, {
|
|
ownership: { kind: 'conversation' },
|
|
background: true,
|
|
})
|
|
const child = await createChild(host, tx, {
|
|
owner: { kind: 'task', taskId: owner },
|
|
definition,
|
|
parentAgent: parent,
|
|
metadata: host.metadata,
|
|
label: label!,
|
|
title: text.trim().slice(0, 80),
|
|
excludeTools: definition.tools?.includes('subagent') ? [] : ['subagent'],
|
|
excludeExtensions: definition.tools?.includes('subagent') ? [] : ['subagents'],
|
|
...(forkAt === undefined
|
|
? {}
|
|
: { fork: { conversationId: api.conversationId, at: forkAt } }),
|
|
cwd: parent.cwd ?? '/src/sausage',
|
|
})
|
|
if ((args.isolate ?? definition.isolate ?? 'none') === 'worktree')
|
|
await host.need({name:'workspaces'}).create(tx, child.id, parent.cwd ?? '/src/sausage', workspaceChain)
|
|
conversationId = child.id
|
|
await started(host, tx, child, label!, 'child')
|
|
const runs = await tx.doc(Runs, ROOT_CONVERSATION_ID)
|
|
const { model: ran } = await tx.doc(AgentDoc, child.id)
|
|
runs[String(child.id)] = {
|
|
...(ran ? { model: ran.modelId } : {}),
|
|
}
|
|
state.agents[String(child.id)] = {
|
|
conversationId,
|
|
agent: label!,
|
|
background: args.background === true,
|
|
reported: [],
|
|
}
|
|
}
|
|
const reporter = await tx.createTask(
|
|
Reporter,
|
|
{
|
|
conversationId,
|
|
text,
|
|
steer: args.steer !== false,
|
|
...(assistant ? { sourceEntry: assistant } : {}),
|
|
},
|
|
{ ownership: { kind: 'conversation' }, background: true },
|
|
)
|
|
const createdAction = { conversationId, reporter }
|
|
state.actions[api.callId] = createdAction
|
|
return createdAction
|
|
}, context)
|
|
const isolateInfo = ''
|
|
const background =
|
|
index.agents[String(action.conversationId)]?.background ?? args.background === true
|
|
const info = details(action.conversationId, background)
|
|
await api.details(info, context)
|
|
if (background)
|
|
return finish(
|
|
`Started ${args.action === 'message' ? 'message to' : 'subagent'} ${action.conversationId}.${isolateInfo}`,
|
|
info,
|
|
)
|
|
if (
|
|
!(await waitInterruptibly(
|
|
api,
|
|
action.conversationId,
|
|
[action.reporter],
|
|
false,
|
|
context,
|
|
))
|
|
)
|
|
return finish(
|
|
`Wait yielded for subagent ${action.conversationId}; work continues.${isolateInfo}`,
|
|
details(action.conversationId, background, { yielded: true }),
|
|
)
|
|
const settled = (await api.snapshot(Subagents, api.conversationId, context))!.actions[
|
|
api.callId
|
|
]
|
|
const answer =
|
|
settled.answer === undefined
|
|
? undefined
|
|
: await api.commit((tx) => tx.entry(AssistantEntry, settled.answer!), context)
|
|
return finish(
|
|
(answer
|
|
? replyText(
|
|
action.conversationId,
|
|
answer.model?.[0] as AssistantMessage | undefined,
|
|
)
|
|
: `Subagent ${action.conversationId}: ${settled.status}`) + isolateInfo,
|
|
details(
|
|
action.conversationId,
|
|
background,
|
|
settled.answer === undefined ? undefined : { answerId: settled.answer },
|
|
),
|
|
)
|
|
}
|
|
const id = args.id as ConversationId
|
|
const info = details(id, index.agents[String(id)].background)
|
|
await api.details(info, context)
|
|
if (args.action === 'stop') {
|
|
// Capture identities before cancellation; replay must leave later reuse alone.
|
|
const live = await api.snapshot(LiveDoc, id, context)
|
|
const state = await api.snapshot(Subagents, api.conversationId, context)
|
|
const watchdogs = await api.commit(async (tx) => {
|
|
const tasks = []
|
|
let cursor: Cursor | undefined
|
|
do {
|
|
const page = await tx.scanTasks(
|
|
{ conversationId: api.conversationId, kind: 'watchdog.failure' },
|
|
500,
|
|
cursor,
|
|
)
|
|
tasks.push(
|
|
...page.items.filter(
|
|
(task) => (task.input as { conversation?: number }).conversation === id,
|
|
),
|
|
)
|
|
cursor = page.next
|
|
} while (cursor)
|
|
return tasks.map((task) => ({
|
|
task: task.id,
|
|
request: `watchdog-retry:${(task.input as { failedTask: number }).failedTask}`,
|
|
}))
|
|
}, context)
|
|
const target = await api.memo(
|
|
'stopTarget',
|
|
{
|
|
run: live?.run?.taskId ?? null,
|
|
inputs: live?.run?.inputs ?? [],
|
|
watchdogs,
|
|
reporters: Object.values(state?.actions ?? {}).flatMap((action) =>
|
|
action.conversationId === id ? [action.reporter] : [],
|
|
),
|
|
},
|
|
context,
|
|
)
|
|
// Join delivery invocations before inspecting admissions that may have raced stop.
|
|
// Capture watchdog identities too: Stop must not restart the child after its delay.
|
|
for (const reporter of [
|
|
...target.reporters,
|
|
...target.watchdogs.map((task) => task.task),
|
|
]) {
|
|
await host.harness().abortTask(reporter, context)
|
|
await api.waitForTask(reporter, context)
|
|
}
|
|
const submissions = await api.commit(async (tx) => {
|
|
return Promise.all(
|
|
[
|
|
...target.reporters.map((reporter) => `subagent:${reporter}`),
|
|
...target.watchdogs.map((task) => task.request),
|
|
].map((request) => tx.submissionByRequest(id, request)),
|
|
)
|
|
}, context)
|
|
for (const submission of submissions)
|
|
if (submission?.status === 'queued')
|
|
await host.harness().abortSubmission(submission.id, context, id)
|
|
const inputs = new Set([
|
|
...target.inputs,
|
|
...submissions.flatMap((submission) => (submission ? [submission.id] : [])),
|
|
])
|
|
// A tool round can hand off between the snapshot and abort; follow only captured inputs.
|
|
while (true) {
|
|
const run = (await api.snapshot(LiveDoc, id, context))?.run
|
|
if (
|
|
!run ||
|
|
(run.taskId !== target.run && !run.inputs.some((input) => inputs.has(input)))
|
|
)
|
|
break
|
|
await host.harness().abortTask(run.taskId, context)
|
|
await api.waitForTask(run.taskId, context)
|
|
}
|
|
return finish(`Stopped ${id}.`, info)
|
|
}
|
|
const reporters = Object.values(index.actions).flatMap((action) =>
|
|
action.conversationId === id ? [action.reporter] : [],
|
|
)
|
|
if (!(await waitInterruptibly(api, id, reporters, true, context)))
|
|
return finish(
|
|
`Wait yielded for subagent ${id}; work continues.`,
|
|
details(id, index.agents[String(id)].background, { yielded: true }),
|
|
)
|
|
const answer = await api.commit(async (tx) => {
|
|
let cursor: Cursor | undefined
|
|
let latest: EntryRecord | undefined
|
|
do {
|
|
const page = await tx.scanEntries({ conversationId: id }, 256, cursor)
|
|
for (const entry of page.items)
|
|
if (AssistantEntry.is(entry) && latest === undefined) latest = entry
|
|
cursor = page.next
|
|
} while (cursor !== undefined)
|
|
if (!latest) return undefined
|
|
const agent = (await tx.doc(Subagents, api.conversationId)).agents[String(id)]
|
|
if (!agent.reported.includes(latest.id)) agent.reported.push(latest.id)
|
|
return latest
|
|
}, context)
|
|
return finish(
|
|
replyText(id, answer?.model?.[0] as AssistantMessage | undefined),
|
|
details(
|
|
id,
|
|
index.agents[String(id)].background,
|
|
answer === undefined ? undefined : { answerId: answer.id },
|
|
),
|
|
)
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
}
|