/** Event and context conditions use the same transactional fire path. */ import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' const onCommit = (harness: any, callback: any) => { const observation = harness.observeCommits({advance: callback, end: () => {}}) return () => observation.detach() } import { ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable' import type { ConversationId, Tx } from '@earendil-works/pi-durable' type ExtensionHost = any import { defineEvent } from '@earendil-works/sausage-events' import type { EventRecord } from '@earendil-works/sausage-events' import { defineDoc } from '@earendil-works/pi-durable' const UserInputEvent = defineEvent('conversations.user-input') export type ContextUsage = {percent?: number; tokens?: number; window?: number} export const ContextUsageDoc = defineDoc({kind:'conversations.context',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false}) import { MainAgent, SubagentsApi } from '../subagents/api.ts' import { describeSchedule } from './schedule.ts' import type { Automations as Ledger } from './server.ts' import type { Automation, TriggerEvent } from './types.ts' import { RUN_TASK, MAIN_AGENT } from './types.ts' export const TRIGGER_EVENTS: TriggerEvent[] = [ { name: 'workspaces.landed', description: 'A submission landed', fields: { sha: 'Landed commit', title: 'Submission title', target: 'Target branch', conversationId: 'Submitting conversation', }, }, { name: 'workspaces.check', description: 'A landing check finished', fields: { result: 'passed | failed | flaky | timeout' }, }, { name: 'health.changed', description: 'Provider health changed', fields: { provider: 'Provider name', state: 'ok | failing | unknown' }, }, { name: 'models.login-changed', description: 'Provider login changed', fields: { provider: 'Provider name' }, }, { name: 'update.applied', description: 'An update was applied', fields: {} }, { name: 'update.refused', description: 'An update was refused', fields: {} }, { name: 'cloud.backup', description: 'Cloud backup changed phase', fields: { phase: 'started | done | failed' }, }, { name: 'lineage.branched', description: 'A branch was created', fields: {} }, { name: 'conversations.compacted', description: 'A conversation compacted', fields: { conversation: 'Conversation id, or @name for a main agent', reason: 'Compaction reason', }, }, { name: 'automations.escalated', description: 'An automation escalated', fields: { id: 'Automation id' }, }, ] export function describeTrigger(a: Automation): string { if (a.schedule) return describeSchedule(a.schedule) if (a.event) return `on ${a.event.name}${ a.event.where && Object.keys(a.event.where).length ? ` where ${Object.entries(a.event.where) .map(([key, value]) => `${key}=${value}`) .join(', ')}` : '' }` if (a.condition) return `when ${a.condition.of} context ≥ ${a.condition.above}%${a.condition.idleFor ? ` after ${hours(a.condition.idleFor)} without the user's input` : ''}` return a.script!.state } /** A pause as people say it: `8h`, `90m`, `1.5h`. */ export const hours = (ms: number) => ms % 3600_000 === 0 || ms >= 10 * 3600_000 ? `${Math.round((ms / 3600_000) * 10) / 10}h` : `${Math.round(ms / 60_000)}m` /** How often idle conditions are looked at: a pause is crossed by time passing, not by a measurement. */ export const IDLE_TICK = 60_000 function summary(event: EventRecord): string { const data = event.data as Record if (event.name === 'workspaces.landed') return `${event.name} ${String(data.sha).slice(0, 7)} ${JSON.stringify(data.title)}`.slice( 0, 200, ) return `${event.name} {${Object.entries(data) .map(([key, value]) => `${key}=${JSON.stringify(value)}`) .join(' ')}}` .replace(/\s+/g, ' ') .slice(0, 200) } export function triggers( host: ExtensionHost, Automations: typeof Ledger, fire: (tx: Tx, a: Automation, at: number, text?: string) => Promise, prepareFires: (tx: Tx) => Promise, ) { async function resolve(of: string): Promise { if (MAIN_AGENT.test(of)) return host.need(SubagentsApi).mainAgent(of.slice(1), BACKGROUND_CONTEXT) if (/^#[1-9]\d*$/.test(of)) return Number(of.slice(1)) as ConversationId return undefined } /** Evaluate `a`'s condition on `usage`, the measurement a commit published, else the conversation's current one. */ async function evaluate(tx: Tx, a: Automation, id?: number, measured?: ContextUsage) { if (!a.enabled || !a.condition || !id) return const usage = measured ?? (await tx.doc(ContextUsageDoc, id as ConversationId)) if (usage.percent === undefined) return const condition = a.condition condition.value = usage.percent if (usage.percent < condition.above - 10) condition.armed = true const now = Date.now() const paused = condition.idleFor ? now - (condition.inputAt ?? a.createdAt) : undefined if ( usage.percent >= condition.above && condition.armed && (paused === undefined || paused >= condition.idleFor!) && // A manual run is not a crossing: the cooldown counts from the condition's own fire. (condition.firedAt === undefined || now - condition.firedAt >= condition.cooldownMs) ) { const count = (n: number) => Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n) await fire( tx, a, now, `${condition.of} context ${Math.round(usage.percent)}%${usage.tokens !== undefined && usage.window !== undefined ? ` (${count(usage.tokens)}/${count(usage.window)})` : ''}${paused === undefined ? '' : ` after ${hours(paused)} without the user's input`}`, ) condition.armed = false condition.firedAt = now } } let stop: (() => void) | undefined let queue = Promise.resolve() let closed = false /** * Evaluate every enabled context condition: on the measurements one commit published (`changed`, by conversation), * or on each conversation's current one; with `idle`, only those that wait for a pause. Each commit's measurements * are evaluated in commit order, so a drop below the re-arm line followed by a crossing is seen as both, however far * evaluation lags. */ async function check(changed?: Map, idle = false) { const state = await host.harness().snapshot(Automations, BACKGROUND_CONTEXT) for (const a of Object.values(state?.automations ?? {})) { if (closed || !a.enabled || !a.condition || (idle && !a.condition.idleFor)) continue const target = await resolve(a.condition.of) if (!target || (changed && !changed.has(target))) continue await host.harness().commit(async (tx) => { const current = (await tx.doc(Automations)).automations[a.id] if (current?.condition?.of === a.condition!.of) await evaluate(tx, current, target, changed?.get(target)) }, BACKGROUND_CONTEXT) } } /** * Whether `event` came from one of `a`'s own prompt runs: its trace's conversation, or the conversation it is about * (`conversation`, `conversationId` in its data), is a run conversation owned by one of `a`'s run tasks. Read from * the conversation's owner, not the bounded run history, so an old or long run still counts. */ async function fromOwnRun(tx: Tx, a: Automation, event: EventRecord) { // No shortcut on `a.prompt`: a run started before the prompt was removed can still emit. const data = event.data as Record const ids = new Set( [data.conversation, data.conversationId].filter( (id): id is number => typeof id === 'number', ), ) for (const id of ids) { const creator = (await tx.conversation(id as ConversationId))?.owner if (!creator) continue const owner = await tx.task(creator.taskId) if ( owner?.kind === RUN_TASK && (owner.input as { automation?: { id?: string } }).automation?.id === a.id ) return true } return false } /** One minute's look at the conditions that wait for a pause: time alone may have crossed it. */ const idleTick = () => { queue = queue .then(() => check(undefined, true)) .catch((error: unknown) => { host.notice(`Automation idle trigger: ${String(error)}`) }) return queue } function install() { // A pause counts from the user's latest input to the conversation a condition watches. host.react( 'user-input', [UserInputEvent], async (tx, { data, at }) => { const state = await tx.doc(Automations) for (const a of Object.values(state.automations)) { if (!a.condition?.idleFor) continue const of = a.condition.of const target = MAIN_AGENT.test(of) ? (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))[of.slice(1)] : Number(of.slice(1)) if (target === data.conversation) a.condition.inputAt = Date.parse(at) } }, { from: 'now' }, ) // Tokens name public events without loading other extensions' runtime refs. host.react( 'events', TRIGGER_EVENTS.map(({ name }) => defineEvent(name)), async (tx, event) => { const state = await tx.doc(Automations) const matching: Automation[] = [] // `@name` matches the main agent's conversation when the event comes: it changes with each chapter. const mainAgent = await tx.doc(MainAgent, ROOT_CONVERSATION_ID) const expected = (value: string | number | boolean) => typeof value === 'string' && MAIN_AGENT.test(value) ? Object.hasOwn(mainAgent, value.slice(1)) ? mainAgent[value.slice(1)] : null : value // Every read before the first fire: a transaction may not scan tasks after it wrote. for (const a of Object.values(state.automations)) { if (!a.enabled || a.event?.name !== event.name || event.id <= a.event.sinceEvent) continue if ( !Object.entries(a.event.where ?? {}).every( ([key, value]) => (event.data as Record)[key] === expected(value), ) ) continue if (!(await fromOwnRun(tx, a, event))) matching.push(a) } if (matching.some((a) => a.prompt)) await prepareFires(tx) for (const a of matching) await fire(tx, a, Date.now(), summary(event)) }, { from: 'now' }, ) } async function open() { closed = false stop?.() // Not a reactor: context usage is a document, not a domain event. stop = onCommit(host.harness(), (publication) => { const entries = publication.changes.filter((change: any) => change.type === 'entry').map((change: any) => change.value) const measured = entries.flatMap((entry: any) => { const message = entry.model?.[0] if(message?.role !== 'assistant' || !message.usage) return [] const model = host.models().getModel(message.provider,message.model) if(!model?.contextWindow) return [] const tokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite return [{id:entry.conversationId,usage:{tokens,window:model.contextWindow,percent:tokens/model.contextWindow*100}}] }) const inputs = entries.filter((entry: any) => entry.kind === 'pi.input') if(measured.length || inputs.length) queue = queue.then(async () => { if(closed) return await host.harness().commit(async (tx: any) => { const ledger = await tx.doc(Automations) const mainAgent = await tx.doc(MainAgent,ROOT_CONVERSATION_ID) for(const entry of inputs) for(const a of Object.values(ledger.automations) as Automation[]) { if(!a.condition?.idleFor) continue const target=MAIN_AGENT.test(a.condition.of) ? mainAgent[a.condition.of.slice(1)] : Number(a.condition.of.slice(1)) if(target === entry.conversationId) a.condition.inputAt = entry.model?.[0]?.timestamp ?? Date.now() } for(const {id,usage} of measured) Object.assign(await tx.doc(ContextUsageDoc,id),usage) },BACKGROUND_CONTEXT) }).catch((error: unknown) => host.notice(`Automation context measurement: ${String(error)}`)) const changed = new Map() for (const change of publication.changes) if ( change.type === 'document' && change.kind === 'change' && change.record.kind === ContextUsageDoc.definition.kind && change.conversationId !== undefined ) changed.set(change.conversationId, structuredClone(change.value as ContextUsage)) if (changed.size) queue = queue .then(() => check(changed)) .catch((error: unknown) => { host.notice(`Automation context trigger: ${String(error)}`) }) }) await check() // A close while the first check ran ends here: no timer outlives it. if (closed) return clearInterval(timer) timer = setInterval(idleTick, IDLE_TICK) } let timer: ReturnType | undefined async function close() { closed = true stop?.() stop = undefined clearInterval(timer) timer = undefined await queue } return { resolve, evaluate, install, open, close, idleTick } }