Files
sausage/extensions/goal/server.ts
T

368 lines
16 KiB
TypeScript

/**
* Hands-off goals: the user puts the main agent on a goal and goes away. While it is open, the main agent decides what it would
* otherwise ask them, records each decision with its reason, and ends the goal with a report. Only the user starts or
* ends a goal early; the main agent hears it on every run through a per-input note.
*/
import { arr, int, lit, obj, oneOf, opt, str, union } from '@earendil-works/pi-schema'
import {
defineDoc,
defineExtension,
defineTool,
GenerationTask,
hook,
ROOT_CONVERSATION_ID,
} from '@earendil-works/pi-durable'
import type { ConversationId, EntryId, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { implementAction } from '../goal/compat.ts'
import { imagesOf, latestImagesEntry } from '../messages/compat.ts'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import { MainAgent } from '../subagents/api.ts'
import { deliver, deliverTask } from './delivery.ts'
import { reminderTask } from './reminder.ts'
import { EndAction, GoalDoc, ImagesAction, StartAction } from './refs.ts'
import type { Criterion, Decision, Ending, Goal } from './refs.ts'
/** Failed tries after which a criterion is blocked; work goes on with the others. */
export const TRIES = 3
const TEXT_CHARS = 2000
export const Goals = defineDoc<{ next: number; goals: Goal[] }>({
kind: GoalDoc.kind,
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
history: 'latest',
fork: 'initial',
initial: () => ({ next: 1, goals: [] }),
})
export const docs = [Goals]
/** A plain copy: the document's overlay is a proxy that structuredClone refuses, and unusable after the commit. */
const copy = (goal: Goal) => JSON.parse(JSON.stringify(goal)) as Goal
const mainAgentOf = async (tx: Tx) => (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent']
/** The open goal, if any. */
export async function openGoal(tx: Tx) {
return (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((goal) => !goal.ended)
}
const text = (value: unknown, name: string) => {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be nonempty`)
if (value.trim().length > TEXT_CHARS)
throw new Error(`${name} must be at most ${TEXT_CHARS} characters`)
return value.trim()
}
/** Start a goal for the user's words `words`; refuses while another is open. */
export async function start(tx: Tx, words: unknown, now: number) {
const goals = await tx.doc(Goals, ROOT_CONVERSATION_ID)
const open = goals.goals.find((goal) => !goal.ended)
if (open) throw new Error(`Goal ${open.id} is still open; end it first`)
const goalText = text(words, 'The goal')
const goal: Goal = {
id: goals.next++,
text: goalText,
startedAt: now,
criteria: [],
decisions: [],
}
goals.goals.push(goal)
return goal
}
/** End the open goal; refuses when none is open. */
export async function end(tx: Tx, ending: Ending) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No goal is open')
goal.ended = ending
return copy(goal)
}
const status = (criterion: Criterion) =>
criterion.status === 'open' && criterion.tries
? `open, ${criterion.tries}/${TRIES} failed tries`
: criterion.status
/**
* the main agent's per-run note while a goal is open: the goal, its criteria, and how it works; on a fire of its reminder,
* what to check.
*/
export function noteText(goal: Goal, reminded = false) {
const criteria = goal.criteria.length
? goal.criteria.map((item, index) => `${index + 1}. [${status(item)}] ${item.text}`).join('\n')
: "None yet: write them first with goal {action: 'criteria', criteria}, each one you can check."
return [
`Hands-off goal ${goal.id}, granted by the user, who is away: ${goal.text}`,
`Done means:\n${criteria}`,
`Work toward it without the user: decide what you would ask them yourself (a go, a decision, a UI preview approval against the criteria), record each with goal {action: 'decide'} and its reason, and keep going. ${goal.decisions.length} decided so far. Mark criteria met or failed as you check them; a criterion is blocked after ${TRIES} failed tries, so learn from each failure before the next try. Finish with goal {action: 'finish'} when every criterion is met or every open one is blocked. Only when you are truly blocked on the user, goal {action: 'pause', reason} stops the reminder that wakes you.`,
...(goal.waiting
? [
`Waiting on the user since ${new Date(goal.waiting.at).toISOString()}: ${goal.waiting.reason}. The reminder is paused. Once they answer and you can go on, goal {action: 'resume'} turns it back on.`,
]
: []),
...(reminded
? [
"This is the goal reminder: check that the goal's branches are moving, the lander queue, reactor lag and watchdog cards. Nudge or fix what is stuck and record a decide only when you changed something; otherwise end the turn quietly.",
]
: []),
].join('\n\n')
}
/** The newest entry with images in this turn of conversation `id`, or entry `ref` when it has some. */
async function imagesEntry(tx: Tx, id: ConversationId, ref: 'latest' | number) {
if (ref !== 'latest') {
const entry = await tx.entry(ref as EntryId)
if (entry?.conversationId !== id || !imagesOf(entry).length)
throw new Error(`images: entry ${ref} in your conversation has no images`)
return ref
}
return (await latestImagesEntry(tx, id)).id as number
}
type ToolArgs = {
action: 'criteria' | 'decide' | 'met' | 'failed' | 'finish' | 'pause' | 'resume'
criteria?: string[]
n?: number
question?: string
picked?: string
reason?: string
conversation?: number
images?: 'latest' | number
outcome?: 'met' | 'blocked'
summary?: string
}
/** One `goal` tool call by the main agent (`mainAgent`) in `tx`; returns its answer, and the goal when it finished. */
export async function act(tx: Tx, mainAgent: ConversationId, args: ToolArgs, now: number) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No hands-off goal is open; ask the user as usual.')
const criterion = () => {
const found = goal.criteria[(args.n ?? 0) - 1]
if (!found) throw new Error(`No criterion ${String(args.n)}; there are ${goal.criteria.length}`)
return found
}
if (args.action === 'criteria') {
const items = (args.criteria ?? []).map((item) => text(item, 'A criterion'))
if (!items.length) throw new Error('criteria must list at least one')
// Rewording keeps what was learned about a criterion with the same text.
goal.criteria = items.map(
(item) =>
goal.criteria.find((old) => old.text === item) ?? { text: item, status: 'open' as const },
)
return { answer: `Goal ${goal.id} has ${items.length} criteria.` }
}
if (args.action === 'decide') {
const decision: Decision = {
at: now,
question: text(args.question, 'question'),
picked: text(args.picked, 'picked'),
reason: text(args.reason, 'reason'),
...(args.conversation === undefined ? {} : { conversation: args.conversation }),
...(args.images === undefined
? {}
: { images: await imagesEntry(tx, mainAgent, args.images) }),
}
goal.decisions.push(decision)
return { answer: `Recorded decision ${goal.decisions.length}: ${decision.picked}` }
}
if (args.action === 'met') {
criterion().status = 'met'
return { answer: `Criterion ${args.n} met.` }
}
if (args.action === 'failed') {
const found = criterion()
found.tries = (found.tries ?? 0) + 1
if (found.tries >= TRIES) found.status = 'blocked'
const open = goal.criteria.filter((item) => item.status === 'open').length
return {
answer:
found.status === 'blocked'
? `Criterion ${args.n} is blocked after ${TRIES} failed tries.${open ? ` Go on with the ${open} still open.` : ' None is open: finish the goal as blocked.'}`
: `Criterion ${args.n}: ${found.tries}/${TRIES} failed tries. Say what you change before the next try.`,
}
}
if (args.action === 'pause') {
goal.waiting = { reason: text(args.reason, 'reason'), at: now }
return {
answer: `Goal ${goal.id} waits on the user; the reminder is paused. Ask them now.`,
sync: goal.id,
}
}
if (args.action === 'resume') {
if (!goal.waiting) throw new Error(`Goal ${goal.id} is not paused`)
delete goal.waiting
return {
answer: `Goal ${goal.id} goes on; the reminder wakes you again.`,
sync: goal.id,
}
}
if (args.outcome !== 'met' && args.outcome !== 'blocked')
throw new Error("finish needs outcome 'met' or 'blocked'")
const ended = await end(tx, {
at: now,
by: 'mainAgent',
outcome: args.outcome,
summary: text(args.summary, 'summary'),
})
return {
answer: `Goal ${goal.id} finished: ${args.outcome}. The report is in your conversation.`,
ended,
}
}
/** The report card's model text: what the main agent (or the user, ending it) should take from it. */
const reportText = (goal: Goal) =>
goal.ended?.by === 'user'
? `The user ended hands-off goal ${goal.id}. From now on ask them as usual.`
: `Hands-off goal ${goal.id} ended (${goal.ended?.outcome}); its report card is here for the user. From now on ask them as usual.`
/**
* Goal `goal`'s card of `kind` for the main agent, delivered by a task created in `tx`, the commit that changed the goal.
* The start card says only that the user started it: what the main agent may do comes from the goal note, built from the
* goal as it is when the input runs, so a start still queued after the user ended the goal grants nothing.
*/
async function card(host: Host, tx: Tx, kind: 'goal.started' | 'goal.report', goal: Goal) {
const to = await mainAgentOf(tx)
if (to === undefined) throw new Error('the main agent has no conversation yet')
await deliver(host, tx, {
to,
goal: goal.id,
kind,
content:
kind === 'goal.started' ? `The user started hands-off goal ${goal.id}.` : reportText(goal),
})
}
/** `name` of an action's arguments; the helpers above validate it. */
const field = (args: unknown, name: string) =>
args && typeof args === 'object' ? (args as Record<string, unknown>)[name] : undefined
/** In the commit that changes goal `goal`: a task that brings its reminder in line once the change is durable. */
const sync = (host: Host, tx: Tx, goal: number) =>
tx.createTask(
reminderTask(host, Goals),
{ goal },
{
conversationId: ROOT_CONVERSATION_ID,
ownership: { kind: 'conversation' },
background: true,
},
)
/** Starting and ending a goal is the user's grant; the main agent only finishes one through its tool. */
export const userActions = ['start', 'end']
export const actions = {
start: implementAction(StartAction, (args, { host }) =>
host.harness().commit(async (tx) => {
const goal = await start(tx, field(args, 'text'), Date.now())
await card(host, tx, 'goal.started', goal)
await sync(host, tx, goal.id)
return copy(goal)
}, context),
),
end: implementAction(EndAction, (_args, { host }) =>
host.harness().commit(async (tx) => {
const goal = await end(tx, { at: Date.now(), by: 'user', outcome: 'ended' })
await card(host, tx, 'goal.report', goal)
await sync(host, tx, goal.id)
return goal
}, context),
),
images: implementAction(ImagesAction, async (args, { host }) =>
host.harness().commit(async (tx) => {
const goal = (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find(
(item) => item.id === field(args, 'goal'),
)
const entry = field(args, 'entry')
// Only the screenshots a decision names, not any entry by id.
if (!goal?.decisions.some((decision) => decision.images === entry))
throw new Error('No decision of that goal has these images')
return imagesOf(await tx.entry(entry as EntryId)).map(({ data, mimeType }) => ({
data,
mimeType,
}))
}, context),
),
}
export default function goalExtension(host: Host) {
// Optional: without automations a goal runs as before, with no reminder.
host.want(AutomationsApi)
return defineExtension({
name: 'goal',
tasks: [deliverTask(host), reminderTask(host, Goals)],
hooks: [
hook(GenerationTask, {
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
// A per-run note while a goal is open, never a system-prompt section, keeps the prompt cached.
beforeInput: async (input, { tx, conversationId }) => {
if ((await mainAgentOf(tx)) !== conversationId) return undefined
const goal = await openGoal(tx)
if (!goal) return undefined
// World automations use a plain input with their fire prefix.
const reminded = goal.reminder !== undefined && (
(input.kind === 'automations.fired' &&
(input.data as { id?: string } | undefined)?.id === goal.reminder) ||
input.model?.some((message) => message.role === 'user' &&
(typeof message.content === 'string' ? message.content :
message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n'))
.startsWith(`[automation ${goal.reminder} fired]`))
)
return {
entries: [
{
kind: 'goal.note',
data: { goal: goal.id },
model: [
{
role: 'user' as const,
content: noteText(goal, reminded),
timestamp: Date.now(),
},
],
},
],
}
},
}),
],
tools: [
defineTool({
name: 'goal',
deferred: true,
description:
"The hands-off goal the user put you on (only while one is open; the goal note names it): criteria {criteria: [...]} writes what done means, each checkable; decide {question, picked, reason, conversation?, images?} records a decision you took instead of asking the user (images: \"latest\" or an entry id of your conversation with screenshots you judged); met {n} / failed {n} mark criterion n; finish {outcome: 'met' | 'blocked', summary} ends the goal and leaves the report card; pause {reason} when you are truly blocked on the user stops the 30-minute goal reminder and shows them what you wait on, resume turns it back on once you can go on.",
parameters: obj({
action: oneOf(['criteria', 'decide', 'met', 'failed', 'finish', 'pause', 'resume']),
criteria: opt(arr(str())),
n: opt(int({ minimum: 1 })),
question: opt(str()),
picked: opt(str()),
reason: opt(str()),
conversation: opt(int({ minimum: 1 })),
images: opt(union([lit('latest'), int({ minimum: 1 })])),
outcome: opt(oneOf(['met', 'blocked'])),
summary: opt(str()),
}),
// Not replay-safe (the default): an interrupted call is never rerun, so a decision is recorded at most once.
async execute(args, api, ctx) {
const caller = api.conversationId
const result = await api.commit(async (tx) => {
if ((await mainAgentOf(tx)) !== caller)
throw new Error('Only the main agent keeps the hands-off goal.')
const acted = await act(tx, caller, args, Date.now())
if (acted.ended) await card(host, tx, 'goal.report', acted.ended)
const changed = acted.sync ?? acted.ended?.id
if (changed !== undefined) await sync(host, tx, changed)
return acted
}, ctx)
return { content: [{ type: 'text', text: result.answer }] }
},
}),
],
})
}