Files
sausage/host/js/patches/pi-durable-1.5.0.patch
T

388 lines
24 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<InboxS
export type QueueModes = Pick<Settings, "steeringMode" | "followUpMode">;
/** 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<InboxState>;
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<Boundary>;
+export declare function prepareBoundary(tx: Tx, conversationId: ConversationId, modes: QueueModes, at: "postTools" | "final", now: number, incoming?: EntryDraft): Promise<Boundary>;
/**
* 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<BoundaryResult>;
/** 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<EntryRecord>;
/** 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<InputEntry>;
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> = T | undefined | Promise<T | undefined>;
/** 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<readonly EntryDraft[]>;
/**
* 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<readonly EntryDraft[]>;
/** Stage writes that belong to every newly created or forked conversation, in its creating transaction. */
conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void>;
/** 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<ConversationRecord>;
+ beforeInput(conversationId: ConversationId, input: EntryDraft): Promise<readonly EntryDraft[]>;
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
appendEntry<D extends JsonValue>(token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<NoInfer<D>>): Promise<TypedEntry<D>>;
createTask<I, S extends {
diff --git a/dist/session/transaction.js b/dist/session/transaction.js
index 7fbe75b11bb2a9ab28f4f23800143231172d71b7..a516853e2f9c4a4437dbc2bb8f1c8d2117386e9a 100644
--- a/dist/session/transaction.js
+++ b/dist/session/transaction.js
@@ -167,6 +167,9 @@ export class Transaction {
this.#assertOpen();
return record;
}
+ beforeInput(conversationId, input) {
+ return this.#read("beforeInput", () => 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<readonly EntryDraft[]>;
conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
entry(id: EntryId): Promise<EntryRecord | undefined>;
/** Undefined when the entry is absent or has another kind. */