diff --git a/dist/harness/generation.js b/dist/harness/generation.js index ac42dfe741b20726ab8eb3d8139b1296d664210d..1e7f1ac26d17b83cb15bf00408fa70b0aee4e747 100644 --- a/dist/harness/generation.js +++ b/dist/harness/generation.js @@ -458,7 +458,7 @@ async function prepareFailure(tx, runtime) { const policy = settings.afterFailedRun; if (policy !== "continue") return { policy }; - return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) }; + return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings, "final", runtime.now()) }; } /** * End a failed run, settling its inputs with `settlement`, and do with the inputs queued behind it what @@ -668,7 +668,7 @@ async function answer(runtime, message, context) { const conversationId = runtime.conversationId; await runtime.commit(async (tx) => { // Queue modes are read on the Session line, when the boundary is decided. - const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now()); const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } }; @@ -762,7 +762,7 @@ function prepareRunEndTasks(runtime, requests, prepared) { async function endAnsweredRun(runtime, answer, context) { const conversationId = runtime.conversationId; await runtime.commit(async (tx) => { - const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now()); const live = await tx.doc(LiveDoc, conversationId); const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now()); endRun(tx, live, runtime.taskId, { status: "done", answer }); @@ -868,7 +868,7 @@ async function finishToolRound(runtime, assistant, tools, context) { const maxTurns = (await runtime.agent(context)).maxTurns; const afterRun = terminate && (await asksAfterRun(runtime, context)); await runtime.commit(async (tx) => { - const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + const boundary = await prepareBoundary(tx, conversationId, runtime.settings, terminate || handoff !== undefined ? "final" : "postTools", runtime.now()); if (added.length > 0) await addTools(tx, conversationId, added); const live = await tx.doc(LiveDoc, conversationId); diff --git a/dist/harness/harness.js b/dist/harness/harness.js index e286f00f872ebec79367a594938040c6a37a879f..44979b05c2bcf30580841797175c099c8c621462 100644 --- a/dist/harness/harness.js +++ b/dist/harness/harness.js @@ -5,7 +5,7 @@ import { ResetEntry } from "../entries.js"; import { contained, SessionImpl } from "../session/session.js"; import { hostTimers } from "../timers.js"; import { ROOT_CONVERSATION_ID } from "../types.js"; -import { AgentDoc, configure, createAgent, resolveAgent, resolveSettings } from "./agent.js"; +import { AgentDoc, agentHooks, configure, createAgent, resolveAgent, resolveSettings } from "./agent.js"; import { createCompaction } from "./compaction.js"; import { readContext } from "./context.js"; import { InboxDoc, withdrawQueuedInputs } from "./inbox.js"; @@ -157,6 +157,15 @@ class HarnessImpl extends SessionImpl { create: (target, createOptions, context) => this.#create(target, createOptions, context), }; } + async beforeInput(tx, conversationId, input, context) { + const agent = resolveAgent(await tx.doc(AgentDoc, conversationId), this.#options.registry.snapshot(), resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { kind: "extension_error", conversationId, extension }, context)); + const entries = []; + for (const { handlers } of agentHooks(agent, "pi.generation")) { + const result = await handlers.beforeInput?.(input, { conversationId, tx }, context); + entries.push(...(result?.entries ?? [])); + } + return entries; + } /** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */ async resolveAgent(id, snapshot, context) { const registry = snapshot ?? this.#options.registry.snapshot(); @@ -179,6 +188,12 @@ class HarnessImpl extends SessionImpl { this.assertUsable(); this.#tasks.resume(); } + hold() { + this.#tasks.hold(); + } + release() { + this.#tasks.release(); + } wake() { this.assertUsable(); return this.#tasks.wake(); diff --git a/dist/harness/inbox.d.ts b/dist/harness/inbox.d.ts index 1197339369188857a1ad62b528cea65e3833f27e..a61d9712ce55e80441847442772da6f3e354fb2b 100644 --- a/dist/harness/inbox.d.ts +++ b/dist/harness/inbox.d.ts @@ -23,6 +23,7 @@ export declare const InboxDoc: import("../types.ts").ConversationDocToken; /** What a boundary reads before the commit's first table write, and the newest head it has seen so far. */ export type Boundary = { + readonly entries: readonly EntryDraft[]; readonly conversationId: ConversationId; readonly inbox: Draft; readonly steeringMode: QueueMode; @@ -43,7 +44,7 @@ export type BoundaryResult = { * Read what a boundary needs. Table reads must precede the commit's first table write, so callers prepare the * boundary at the start of their commit. */ -export declare function prepareBoundary(tx: Tx, conversationId: ConversationId, modes: QueueModes): Promise; +export declare function prepareBoundary(tx: Tx, conversationId: ConversationId, modes: QueueModes, at: "postTools" | "final", now: number, incoming?: EntryDraft): Promise; /** * Place the queued items a boundary selects (spec §6): every write, the first or all steers, and at `final` the first * or all follow-ups. A selected reset turns a `postTools` boundary into `final`. Writes are placed first and user @@ -53,6 +54,7 @@ export declare function prepareBoundary(tx: Tx, conversationId: ConversationId, */ export declare function applyBoundary(tx: Tx, boundary: Boundary, at: "postTools" | "final", now: number): Promise; /** Append the entry an input is placed as: `entry`'s kind and data, or `pi.user`, with the user message of `content`. */ +export declare function inputDraft(content: UserInput, entry: InputEntry | undefined, now: number): EntryDraft; export declare function appendInput(tx: Tx, conversationId: ConversationId, content: UserInput, entry: InputEntry | undefined, now: number): Promise; /** The kind and data of an input's entry as strict JSON, without other fields an untyped caller might pass. */ export declare function pickInputEntry({ kind, data }: InputEntry): JsonRepresentation; diff --git a/dist/harness/inbox.js b/dist/harness/inbox.js index 5ff8c1703808c5ffd703164e6c339150e1f1e131..ec4cf6d5adc599e5fe428514a7cd7e0dac1a7c9d 100644 --- a/dist/harness/inbox.js +++ b/dist/harness/inbox.js @@ -14,10 +14,15 @@ export const InboxDoc = defineDoc({ * Read what a boundary needs. Table reads must precede the commit's first table write, so callers prepare the * boundary at the start of their commit. */ -export async function prepareBoundary(tx, conversationId, modes) { +export async function prepareBoundary(tx, conversationId, modes, at, now, incoming) { const head = (await tx.latestHeadMarker(conversationId))?.head; const inbox = await tx.doc(InboxDoc, conversationId); + const first = inbox.items.find(item => item.mode === "steer" || item.mode === "followUp"); + const input = first === undefined ? incoming : inputDraft(first.content, first.entry, now); + const entries = input !== undefined && (at === "final" || hasQueuedReset(inbox.items)) + ? await tx.beforeInput(conversationId, input) : []; return { + entries, conversationId, inbox, steeringMode: modes.steeringMode, @@ -61,6 +66,9 @@ export async function applyBoundary(tx, boundary, at, now) { } const placed = []; for (const index of users) { + if (placed.length === 0) + for (const draft of boundary.entries) + await tx.appendEntry(conversationId, draft); const item = items[index]; const entry = await appendInput(tx, conversationId, item.content, item.entry, now); tx.placeSubmission(item.id, entry.id); @@ -74,10 +82,11 @@ export async function applyBoundary(tx, boundary, at, now) { } /** Append the entry an input is placed as: `entry`'s kind and data, or `pi.user`, with the user message of `content`. */ export function appendInput(tx, conversationId, content, entry, now) { + return tx.appendEntry(conversationId, inputDraft(content, entry, now)); +} +export function inputDraft(content, entry, now) { const message = { role: "user", content, timestamp: now }; - if (entry === undefined) - return tx.appendEntry(UserEntry, conversationId, { model: [message] }); - return tx.appendEntry(conversationId, { ...pickInputEntry(entry), model: [message] }); + return { ...(entry === undefined ? { kind: UserEntry.kind } : pickInputEntry(entry)), input: true, model: [message] }; } /** The kind and data of an input's entry as strict JSON, without other fields an untyped caller might pass. */ export function pickInputEntry({ kind, data }) { diff --git a/dist/harness/scheduler.d.ts b/dist/harness/scheduler.d.ts index f6189edb71ac0557060b623625fc4be4bec5e904..269a3c4ab1676da22db5a723f30d2974f84b6bea 100644 --- a/dist/harness/scheduler.d.ts +++ b/dist/harness/scheduler.d.ts @@ -107,6 +107,8 @@ export declare class TaskScheduler { * Idempotent; the kick does nothing once closing. */ resume(): void; + hold(): void; + release(): void; /** * Why a live task cannot run here, if it cannot: its definition is missing, too old, or failed to migrate. A running * task, an abort-marked one, and one holding its outcome are never blocked. diff --git a/dist/harness/scheduler.js b/dist/harness/scheduler.js index 365ea3ab42dd4406ab11a03d6702b238dfd0776b..3e8170fdb790e492e95a390fa6ea4693eb3d2f97 100644 --- a/dist/harness/scheduler.js +++ b/dist/harness/scheduler.js @@ -179,6 +179,7 @@ export class TaskScheduler { #cascadePending = false; #unsubscribeRegistry = () => { }; #enabled = false; + #held = false; #readOnly; #closing = false; #dirty = false; @@ -207,6 +208,14 @@ export class TaskScheduler { this.#context = options.context; this.#readOnly = options.readOnly === true; } + /** Start no run invocation until `release()`; running invocations and abort handlers go on. Overrides `resume()`. */ + hold() { + this.#held = true; + } + release() { + this.#held = false; + this.#kick(); + } /** * Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing; * a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a @@ -1771,6 +1780,8 @@ export class TaskScheduler { for (const record of plan.ready) { const id = record.id; const mode = record.abortRequested ? "abort" : "run"; + if (mode === "run" && this.#held) + continue; // Work cut off `restartLimit` times in a row would run forever: an abort handler, or a phase of a kind that // does not end itself at the limit, ends the task `faulted` instead. // The settings are read only for a task that was cut off at all. @@ -1973,7 +1984,7 @@ export class TaskScheduler { const tasks = []; for (const record of this.#live.values()) tasks.push({ record, state: this.#inspectTask(record, snapshot) }); - const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused"; + const scheduling = this.#closing ? "closing" : this.#enabled && !this.#held ? "running" : "paused"; return { scheduling, tasks }; } #inspectTask(record, snapshot) { @@ -2151,6 +2162,11 @@ export class TaskScheduler { * invocation continues. */ #progress(tx, current, state, extension, context) { + // Held: park at this phase boundary, as a handover does; reservation starts it again after `release()`. + if (this.#held) { + tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint })); + return false; + } state.snapshot = this.#registry.snapshot(); const next = state.snapshot.task(current.kind); if (next === state.task) diff --git a/dist/harness/submissions.js b/dist/harness/submissions.js index 6e882a8c252dda942b8b45e5b1837cf2fee28341..89a85e8a52bf67044ff15c3c856a650dea4c7c27 100644 --- a/dist/harness/submissions.js +++ b/dist/harness/submissions.js @@ -3,7 +3,7 @@ import { withTelemetry } from "@earendil-works/chord/context"; import { createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT, } from "@earendil-works/pi-telemetry"; import { ConversationBusy, ConversationIdle, ReadAfterWrite } from "../errors.js"; import { startRun } from "./generation.js"; -import { appendInput, applyBoundary, hasQueuedReset, InboxDoc, isDisplayOnly, isStale, pickInputEntry, prepareBoundary, removeInboxItem, } from "./inbox.js"; +import { appendInput, inputDraft, applyBoundary, hasQueuedReset, InboxDoc, isDisplayOnly, isStale, pickInputEntry, prepareBoundary, removeInboxItem, } from "./inbox.js"; import { LiveDoc } from "./live.js"; import { DURABLE_TELEMETRY_SCHEMA } from "./telemetry.js"; import { closedError, Waiters } from "./util.js"; @@ -205,7 +205,7 @@ export async function admitSubmission(tx, conversationId, draft, now, queueModes // Never queued: whether a run can continue depends on how the current one ends. Rejected before any write. if (busy) throw new ConversationBusy(conversationId); - const boundary = await prepareBoundary(tx, conversationId, queueModes); + const boundary = await prepareBoundary(tx, conversationId, queueModes, "final", now); const { id } = await tx.createSubmission({ ...fields, type: "continue", status: "placed" }); // Items an earlier failed run left queued (`afterFailedRun: "keep"`) are placed first and join the run. Its history // starts with the failed run's, so `afterRun` hooks see what that run did. @@ -214,7 +214,7 @@ export async function admitSubmission(tx, conversationId, draft, now, queueModes return { id, status: "placed" }; } // A boundary reads the table, so it is prepared before the first table write; a busy one needs none. - const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes); + const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes, "final", now, draft.type === "input" ? inputDraft(draft.content, draft.entry, now) : undefined); if (boundary === undefined || boundary.inbox.items.length > 0) { const { id } = await tx.createSubmission({ ...fields, @@ -253,6 +253,8 @@ export async function admitSubmission(tx, conversationId, draft, now, queueModes const write = { ...fields, type: "write", status: "done", entry: entry.id }; return { id: (await tx.createSubmission(write)).id, status: "done" }; } + for (const before of boundary.entries) + await tx.appendEntry(conversationId, before); const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now); const input = { ...fields, type: "input", status: "placed", entry: entry.id }; const { id } = await tx.createSubmission(input); diff --git a/dist/harness/types.d.ts b/dist/harness/types.d.ts index eb64e474162b158a43563e6c68d8ffea8c18b74a..cce695c6d87c79dadea54560bafcca62cf073ca1 100644 --- a/dist/harness/types.d.ts +++ b/dist/harness/types.d.ts @@ -816,6 +816,9 @@ export interface Harness extends Session { * never do. */ resume(): void; + /** Start no task run until `release()`, whatever `resume()` says; running invocations and abort handlers go on. */ + hold(): void; + release(): void; /** * When this Session next needs a running process, from the scheduler's memory: `active` while some task can run now * or is running, `at` for the earliest sleep. Answers also while scheduling is paused. A host that wakes the @@ -888,6 +891,8 @@ export interface HookApi extends DocumentReader { export type HookResult = T | undefined | Promise; /** Hooks of the built-in generation task. */ export interface GenerationHooks { + /** Before a new run’s first input is placed. Use only tx for storage access; effects commit with the input. */ + beforeInput(input: EntryDraft, api: { readonly conversationId: ConversationId; readonly tx: Tx }, context: Context): HookResult<{ readonly entries?: readonly EntryDraft[] }>; /** Before every request attempt, including recovery; the result is used for that request only. */ beforeRequest(request: { readonly messages: readonly Message[]; diff --git a/dist/session/session.d.ts b/dist/session/session.d.ts index 8201d7cfefb710516a3d4d89ba208adc2cbebd19..37c9f362401b5bc81f2f24233aa3dd7f57e29204 100644 --- a/dist/session/session.d.ts +++ b/dist/session/session.d.ts @@ -1,5 +1,5 @@ import { type Context, type JsonValue, type WatchEnd } from "@earendil-works/chord"; -import type { CommitChange, CommitObservation, CommitObserver, CommitOptions, ConversationDocFamilyToken, ConversationDocToken, ConversationId, ConversationRecord, DocumentCommitChange, DocumentRecord, DocumentWatch, EntryId, JsonObject, Observation, Observer, ReportInfo, RewindableConversationDocFamilyToken, RewindableConversationDocToken, Seq, Session, SessionDocFamilyToken, SessionDocToken, SessionEnd, SetTimer, Storage, StorageWrite, TaskDocFamilyToken, TaskDocToken, TaskId, Tx } from "../types.ts"; +import type { CommitChange, CommitObservation, CommitObserver, CommitOptions, ConversationDocFamilyToken, ConversationDocToken, EntryDraft, ConversationId, ConversationRecord, DocumentCommitChange, DocumentRecord, DocumentWatch, EntryId, JsonObject, Observation, Observer, ReportInfo, RewindableConversationDocFamilyToken, RewindableConversationDocToken, Seq, Session, SessionDocFamilyToken, SessionDocToken, SessionEnd, SetTimer, Storage, StorageWrite, TaskDocFamilyToken, TaskDocToken, TaskId, Tx } from "../types.ts"; import { type HeadMarker } from "./conversation-tails.ts"; import { Transaction, type TransactionScope } from "./transaction.ts"; /** @@ -171,6 +171,7 @@ export declare class SessionImpl implements Session { * listener. A plain Session drops them. */ report(_error: unknown, _info?: ReportInfo, _context?: Context): void; + protected beforeInput(tx: Transaction, id: ConversationId, input: EntryDraft, context: Context): Promise; /** * Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A * plain Session stages nothing; a Harness stages its built-in documents. diff --git a/dist/session/session.js b/dist/session/session.js index e9ae9ebfc2c45765ccda141d7e60cdc9a15a0894..e1e5c69deed9f0d09cf67c26cdb903fe4593f9c6 100644 --- a/dist/session/session.js +++ b/dist/session/session.js @@ -224,6 +224,7 @@ export class SessionImpl { if (this.#documents.get(id)?.record.id === recordId) this.#dropDocument(id); }, + beforeInput: (tx, id, input, context) => this.beforeInput(tx, id, input, context), conversationCreated: (tx, record) => this.conversationCreated(tx, record), taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(), latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context), @@ -570,6 +571,9 @@ export class SessionImpl { * listener. A plain Session drops them. */ report(_error, _info = LISTENER, _context) { } + async beforeInput(_tx, _id, _input, _context) { + return []; + } /** * Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A * plain Session stages nothing; a Harness stages its built-in documents. diff --git a/dist/session/transaction.d.ts b/dist/session/transaction.d.ts index 5508fa2d021d2153f4f104b4ef69b99ef98a6a74..1c94a4a6e29b8371d49f7d6cb4d2e4e7cd6aec50 100644 --- a/dist/session/transaction.d.ts +++ b/dist/session/transaction.d.ts @@ -48,6 +48,7 @@ export interface TransactionHost { install(document: LoadedDocument): void; /** Remove a retired incarnation if it is still the cached occupant of its address. */ evict(addressId: string, recordId: DocumentId): void; + beforeInput(tx: Transaction, conversationId: ConversationId, input: EntryDraft, context: Context): Promise; /** Stage writes that belong to every newly created or forked conversation, in its creating transaction. */ conversationCreated(tx: Transaction, record: ConversationRecord): Promise; /** The newest entry ID visible from a conversation, known from the Session's commits. */ @@ -112,6 +113,7 @@ export declare class Transaction implements Tx { forkConversation(parentConversationId: ConversationId, at: EntryId, options: { readonly ownership: ConversationOwnership; }): Promise; + beforeInput(conversationId: ConversationId, input: EntryDraft): Promise; appendEntry(conversationId: ConversationId, value: EntryDraft): Promise; appendEntry(token: Entry, conversationId: ConversationId, value: TypedEntryDraft>): Promise>; createTask this.#host.beforeInput(this, conversationId, input, this.#context)); + } appendEntry(first, second, third) { if (typeof first === "number") return this.#appendEntry(first, second); diff --git a/dist/types.d.ts b/dist/types.d.ts index b43143c5e7998b36034ffe9577f74f3d88ab932b..a9ca10ae41c48ad0ebb813e62caa3699dec5c51e 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -409,6 +409,8 @@ export type ContextEdit = { }); /** Immutable transcript event with separate model-facing and application-facing payloads. */ export type EntryRecord = { + /** Placed by the harness from an input submission, not a write. */ + readonly input?: true; readonly id: EntryId; readonly conversationId: ConversationId; /** Application-defined entry discriminator. */ @@ -891,6 +893,8 @@ export type CommitPublication = { * Session may freeze them (its caches and context ranges keep them), so mutating one can throw. */ export interface Tx { + /** Prepare entries before a new run’s first input; called before table writes, in the placement transaction. */ + beforeInput(conversationId: ConversationId, input: EntryDraft): Promise; conversation(id: ConversationId): Promise; entry(id: EntryId): Promise; /** Undefined when the entry is absent or has another kind. */