/** The `board.delivery` task: it places a message in its recipient, then returns the answer as a reply. */ import type { Context } from '@earendil-works/chord' import { awaitWithContext } from '@earendil-works/chord/context' import { AssistantEntry, InboxDoc, defineTask, ROOT_CONVERSATION_ID, } from '@earendil-works/pi-durable' import type { ConversationId } from '@earendil-works/pi-durable' import type { Host as ExtensionHost } from '../../host/js/extensions.ts' import { Standing, SubagentsApi } from '../subagents/api.ts' import { Messages } from './api.ts' import type { Message } from './api.ts' import { imagesOf } from './compat.ts' import { append, terminal } from './log.ts' export function delivery(host: ExtensionHost) { const refresh = async (id: number, ctx: Context) => { const index = await host.harness().snapshot(Standing, ROOT_CONVERSATION_ID, ctx) const name = Object.keys(index ?? {}).find((candidate) => index![candidate] === id) if (name === undefined) return const service = host.need(SubagentsApi) // A retired agent's conversation stays addressable by id until the next open prunes it. if ((await service.residents(ctx)).some((agent) => agent.name === name)) await service.standing(name, ctx) } return defineTask< { message: Message steer: boolean note: boolean fromLabel: string toLabel: string /** Another project's Ultimate answered `message`: deliver only this reply. */ reply?: { message: Message; failed: boolean; stopped?: boolean } }, | { phase: 'deliver' } /** `stopped`: the recipient's run was stopped before it answered; `failed` is set too, as for any non-answer. */ | { phase: 'reply'; message: Message; failed: boolean; stopped?: boolean }, null >({ name: 'board.delivery', version: 1, initial: (input) => (input.reply ? { phase: 'reply', ...input.reply } : { phase: 'deliver' }), phases: { deliver: async (task, runtime, ctx) => { const m = task.input.message if (typeof m.to !== 'number') throw new Error('Only conversations take a delivery') await refresh(m.to, ctx) const recipient = await host.harness().conversation(m.to as ConversationId, ctx) if (!recipient) throw new Error(`Unknown conversation #${m.to}`) // Entries never change, so a replayed delivery attaches the same images. const images = m.images ? imagesOf(await host.harness().commit((tx) => tx.entry(m.images!.entry), ctx)) : [] const entry = { kind: 'board.message', data: { id: m.id, from: m.from, text: m.text, reply: m.reply, // The question an answer answers, kept with the entry: the log it is in drops old messages. ...(m.re !== undefined ? { re: m.re } : {}), ...(m.routed !== undefined ? { routed: true } : {}), ...(m.echo ? { echo: true } : {}), delivery: task.input.note ? 'note' : task.input.steer ? 'steer' : 'followUp', ...(images.length ? { images: images.length } : {}), }, } const body = task.input.note ? `Note from ${task.input.fromLabel} (one-way update, no answer goes back):\n${m.text}` : `Message from ${task.input.fromLabel}:\n${m.text}${m.reply ? `\nAnswer here; your answer is sent to ${task.input.fromLabel} automatically. Do not use message to reply.` : `\nOne-way message: ${task.input.fromLabel} expects no answer, and your answer here is not sent to it. If it needs to hear from you, message it.`}` const content = images.length ? [{ type: 'text' as const, text: body }, ...images] : body const requestId = `board:${m.id}` const placing = task.input.note ? recipient.submit( { type: 'write', requestId, entry: { ...entry, model: [{ role: 'user', content, timestamp: Date.now() }] }, }, ctx, ) : recipient.submit( { type: 'input', requestId, whenBusy: task.input.steer ? 'steer' : 'followUp', entry, content, }, ctx, ) // A recipient's input hook can refuse the input; its placement rolled back, so the message failed, with why. const receipt = await placing.catch(async (cause: unknown) => { if (runtime.signal.aborted) throw cause const error = `Not delivered to ${task.input.toLabel}: ${cause instanceof Error ? cause.message : String(cause)}` await runtime.commit(async (tx) => { const stored = (await tx.doc(Messages, ROOT_CONVERSATION_ID)).messages.find( (item) => item.id === m.id, ) if (stored) { stored.state = 'failed' stored.error = error } return terminal }, ctx) return undefined }) if (!receipt) return // Admission can precede placement while a recipient is busy. Watch its durable queue, // not a timer, so messages become delivered only after leaving the queue. const inbox = await runtime.watchDoc(InboxDoc, recipient.id, ctx) if (inbox) { let wake!: () => void const placed = new Promise((done) => { wake = done }) const inspect = (value: typeof inbox.value) => { if (!value?.items.some((item) => item.id === receipt.id)) wake() } inbox.start(async (call) => { if (call.kind === 'value') inspect(call.value) }) try { inspect(inbox.value) await awaitWithContext(placed, ctx) } finally { await inbox.stop() } } const placement = await receipt.status(ctx) await runtime.commit(async (tx) => { const stored = (await tx.doc(Messages, ROOT_CONVERSATION_ID)).messages.find( (item) => item.id === m.id, ) // A recipient that answered with a message before this commit has moved it past delivered already. if (stored?.state === 'sent') { // A queued input withdrawn by a Stop or a cancel was stopped, not failed. stored.state = placement.status !== 'unanswered' ? 'delivered' : placement.reason === 'aborted' ? 'stopped' : 'failed' if ('entry' in placement && placement.entry !== undefined) stored.entry = placement.entry } if (task.input.note) return terminal return undefined }, ctx) if (task.input.note) return const settled = await receipt.wait(ctx) await runtime.commit(async (tx) => { const answer = settled.status === 'done' && settled.type === 'input' ? settled.answer : undefined const original = (await tx.doc(Messages, ROOT_CONVERSATION_ID)).messages.find( (item) => item.id === m.id, ) if (!m.reply) { if (original && answer !== undefined) original.answer = answer return terminal } // The recipient answered with a message; its final answer stays where it is. if (original?.state === 'answered') return terminal const model = answer === undefined ? undefined : (await tx.entry(AssistantEntry, answer))?.model?.[0] const text = model?.role === 'assistant' ? model.content.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('') : '' const failed = !text.trim() || model?.role !== 'assistant' || model.stopReason === 'error' || model.stopReason === 'aborted' // A Stop (the run or its queued input aborted) is not a crash: the sender is told it was stopped. const stopped = failed && ((settled.status === 'unanswered' && settled.reason === 'aborted') || (model?.role === 'assistant' && model.stopReason === 'aborted')) const error = stopped ? 'was stopped by the user before it answered' : failed ? `ended its turn without an answer (${settled.status === 'unanswered' ? settled.reason : settled.status})` : undefined const state = stopped ? 'stopped' : failed ? 'failed' : undefined if (original) { original.state = state ?? 'answered' if (answer !== undefined) original.answer = answer if (error !== undefined) original.error = error if ('entry' in settled && settled.entry !== undefined) original.entry = settled.entry } const response = await append(tx, { from: m.to, to: m.from, text: error ?? text, reply: false, re: m.id, state: state ?? 'sent', ...(answer ? { answer } : {}), ...(error ? { error } : {}), }) return { status: 'running', checkpoint: { phase: 'reply', message: response, failed, ...(stopped ? { stopped } : {}), }, } }, ctx) }, reply: async (task, runtime, ctx) => { const { message: m, failed, stopped } = task.state.checkpoint if (m.to !== 'user') { await refresh(m.to, ctx) const sender = await runtime.conversation(m.to as ConversationId, ctx) // Entries never change, so a replayed reply attaches the same images. const images = m.images ? imagesOf(await host.harness().commit((tx) => tx.entry(m.images!.entry), ctx)) : [] const entry = { kind: 'board.reply', data: { id: m.id, re: m.re!, from: m.from, text: m.text, failed, ...(stopped ? { stopped } : {}), ...(images.length ? { images: images.length } : {}), }, } const body = `Reply from ${task.input.toLabel} to your message ${m.re}${stopped ? ' (stopped)' : failed ? ' (failed)' : ''}:\n${m.text}` const content = images.length ? [{ type: 'text' as const, text: body }, ...images] : body await sender?.submit( { type: 'input', requestId: `board-reply:${m.id}`, whenBusy: 'steer', entry, content, }, ctx, ) } await runtime.commit(async (tx) => { const stored = (await tx.doc(Messages, ROOT_CONVERSATION_ID)).messages.find( (item) => item.id === m.id, ) if (stored && !failed) stored.state = 'delivered' return { status: 'terminal', outcome: { status: 'completed', result: null }, } }, ctx) }, }, abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx), }) }