Files
sausage/extensions/messages/delivery.ts
T

268 lines
11 KiB
TypeScript

/** 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 Standing agent 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<void>((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),
})
}