Port goal and question-card server extensions
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Hands-off goals
|
||||
|
||||
The user starts or ends a goal through the exported server actions.
|
||||
Only the standing `@ultimate` conversation uses the `goal` tool.
|
||||
The root document stores one open goal and its decisions.
|
||||
Three failed tries block a criterion. Rewording preserves unchanged criteria.
|
||||
Decisions can name screenshots from the caller's conversation.
|
||||
The `latest` reference never crosses an input boundary.
|
||||
|
||||
Each input to `@ultimate` receives the current open goal note.
|
||||
A queued start card grants nothing after the user ends the goal.
|
||||
The start wakes `@ultimate`. The report uses a passive write.
|
||||
|
||||
The optional automations service creates one keyed reminder every 30 minutes.
|
||||
A durable task synchronizes its state after each lifecycle change.
|
||||
A replay adopts the existing reminder. A user-deleted reminder stays deleted.
|
||||
Pause disables the reminder. Resume enables it. End deletes it.
|
||||
A reminder input adds the reminder checklist to the goal note.
|
||||
World automations identify a fire with the `[automation <id> fired]` prefix.
|
||||
The goal hook recognizes that prefix and the structured `automations.fired` entry.
|
||||
|
||||
Side chats and browser components are outside this server port.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Server action references. Side chats are not part of the world port. */
|
||||
export const docRef = <T>(kind: string) => ({kind})
|
||||
export const actionRef = <A, R>(extension: string, action: string) => ({extension, action})
|
||||
export const implementAction = (_ref: unknown, run: any) => run
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Durable delivery of goal start cards and passive report cards. */
|
||||
import { defineTask } from '@earendil-works/pi-durable'
|
||||
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
|
||||
/** A goal card for @ultimate: `goal.started` as an input that wakes it, `goal.report` as a write. */
|
||||
type Card = { to: number; goal: number; kind: 'goal.started' | 'goal.report'; content: string }
|
||||
|
||||
/** Created in the commit that changes the goal, so a crash cannot lose the card; the request id makes retries harmless. */
|
||||
export const deliverTask = (host: Pick<Host, 'harness'>) =>
|
||||
defineTask<Card, { phase: 'submit' }, null>({
|
||||
name: 'goal.deliver',
|
||||
version: 1,
|
||||
initial: () => ({ phase: 'submit' }),
|
||||
phases: {
|
||||
submit: async (task, runtime, ctx) => {
|
||||
const { to, goal, kind, content } = task.input
|
||||
const requestId = `${kind}:${goal}`
|
||||
const data = { goal }
|
||||
// The host handle, unlike the task runtime's, admits passive writes.
|
||||
const target = (await host.harness().conversation(to as ConversationId, ctx))!
|
||||
await target.submit(
|
||||
kind === 'goal.started'
|
||||
? { type: 'input', requestId, content, entry: { kind, data }, whenBusy: 'followUp' }
|
||||
: {
|
||||
type: 'write',
|
||||
requestId,
|
||||
entry: { kind, data, model: [{ role: 'user', content, timestamp: Date.now() }] },
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
await runtime.commit(
|
||||
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
},
|
||||
abort: (_task, runtime, ctx) =>
|
||||
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
||||
})
|
||||
|
||||
export const deliver = (host: Pick<Host, 'harness'>, tx: Tx, card: Card) =>
|
||||
tx.createTask(deliverTask(host), card, {
|
||||
conversationId: card.to as ConversationId,
|
||||
ownership: { kind: 'conversation' },
|
||||
background: true,
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Hands-off goals in the browser: the goal the user put @ultimate on, and every decision it took for them. Browser-safe. */
|
||||
import { actionRef, docRef } from '../goal/compat.ts'
|
||||
|
||||
/** One decision @ultimate took instead of asking the user. */
|
||||
export type Decision = {
|
||||
at: number
|
||||
/** What it would have asked the user. */
|
||||
question: string
|
||||
/** What it chose. */
|
||||
picked: string
|
||||
reason: string
|
||||
/** The branch the decision was for. */
|
||||
conversation?: number
|
||||
/** The entry of @ultimate's conversation holding the screenshots it judged, such as a UI preview it approved. */
|
||||
images?: number
|
||||
}
|
||||
|
||||
/** How a goal ended: met, blocked (every remaining criterion stuck), or ended by the user. */
|
||||
export type Ending = {
|
||||
at: number
|
||||
by: 'user' | 'ultimate'
|
||||
outcome: 'met' | 'blocked' | 'ended'
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** What "done" means, as @ultimate wrote it down: each criterion open, met, or blocked after repeated failed tries. */
|
||||
export type Criterion = { text: string; status: 'open' | 'met' | 'blocked'; tries?: number }
|
||||
|
||||
export type Goal = {
|
||||
id: number
|
||||
/** The user's words: what to reach, and when to stop if they said so. */
|
||||
text: string
|
||||
startedAt: number
|
||||
criteria: Criterion[]
|
||||
decisions: Decision[]
|
||||
/** The "Goal reminder" automation that wakes @ultimate every 30 minutes while the goal is open. */
|
||||
reminder?: string
|
||||
/** Set while @ultimate is blocked on the user: the reminder is paused until it resumes. */
|
||||
waiting?: { reason: string; at: number }
|
||||
ended?: Ending
|
||||
}
|
||||
|
||||
/** `{ next, goals }` on conversation 1, oldest first; at most one goal is open (no `ended`). */
|
||||
export const GoalDoc = docRef<{ next: number; goals: Goal[] }>('goal')
|
||||
|
||||
/** `goal.started` and `goal.report` entries in @ultimate's conversation name their goal. */
|
||||
export type GoalEntry = { goal: number }
|
||||
|
||||
/** `{ text }` → the new goal. User-only: puts @ultimate hands-off on it. */
|
||||
export const StartAction = actionRef<{ text: string }, Goal>('goal', 'start')
|
||||
/** No arguments → the ended goal. User-only: ends the open goal; @ultimate asks again as usual. */
|
||||
export const EndAction = actionRef<null, Goal>('goal', 'end')
|
||||
/** `{ goal, entry }` → the images of a decision's screenshot entry. */
|
||||
export const ImagesAction = actionRef<
|
||||
{ goal: number; entry: number },
|
||||
{ data: string; mimeType: string }[]
|
||||
>('goal', 'images')
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The goal reminder: an automation that wakes @ultimate every 30 minutes while a goal is open, so a stalled goal never
|
||||
* waits silently. Every goal change (start, pause, resume, end) creates a sync task in its own commit; the task brings
|
||||
* the automation in line with the goal as it is when the task runs. A crash leaves at most a task to rerun, and a rerun
|
||||
* gets the automation an earlier try made back from its create key instead of making a second one.
|
||||
*/
|
||||
import { defineTask, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import type { ConversationDocToken, Tx } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
import { AutomationsApi } from '../automations/server.ts'
|
||||
import type { Automation } from '../automations/types.ts'
|
||||
import type { Goal } from './refs.ts'
|
||||
|
||||
export const SCHEDULE = 'every 30m'
|
||||
export const reminderName = (goal: number) => `Goal reminder · goal ${goal}`
|
||||
/** Each goal's reminder has its own create key: a rerun gets it back, and no goal touches another's. */
|
||||
const reminderKey = (goal: number) => `goal.reminder:${goal}`
|
||||
|
||||
/**
|
||||
* Steps at most: each makes one change (create, link, delete, pause or resume) and looks again, so a pause and resume
|
||||
* that race still end on the newest goal.
|
||||
*/
|
||||
const STEPS = 6
|
||||
|
||||
type Goals = ConversationDocToken<{ next: number; goals: Goal[] }>
|
||||
|
||||
export function reminderTask(host: Host, Goals: Goals) {
|
||||
const find = async (tx: Tx, id: number) =>
|
||||
(await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((item) => item.id === id)
|
||||
/** One look: true when it changed something and must look again, false once the automation matches the goal. */
|
||||
async function step(id: number): Promise<boolean> {
|
||||
const automations = host.want<{ operate(args: Record<string, unknown>): Promise<unknown> }>(AutomationsApi)
|
||||
// Automations is off: the goal runs without a reminder.
|
||||
if (!automations) return false
|
||||
// A plain copy: the document's overlay is unusable after the commit.
|
||||
const goal = await host
|
||||
.harness()
|
||||
.commit(
|
||||
async (tx) =>
|
||||
await find(tx, id).then((found) => found && (JSON.parse(JSON.stringify(found)) as Goal)),
|
||||
context,
|
||||
)
|
||||
const list = (await automations.operate({ action: 'list' })) as Automation[]
|
||||
// The linked one by its id, whatever the user renamed it to; before it is linked, the one its key made.
|
||||
const own = goal?.reminder
|
||||
? list.find((item) => item.id === goal.reminder)
|
||||
: list.find((item) => item.key === reminderKey(id))
|
||||
if (!goal || goal.ended) {
|
||||
if (!own) return false
|
||||
await automations.operate({ action: 'delete', id: own.id })
|
||||
return true
|
||||
}
|
||||
if (!own) {
|
||||
// One the user deleted in the Automations panel stays deleted.
|
||||
if (goal.reminder) return false
|
||||
await automations.operate({
|
||||
action: 'create',
|
||||
name: reminderName(id),
|
||||
schedule: SCHEDULE,
|
||||
subscribers: ['@ultimate'],
|
||||
key: reminderKey(id),
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (goal.reminder !== own.id) {
|
||||
await host.harness().commit(async (tx) => {
|
||||
const current = await find(tx, id)
|
||||
if (current) current.reminder = own.id
|
||||
}, context)
|
||||
return true
|
||||
}
|
||||
if (own.enabled === !goal.waiting) return false
|
||||
if (goal.waiting) await automations.operate({ action: 'pause', id: own.id })
|
||||
else await automations.operate({ action: 'resume', id: own.id })
|
||||
return true
|
||||
}
|
||||
return defineTask<{ goal: number }, { phase: 'sync' }, null>({
|
||||
name: 'goal.reminder',
|
||||
version: 1,
|
||||
initial: () => ({ phase: 'sync' }),
|
||||
phases: {
|
||||
sync: async (task, runtime, ctx) => {
|
||||
for (let i = 0; i < STEPS; i++) if (!(await step(task.input.goal))) break
|
||||
await runtime.commit(
|
||||
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
},
|
||||
abort: (_task, runtime, ctx) =>
|
||||
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Hands-off goals: the user puts @ultimate on a goal and goes away. While it is open, @ultimate 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; @ultimate 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 { Standing } 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 ultimateOf = async (tx: Tx) => (await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* @ultimate'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 @ultimate (`ultimate`) in `tx`; returns its answer, and the goal when it finished. */
|
||||
export async function act(tx: Tx, ultimate: 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, ultimate, 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: 'ultimate',
|
||||
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 @ultimate (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 @ultimate, delivered by a task created in `tx`, the commit that changed the goal.
|
||||
* The start card says only that the user started it: what @ultimate 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 ultimateOf(tx)
|
||||
if (to === undefined) throw new Error('@ultimate 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; @ultimate 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 ultimateOf(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 ultimateOf(tx)) !== caller)
|
||||
throw new Error('Only @ultimate 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 }] }
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Question cards
|
||||
|
||||
The exported user actions answer or dismiss an open message to the user.
|
||||
The messages service delivers the answer to the asking conversation.
|
||||
The action validates the question text and rejects duplicate answers.
|
||||
Only one concurrent answer settles an open message.
|
||||
|
||||
Answer text preserves the message's numbers and option keys.
|
||||
A note follows the answers. A note alone also settles the message.
|
||||
Free text answers a message without structured answers.
|
||||
Dismissal sends a passive note and starts no run.
|
||||
Closed messages reject further answers and dismissals.
|
||||
|
||||
The messages extension owns question schemas and open-question notes.
|
||||
Browser components are outside this server port.
|
||||
The “settling for the user” case awaits the later world UI work.
|
||||
It uses the messages UI action, not the question-card service.
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Questions in the browser: the actions that answer an agent's questions to the user from its message's card.
|
||||
* Browser-safe.
|
||||
*/
|
||||
import { actionRef } from '../goal/compat.ts'
|
||||
|
||||
/** One answered question: the question's text and the chosen or typed answer (several choices joined by ", "). */
|
||||
export type Answer = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* `{ messageId, answers?, note? }` or `{ messageId, text }` → `{ id }`: answer open message `messageId` with a reply
|
||||
* to its sender, from its questions (`note` goes after the answer lines) or as free text; `id` is the reply's message
|
||||
* id. Fails when the message no longer waits on the user.
|
||||
*/
|
||||
export const AnswerAction = actionRef<
|
||||
{ messageId: number; answers?: Answer[]; note?: string; text?: string },
|
||||
{ id: number }
|
||||
>('questions', 'answer')
|
||||
/**
|
||||
* `{ messageId }` → `{ id }`: close open message `messageId` without an answer; its sender gets a one-way note,
|
||||
* `id`, that starts no run. Fails when the message no longer waits on the user.
|
||||
*/
|
||||
export const DismissAction = actionRef<{ messageId: number }, { id: number }>(
|
||||
'questions',
|
||||
'dismiss',
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
/** User actions that answer or dismiss question cards through the messages service. */
|
||||
import { defineExtension, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT as context, withAbortSignal } from '@earendil-works/chord/context'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
import { Messages, MessagesApi } from '../messages/api.ts'
|
||||
import type { Message, Question } from '../messages/api.ts'
|
||||
import { implementAction } from '../goal/compat.ts'
|
||||
import { AnswerAction, DismissAction } from './refs.ts'
|
||||
|
||||
const open = (message: Message) =>
|
||||
message.to === 'user' && message.reply && message.state === 'delivered'
|
||||
const NOT_OPEN = 'These questions are no longer open'
|
||||
|
||||
function validAnswers(value: unknown): value is { question: string; answer: string }[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(a: { question?: unknown; answer?: unknown } | null) =>
|
||||
a && typeof a.question === 'string' && typeof a.answer === 'string' && a.answer.trim(),
|
||||
)
|
||||
)
|
||||
}
|
||||
function checkAnswers(answers: { question: string }[], questions: Question[]) {
|
||||
if (
|
||||
new Set(answers.map((a) => a.question)).size !== answers.length ||
|
||||
answers.some((a) => !questions.some((q) => q.text === a.question))
|
||||
)
|
||||
throw new Error('Unknown question')
|
||||
}
|
||||
/** The keys of the chosen options, in option order: "a", "a,c"; none when an answer names no option. */
|
||||
function keysOf(question: Question, answer: string) {
|
||||
const labels = question.kind === 'multi' ? answer.split(', ') : [answer]
|
||||
const keys = (question.options ?? []).flatMap((o) =>
|
||||
o.key && labels.includes(o.label) ? [o.key] : [],
|
||||
)
|
||||
return keys.length === labels.length ? keys.join(',') : ''
|
||||
}
|
||||
/**
|
||||
* The reply naming each answer by the message's own numbering: `1a. Topic: label` for an option (`2a,c.` for
|
||||
* several), `3: text` for typed text or a rating, `Topic: answer` when the message numbered nothing; a note from the
|
||||
* card's field follows the answers.
|
||||
*/
|
||||
export function answerText(
|
||||
answers: { question: string; answer: string }[],
|
||||
questions: Question[],
|
||||
note?: string,
|
||||
) {
|
||||
const lines = answers.map(({ question, answer }) => {
|
||||
const q = questions.find((candidate) => candidate.text === question)!
|
||||
const keys = keysOf(q, answer)
|
||||
if (!q.ref) return `${q.text}: ${answer}`
|
||||
return keys ? `${q.ref}${keys}. ${q.text}: ${answer}` : `${q.ref}: ${answer}`
|
||||
})
|
||||
if (note?.trim()) lines.push(answers.length ? `Note: ${note.trim()}` : note.trim())
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
export const userActions = ['answer', 'dismiss']
|
||||
export const actions = {
|
||||
/** Answer an open message from its questions, with a note, or as free text: a user reply to its sender. */
|
||||
answer: implementAction(AnswerAction, async (args, { host, signal, device }) => {
|
||||
const data = args as {
|
||||
messageId: number
|
||||
answers?: { question: string; answer: string }[]
|
||||
note?: unknown
|
||||
text?: unknown
|
||||
}
|
||||
const text = typeof data?.text === 'string' ? data.text.trim() : undefined
|
||||
const note = typeof data?.note === 'string' ? data.note.trim() : undefined
|
||||
const answers = data?.answers ?? []
|
||||
if (
|
||||
!data ||
|
||||
!Number.isSafeInteger(data.messageId) ||
|
||||
(text === undefined
|
||||
? !validAnswers(answers) || (!answers.length && !note)
|
||||
: !text || data.answers !== undefined || note)
|
||||
)
|
||||
throw new Error('Invalid answers')
|
||||
const reply = await host.harness().commit(async (tx) => {
|
||||
const log = await tx.doc(Messages, ROOT_CONVERSATION_ID)
|
||||
const message = log.messages.find((m) => m.id === data.messageId)
|
||||
if (!message || !open(message)) throw new Error(NOT_OPEN)
|
||||
if (text !== undefined) return text
|
||||
checkAnswers(answers, message.questions ?? [])
|
||||
return answerText(answers, message.questions ?? [], note)
|
||||
}, context)
|
||||
return {
|
||||
id: await host
|
||||
.need(MessagesApi)
|
||||
.answer(data.messageId, reply, withAbortSignal(signal, context), device),
|
||||
}
|
||||
}),
|
||||
/** Close an open message to the user unanswered; its sender gets a one-way note. */
|
||||
dismiss: implementAction(DismissAction, async (args, { host, signal, device }) => {
|
||||
const data = args as { messageId: number }
|
||||
if (!data || !Number.isSafeInteger(data.messageId)) throw new Error('Invalid message')
|
||||
return {
|
||||
id: await host
|
||||
.need(MessagesApi)
|
||||
.dismiss(data.messageId, withAbortSignal(signal, context), device),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
export default function questionsExtension(host: Host) {
|
||||
// Core, always on; resolved where it is called, declared here so the catalogue lists it.
|
||||
host.need(MessagesApi)
|
||||
return defineExtension({ name: 'questions' })
|
||||
}
|
||||
Reference in New Issue
Block a user