Files
sausage/extensions/subagents/server.ts
T

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, Standing, Profile, Runs, SubagentsApi } from './api.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) => {
const observation = harness.observeCommits({advance: callback, end: () => {}})
return () => observation.detach()
}
const Anchor = defineTask<null, { phase: 'done' }, null>({
name: 'standing.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: 'standing.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})),
standing: 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 standing 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(Standing, ROOT_CONVERSATION_ID)
const primary = name === found.standing
const key = primary ? '$standing' : name
const existing = index[key] && await tx.conversation(index[key])
const lifecycle = host.want({name:'conversations'})
if (existing) {
await lifecycle?.setStanding(tx, existing.id, true)
await lifecycle?.setAutomaticSummaries(tx, existing.id, false)
if (primary) for (const alias of Object.keys(index)) if (alias !== '$standing' && 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, standing:primary})
return existing.id
}
const child = await createChild(host, tx, {owner: {kind:'ownerless'}, definition, parentAgent, metadata:host.metadata, label:name, title:`@${name}`, standing:primary})
index[key] = child.id
index[name] = child.id
await lifecycle?.setStanding(tx, child.id, true)
await lifecycle?.setAutomaticSummaries(tx, child.id, false)
await started(host, tx, child, name, 'standing')
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(Standing, ROOT_CONVERSATION_ID)
for (const name of Object.keys(index)) if (name !== '$standing' && !found.definitions.some((d: any) => d.name === name && d.resident)) {
const id = index[name]
delete index[name]
if (id !== index['$standing'] && await tx.conversation(id)) {
delete (await tx.doc(host.metadata, id)).label
await host.want({name:'conversations'})?.setStanding(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 },
),
)
},
}),
],
})
}