chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
314
third_party/pi/packages/durable/src/harness/agent.ts
vendored
Normal file
314
third_party/pi/packages/durable/src/harness/agent.ts
vendored
Normal file
@@ -0,0 +1,314 @@
|
||||
import { copyJson, type Draft } from "@earendil-works/chord";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import { documentRetention } from "../session/session.ts";
|
||||
import type { ConversationId, ConversationRecord, Tx } from "../types.ts";
|
||||
import type {
|
||||
Agent,
|
||||
AgentChange,
|
||||
AgentState,
|
||||
CompactionPolicy,
|
||||
ConversationRetryPolicy,
|
||||
Extension,
|
||||
HarnessSettings,
|
||||
ProgressPolicy,
|
||||
PromptSection,
|
||||
RegistrySnapshot,
|
||||
Settings,
|
||||
ToolRegistration,
|
||||
} from "./types.ts";
|
||||
|
||||
export const DEFAULT_RETRY_POLICY: ConversationRetryPolicy = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 2000,
|
||||
maxAgentDelayMs: 60000,
|
||||
};
|
||||
|
||||
export const DEFAULT_COMPACTION_POLICY: CompactionPolicy = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
backgroundTokens: 32768,
|
||||
};
|
||||
|
||||
export const DEFAULT_PROGRESS_POLICY: ProgressPolicy = {
|
||||
partialIntervalMs: 100,
|
||||
outputIntervalMs: 100,
|
||||
};
|
||||
|
||||
/** The reserved section key of the agent's `instructions`. */
|
||||
export const INSTRUCTIONS_KEY = "instructions";
|
||||
|
||||
/** Built-in agent document; rewindable so forks start from the agent at their fork entry. */
|
||||
export const AgentDoc = defineDoc<AgentState>({
|
||||
kind: "pi.agent",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({}),
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
/** Default `settings.contextRetentionMs`: ten minutes. */
|
||||
const DEFAULT_CONTEXT_RETENTION_MS = 600_000;
|
||||
|
||||
/** Resolve the host settings: every field over its built-in default, object fields merged. */
|
||||
export function resolveSettings(settings: HarnessSettings | undefined): Settings {
|
||||
const extensions = settings?.extensions;
|
||||
return {
|
||||
...(extensions === undefined ? {} : { extensions }),
|
||||
stream: { ...settings?.stream },
|
||||
retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry },
|
||||
compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction },
|
||||
// Field by field, so an explicitly undefined interval keeps its default.
|
||||
progress: {
|
||||
partialIntervalMs: settings?.progress?.partialIntervalMs ?? DEFAULT_PROGRESS_POLICY.partialIntervalMs,
|
||||
outputIntervalMs: settings?.progress?.outputIntervalMs ?? DEFAULT_PROGRESS_POLICY.outputIntervalMs,
|
||||
},
|
||||
toolExecution: settings?.toolExecution ?? "parallel",
|
||||
steeringMode: settings?.steeringMode ?? "one-at-a-time",
|
||||
followUpMode: settings?.followUpMode ?? "one-at-a-time",
|
||||
contextRetentionMs: settings?.contextRetentionMs ?? DEFAULT_CONTEXT_RETENTION_MS,
|
||||
documentRetentionMs: documentRetention(settings?.documentRetentionMs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply one change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */
|
||||
export async function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void> {
|
||||
const state = await tx.doc(AgentDoc, conversationId);
|
||||
applyChange(state, change);
|
||||
}
|
||||
|
||||
/**
|
||||
* `addTools` of a tool round, applied to `tools` and `modelTools`, so the next request offers the tools: an array gets
|
||||
* each name it lacks appended, and `{ remove }` loses the names. An unset `tools` already enables every tool, so nothing
|
||||
* is written for it. An unset or object `modelTools` also gets each name appended to its `add`, which loads deferred
|
||||
* tools and has no effect for others. By name, without the registry: a tool that is not registered now, such as one of
|
||||
* an MCP server that is reconnecting, is offered once it is again.
|
||||
*/
|
||||
export async function addTools(tx: Tx, conversationId: ConversationId, added: readonly string[]): Promise<void> {
|
||||
const state = await tx.doc(AgentDoc, conversationId);
|
||||
const tools = state.tools;
|
||||
if (Array.isArray(tools)) {
|
||||
for (const name of added) if (!tools.includes(name)) tools.push(name);
|
||||
} else if (tools?.remove.some((name) => added.includes(name))) {
|
||||
state.tools = { remove: tools.remove.filter((name) => !added.includes(name)) };
|
||||
}
|
||||
const modelTools = state.modelTools;
|
||||
if (Array.isArray(modelTools)) {
|
||||
for (const name of added) if (!modelTools.includes(name)) modelTools.push(name);
|
||||
return;
|
||||
}
|
||||
// Assigned whole; a document write of an equal value records no change.
|
||||
const remove = (modelTools?.remove ?? []).filter((name) => !added.includes(name));
|
||||
const add = [...(modelTools?.add ?? [])];
|
||||
for (const name of added) if (!add.includes(name)) add.push(name);
|
||||
state.modelTools = { ...(remove.length === 0 ? {} : { remove }), add };
|
||||
}
|
||||
|
||||
function applyChange(state: Draft<AgentState>, change: AgentChange): void {
|
||||
const set = <K extends keyof AgentState>(key: K, value: AgentState[K] | null | undefined) => {
|
||||
if (value === undefined) return;
|
||||
if (value === null) delete state[key];
|
||||
else state[key] = value as Draft<AgentState>[K];
|
||||
};
|
||||
set("model", change.model === undefined || change.model === null ? change.model : { ...change.model });
|
||||
set("thinkingLevel", change.thinkingLevel);
|
||||
const extensions = change.extensions;
|
||||
set(
|
||||
"extensions",
|
||||
extensions === undefined || extensions === null
|
||||
? extensions
|
||||
: isList(extensions)
|
||||
? names(extensions)
|
||||
: {
|
||||
...(extensions.add === undefined ? {} : { add: names(extensions.add) }),
|
||||
...(extensions.remove === undefined ? {} : { remove: names(extensions.remove) }),
|
||||
},
|
||||
);
|
||||
const tools = change.tools;
|
||||
set(
|
||||
"tools",
|
||||
tools === undefined || tools === null ? tools : isList(tools) ? names(tools) : { remove: names(tools.remove) },
|
||||
);
|
||||
const modelTools = change.modelTools;
|
||||
set(
|
||||
"modelTools",
|
||||
modelTools === undefined || modelTools === null
|
||||
? modelTools
|
||||
: isList(modelTools)
|
||||
? names(modelTools)
|
||||
: {
|
||||
...(modelTools.remove === undefined ? {} : { remove: names(modelTools.remove) }),
|
||||
...(modelTools.add === undefined ? {} : { add: names(modelTools.add) }),
|
||||
},
|
||||
);
|
||||
set("instructions", change.instructions);
|
||||
set("cwd", change.cwd);
|
||||
}
|
||||
|
||||
function isList<T>(value: readonly T[] | object): value is readonly T[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function names(items: readonly { readonly name: string }[]): string[] {
|
||||
return items.map((item) => item.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in part of every Harness commit that creates or forks a conversation, for `pi.agent`: a fork keeps its `asOf`
|
||||
* copy; a new task-owned conversation copies the stored agent of its owner task's conversation; a new ownerless one
|
||||
* starts empty.
|
||||
*/
|
||||
export async function createAgent(tx: Tx, conversation: ConversationRecord): Promise<void> {
|
||||
if (conversation.parent !== undefined) return;
|
||||
const agent = await tx.doc(AgentDoc, conversation.id);
|
||||
if (conversation.owner === undefined) return;
|
||||
const owner = await tx.doc(AgentDoc, conversation.owner.conversationId);
|
||||
Object.assign(agent, copyJson(owner) as AgentState);
|
||||
}
|
||||
|
||||
/** Handlers of the selected extensions' hooks for a task name, in extension order. */
|
||||
export function agentHooks(agent: Agent, taskName: string): object[] {
|
||||
const handlers: object[] = [];
|
||||
for (const extension of agent.extensions) {
|
||||
for (const hook of extension.hooks ?? []) if (hook.task === taskName) handlers.push(hook.handlers);
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent from its stored state (absent: every field unset), a registry snapshot, and resolved settings. A
|
||||
* wrapper that throws or renames drops its target and is reported; a wrapper without a target does nothing.
|
||||
*/
|
||||
export function resolveAgent<Tool extends ToolRegistration>(
|
||||
state: Readonly<AgentState> | undefined,
|
||||
snapshot: RegistrySnapshot<Tool>,
|
||||
settings: Settings,
|
||||
report: (error: unknown) => void,
|
||||
): Agent<Tool> {
|
||||
const extensions = selectExtensions(state?.extensions, snapshot, settings);
|
||||
|
||||
const composed = new Map<string, Tool>();
|
||||
for (const extension of extensions) for (const tool of extension.tools ?? []) composed.set(tool.name, tool);
|
||||
const sections = new Map<string, PromptSection<Tool>>();
|
||||
for (const extension of extensions) {
|
||||
for (const section of extension.sections ?? []) sections.set(section.key, section);
|
||||
}
|
||||
for (const extension of extensions) {
|
||||
for (const wrap of extension.wraps ?? []) {
|
||||
if ("tool" in wrap)
|
||||
applyWrap(
|
||||
composed,
|
||||
wrap.tool,
|
||||
(tool) => wrap.wrap(tool),
|
||||
(tool) => tool.name,
|
||||
report,
|
||||
);
|
||||
else
|
||||
applyWrap(
|
||||
sections,
|
||||
wrap.section,
|
||||
(section) => wrap.wrap(section),
|
||||
(section) => section.key,
|
||||
report,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = filterTools([...composed.values()], state?.tools);
|
||||
const callableBy = (caller: "model" | "tools") => (tool: Tool) =>
|
||||
tool.callers === undefined || tool.callers.includes(caller);
|
||||
const modelCallable = enabled.filter(callableBy("model"));
|
||||
const tools = offeredTools(modelCallable, state?.modelTools);
|
||||
const offered = new Set(tools);
|
||||
const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));
|
||||
const callable = enabled.filter(callableBy("tools"));
|
||||
|
||||
const instructions = state?.instructions;
|
||||
const agentSections = [...sections.values()];
|
||||
if (instructions !== undefined) agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions });
|
||||
|
||||
const agent: Agent<Tool> = {
|
||||
...(state?.model === undefined ? {} : { model: state.model }),
|
||||
thinkingLevel: state?.thinkingLevel ?? "off",
|
||||
extensions,
|
||||
tools,
|
||||
callable,
|
||||
deferred,
|
||||
sections: agentSections,
|
||||
...(instructions === undefined ? {} : { instructions }),
|
||||
...(state?.cwd === undefined ? {} : { cwd: state.cwd }),
|
||||
};
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** The tools offered to the model, as `AgentState.modelTools` describes. */
|
||||
function offeredTools<Tool extends ToolRegistration>(tools: Tool[], filter: AgentState["modelTools"]): Tool[] {
|
||||
if (Array.isArray(filter)) return filterTools(tools, filter);
|
||||
const removed = new Set(filter?.remove ?? []);
|
||||
const kept = tools.filter((tool) => tool.deferred !== true && !removed.has(tool.name));
|
||||
// Loaded deferred tools follow in load order, so loading one appends to the declared tools.
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
const loaded = [...new Set(filter?.add ?? [])].flatMap((name) => {
|
||||
const tool = byName.get(name);
|
||||
return tool?.deferred === true && !removed.has(name) ? [tool] : [];
|
||||
});
|
||||
return [...kept, ...loaded];
|
||||
}
|
||||
|
||||
/** `tools` through a stored filter: an array selects exactly its names, in its order; `{ remove }` drops names. */
|
||||
function filterTools<Tool extends ToolRegistration>(tools: Tool[], filter: AgentState["tools"]): Tool[] {
|
||||
if (filter === undefined) return tools;
|
||||
if (Array.isArray(filter)) {
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
return [...new Set(filter)].flatMap((name) => {
|
||||
const tool = byName.get(name);
|
||||
return tool === undefined ? [] : [tool];
|
||||
});
|
||||
}
|
||||
const removed = new Set((filter as { remove: string[] }).remove);
|
||||
return tools.filter((tool) => !removed.has(tool.name));
|
||||
}
|
||||
|
||||
/** Selected installed extensions: the stored array, or the default selection edited by `{ add, remove }`. */
|
||||
function selectExtensions<Tool extends ToolRegistration>(
|
||||
stored: AgentState["extensions"],
|
||||
snapshot: RegistrySnapshot<Tool>,
|
||||
settings: Settings,
|
||||
): Extension<Tool>[] {
|
||||
let selected: string[];
|
||||
if (Array.isArray(stored)) selected = stored;
|
||||
else {
|
||||
const base = settings.extensions?.map((extension) => extension.name) ?? snapshot.installed().map((e) => e.name);
|
||||
const edit = stored as { add?: string[]; remove?: string[] } | undefined;
|
||||
const removed = new Set(edit?.remove ?? []);
|
||||
selected = [...base, ...(edit?.add ?? [])].filter((name) => !removed.has(name));
|
||||
}
|
||||
const extensions: Extension<Tool>[] = [];
|
||||
for (const name of new Set(selected)) {
|
||||
const extension = snapshot.extension(name);
|
||||
if (extension !== undefined) extensions.push(extension);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
function applyWrap<T>(
|
||||
items: Map<string, T>,
|
||||
target: string,
|
||||
wrap: (item: T) => T,
|
||||
nameOf: (item: T) => string,
|
||||
report: (error: unknown) => void,
|
||||
): void {
|
||||
const item = items.get(target);
|
||||
if (item === undefined) return;
|
||||
try {
|
||||
const wrapped = wrap(item);
|
||||
if (nameOf(wrapped) !== target) throw new Error(`Wrapper renamed ${target} to ${nameOf(wrapped)}`);
|
||||
items.set(target, wrapped);
|
||||
} catch (error) {
|
||||
items.delete(target);
|
||||
report(error);
|
||||
}
|
||||
}
|
||||
464
third_party/pi/packages/durable/src/harness/compaction.ts
vendored
Normal file
464
third_party/pi/packages/durable/src/harness/compaction.ts
vendored
Normal file
@@ -0,0 +1,464 @@
|
||||
import type { Context, Draft } from "@earendil-works/chord";
|
||||
import type { AssistantMessage, Message, ModelThinkingLevel, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||
import { calculateContextTokens, estimateMessageTokens } from "@earendil-works/pi-ai/utils/estimate";
|
||||
import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry";
|
||||
import { CompactionEntry } from "../entries.ts";
|
||||
import { defineTask } from "../tasks.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
EntryDraft,
|
||||
EntryId,
|
||||
NextTaskState,
|
||||
RunningTask,
|
||||
TaskId,
|
||||
TaskRuntime,
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import { orderToolResults } from "./context.ts";
|
||||
import {
|
||||
addCompactionStatus,
|
||||
compactionStatus,
|
||||
failCompactionStatus,
|
||||
LiveDoc,
|
||||
type LiveState,
|
||||
removeCompactionStatus,
|
||||
} from "./live.ts";
|
||||
import { ensureProviderSessionId } from "./provider.ts";
|
||||
import { admitSubmission } from "./submissions.ts";
|
||||
import type {
|
||||
CompactionHooks,
|
||||
CompactionReason,
|
||||
CompactionResult,
|
||||
ContextView,
|
||||
ConversationStreamOptions,
|
||||
ModelRef,
|
||||
} from "./types.ts";
|
||||
import { recordUsage } from "./usage.ts";
|
||||
|
||||
export type CompactionInput = { reason: CompactionReason; instructions?: string };
|
||||
|
||||
/** The pinned summarization request. */
|
||||
export type SummaryRequest = {
|
||||
attempt: number;
|
||||
model: ModelRef;
|
||||
thinkingLevel: ModelThinkingLevel;
|
||||
streamOptions: ConversationStreamOptions;
|
||||
maxTokens: number;
|
||||
/** Newest entry of the context the range was selected from. */
|
||||
tail: EntryId;
|
||||
/** First entry kept verbatim; the summary's `head`. */
|
||||
firstKept: EntryId;
|
||||
};
|
||||
|
||||
export type CompactionCheckpoint =
|
||||
| { phase: "select" }
|
||||
| ({ phase: "summarize" } & SummaryRequest)
|
||||
| ({ phase: "retry"; until: number } & SummaryRequest);
|
||||
|
||||
type Runtime = TaskRuntime<CompactionInput, CompactionCheckpoint, CompactionResult, CompactionHooks>;
|
||||
type Next = NextTaskState<CompactionCheckpoint, CompactionResult>;
|
||||
|
||||
/** Longest tool result text a serialized summary source keeps. */
|
||||
const TOOL_RESULT_MAX_CHARS = 2000;
|
||||
|
||||
const SUMMARY_PREFIX =
|
||||
"The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
|
||||
const SUMMARY_SUFFIX = "\n</summary>";
|
||||
|
||||
const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
||||
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. If the conversation starts with an earlier summary, preserve its information and fold the newer messages into it.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned by user]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [Ordered list of what should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Built-in compaction task (spec §8.7): select an old prefix of the model context, summarize it, and place a summary
|
||||
* entry whose `head` is the first kept entry. A compaction the generation owns blocks it and appends directly; a
|
||||
* conversation-owned one places its summary through a write submission.
|
||||
*/
|
||||
export const CompactionTask = defineTask<CompactionInput, CompactionCheckpoint, CompactionResult, CompactionHooks>({
|
||||
name: "pi.compaction",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "select" }),
|
||||
phases: {
|
||||
select: async (task, runtime, context) => {
|
||||
const { conversationId } = runtime;
|
||||
const agent = await runtime.agent(context);
|
||||
const settings = runtime.settings;
|
||||
const ref = agent.model;
|
||||
const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId);
|
||||
if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context);
|
||||
const policy = settings.compaction;
|
||||
const view = await runtime.context(conversationId, context);
|
||||
const cut = selectCut(view, policy.keepRecentTokens);
|
||||
if (cut === undefined) return complete(runtime, context);
|
||||
const firstKept = view.entries[cut]!.id;
|
||||
const { reason, instructions } = task.input;
|
||||
const compaction = {
|
||||
reason,
|
||||
entries: view.entries.slice(0, cut),
|
||||
messages: summarizedMessages(view, cut),
|
||||
firstKept,
|
||||
...(instructions === undefined ? {} : { instructions }),
|
||||
};
|
||||
let decision: { readonly decline: true } | { readonly summary: string } | undefined;
|
||||
await runtime.hooks.each("beforeCompact", async (hook) => {
|
||||
if (decision === undefined) decision = await hook(compaction, runtime, context);
|
||||
});
|
||||
if (decision !== undefined && "decline" in decision) return complete(runtime, context);
|
||||
if (decision !== undefined) return place(runtime, firstKept, decision.summary, context);
|
||||
const request: SummaryRequest = {
|
||||
attempt: 1,
|
||||
model: ref,
|
||||
thinkingLevel: agent.thinkingLevel,
|
||||
streamOptions: settings.stream,
|
||||
maxTokens: Math.min(
|
||||
Math.floor(0.8 * policy.reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
),
|
||||
tail: view.entries.reduce((tail, entry) => (entry.id > tail ? entry.id : tail), firstKept),
|
||||
firstKept,
|
||||
};
|
||||
await runtime.commit(() => ({ status: "running", checkpoint: { phase: "summarize", ...request } }), context);
|
||||
},
|
||||
summarize: async (task, runtime, context) => {
|
||||
const { phase: _, ...request } = task.state.checkpoint;
|
||||
const { model: ref, thinkingLevel, streamOptions, maxTokens, tail, firstKept, attempt } = request;
|
||||
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
||||
if (model === undefined) return failNoModel(runtime, ref, context);
|
||||
// The context at `tail` is immutable, so this is the range `select` chose.
|
||||
const view = await runtime.context(runtime.conversationId, context, { at: tail });
|
||||
const cut = view.entries.findIndex((entry) => entry.id === firstKept);
|
||||
const now = runtime.now();
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: SUMMARIZATION_SYSTEM_PROMPT, timestamp: now },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: summaryPrompt(summarizedMessages(view, cut), task.input.instructions) }],
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
const { deferred: _deferred, ...forwarded } = streamOptions;
|
||||
const options: SimpleStreamOptions = {
|
||||
...forwarded,
|
||||
cacheRetention: "none",
|
||||
maxTokens,
|
||||
signal: runtime.signal,
|
||||
sessionId: await ensureProviderSessionId(runtime, context),
|
||||
...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),
|
||||
};
|
||||
const message = await runtime.models.completeSimple(model, { messages }, options);
|
||||
// An abort mark or close: the abort invocation or the reopened task handles the committed state.
|
||||
runtime.signal.throwIfAborted();
|
||||
const summary = summaryText(message);
|
||||
const policy = runtime.settings.retry;
|
||||
const retry =
|
||||
message.stopReason === "error" &&
|
||||
isRetryableAssistantError(message) &&
|
||||
policy.enabled &&
|
||||
attempt <= policy.maxRetries;
|
||||
const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;
|
||||
await runtime.commit(async (tx, current): Promise<Next> => {
|
||||
await recordUsage(
|
||||
tx,
|
||||
runtime.conversationId,
|
||||
"models",
|
||||
`${message.provider}/${message.model}`,
|
||||
message.usage,
|
||||
);
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
if (summary !== undefined) return placeSummary(tx, runtime, current, live, firstKept, summary);
|
||||
if (retry) {
|
||||
const status = compactionStatus(live, runtime.taskId);
|
||||
if (status !== undefined) status.retry = { at: until, error: message.errorMessage ?? "" };
|
||||
return { status: "running", checkpoint: { phase: "retry", ...request, until } };
|
||||
}
|
||||
const text = summaryFailure(message);
|
||||
failCompactionStatus(live, runtime.taskId, text);
|
||||
return {
|
||||
status: "terminal",
|
||||
outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
|
||||
};
|
||||
}, context);
|
||||
},
|
||||
retry: async (task, runtime, context) => {
|
||||
const { phase: _, until, ...request } = task.state.checkpoint;
|
||||
await runtime.sleep(until, context);
|
||||
const attempt = request.attempt + 1;
|
||||
await runtime.commit(async (tx) => {
|
||||
const status = compactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
|
||||
if (status !== undefined) {
|
||||
status.attempt = attempt;
|
||||
delete status.retry;
|
||||
}
|
||||
return { status: "running", checkpoint: { phase: "summarize", ...request, attempt } };
|
||||
}, context);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, context) => {
|
||||
await runtime.commit(async (tx) => {
|
||||
removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
|
||||
return { status: "terminal", outcome: { status: "aborted" } };
|
||||
}, context);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a compaction task with its status in this commit. `owner` is the generation that waits for it (a blocking
|
||||
* compaction); without one it is conversation-owned, and `background` unless it is manual.
|
||||
*/
|
||||
export async function createCompaction(
|
||||
tx: Tx,
|
||||
conversationId: ConversationId,
|
||||
input: CompactionInput,
|
||||
owner?: TaskId,
|
||||
): Promise<TaskId<CompactionResult>> {
|
||||
const ownership =
|
||||
owner === undefined ? ({ kind: "conversation" } as const) : { kind: "task" as const, taskId: owner };
|
||||
const background = owner === undefined && input.reason !== "manual";
|
||||
const taskId = await tx.createTask(CompactionTask, input, { ownership, conversationId, background });
|
||||
const status = { taskId, reason: input.reason, blocking: owner !== undefined, attempt: 1 };
|
||||
addCompactionStatus(await tx.doc(LiveDoc, conversationId), status);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index in `view.entries` of the first entry a summary keeps, or `undefined` when there is nothing to compact
|
||||
* (spec §8.7). Walks back from the tail until `keepRecentTokens` are kept, then cuts at the first candidate at or after
|
||||
* that entry: an entry whose contribution starts with a user or assistant message, never a tool result, and never a
|
||||
* user entry that a result of the preceding assistant's calls still follows.
|
||||
*/
|
||||
export function selectCut(view: ContextView, keepRecentTokens: number): number | undefined {
|
||||
const { contributions } = view;
|
||||
const start = view.head === undefined ? 0 : 1;
|
||||
const candidates: number[] = [];
|
||||
for (let index = start; index < contributions.length; index++) {
|
||||
if (isCandidate(contributions, index)) candidates.push(index);
|
||||
}
|
||||
let kept = 0;
|
||||
let cut: number | undefined;
|
||||
for (let index = contributions.length - 1; index >= start; index--) {
|
||||
for (const message of contributions[index]!) kept += estimateMessageTokens(message);
|
||||
if (kept < keepRecentTokens) continue;
|
||||
cut = candidates.find((candidate) => candidate >= index) ?? candidates.at(-1);
|
||||
break;
|
||||
}
|
||||
if (cut === undefined) return undefined;
|
||||
for (let index = start; index < cut; index++) if (contributions[index]!.length > 0) return cut;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isCandidate(contributions: ContextView["contributions"], index: number): boolean {
|
||||
const first = contributions[index]![0];
|
||||
if (first?.role === "assistant") return true;
|
||||
if (first?.role !== "user") return false;
|
||||
// A result of the preceding assistant's calls that follows this entry, before the next assistant, belongs before it.
|
||||
let calls: ReadonlySet<string> = new Set();
|
||||
for (let before = index - 1; before >= 0; before--) {
|
||||
const assistant = contributions[before]!.findLast((message) => message.role === "assistant");
|
||||
if (assistant === undefined) continue;
|
||||
calls = new Set(assistant.content.flatMap((content) => (content.type === "toolCall" ? [content.id] : [])));
|
||||
break;
|
||||
}
|
||||
if (calls.size === 0) return true;
|
||||
for (let after = index; after < contributions.length; after++) {
|
||||
for (const [position, message] of contributions[after]!.entries()) {
|
||||
if (message.role === "assistant" && (after > index || position > 0)) return true;
|
||||
if (message.role === "toolResult" && calls.has(message.toolCallId)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Model messages of the entries before `cut`: the head marker first, ordered like model context (spec §2.1). */
|
||||
export function summarizedMessages(view: ContextView, cut: number): Message[] {
|
||||
return orderToolResults(view.contributions.slice(0, cut).flat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Size of a request over `view` followed by `extra` (spec §8.3): the usage of the newest assistant appended after the
|
||||
* head marker, whose request included the marker, plus estimates of the messages after it; without one, estimates of
|
||||
* every message.
|
||||
*/
|
||||
export function estimateContext(view: ContextView, extra: readonly Message[]): number {
|
||||
let measured: AssistantMessage | undefined;
|
||||
const after = view.head?.id ?? Number.NEGATIVE_INFINITY;
|
||||
for (let index = view.entries.length - 1; index >= 0 && measured === undefined; index--) {
|
||||
if (view.entries[index]!.id <= after) continue;
|
||||
measured = view.contributions[index]!.findLast(
|
||||
(message): message is AssistantMessage =>
|
||||
message.role === "assistant" && calculateContextTokens(message.usage) > 0,
|
||||
);
|
||||
}
|
||||
const from = measured === undefined ? 0 : view.messages.lastIndexOf(measured) + 1;
|
||||
let tokens = measured === undefined ? 0 : calculateContextTokens(measured.usage);
|
||||
for (const message of view.messages.slice(from)) tokens += estimateMessageTokens(message);
|
||||
for (const message of extra) tokens += estimateMessageTokens(message);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** The summary of a clean `stop` with text and no tool call; anything else is not a summary. */
|
||||
function summaryText(message: AssistantMessage): string | undefined {
|
||||
if (message.stopReason !== "stop" || message.content.some((content) => content.type === "toolCall"))
|
||||
return undefined;
|
||||
const text = message.content
|
||||
.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
.join("\n")
|
||||
.trim();
|
||||
return text.length === 0 ? undefined : text;
|
||||
}
|
||||
|
||||
function summaryFailure(message: AssistantMessage): string {
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
return `Summarization failed: ${message.errorMessage ?? message.stopReason}`;
|
||||
}
|
||||
if (message.stopReason === "length") return "Summarization hit the token limit; the summary is incomplete";
|
||||
if (message.content.some((content) => content.type === "toolCall")) return "Summarization attempted to call a tool";
|
||||
return "Summarization produced no text";
|
||||
}
|
||||
|
||||
/** The summarizer's user message: the serialized conversation, the prompt, and any instructions. */
|
||||
function summaryPrompt(messages: readonly Message[], instructions: string | undefined): string {
|
||||
const focus = instructions === undefined ? "" : `\n\nAdditional focus: ${instructions}`;
|
||||
return `<conversation>\n${serializeConversation(messages)}\n</conversation>\n\n${SUMMARIZATION_PROMPT}${focus}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages as plain text, so the summarizer reads a transcript instead of continuing it. System messages are omitted.
|
||||
* Thinking is omitted too: providers return summaries of the real reasoning, and replayed thinking is often not part of
|
||||
* the live context (a block that does not match its request is dropped), so including it can make the request larger
|
||||
* than the context it summarizes. Visible text, tool calls, and tool results record what happened.
|
||||
*/
|
||||
export function serializeConversation(messages: readonly Message[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
const text = contentText(message.content);
|
||||
if (text.length > 0) parts.push(`[User]: ${text}`);
|
||||
} else if (message.role === "assistant") {
|
||||
const text = message.content.flatMap((content) => (content.type === "text" ? [content.text] : []));
|
||||
const calls = message.content.flatMap((content) =>
|
||||
content.type === "toolCall"
|
||||
? [
|
||||
`${content.name}(${Object.entries(content.arguments)
|
||||
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
||||
.join(", ")})`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
if (text.length > 0) parts.push(`[Assistant]: ${text.join("\n")}`);
|
||||
if (calls.length > 0) parts.push(`[Assistant tool calls]: ${calls.join("; ")}`);
|
||||
} else if (message.role === "toolResult") {
|
||||
const text = contentText(message.content);
|
||||
if (text.length > 0) parts.push(`[Tool result]: ${truncate(text, TOOL_RESULT_MAX_CHARS)}`);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function contentText(content: string | readonly { readonly type: string; readonly text?: string }[]): string {
|
||||
if (typeof content === "string") return content;
|
||||
return content
|
||||
.flatMap((block) => (block.type === "text" && block.text !== undefined ? [block.text] : []))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function truncate(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}\n\n[... ${text.length - maxChars} more characters truncated]`;
|
||||
}
|
||||
|
||||
/** Place a summary supplied by a hook in its own commit. */
|
||||
async function place(runtime: Runtime, firstKept: EntryId, summary: string, context: Context): Promise<void> {
|
||||
await runtime.commit(
|
||||
async (tx, current) =>
|
||||
placeSummary(tx, runtime, current, await tx.doc(LiveDoc, runtime.conversationId), firstKept, summary),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the summary entry and complete (spec §8.7). A blocking compaction, owned by its generation, appends it: the
|
||||
* generation holds the run and waits. A conversation-owned one admits it as a write submission, placed at once when
|
||||
* idle, otherwise at the next boundary, or settled `stale`.
|
||||
* REMINDER: nothing else may append to a busy conversation, so every non-blocking summary goes through admission.
|
||||
*/
|
||||
async function placeSummary(
|
||||
tx: Tx,
|
||||
runtime: Runtime,
|
||||
current: RunningTask<CompactionInput, CompactionCheckpoint, CompactionResult>,
|
||||
live: Draft<LiveState>,
|
||||
firstKept: EntryId,
|
||||
summary: string,
|
||||
): Promise<Next> {
|
||||
removeCompactionStatus(live, runtime.taskId);
|
||||
delete live.compactionFailure;
|
||||
const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`;
|
||||
const entry = {
|
||||
kind: CompactionEntry.kind,
|
||||
head: firstKept,
|
||||
model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }],
|
||||
data: { reason: current.input.reason },
|
||||
} satisfies EntryDraft;
|
||||
const result: CompactionResult =
|
||||
current.owner === undefined
|
||||
? {
|
||||
submissionId: await admitSubmission(
|
||||
tx,
|
||||
runtime.conversationId,
|
||||
{ type: "write", requestId: `compaction:${runtime.taskId}`, entry },
|
||||
runtime.now(),
|
||||
runtime.settings,
|
||||
),
|
||||
}
|
||||
: { entryId: (await tx.appendEntry(runtime.conversationId, entry)).id };
|
||||
return { status: "terminal", outcome: { status: "completed", result } };
|
||||
}
|
||||
|
||||
/** Remove the status and complete without a summary. */
|
||||
async function complete(runtime: Runtime, context: Context): Promise<void> {
|
||||
await runtime.commit(async (tx) => {
|
||||
removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
|
||||
return { status: "terminal", outcome: { status: "completed", result: {} } };
|
||||
}, context);
|
||||
}
|
||||
|
||||
async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context: Context): Promise<void> {
|
||||
const message =
|
||||
ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
|
||||
await runtime.commit(async (tx) => {
|
||||
failCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId, message);
|
||||
return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
|
||||
}, context);
|
||||
}
|
||||
333
third_party/pi/packages/durable/src/harness/context.ts
vendored
Normal file
333
third_party/pi/packages/durable/src/harness/context.ts
vendored
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import { SystemEntry } from "../entries.ts";
|
||||
import { idFromNumber } from "../ids.ts";
|
||||
import type { SessionImpl } from "../session/session.ts";
|
||||
import type { ContextEdit, ConversationId, Cursor, EntryId, EntryQuery, EntryRecord, Storage } from "../types.ts";
|
||||
import type { ContextView } from "./types.ts";
|
||||
|
||||
const SCAN_PAGE_SIZE = 256;
|
||||
const EXCLUDED_STOP_REASONS: ReadonlySet<AssistantMessage["stopReason"]> = new Set(["aborted", "error", "deferred"]);
|
||||
const MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed.";
|
||||
|
||||
/** Head marker and newest visible entry that fix one committed context range. */
|
||||
export type ContextBounds = {
|
||||
readonly head: (EntryRecord & { readonly head: EntryId }) | undefined;
|
||||
readonly tail: EntryId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture the bounds of the current context, or of the context cut off at the visible entry `at`, with two O(1)
|
||||
* reads. Run this on the Session line; entries at or below the tail are immutable, so `deriveContext()` can then scan
|
||||
* them off the line.
|
||||
*/
|
||||
export async function captureContextBounds(
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
context: Context,
|
||||
at?: EntryId,
|
||||
): Promise<ContextBounds | undefined> {
|
||||
let tail: EntryId | undefined;
|
||||
if (at === undefined) {
|
||||
tail = (await storage.scanEntries({ conversationId }, 1, undefined, context)).items[0]?.id;
|
||||
if (tail === undefined) return undefined;
|
||||
} else {
|
||||
if ((await storage.entry(conversationId, at, context)) === undefined) {
|
||||
throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
|
||||
}
|
||||
tail = at;
|
||||
}
|
||||
return { head: await storage.findLatestHeadMarker(conversationId, tail, context), tail };
|
||||
}
|
||||
|
||||
/**
|
||||
* One context read: the visible entries it scanned, from the head marker's head, or transcript start, through
|
||||
* `bounds.tail`, oldest first, and the view derived from them. Entries at or below the tail never change, so a later
|
||||
* read with the same head marker extends both.
|
||||
*/
|
||||
export type ContextRange = {
|
||||
readonly bounds: ContextBounds;
|
||||
readonly entries: readonly EntryRecord[];
|
||||
readonly view: ContextView;
|
||||
/** Targets of the edits in `entries`. */
|
||||
readonly edited: ReadonlySet<EntryId>;
|
||||
/**
|
||||
* `view.messages` from the contributed messages before the last assistant message. Tool results are ordered within
|
||||
* the messages up to the next assistant message, so later entries cannot change these.
|
||||
*/
|
||||
readonly settled: readonly Message[];
|
||||
/** Contributed messages from the last assistant message on, before tool result ordering. */
|
||||
readonly open: readonly Message[];
|
||||
/** The baseline's system message; see `findBaseline()`. */
|
||||
readonly baseline: Baseline;
|
||||
};
|
||||
|
||||
/** A baseline's system message, `null` when the range cannot have one, or `undefined` while a later entry may be one. */
|
||||
type Baseline = Message | null | undefined;
|
||||
|
||||
/**
|
||||
* `readContext()` that reuses `previous`, an earlier range of the same conversation: with the same head marker, only
|
||||
* entries after its tail are scanned. Returns the view and the range to pass to the next read. A range outlives the
|
||||
* read, so its entries are frozen like `MemoryStorage` records, and the returned view has its own arrays.
|
||||
*/
|
||||
export async function readContextFrom(
|
||||
session: SessionImpl,
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
context: Context,
|
||||
at: EntryId | undefined,
|
||||
previous: ContextRange | undefined,
|
||||
): Promise<{ readonly view: ContextView; readonly range: ContextRange | undefined }> {
|
||||
const bounds = await session.readOnLine(() => captureContextBounds(storage, conversationId, context, at));
|
||||
if (bounds === undefined) return { view: emptyView(), range: undefined };
|
||||
freezeJson(bounds.head);
|
||||
let range: ContextRange;
|
||||
if (previous === undefined || previous.bounds.head?.id !== bounds.head?.id) {
|
||||
range = deriveRange(bounds, freezeJson(await scanRange(storage, conversationId, rangeQuery(bounds), context)));
|
||||
} else if (bounds.tail === previous.bounds.tail) {
|
||||
range = previous;
|
||||
} else if (bounds.tail < previous.bounds.tail) {
|
||||
range = deriveRange(
|
||||
bounds,
|
||||
previous.entries.filter((entry) => entry.id <= bounds.tail),
|
||||
);
|
||||
} else {
|
||||
const minEntryId = idFromNumber<EntryId>(previous.bounds.tail + 1);
|
||||
const added = await scanRange(storage, conversationId, { minEntryId, maxEntryId: bounds.tail }, context);
|
||||
range = extendRange(previous, bounds, freezeJson(added));
|
||||
}
|
||||
const { view } = range;
|
||||
return {
|
||||
view: {
|
||||
...view,
|
||||
entries: [...view.entries],
|
||||
contributions: [...view.contributions],
|
||||
messages: [...view.messages],
|
||||
},
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
/** Committed context of one conversation: bounds captured on the Session line, entries derived off it. */
|
||||
export async function readContext(
|
||||
session: SessionImpl,
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
context: Context,
|
||||
at?: EntryId,
|
||||
): Promise<ContextView> {
|
||||
const bounds = await session.readOnLine(() => captureContextBounds(storage, conversationId, context, at));
|
||||
return deriveContext(storage, conversationId, bounds, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the active transcript and model context of one conversation within captured bounds.
|
||||
*
|
||||
* H = newest visible head marker; the range runs from `H.head` (or transcript start) through the tail. Per target,
|
||||
* the newest edit in the range wins. Context entries are H followed by the range's non-head entries.
|
||||
*/
|
||||
export async function deriveContext(
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
bounds: ContextBounds | undefined,
|
||||
context: Context,
|
||||
): Promise<ContextView> {
|
||||
if (bounds === undefined) return emptyView();
|
||||
return deriveRange(bounds, await scanRange(storage, conversationId, rangeQuery(bounds), context)).view;
|
||||
}
|
||||
|
||||
function emptyView(): ContextView {
|
||||
return { head: undefined, entries: [], contributions: [], messages: [] };
|
||||
}
|
||||
|
||||
/** Derive the context view from the entries scanned within `bounds`. */
|
||||
function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange {
|
||||
const edits = new Map<EntryId, ContextEdit>();
|
||||
// Edits of every entry in the range count, including older head markers that `selectActive()` drops.
|
||||
for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);
|
||||
const active = selectActive(bounds.head, entries);
|
||||
const contributions = active.map((entry) => contribute(entry, edits.get(entry.id)));
|
||||
const { settled, open } = settle([], contributions.flat());
|
||||
const baseline = findBaseline(bounds.head, active);
|
||||
const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline);
|
||||
const view = { head: bounds.head, entries: active, contributions, messages };
|
||||
return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };
|
||||
}
|
||||
|
||||
/**
|
||||
* `previous` extended by the entries `added` after its tail under the same head marker: only their contributions and
|
||||
* the open messages are derived. An added edit can change an earlier entry, so it derives the whole range again.
|
||||
*/
|
||||
function extendRange(previous: ContextRange, bounds: ContextBounds, added: readonly EntryRecord[]): ContextRange {
|
||||
const entries = [...previous.entries, ...added];
|
||||
if (added.some((entry) => entry.edits !== undefined || entry.head !== undefined || previous.edited.has(entry.id))) {
|
||||
return deriveRange(bounds, entries);
|
||||
}
|
||||
const contributions = added.map((entry) => contribute(entry, undefined));
|
||||
const { settled, open } = settle(previous.settled, [...previous.open, ...contributions.flat()]);
|
||||
const baseline = previous.baseline !== undefined ? previous.baseline : findBaseline(bounds.head, added);
|
||||
const view = {
|
||||
head: bounds.head,
|
||||
entries: [...previous.view.entries, ...added],
|
||||
contributions: [...previous.view.contributions, ...contributions],
|
||||
messages: leadWithSystem([...settled, ...orderToolResults(open)], baseline),
|
||||
};
|
||||
return { bounds, entries, view, edited: previous.edited, settled, open, baseline };
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline among `entries`, oldest first: the system message of the first `pi.system` entry after the head
|
||||
* marker, or of the transcript without one, when generation wrote it with every section and tool in effect. After a
|
||||
* head marker it always does, and omits the retained `pi.system` entries. Without one it does when it saw no system
|
||||
* message before, which holds unless an earlier entry carries one, even if an edit omits it later. An edit that targets
|
||||
* the baseline contributes other messages, or none, so none leads; a later entry does not take its place.
|
||||
*/
|
||||
function findBaseline(head: ContextBounds["head"], entries: readonly EntryRecord[]): Baseline {
|
||||
for (const entry of entries) {
|
||||
if (head !== undefined && entry.id <= head.id) continue;
|
||||
if (SystemEntry.is(entry)) return entry.model?.find((message) => message.role === "system") ?? null;
|
||||
if (head === undefined && carriesSystemMessage(entry)) return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether an entry's own messages, or the replacement messages of its edits, include a system message. */
|
||||
function carriesSystemMessage(entry: EntryRecord): boolean {
|
||||
const isSystem = (message: Message) => message.role === "system";
|
||||
return (
|
||||
(entry.model?.some(isSystem) ?? false) ||
|
||||
(entry.edits?.some((edit) => edit.action === "replace" && edit.messages.some(isSystem)) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the first system message to the front when only user messages precede it, or when it is the baseline's. A run's
|
||||
* input is committed before generation renders the system prompt, so a transcript starts with the input followed by
|
||||
* the baseline, and the range after a compaction with the kept history, assistant messages included. Providers treat
|
||||
* only a leading system message as the initial prompt and tool set; without it, a later tool change rewrites the
|
||||
* request's tool list and invalidates the whole prompt cache. Only the first system message moves, so replaying
|
||||
* system messages in order gives the same sections and tools. Any other system message, before an assistant message,
|
||||
* stays where it was committed: moving it would change what it means to the model.
|
||||
*/
|
||||
function leadWithSystem(messages: Message[], baseline: Baseline): Message[] {
|
||||
const index = messages.findIndex((message) => message.role === "system");
|
||||
if (index <= 0) return messages;
|
||||
const system = messages[index]!;
|
||||
if (system !== baseline && messages.slice(0, index).some((message) => message.role !== "user")) {
|
||||
return messages;
|
||||
}
|
||||
return [system, ...messages.slice(0, index), ...messages.slice(index + 1)];
|
||||
}
|
||||
|
||||
/** One active entry's model messages after its edit and excluded stop reasons, before tool result ordering. */
|
||||
function contribute(entry: EntryRecord, edit: ContextEdit | undefined): readonly Message[] {
|
||||
if (edit?.action === "omit") return Object.freeze([]);
|
||||
const contributed = edit?.action === "replace" ? edit.messages : (entry.model ?? []);
|
||||
return Object.freeze(
|
||||
contributed.filter((message) => message.role !== "assistant" || !EXCLUDED_STOP_REASONS.has(message.stopReason)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Objects `freezeJson()` froze with everything in them. `Object.isFrozen()` holds for shallow freezes too. */
|
||||
const deeplyFrozen = new WeakSet<object>();
|
||||
|
||||
/** Freeze a JSON value and everything in it. */
|
||||
function freezeJson<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object" || deeplyFrozen.has(value)) return value;
|
||||
for (const child of Object.values(value)) freezeJson(child);
|
||||
Object.freeze(value);
|
||||
deeplyFrozen.add(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the ordered messages before the last assistant message of `open` to `settled`. `orderToolResults()` of a
|
||||
* sequence equals the concatenation over its parts when each later part starts with an assistant message.
|
||||
*/
|
||||
function settle(
|
||||
settled: readonly Message[],
|
||||
open: readonly Message[],
|
||||
): { readonly settled: readonly Message[]; readonly open: readonly Message[] } {
|
||||
const last = open.findLastIndex((message) => message.role === "assistant");
|
||||
if (last <= 0) return { settled, open };
|
||||
return { settled: [...settled, ...orderToolResults(open.slice(0, last))], open: open.slice(last) };
|
||||
}
|
||||
|
||||
/** The raw active entries within captured bounds, without deriving model context. */
|
||||
export async function activeEntries(
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
bounds: ContextBounds | undefined,
|
||||
context: Context,
|
||||
): Promise<readonly EntryRecord[]> {
|
||||
if (bounds === undefined) return [];
|
||||
return selectActive(bounds.head, await scanRange(storage, conversationId, rangeQuery(bounds), context));
|
||||
}
|
||||
|
||||
/** The visible range of `bounds`: from the head marker's head, or transcript start, through the tail. */
|
||||
function rangeQuery(bounds: ContextBounds): Omit<EntryQuery, "conversationId"> {
|
||||
const head = bounds.head;
|
||||
return head === undefined ? { maxEntryId: bounds.tail } : { minEntryId: head.head, maxEntryId: bounds.tail };
|
||||
}
|
||||
|
||||
/** Visible entries within `range`, oldest first. */
|
||||
async function scanRange(
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
range: Omit<EntryQuery, "conversationId">,
|
||||
context: Context,
|
||||
): Promise<EntryRecord[]> {
|
||||
const entries: EntryRecord[] = [];
|
||||
let cursor: Cursor | undefined;
|
||||
do {
|
||||
const page = await storage.scanEntries({ conversationId, ...range }, SCAN_PAGE_SIZE, cursor, context);
|
||||
entries.push(...page.items);
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
return entries.reverse();
|
||||
}
|
||||
|
||||
/** The head marker followed by the range's non-head entries, or the whole range without a marker. */
|
||||
function selectActive(head: ContextBounds["head"], range: readonly EntryRecord[]): readonly EntryRecord[] {
|
||||
return head === undefined ? range : [head, ...range.filter((entry) => entry.head === undefined)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Place each assistant's tool results directly after it in call order. Results are taken from the messages before
|
||||
* the next assistant; a missing result is synthesized and unmatched results are dropped.
|
||||
*/
|
||||
export function orderToolResults(messages: readonly Message[]): Message[] {
|
||||
const ordered: Message[] = [];
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const message = messages[index]!;
|
||||
if (message.role === "toolResult") continue;
|
||||
ordered.push(message);
|
||||
if (message.role !== "assistant") continue;
|
||||
const calls = message.content.filter((content): content is ToolCall => content.type === "toolCall");
|
||||
if (calls.length === 0) continue;
|
||||
const results = new Map<string, number>();
|
||||
for (let next = index + 1; next < messages.length && messages[next]!.role !== "assistant"; next++) {
|
||||
const candidate = messages[next]!;
|
||||
if (candidate.role === "toolResult" && !results.has(candidate.toolCallId)) {
|
||||
results.set(candidate.toolCallId, next);
|
||||
}
|
||||
}
|
||||
for (const call of calls) {
|
||||
const resultIndex = results.get(call.id);
|
||||
ordered.push(resultIndex === undefined ? missingResult(call, message.timestamp) : messages[resultIndex]!);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function missingResult(call: ToolCall, timestamp: number): ToolResultMessage {
|
||||
return freezeJson({
|
||||
role: "toolResult",
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
content: [{ type: "text", text: MISSING_RESULT_TEXT }],
|
||||
isError: true,
|
||||
details: { reason: "missing_result" },
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
44
third_party/pi/packages/durable/src/harness/define.ts
vendored
Normal file
44
third_party/pi/packages/durable/src/harness/define.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { JsonValue } from "@earendil-works/chord";
|
||||
import type { TSchema } from "@earendil-works/pi-ai";
|
||||
import type { AnyTask, Extension, HookRegistration, HooksOf, PromptSection, ToolRegistration, Wrap } from "./types.ts";
|
||||
|
||||
/** Identity function that types an extension. */
|
||||
export function defineExtension<Tool extends ToolRegistration = ToolRegistration>(
|
||||
extension: Extension<Tool>,
|
||||
): Extension<Tool> {
|
||||
return extension;
|
||||
}
|
||||
|
||||
/** Identity function that types a tool: `args` from `parameters`, details from what it reports. */
|
||||
export function defineTool<TParameters extends TSchema, TDetails extends JsonValue = JsonValue>(
|
||||
tool: ToolRegistration<TParameters, TDetails>,
|
||||
): ToolRegistration<TParameters, TDetails> {
|
||||
return tool;
|
||||
}
|
||||
|
||||
/** A prompt section; tagged unless `tag` is false. */
|
||||
export function section<Tool extends ToolRegistration = ToolRegistration>(
|
||||
key: string,
|
||||
render: PromptSection<Tool>["render"],
|
||||
options?: { readonly tag?: boolean },
|
||||
): PromptSection<Tool> {
|
||||
return options?.tag === undefined ? { key, render } : { key, render, tag: options.tag };
|
||||
}
|
||||
|
||||
/** Hook handlers for tasks with `task`'s name. */
|
||||
export function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration {
|
||||
return { task: task.definition.name, handlers };
|
||||
}
|
||||
|
||||
/** Wrap the tool named like `tool` wherever the wrapping extension is selected. */
|
||||
export function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool> {
|
||||
return { tool: tool.name, wrap: wrapper };
|
||||
}
|
||||
|
||||
/** Wrap the section `key` wherever the wrapping extension is selected. */
|
||||
export function wrapSection<Tool extends ToolRegistration = ToolRegistration>(
|
||||
key: string,
|
||||
wrapper: (section: PromptSection<Tool>) => PromptSection<Tool>,
|
||||
): Wrap<Tool> {
|
||||
return { section: key, wrap: wrapper };
|
||||
}
|
||||
506
third_party/pi/packages/durable/src/harness/events.ts
vendored
Normal file
506
third_party/pi/packages/durable/src/harness/events.ts
vendored
Normal file
@@ -0,0 +1,506 @@
|
||||
import type { Context, JsonValue } from "@earendil-works/chord";
|
||||
import type { Op, Path } from "@earendil-works/chord/delta";
|
||||
import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";
|
||||
import { CommittedWatch } from "../session/observation.ts";
|
||||
import type {
|
||||
CommitChange,
|
||||
CommitPublication,
|
||||
ConversationId,
|
||||
EntryRecord,
|
||||
JsonObject,
|
||||
SubmissionId,
|
||||
SubmissionRecord,
|
||||
TaskId,
|
||||
WatchEnd,
|
||||
} from "../types.ts";
|
||||
import { AgentDoc } from "./agent.ts";
|
||||
import type { InboxItem, InboxState } from "./inbox.ts";
|
||||
import type { CompactionFailure, CompactionStatus, LiveState, NestedToolSlot, ToolSlot } from "./live.ts";
|
||||
import { NestedCallDoc, type NestedCallState } from "./tool.ts";
|
||||
import type { AgentState, CompactionReason, Harness, NestedToolExecutionResult, ToolDiagnostic } from "./types.ts";
|
||||
import { UsageDoc, type UsageState } from "./usage.ts";
|
||||
import { scanAll } from "./util.ts";
|
||||
import { type ConversationView, conversationViews } from "./view.ts";
|
||||
|
||||
type Block = AssistantMessage["content"][number];
|
||||
type QueuedItem = { id: SubmissionId; mode: InboxItem["mode"] };
|
||||
|
||||
/** One change to the in-flight assistant message, relative to that message. */
|
||||
export type MessageChange =
|
||||
| { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: Block }
|
||||
| { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
|
||||
| { type: "block"; contentIndex: number; block: Block }
|
||||
| { type: "message"; message: AssistantMessage };
|
||||
|
||||
export type SnapshotEvent = {
|
||||
type: "snapshot";
|
||||
entries: readonly EntryRecord[];
|
||||
run?: { inputs: readonly SubmissionId[] };
|
||||
/** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
|
||||
generation?: {
|
||||
attempt: number;
|
||||
message?: AssistantMessage;
|
||||
retry?: { at: number; error: string };
|
||||
deferred?: { pollAt: number };
|
||||
};
|
||||
tools: readonly ToolSlot[];
|
||||
/** `pi.live.nestedTools`: nested calls of running tool calls. */
|
||||
nestedTools: readonly NestedToolSlot[];
|
||||
/** `pi.live.compactions`: live compactions with their attempt and retry backoff. */
|
||||
compactions: readonly CompactionStatus[];
|
||||
/** `pi.live.compactionFailure`: the newest compaction failure since a compaction last produced a summary. */
|
||||
compactionFailure?: CompactionFailure;
|
||||
inbox: readonly QueuedItem[];
|
||||
/** `pi.agent`; `{}` when absent. */
|
||||
agent: AgentState;
|
||||
usage: UsageState;
|
||||
};
|
||||
|
||||
/** Experimental agent event, shaped like the coding agent's session events (spec §9.4). */
|
||||
export type AgentEvent =
|
||||
| SnapshotEvent
|
||||
| { type: "run_start"; inputs: readonly SubmissionId[] }
|
||||
| { type: "run_end"; inputs: readonly SubmissionId[] }
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end" }
|
||||
| { type: "message_start"; message: Message }
|
||||
/** `usage` is the partial's current usage, as in the coding agent's JSON mode. */
|
||||
| { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
|
||||
| { type: "message_end"; entry: EntryRecord }
|
||||
| ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall)
|
||||
| ({
|
||||
type: "tool_execution_update";
|
||||
/** A front trim and then an append of the retained window, or its replacement. */
|
||||
output?: { trimStart?: number; append?: string } | { set: string };
|
||||
details?: JsonValue;
|
||||
diagnostics?: readonly ToolDiagnostic[];
|
||||
} & ToolEventCall)
|
||||
/**
|
||||
* A model-issued call ends with its result `entry`, a nested call with its `result`. Both are absent when the tool
|
||||
* task faulted or was orphaned, or when the call's slot left `pi.live` unfinished: its run ended, or its parent
|
||||
* settled first.
|
||||
*/
|
||||
| ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall)
|
||||
| { type: "inbox_update"; items: readonly QueuedItem[] }
|
||||
| { type: "submission"; record: SubmissionRecord }
|
||||
| { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
|
||||
| { type: "auto_retry_end"; attempt: number }
|
||||
| { type: "deferred_poll"; pollAt: number }
|
||||
| { type: "entry_appended"; entry: EntryRecord }
|
||||
| { type: "agent_changed"; agent: AgentState }
|
||||
| { type: "usage_changed"; usage: UsageState }
|
||||
| { type: "task_failed"; taskId: TaskId; kind: string; message: string }
|
||||
| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
|
||||
/**
|
||||
* The task's receipt tells whether it produced a summary; the summary entry has its own events. `error` is set when
|
||||
* the compaction failed, faulted, or was orphaned, as in `pi.live.compactionFailure`.
|
||||
*/
|
||||
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };
|
||||
|
||||
/**
|
||||
* Which call a tool event is about. `toolCallId` is the call's ID in the transcript and in tool events: the provider's
|
||||
* ID for a model-issued call, `<parent call ID>/<key>` for a nested one. Match events by it; do not parse it. `taskId`
|
||||
* is the call's tool task, absent for a call that never got one (not offered, or not started in a sequential round).
|
||||
* A nested call also names the call that made it, by call ID and by tool task.
|
||||
*/
|
||||
export type ToolEventCall = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
taskId?: TaskId;
|
||||
parentToolCallId?: string;
|
||||
parentTaskId?: TaskId;
|
||||
};
|
||||
|
||||
/** Serialized stream of one conversation's event batches, one per commit. */
|
||||
export interface AgentEventStream {
|
||||
/** The `snapshot` event at attachment. */
|
||||
readonly snapshot: SnapshotEvent;
|
||||
start(listener: (events: readonly AgentEvent[], context: Context) => Promise<void>): void;
|
||||
stop(): Promise<WatchEnd>;
|
||||
readonly closed: Promise<WatchEnd>;
|
||||
}
|
||||
|
||||
/** The typed parts of a view the events read. */
|
||||
type Parts = {
|
||||
live: LiveState;
|
||||
inbox: InboxState | undefined;
|
||||
agent: AgentState | undefined;
|
||||
usage: UsageState | undefined;
|
||||
};
|
||||
|
||||
function parts(view: ConversationView): Parts {
|
||||
return {
|
||||
live: (view.docs["pi.live"] ?? {}) as LiveState,
|
||||
inbox: view.docs["pi.inbox"] as InboxState | undefined,
|
||||
agent: view.docs["pi.agent"] as AgentState | undefined,
|
||||
usage: view.docs["pi.usage"] as UsageState | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotOf(view: ConversationView): SnapshotEvent {
|
||||
const { live, inbox, agent, usage } = parts(view);
|
||||
return {
|
||||
type: "snapshot",
|
||||
entries: view.entries,
|
||||
...(live.run === undefined ? {} : { run: { inputs: live.run.inputs } }),
|
||||
...(live.generation === undefined ? {} : { generation: live.generation as SnapshotEvent["generation"] }),
|
||||
tools: live.tools ?? [],
|
||||
nestedTools: live.nestedTools ?? [],
|
||||
compactions: live.compactions ?? [],
|
||||
...(live.compactionFailure === undefined ? {} : { compactionFailure: live.compactionFailure }),
|
||||
inbox: queued(inbox),
|
||||
agent: agent ?? AgentDoc.definition.initial(),
|
||||
usage: usage ?? UsageDoc.definition.initial(),
|
||||
};
|
||||
}
|
||||
|
||||
function queued(inbox: InboxState | undefined): QueuedItem[] {
|
||||
return (inbox?.items ?? []).map((item) => ({ id: item.id, mode: item.mode }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Experimental: attach to one conversation's agent events (spec §9.4). The snapshot and the registration for later
|
||||
* commits are captured atomically on the Session line; overflow replaces undelivered batches with one snapshot.
|
||||
*/
|
||||
export async function watchEvents(
|
||||
harness: Harness,
|
||||
conversationId: ConversationId,
|
||||
context: Context,
|
||||
): Promise<AgentEventStream> {
|
||||
const views = conversationViews(harness);
|
||||
let watch!: CommittedWatch<readonly AgentEvent[]>;
|
||||
let snapshot!: SnapshotEvent;
|
||||
await views.attach(
|
||||
conversationId,
|
||||
async (initial, release, storage) => {
|
||||
// Generations whose held outcome already ended their turn, read on the line with the snapshot.
|
||||
const query = { conversationId, kind: "pi.generation", status: "completing" } as const;
|
||||
const completing = await scanAll((cursor) => storage.scanTasks(query, 100, cursor, context));
|
||||
const held = new Set<TaskId>(completing.map((record) => record.id));
|
||||
let current = initial;
|
||||
snapshot = snapshotOf(initial);
|
||||
// Batches are the watch's values; an overflow delivers a snapshot of the newest view instead.
|
||||
watch = new CommittedWatch<readonly AgentEvent[]>(
|
||||
[],
|
||||
release,
|
||||
(error) => views.report(error),
|
||||
() => [snapshotOf(current)],
|
||||
);
|
||||
return {
|
||||
publication: (before, after, ops, publication, commitContext) => {
|
||||
current = after;
|
||||
const events = translate(conversationId, before, after, ops, publication, held);
|
||||
if (events.length > 0) watch.advance(events, [], commitContext);
|
||||
},
|
||||
closeSession: (failure) => watch.closeSession(failure),
|
||||
};
|
||||
},
|
||||
context,
|
||||
);
|
||||
// Like a watch, the acquisition context governs the stream's lifetime.
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) {
|
||||
watch.cancel();
|
||||
throw signal.reason;
|
||||
}
|
||||
if (signal !== undefined) watch.observeCancellation(signal);
|
||||
return {
|
||||
snapshot,
|
||||
start: (listener) => watch.start((events, _ops, deliveryContext) => listener(events, deliveryContext)),
|
||||
stop: () => watch.stop(),
|
||||
closed: watch.closed,
|
||||
};
|
||||
}
|
||||
|
||||
type TaskChange = Extract<CommitChange, { type: "task" }>;
|
||||
|
||||
/** The tool result for `callId` among `entries`. */
|
||||
function resultOf(entries: readonly EntryRecord[], callId: string): EntryRecord | undefined {
|
||||
return entries.find((entry) => {
|
||||
const message = entry.model?.[0];
|
||||
return message?.role === "toolResult" && message.toolCallId === callId;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every event one publication causes, in the order of spec §9.4. */
|
||||
function translate(
|
||||
conversationId: ConversationId,
|
||||
before: ConversationView,
|
||||
after: ConversationView,
|
||||
viewOps: readonly Op[],
|
||||
publication: CommitPublication,
|
||||
held: Set<TaskId>,
|
||||
): AgentEvent[] {
|
||||
const entries: EntryRecord[] = [];
|
||||
const tasks = new Map<TaskId, TaskChange["value"]>();
|
||||
const submissions: SubmissionRecord[] = [];
|
||||
// Nested results this commit stored, by nested task ID; read now, since the caller's documents may retire next.
|
||||
const nestedResults = new Map<TaskId, NestedToolExecutionResult>();
|
||||
for (const change of publication.changes) {
|
||||
if (
|
||||
change.type === "document" &&
|
||||
change.record.kind === NestedCallDoc.definition.kind &&
|
||||
change.value !== null &&
|
||||
change.conversationId === conversationId
|
||||
) {
|
||||
const state = change.value as NestedCallState;
|
||||
if (state.taskId !== undefined && state.result !== undefined) {
|
||||
nestedResults.set(state.taskId, state.result as unknown as NestedToolExecutionResult);
|
||||
}
|
||||
}
|
||||
if (change.type === "entry" && change.value.conversationId === conversationId) entries.push(change.value);
|
||||
if (change.type === "task" && change.value.conversationId === conversationId)
|
||||
tasks.set(change.value.id, change.value);
|
||||
if (change.type === "submission" && change.value.conversationId === conversationId) {
|
||||
submissions.push(change.value);
|
||||
}
|
||||
}
|
||||
if (viewOps.length === 0 && entries.length === 0 && tasks.size === 0 && submissions.length === 0) return [];
|
||||
// Entries are appended in ID order; submission records are published in the order the commit first touched them.
|
||||
submissions.sort((a, b) => a.id - b.id);
|
||||
const was = parts(before);
|
||||
const now = parts(after);
|
||||
const events: AgentEvent[] = [];
|
||||
|
||||
// Progress: tool starts, the in-flight message, tool updates, retry and deferred state.
|
||||
const slotsBefore = new Map((was.live.tools ?? []).map((slot) => [slot.callId, slot]));
|
||||
const slots = now.live.tools ?? [];
|
||||
const nestedBefore = new Map((was.live.nestedTools ?? []).map((slot) => [slot.taskId, slot]));
|
||||
const nested = now.live.nestedTools ?? [];
|
||||
for (const slot of [...slots, ...nested]) {
|
||||
const previous = "parentCallId" in slot ? nestedBefore.get(slot.taskId) : slotsBefore.get(slot.callId);
|
||||
if (slot.status !== "running" || previous?.status === "running") continue;
|
||||
// A nested slot carries the arguments the call runs with; a model-issued call's are in its intent checkpoint.
|
||||
const checkpoint = slot.taskId === undefined ? undefined : tasks.get(slot.taskId)?.state.checkpoint;
|
||||
const args =
|
||||
"parentCallId" in slot
|
||||
? slot.arguments
|
||||
: ((checkpoint as { arguments?: JsonObject } | undefined)?.arguments ?? {});
|
||||
events.push({ type: "tool_execution_start", ...callOf(slot), args });
|
||||
}
|
||||
const partialBefore = was.live.generation?.message as AssistantMessage | undefined;
|
||||
const partial = now.live.generation?.message as AssistantMessage | undefined;
|
||||
if (partial !== undefined && partialBefore === undefined) events.push({ type: "message_start", message: partial });
|
||||
else if (partial !== undefined && partial !== partialBefore) {
|
||||
events.push({ type: "message_update", usage: partial.usage, changes: messageChanges(viewOps, partial) });
|
||||
}
|
||||
for (const [index, slot] of slots.entries()) {
|
||||
const previous = slotsBefore.get(slot.callId);
|
||||
if (slot.status !== "running" || previous?.status !== "running") continue;
|
||||
const update = toolUpdate(viewOps, ["tools", index], slot, previous);
|
||||
if (update === undefined) continue;
|
||||
events.push({ type: "tool_execution_update", ...callOf(slot), ...update });
|
||||
}
|
||||
for (const [index, slot] of nested.entries()) {
|
||||
const previous = nestedBefore.get(slot.taskId);
|
||||
if (slot.status !== "running" || previous?.status !== "running") continue;
|
||||
const update = toolUpdate(viewOps, ["nestedTools", index], slot, previous);
|
||||
if (update === undefined) continue;
|
||||
events.push({ type: "tool_execution_update", ...callOf(slot), ...update });
|
||||
}
|
||||
const generation = now.live.generation;
|
||||
const generationBefore = was.live.generation;
|
||||
if (generation?.retry !== undefined && generationBefore?.retry === undefined) {
|
||||
const { at, error } = generation.retry;
|
||||
events.push({ type: "auto_retry_start", attempt: generation.attempt, at, errorMessage: error });
|
||||
}
|
||||
if (generationBefore?.retry !== undefined && generation?.retry === undefined) {
|
||||
events.push({ type: "auto_retry_end", attempt: generationBefore.attempt });
|
||||
}
|
||||
if (generation?.deferred !== undefined && generation.deferred.pollAt !== generationBefore?.deferred?.pollAt) {
|
||||
events.push({ type: "deferred_poll", pollAt: generation.deferred.pollAt });
|
||||
}
|
||||
|
||||
// Tools that end in this commit: a slot that becomes done, one created done (a call not offered), or an unfinished
|
||||
// one that vanishes because its run ended. A done slot that vanishes ended earlier.
|
||||
const toolEnds: Extract<AgentEvent, { type: "tool_execution_end" }>[] = [];
|
||||
const endTool = (slot: ToolSlot, entryId: number | undefined): void => {
|
||||
const entry = entries.find((candidate) => candidate.id === entryId);
|
||||
toolEnds.push({ type: "tool_execution_end", ...callOf(slot), ...(entry === undefined ? {} : { entry }) });
|
||||
};
|
||||
for (const previous of slotsBefore.values()) {
|
||||
if (previous.status === "done") continue;
|
||||
const slot = slots.find((candidate) => candidate.callId === previous.callId);
|
||||
if (slot?.status === "done") endTool(slot, slot.entry);
|
||||
// A slot whose run ended in this commit may have had its result appended with it, as for unstarted calls.
|
||||
else if (slot === undefined) endTool(previous, resultOf(entries, previous.callId)?.id);
|
||||
}
|
||||
for (const slot of slots) {
|
||||
if (slot.status === "done" && !slotsBefore.has(slot.callId)) endTool(slot, slot.entry);
|
||||
}
|
||||
// Nested calls that end in this commit, the same way, children before the calls that made them: the lists hold
|
||||
// parents first, so walk them backwards.
|
||||
const endNested = (slot: NestedToolSlot): void => {
|
||||
const result = nestedResults.get(slot.taskId);
|
||||
events.push({ type: "tool_execution_end", ...callOf(slot), ...(result === undefined ? {} : { result }) });
|
||||
};
|
||||
const nestedNow = new Map(nested.map((slot) => [slot.taskId, slot]));
|
||||
for (const previous of [...nestedBefore.values()].reverse()) {
|
||||
if (previous.status === "done") continue;
|
||||
const slot = nestedNow.get(previous.taskId);
|
||||
if (slot?.status === "done" || slot === undefined) endNested(slot ?? previous);
|
||||
}
|
||||
for (const slot of [...nested].reverse()) {
|
||||
if (slot.status === "done" && !nestedBefore.has(slot.taskId)) endNested(slot);
|
||||
}
|
||||
|
||||
// Entries in append order; a tool's end directly precedes its result's message, as in the coding agent.
|
||||
let assistantAppended = false;
|
||||
for (const entry of entries) {
|
||||
events.push(...toolEnds.filter((end) => end.entry === entry));
|
||||
const message = entry.model?.[0];
|
||||
if (message === undefined) {
|
||||
events.push({ type: "entry_appended", entry });
|
||||
continue;
|
||||
}
|
||||
// A streamed answer already started with its first partial.
|
||||
const streamed = message.role === "assistant" && partialBefore !== undefined && !assistantAppended;
|
||||
if (message.role === "assistant") assistantAppended = true;
|
||||
if (!streamed) events.push({ type: "message_start", message });
|
||||
events.push({ type: "message_end", entry });
|
||||
}
|
||||
// Ends without a result entry: a faulted or orphaned tool, or one whose run ended.
|
||||
events.push(...toolEnds.filter((end) => end.entry === undefined));
|
||||
|
||||
// Compaction ends, task failures, then turn and run ends.
|
||||
const compactionsBefore = was.live.compactions ?? [];
|
||||
const compactions = now.live.compactions ?? [];
|
||||
const failure = now.live.compactionFailure;
|
||||
for (const { taskId, reason } of compactionsBefore) {
|
||||
if (!compactions.some((status) => status.taskId === taskId)) {
|
||||
const error = failure?.taskId === taskId ? failure.error : undefined;
|
||||
events.push({ type: "compaction_end", taskId, reason, ...(error === undefined ? {} : { error }) });
|
||||
}
|
||||
}
|
||||
// A generation's turn ends when its outcome is committed: at a `completing` hold or at terminal, whichever comes
|
||||
// first, so a successor created at the hold starts after it.
|
||||
let turnEnded = false;
|
||||
for (const task of tasks.values()) {
|
||||
const status = task.state.status;
|
||||
if (task.kind === "pi.generation" && status === "completing" && !held.has(task.id)) {
|
||||
held.add(task.id);
|
||||
turnEnded = true;
|
||||
}
|
||||
if (status !== "terminal") continue;
|
||||
if (task.kind === "pi.generation" && !held.delete(task.id)) turnEnded = true;
|
||||
const outcome = task.state.outcome;
|
||||
if (outcome.status === "faulted" || outcome.status === "orphaned") {
|
||||
const message = outcome.status === "faulted" ? outcome.error.message : outcome.reason;
|
||||
events.push({ type: "task_failed", taskId: task.id, kind: task.kind, message });
|
||||
}
|
||||
}
|
||||
if (turnEnded) events.push({ type: "turn_end" });
|
||||
const run = now.live.run;
|
||||
const runBefore = was.live.run;
|
||||
const runChanged = run?.inputs[0] !== runBefore?.inputs[0];
|
||||
if (runBefore !== undefined && runChanged) events.push({ type: "run_end", inputs: runBefore.inputs });
|
||||
|
||||
// Submissions, document state, then what began.
|
||||
for (const record of submissions) events.push({ type: "submission", record });
|
||||
if (now.inbox !== was.inbox) events.push({ type: "inbox_update", items: queued(now.inbox) });
|
||||
// A retired document reads as its initial value, as in a snapshot.
|
||||
if (now.agent !== was.agent) {
|
||||
events.push({ type: "agent_changed", agent: now.agent ?? AgentDoc.definition.initial() });
|
||||
}
|
||||
if (now.usage !== was.usage)
|
||||
events.push({ type: "usage_changed", usage: now.usage ?? UsageDoc.definition.initial() });
|
||||
for (const { taskId, reason, blocking } of compactions) {
|
||||
if (!compactionsBefore.some((status) => status.taskId === taskId)) {
|
||||
events.push({ type: "compaction_start", taskId, reason, blocking });
|
||||
}
|
||||
}
|
||||
if (run !== undefined && runChanged) events.push({ type: "run_start", inputs: run.inputs });
|
||||
if (run !== undefined && run.taskId !== runBefore?.taskId && tasks.get(run.taskId)?.kind === "pi.generation") {
|
||||
events.push({ type: "turn_start" });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const PARTIAL_PATH = ["docs", "pi.live", "generation", "message"];
|
||||
|
||||
/** Translate the view operations on the in-flight message into message changes (spec §9.4). */
|
||||
function messageChanges(viewOps: readonly Op[], message: AssistantMessage): MessageChange[] {
|
||||
const changes: MessageChange[] = [];
|
||||
// A block sent whole already holds every later change to it in this batch.
|
||||
const whole = new Set<number>();
|
||||
for (const op of viewOps) {
|
||||
// View operations never replace the root.
|
||||
const path = op[1] as Path;
|
||||
if (!startsWith(path, PARTIAL_PATH)) {
|
||||
// The whole message or generation was replaced.
|
||||
if (startsWith(PARTIAL_PATH, path)) return [{ type: "message", message }];
|
||||
continue;
|
||||
}
|
||||
const rest = path.slice(PARTIAL_PATH.length);
|
||||
if (rest[0] === "usage") continue;
|
||||
if (rest[0] !== "content") return [{ type: "message", message }];
|
||||
if (rest.length === 1) {
|
||||
if (op[0] !== "p" || op[3] !== 0) return [{ type: "message", message }];
|
||||
for (const [offset, block] of (op[4] as unknown as Block[]).entries()) {
|
||||
const type =
|
||||
block.type === "text" ? "text_start" : block.type === "thinking" ? "thinking_start" : "toolcall_start";
|
||||
changes.push({ type, contentIndex: op[2] + offset, block });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const contentIndex = rest[1] as number;
|
||||
const field = rest[2];
|
||||
if (whole.has(contentIndex)) continue;
|
||||
if (op[0] === "a" && rest.length === 3 && (field === "text" || field === "thinking")) {
|
||||
changes.push({ type: field === "text" ? "text_delta" : "thinking_delta", contentIndex, delta: op[2] });
|
||||
} else if (op[0] === "a" && field === "arguments") {
|
||||
changes.push({ type: "toolcall_delta", contentIndex, path: rest.slice(3), delta: op[2] });
|
||||
} else {
|
||||
whole.add(contentIndex);
|
||||
changes.push({ type: "block", contentIndex, block: message.content[contentIndex]! });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** The identifying fields of a slot's tool events. */
|
||||
function callOf(slot: ToolSlot | NestedToolSlot): ToolEventCall {
|
||||
return {
|
||||
toolCallId: slot.callId,
|
||||
toolName: slot.name,
|
||||
...(slot.taskId === undefined ? {} : { taskId: slot.taskId }),
|
||||
...("parentCallId" in slot ? { parentToolCallId: slot.parentCallId, parentTaskId: slot.parentTaskId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Output, details, and diagnostics changes of a running slot at `at` in `pi.live`, from the view operations on it. */
|
||||
function toolUpdate(
|
||||
viewOps: readonly Op[],
|
||||
at: readonly ["tools" | "nestedTools", number],
|
||||
slot: ToolSlot | NestedToolSlot,
|
||||
previous: ToolSlot | NestedToolSlot,
|
||||
): Omit<Extract<AgentEvent, { type: "tool_execution_update" }>, "type" | keyof ToolEventCall> | undefined {
|
||||
const outputPath = ["docs", "pi.live", ...at, "output"];
|
||||
let trimStart = 0;
|
||||
let append = "";
|
||||
let set = false;
|
||||
for (const op of viewOps) {
|
||||
if (!startsWith(op[1] as Path, outputPath)) continue;
|
||||
if (op[0] === "t") trimStart += op[2];
|
||||
else if (op[0] === "a") append += op[2];
|
||||
else set = true;
|
||||
}
|
||||
let output: { trimStart?: number; append?: string } | { set: string } | undefined;
|
||||
if (set || (slot.output !== previous.output && trimStart === 0 && append === ""))
|
||||
output = { set: slot.output ?? "" };
|
||||
else if (trimStart > 0 || append !== "") {
|
||||
output = { ...(trimStart > 0 ? { trimStart } : {}), ...(append === "" ? {} : { append }) };
|
||||
}
|
||||
// A safe replay clears a running slot's progress: removed details send `null`, removed diagnostics `[]`.
|
||||
const update = {
|
||||
...(output === undefined ? {} : { output }),
|
||||
...(slot.details === previous.details ? {} : { details: slot.details ?? null }),
|
||||
...(slot.diagnostics === previous.diagnostics ? {} : { diagnostics: slot.diagnostics ?? [] }),
|
||||
};
|
||||
return Object.keys(update).length === 0 ? undefined : update;
|
||||
}
|
||||
|
||||
function startsWith(path: Path, prefix: readonly (string | number)[]): boolean {
|
||||
return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
|
||||
}
|
||||
715
third_party/pi/packages/durable/src/harness/generation.ts
vendored
Normal file
715
third_party/pi/packages/durable/src/harness/generation.ts
vendored
Normal file
@@ -0,0 +1,715 @@
|
||||
import { type Context, copyJson, type Draft, type JsonValue } from "@earendil-works/chord";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
DeferredHandle,
|
||||
Message,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
SimpleStreamOptions,
|
||||
ToolCall,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { isContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
|
||||
import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry";
|
||||
import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
||||
import { AssistantEntry, ResetEntry, SystemEntry, UserEntry } from "../entries.ts";
|
||||
import type { ExecutionEnv } from "../env/index.ts";
|
||||
import { defineTask } from "../tasks.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
EntryId,
|
||||
NextTaskState,
|
||||
SubmissionId,
|
||||
TaskId,
|
||||
TaskOutcome,
|
||||
TaskRuntime,
|
||||
Tx,
|
||||
TypedEntry,
|
||||
} from "../types.ts";
|
||||
import { addTools } from "./agent.ts";
|
||||
import { createCompaction, estimateContext, selectCut } from "./compaction.ts";
|
||||
import { applyBoundary, prepareBoundary } from "./inbox.ts";
|
||||
import { assignJson } from "./json.ts";
|
||||
import { endRun, LiveDoc, type LiveState, type ToolSlot } from "./live.ts";
|
||||
import { planSystemEntries, renderSections, replaySections } from "./prompt.ts";
|
||||
import { ensureProviderSessionId } from "./provider.ts";
|
||||
import { appendToolResult, harnessError, ToolTask, type ToolTaskResult } from "./tool.ts";
|
||||
import type {
|
||||
CompactionPolicy,
|
||||
CompactionResult,
|
||||
ContextView,
|
||||
ConversationStreamOptions,
|
||||
GenerationHooks,
|
||||
ModelRef,
|
||||
PromptInput,
|
||||
ToolControl,
|
||||
UserInput,
|
||||
} from "./types.ts";
|
||||
import { recordUsage } from "./usage.ts";
|
||||
|
||||
export type GenerationInput = Record<string, never>;
|
||||
|
||||
export type GenerationCheckpoint =
|
||||
| {
|
||||
phase: "prepare";
|
||||
attempt: number;
|
||||
/** The blocking compaction this generation waited for; it starts no other compaction (spec §8.3). */
|
||||
compacted?: TaskId<CompactionResult>;
|
||||
/** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
|
||||
overflow?: string;
|
||||
/** Set when the blocking threshold started `compacted`; its outcome is checked once when `prepare` resumes. */
|
||||
threshold?: true;
|
||||
}
|
||||
| {
|
||||
phase: "request";
|
||||
attempt: number;
|
||||
compacted?: TaskId<CompactionResult>;
|
||||
model: ModelRef;
|
||||
thinkingLevel: ModelThinkingLevel;
|
||||
/** The settings' request options when preparation committed; a resend after recovery uses them unchanged. */
|
||||
streamOptions: ConversationStreamOptions;
|
||||
/** Newest entry included in the request. */
|
||||
cutoff: EntryId;
|
||||
}
|
||||
| { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number }
|
||||
| {
|
||||
phase: "poll";
|
||||
attempt: number;
|
||||
compacted?: TaskId<CompactionResult>;
|
||||
model: ModelRef;
|
||||
cutoff: EntryId;
|
||||
handle: DeferredHandle;
|
||||
pollAt: number;
|
||||
}
|
||||
| {
|
||||
/** Waiting on the round's tool tasks, which the generation owns (spec §8.5). */
|
||||
phase: "tools";
|
||||
/** The tool-calling answer. */
|
||||
assistant: EntryId;
|
||||
/** Tool tasks created so far, in call order; grows by one per started call of a sequential round. */
|
||||
tools: TaskId<ToolTaskResult>[];
|
||||
/** Calls of a sequential round not started yet, in call order. */
|
||||
pending: string[];
|
||||
};
|
||||
|
||||
export type GenerationResult = { entryId: EntryId };
|
||||
|
||||
type Runtime = TaskRuntime<GenerationInput, GenerationCheckpoint, GenerationResult, GenerationHooks>;
|
||||
type Next = NextTaskState<GenerationCheckpoint, GenerationResult>;
|
||||
|
||||
/** What classification needs from the request that produced a message. */
|
||||
type Request = {
|
||||
readonly attempt: number;
|
||||
readonly compacted: TaskId<CompactionResult> | undefined;
|
||||
readonly model: ModelRef;
|
||||
readonly cutoff: EntryId;
|
||||
/** Committed model context through `cutoff`, when the phase already derived it. */
|
||||
readonly messages?: readonly Message[];
|
||||
/** Set when the message came from polling, so a still deferred result polls strictly later. */
|
||||
readonly pollAt?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_POLL_AFTER_MS = 5000;
|
||||
|
||||
/**
|
||||
* Built-in generation task: prepares the positional system prompt and tool loadout, requests or polls the model,
|
||||
* retries, and classifies the response. The run's inputs live in `pi.live.run`.
|
||||
*/
|
||||
export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint, GenerationResult, GenerationHooks>({
|
||||
name: "pi.generation",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "prepare", attempt: 1 }),
|
||||
phases: {
|
||||
/**
|
||||
* Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to
|
||||
* `request`. The agent and settings resolved here are fixed for this request. Only the Harness writes model
|
||||
* context to a busy conversation, so the context read here is still current at the commit; display-only entries
|
||||
* may land meanwhile.
|
||||
*/
|
||||
prepare: async (task, runtime, context) => {
|
||||
const { conversationId } = runtime;
|
||||
const agent = await runtime.agent(context);
|
||||
const settings = runtime.settings;
|
||||
const { model, thinkingLevel } = agent;
|
||||
const resolved = model === undefined ? undefined : runtime.models.getModel(model.provider, model.modelId);
|
||||
if (model === undefined || resolved === undefined) return failNoModel(runtime, model, context);
|
||||
const { attempt, compacted, overflow, threshold: blocked } = task.state.checkpoint;
|
||||
if (compacted !== undefined && overflow !== undefined) {
|
||||
const [outcome] = await runtime.outcomes([compacted], context);
|
||||
if (outcome?.status !== "completed" || outcome.result.entryId === undefined) {
|
||||
return failModelError(runtime, overflow, context);
|
||||
}
|
||||
}
|
||||
// A threshold compaction that found nothing to compact or was declined leaves a request that may still fit. One
|
||||
// that failed left a context it judged too large: sending it would at best leave no room for the answer.
|
||||
if (compacted !== undefined && blocked === true) {
|
||||
const [outcome] = await runtime.outcomes([compacted], context);
|
||||
if (outcome !== undefined && outcome.status !== "completed") {
|
||||
return failModelError(runtime, compactionFailure(outcome), context);
|
||||
}
|
||||
}
|
||||
const view = await runtime.context(conversationId, context);
|
||||
const shown = replaySections(view.messages);
|
||||
const report = (error: unknown) => runtime.report(error);
|
||||
let env: ExecutionEnv | undefined;
|
||||
try {
|
||||
env = await runtime.env(context);
|
||||
} catch (error) {
|
||||
if (context.abortSignal?.aborted) throw error;
|
||||
report(error);
|
||||
}
|
||||
const input: PromptInput = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };
|
||||
const desired = await renderSections(agent.sections, input, shown, report, context);
|
||||
const entries = planSystemEntries(view, desired, agent.tools, runtime.now());
|
||||
const threshold =
|
||||
compacted === undefined
|
||||
? thresholdCompaction(view, entries, resolved.contextWindow, settings.compaction)
|
||||
: undefined;
|
||||
if (threshold === "blocking") {
|
||||
// Compact first and prepare again; the transcript is unchanged until the compaction appends.
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId);
|
||||
const checkpoint = { phase: "prepare", attempt, compacted: child, threshold: true } as const;
|
||||
return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };
|
||||
}, context);
|
||||
return;
|
||||
}
|
||||
await runtime.commit(async (tx) => {
|
||||
let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;
|
||||
for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;
|
||||
if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`);
|
||||
// Checked in this commit, so a compaction admitted during preparation counts.
|
||||
if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {
|
||||
await createCompaction(tx, conversationId, { reason: "threshold" });
|
||||
}
|
||||
const request = {
|
||||
attempt,
|
||||
...(compacted === undefined ? {} : { compacted }),
|
||||
model,
|
||||
thinkingLevel,
|
||||
streamOptions: settings.stream,
|
||||
cutoff,
|
||||
};
|
||||
return { status: "running", checkpoint: { phase: "request", ...request } };
|
||||
}, context);
|
||||
},
|
||||
request: async (task, runtime, context) => {
|
||||
const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
await convertPartial(tx, live, conversationId);
|
||||
live.generation = { attempt };
|
||||
return undefined;
|
||||
}, context);
|
||||
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
||||
if (model === undefined) return failNoModel(runtime, ref, context);
|
||||
const view = await runtime.context(conversationId, context, { at: cutoff });
|
||||
let messages = view.messages;
|
||||
await runtime.hooks.each("beforeRequest", async (hook) => {
|
||||
const replaced = await hook({ messages }, runtime, context);
|
||||
if (replaced !== undefined) messages = replaced.messages;
|
||||
});
|
||||
const options: SimpleStreamOptions = {
|
||||
...streamOptions,
|
||||
signal: runtime.signal,
|
||||
sessionId: await ensureProviderSessionId(runtime, context),
|
||||
...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),
|
||||
};
|
||||
const message = await streamResponse(runtime, model, messages, options, attempt, context);
|
||||
const request = { attempt, compacted, model: ref, cutoff, messages: view.messages };
|
||||
await classify(runtime, request, message, context);
|
||||
},
|
||||
retry: async (task, runtime, context) => {
|
||||
const { attempt, compacted, until } = task.state.checkpoint;
|
||||
await runtime.sleep(until, context);
|
||||
await runtime.commit(async (tx) => {
|
||||
(await tx.doc(LiveDoc, runtime.conversationId)).generation = { attempt: attempt + 1 };
|
||||
const checkpoint: GenerationCheckpoint = {
|
||||
phase: "prepare",
|
||||
attempt: attempt + 1,
|
||||
...(compacted === undefined ? {} : { compacted }),
|
||||
};
|
||||
return { status: "running", checkpoint };
|
||||
}, context);
|
||||
},
|
||||
poll: async (task, runtime, context) => {
|
||||
const { attempt, compacted, model: ref, cutoff, handle, pollAt } = task.state.checkpoint;
|
||||
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
||||
if (model === undefined) return failNoModel(runtime, ref, context);
|
||||
await runtime.sleep(pollAt, context);
|
||||
const message = await runtime.models.fetchDeferred(model, handle, { signal: runtime.signal });
|
||||
const request = { attempt, compacted, model: ref, cutoff, pollAt };
|
||||
await classify(runtime, request, message, context);
|
||||
},
|
||||
tools: async (task, runtime, context) => {
|
||||
const { assistant, tools, pending } = task.state.checkpoint;
|
||||
const [next, ...rest] = pending;
|
||||
if (next === undefined) return finishToolRound(runtime, assistant, tools, context);
|
||||
// Sequential round: start the next call and wait for it.
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
const taskId = await createToolTask(tx, runtime, assistant, next);
|
||||
const slot = live.tools?.find((slot) => slot.callId === next && slot.taskId === undefined);
|
||||
if (slot !== undefined) slot.taskId = taskId;
|
||||
const checkpoint: GenerationCheckpoint = {
|
||||
phase: "tools",
|
||||
assistant,
|
||||
tools: [...tools, taskId],
|
||||
pending: rest,
|
||||
};
|
||||
return { status: "waiting", checkpoint, on: [taskId], policy: "allSettled" };
|
||||
}, context);
|
||||
},
|
||||
},
|
||||
abort: async (task, runtime, context) => {
|
||||
const checkpoint = task.state.checkpoint;
|
||||
if (checkpoint.phase === "poll") {
|
||||
const model = runtime.models.getModel(checkpoint.model.provider, checkpoint.model.modelId);
|
||||
if (model !== undefined) {
|
||||
try {
|
||||
await runtime.models.cancelDeferred(model, checkpoint.handle, { signal: runtime.signal });
|
||||
} catch (error) {
|
||||
runtime.report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
const conversationId = runtime.conversationId;
|
||||
// Runs after the round's tool tasks are terminal; calls never started get `aborted` results (spec §8.5).
|
||||
const unstarted =
|
||||
checkpoint.phase === "tools"
|
||||
? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context)
|
||||
: [];
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
await convertPartial(tx, live, conversationId);
|
||||
for (const call of unstarted) {
|
||||
const result = harnessError("aborted", `Tool ${call.name} was aborted`);
|
||||
await appendToolResult(tx, conversationId, call, result, { timestamp: runtime.now() });
|
||||
}
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" });
|
||||
return { status: "terminal", outcome: { status: "aborted" } };
|
||||
}, context);
|
||||
},
|
||||
});
|
||||
|
||||
/** The calls `callIds` of the assistant entry, in the given order. */
|
||||
async function readCalls(
|
||||
runtime: Runtime,
|
||||
assistant: EntryId,
|
||||
callIds: readonly string[],
|
||||
context: Context,
|
||||
): Promise<ToolCall[]> {
|
||||
const message = (await runtime.entry(AssistantEntry, assistant, context))?.model?.[0];
|
||||
const calls = message?.role === "assistant" ? message.content.filter((content) => content.type === "toolCall") : [];
|
||||
return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1));
|
||||
}
|
||||
|
||||
/** A tool task for call `callId`, owned by the generation. */
|
||||
function createToolTask(tx: Tx, runtime: Runtime, assistant: EntryId, callId: string): Promise<TaskId<ToolTaskResult>> {
|
||||
return tx.createTask(
|
||||
ToolTask,
|
||||
{ kind: "model", assistant, callId },
|
||||
{ ownership: { kind: "task", taskId: runtime.taskId } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which threshold compaction preparation starts before its request (spec §8.3): `blocking` above
|
||||
* `contextWindow - reserveTokens`, `background` above the background threshold, and only when range selection finds a
|
||||
* cut. The caller starts a background one only while no compaction is listed.
|
||||
*/
|
||||
function thresholdCompaction(
|
||||
view: ContextView,
|
||||
planned: readonly { readonly model?: readonly Message[] }[],
|
||||
contextWindow: number,
|
||||
policy: CompactionPolicy,
|
||||
): "blocking" | "background" | undefined {
|
||||
if (!policy.enabled || contextWindow <= 0) return undefined;
|
||||
const tokens = estimateContext(
|
||||
view,
|
||||
planned.flatMap((entry) => entry.model ?? []),
|
||||
);
|
||||
const blocking = contextWindow - policy.reserveTokens;
|
||||
const background = blocking - policy.backgroundTokens;
|
||||
const over =
|
||||
tokens > blocking ? "blocking" : policy.backgroundTokens > 0 && tokens > background ? "background" : undefined;
|
||||
if (over === undefined || selectCut(view, policy.keepRecentTokens) === undefined) return undefined;
|
||||
return over;
|
||||
}
|
||||
|
||||
/** Error text for a generation whose blocking compaction ended without completing. */
|
||||
function compactionFailure(outcome: Exclude<TaskOutcome<CompactionResult>, { status: "completed" }>): string {
|
||||
switch (outcome.status) {
|
||||
case "failed":
|
||||
case "faulted":
|
||||
return `Compaction failed: ${outcome.error.message}`;
|
||||
case "orphaned":
|
||||
return `Compaction failed: ${outcome.reason}`;
|
||||
case "aborted":
|
||||
return "Compaction was aborted";
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle the run's inputs `unanswered` with `model_error` and fail with `text`. */
|
||||
async function failModelError(runtime: Runtime, text: string, context: Context): Promise<void> {
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
|
||||
return {
|
||||
status: "terminal",
|
||||
outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
|
||||
};
|
||||
}, context);
|
||||
}
|
||||
|
||||
/** Settle the run's inputs `unanswered` with `no_model` and fail. */
|
||||
async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context: Context): Promise<void> {
|
||||
const message =
|
||||
ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "no_model" });
|
||||
return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a committed partial left by an interrupted, aborted, faulted, or orphaned attempt as an aborted assistant
|
||||
* entry; the caller replaces or removes `generation`.
|
||||
*/
|
||||
export async function convertPartial(tx: Tx, live: Draft<LiveState>, conversationId: ConversationId): Promise<void> {
|
||||
const partial = live.generation?.message;
|
||||
if (partial === undefined) return;
|
||||
const message = copyJson(partial) as unknown as AssistantMessage;
|
||||
await appendAssistant(tx, conversationId, { ...message, stopReason: "aborted" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one request and return the terminal message. Partials commit as trailing writes at most every
|
||||
* `progress.partialIntervalMs` (default 100 ms) with one commit in flight; `finally` stops the throttle and awaits that
|
||||
* commit, so no stale partial lands after the outcome.
|
||||
*/
|
||||
async function streamResponse(
|
||||
runtime: Runtime,
|
||||
model: Model<Api>,
|
||||
messages: readonly Message[],
|
||||
options: SimpleStreamOptions,
|
||||
attempt: number,
|
||||
context: Context,
|
||||
): Promise<AssistantMessage> {
|
||||
const interval = runtime.settings.progress.partialIntervalMs;
|
||||
let pending: AssistantMessage | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let inFlight: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
const flush = (): void => {
|
||||
timer = undefined;
|
||||
const partial = pending;
|
||||
pending = undefined;
|
||||
if (partial === undefined || stopped) return;
|
||||
inFlight = (async () => {
|
||||
// Copy synchronously: the provider keeps mutating its partial.
|
||||
const message = copyJson(partial, { omitUndefinedProperties: true });
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
live.generation ??= { attempt };
|
||||
assignJson(live.generation as Draft<Record<string, JsonValue>>, "message", message);
|
||||
return undefined;
|
||||
}, context);
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
// Rejections after an abort mark or close are expected; the committed state stays consistent.
|
||||
if (!runtime.signal.aborted) runtime.report(error);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
if (pending !== undefined && !stopped) timer = setTimeout(flush, interval);
|
||||
});
|
||||
};
|
||||
try {
|
||||
const events = runtime.models.streamSimple(model, { messages: [...messages] }, options);
|
||||
for await (const event of events) {
|
||||
// A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response
|
||||
// never gets past it, so it never leaves a partial.
|
||||
if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) continue;
|
||||
pending = event.partial;
|
||||
if (timer === undefined && inFlight === undefined) timer = setTimeout(flush, interval);
|
||||
}
|
||||
return await events.result();
|
||||
} finally {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
await inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a terminal provider message in one commit that also clears the partial. */
|
||||
async function classify(
|
||||
runtime: Runtime,
|
||||
request: Request,
|
||||
message: AssistantMessage,
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
// An abort mark or close: the abort invocation or the reopened run handles the committed state.
|
||||
runtime.signal.throwIfAborted();
|
||||
const conversationId = runtime.conversationId;
|
||||
const { attempt, compacted, model: ref, cutoff } = request;
|
||||
if (message.stopReason === "deferred" && message.deferred !== undefined) {
|
||||
const handle = message.deferred;
|
||||
const pollAt = Math.max(
|
||||
runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS),
|
||||
request.pollAt === undefined ? Number.NEGATIVE_INFINITY : request.pollAt + 1,
|
||||
);
|
||||
await runtime.commit(async (tx) => {
|
||||
(await tx.doc(LiveDoc, conversationId)).generation = { attempt, deferred: { pollAt } };
|
||||
const checkpoint = {
|
||||
phase: "poll",
|
||||
attempt,
|
||||
...(compacted === undefined ? {} : { compacted }),
|
||||
model: ref,
|
||||
cutoff,
|
||||
handle,
|
||||
pollAt,
|
||||
} as const;
|
||||
return { status: "running", checkpoint };
|
||||
}, context);
|
||||
return;
|
||||
}
|
||||
await runtime.hooks.each("afterResponse", (hook) => hook(message, runtime, context));
|
||||
const calls = message.content.filter((content): content is ToolCall => content.type === "toolCall");
|
||||
if (message.stopReason === "toolUse" && calls.length > 0) {
|
||||
return startToolRound(runtime, request, message, calls, context);
|
||||
}
|
||||
if (message.stopReason === "stop" || message.stopReason === "length" || message.stopReason === "toolUse") {
|
||||
return answer(runtime, message, context);
|
||||
}
|
||||
// The retry and compaction policies govern the next attempt, so they are read now rather than pinned at preparation.
|
||||
const settings = runtime.settings;
|
||||
const overflow = message.stopReason === "error" && isContextOverflow(message);
|
||||
if (overflow && compacted === undefined && settings.compaction.enabled) {
|
||||
const policy = settings.compaction;
|
||||
const view = await runtime.context(conversationId, context, { at: cutoff });
|
||||
if (selectCut(view, policy.keepRecentTokens) !== undefined) {
|
||||
const text = message.errorMessage ?? "Context overflow";
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
await appendAssistant(tx, conversationId, message);
|
||||
delete live.generation;
|
||||
const child = await createCompaction(tx, conversationId, { reason: "overflow" }, runtime.taskId);
|
||||
const checkpoint = { phase: "prepare", attempt, compacted: child, overflow: text } as const;
|
||||
return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };
|
||||
}, context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const policy = settings.retry;
|
||||
// An overflow is never retried: only a compaction can make the next request fit.
|
||||
const retry =
|
||||
message.stopReason === "error" &&
|
||||
!overflow &&
|
||||
isRetryableAssistantError(message) &&
|
||||
policy.enabled &&
|
||||
attempt <= policy.maxRetries;
|
||||
const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
await appendAssistant(tx, conversationId, message);
|
||||
if (retry) {
|
||||
live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } };
|
||||
const checkpoint = {
|
||||
phase: "retry",
|
||||
attempt,
|
||||
...(compacted === undefined ? {} : { compacted }),
|
||||
until,
|
||||
} as const;
|
||||
return { status: "running", checkpoint };
|
||||
}
|
||||
const text = message.errorMessage ?? `Model response ended with stop reason ${message.stopReason}`;
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
|
||||
return {
|
||||
status: "terminal",
|
||||
outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
|
||||
};
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* A final answer; the final boundary places queued items (spec §6). The first `onYield` continuation appends a user
|
||||
* message and hands the run to a successor generation, but only when the boundary selected no user item and no reset.
|
||||
* Otherwise the run's inputs settle `done`, and selected user items start the next run.
|
||||
*/
|
||||
async function answer(runtime: Runtime, message: AssistantMessage, context: Context): Promise<void> {
|
||||
let continuation: UserInput | undefined;
|
||||
await runtime.hooks.each("onYield", async (hook) => {
|
||||
if (continuation !== undefined) return;
|
||||
continuation = (await hook(message, runtime, context))?.continue;
|
||||
});
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
// Queue modes are read on the Session line, when the boundary is decided.
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry = await appendAssistant(tx, conversationId, message);
|
||||
const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
|
||||
const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());
|
||||
if (continuation !== undefined && users.length === 0 && !reset) {
|
||||
const user = { role: "user", content: continuation, timestamp: runtime.now() } as const;
|
||||
await tx.appendEntry(UserEntry, conversationId, { model: [user] });
|
||||
handOver(live, runtime.taskId, await createGeneration(tx, conversationId));
|
||||
delete live.generation;
|
||||
return result;
|
||||
}
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
|
||||
if (users.length > 0) await startRun(tx, conversationId, live, users);
|
||||
return result;
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the tool-calling answer and start its tool round in one commit (spec §8.3). A call to a tool the request did
|
||||
* not offer gets its `tool_unavailable` result here; every other call gets a tool task owned by the generation, only the
|
||||
* first one now when the round is sequential. The generation then waits for them in its `tools` phase, keeping the run.
|
||||
*/
|
||||
async function startToolRound(
|
||||
runtime: Runtime,
|
||||
request: Request,
|
||||
message: AssistantMessage,
|
||||
calls: readonly ToolCall[],
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
const conversationId = runtime.conversationId;
|
||||
const messages =
|
||||
request.messages ?? (await runtime.context(conversationId, context, { at: request.cutoff })).messages;
|
||||
const offered = new Set(getCurrentTools(messages).map((tool) => tool.name));
|
||||
// Read as the round starts; a tool is resolved as its tool task resolves it.
|
||||
const tools = (await runtime.agent(context)).tools;
|
||||
const sequential =
|
||||
runtime.settings.toolExecution === "sequential" ||
|
||||
calls.some(
|
||||
(call) =>
|
||||
offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential",
|
||||
);
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry = await appendAssistant(tx, conversationId, message);
|
||||
const slots: ToolSlot[] = [];
|
||||
const tools: TaskId<ToolTaskResult>[] = [];
|
||||
const pending: string[] = [];
|
||||
for (const call of calls) {
|
||||
if (!offered.has(call.name)) {
|
||||
const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
|
||||
const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });
|
||||
slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });
|
||||
continue;
|
||||
}
|
||||
if (sequential && tools.length > 0) {
|
||||
pending.push(call.id);
|
||||
slots.push({ callId: call.id, name: call.name, status: "pending" });
|
||||
continue;
|
||||
}
|
||||
const taskId = await createToolTask(tx, runtime, entry.id, call.id);
|
||||
tools.push(taskId);
|
||||
slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });
|
||||
}
|
||||
delete live.generation;
|
||||
live.tools = slots;
|
||||
const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const;
|
||||
return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* The round's tools are terminal: apply their controls and either end the run at the final boundary (`terminate`,
|
||||
* `handoff`, or a queued reset) or hand it to the next generation at the `postTools` boundary (spec §8.5).
|
||||
*/
|
||||
async function finishToolRound(
|
||||
runtime: Runtime,
|
||||
assistant: EntryId,
|
||||
tools: readonly TaskId<ToolTaskResult>[],
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
const conversationId = runtime.conversationId;
|
||||
const controls = new Map<TaskId, ToolControl | undefined>();
|
||||
const outcomes = await runtime.outcomes(tools, context);
|
||||
tools.forEach((id, index) => {
|
||||
const outcome = outcomes[index]!;
|
||||
// Version 1 results have no kind; every result a generation's round reads is a model-issued call's.
|
||||
controls.set(
|
||||
id,
|
||||
outcome.status === "completed" && outcome.result.kind !== "nested" ? outcome.result.control : undefined,
|
||||
);
|
||||
});
|
||||
const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
|
||||
const results = slots.flatMap((slot) => (slot.entry === undefined ? [] : [slot.entry]));
|
||||
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
|
||||
// Every call of the round, including those answered without a task, must ask to terminate.
|
||||
const terminate =
|
||||
slots.length > 0 &&
|
||||
slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);
|
||||
const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);
|
||||
// The last handoff in call order wins.
|
||||
const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff;
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
if (added.length > 0) await addTools(tx, conversationId, added);
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const now = runtime.now();
|
||||
if (terminate || handoff !== undefined) {
|
||||
if (handoff !== undefined) {
|
||||
const message = { role: "user", content: handoff, timestamp: now } as const;
|
||||
const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });
|
||||
boundary.head = entry.id;
|
||||
}
|
||||
const { users } = await applyBoundary(tx, boundary, "final", now);
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });
|
||||
if (users.length > 0) await startRun(tx, conversationId, live, users);
|
||||
} else {
|
||||
const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);
|
||||
if (reset) {
|
||||
// The queued reset cut the run's context before an answer.
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });
|
||||
if (users.length > 0) await startRun(tx, conversationId, live, users);
|
||||
} else {
|
||||
delete live.tools;
|
||||
delete live.nestedTools;
|
||||
if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users);
|
||||
handOver(live, runtime.taskId, await createGeneration(tx, conversationId));
|
||||
}
|
||||
}
|
||||
return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a provider result and add its usage to `pi.usage` in the same commit.
|
||||
* REMINDER: every built-in writer of assistant entries goes through here, so the usage ledger stays complete.
|
||||
*/
|
||||
async function appendAssistant(
|
||||
tx: Tx,
|
||||
conversationId: ConversationId,
|
||||
message: AssistantMessage,
|
||||
): Promise<TypedEntry<never>> {
|
||||
await recordUsage(tx, conversationId, "models", `${message.provider}/${message.model}`, message.usage);
|
||||
return tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
|
||||
}
|
||||
|
||||
/** Start a run for `inputs`, placed input submissions: a new generation takes `pi.live.run`. */
|
||||
export async function startRun(
|
||||
tx: Tx,
|
||||
conversationId: ConversationId,
|
||||
live: Draft<LiveState>,
|
||||
inputs: SubmissionId[],
|
||||
): Promise<void> {
|
||||
live.run = { taskId: await createGeneration(tx, conversationId), inputs };
|
||||
}
|
||||
|
||||
/** A generation owned by its conversation. */
|
||||
function createGeneration(tx: Tx, conversationId: ConversationId): Promise<TaskId<GenerationResult>> {
|
||||
return tx.createTask(GenerationTask, {}, { ownership: { kind: "conversation" }, conversationId });
|
||||
}
|
||||
|
||||
/** Hand run control from `from` to `to`; the run's inputs move with it. */
|
||||
export function handOver(live: Draft<LiveState>, from: TaskId, to: TaskId): void {
|
||||
if (live.run?.taskId === from) live.run.taskId = to;
|
||||
}
|
||||
470
third_party/pi/packages/durable/src/harness/harness.ts
vendored
Normal file
470
third_party/pi/packages/durable/src/harness/harness.ts
vendored
Normal file
@@ -0,0 +1,470 @@
|
||||
import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord";
|
||||
import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import { ResetEntry } from "../entries.ts";
|
||||
import type { ExecutionEnv } from "../env/index.ts";
|
||||
import { contained, SessionImpl } from "../session/session.ts";
|
||||
import type { Transaction } from "../session/transaction.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
ConversationOwnership,
|
||||
ConversationRecord,
|
||||
Cursor,
|
||||
EntryId,
|
||||
EntryQuery,
|
||||
EntryRecord,
|
||||
Page,
|
||||
Storage,
|
||||
SubmissionId,
|
||||
TaskId,
|
||||
TaskRecord,
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import { ROOT_CONVERSATION_ID } from "../types.ts";
|
||||
import { AgentDoc, configure, createAgent, resolveAgent, resolveSettings } from "./agent.ts";
|
||||
import { createCompaction } from "./compaction.ts";
|
||||
import { readContext } from "./context.ts";
|
||||
import { InboxDoc, withdrawQueuedInputs } from "./inbox.ts";
|
||||
import { LiveDoc, settleSchedulerOutcome } from "./live.ts";
|
||||
import { ProviderDoc } from "./provider.ts";
|
||||
import { BUILTIN_TASKS } from "./registry.ts";
|
||||
import { type InvocationBinding, TaskScheduler } from "./scheduler.ts";
|
||||
import { Submissions } from "./submissions.ts";
|
||||
import { type TaskGraph, TaskGraphView, type TaskGraphWatch } from "./task-graph.ts";
|
||||
import type {
|
||||
Agent,
|
||||
AgentChange,
|
||||
CompactionResult,
|
||||
ContextView,
|
||||
Conversation,
|
||||
ConversationAbortOptions,
|
||||
ConversationCreateOptions,
|
||||
ConversationHandle,
|
||||
ConversationInit,
|
||||
ConversationWatch,
|
||||
HarnessInspection,
|
||||
HarnessOptions,
|
||||
Harness as HarnessType,
|
||||
RegistrySnapshot,
|
||||
SettledTask,
|
||||
Submission,
|
||||
SubmissionDraft,
|
||||
ToolRegistration,
|
||||
} from "./types.ts";
|
||||
import { addUsageState, UsageDoc, type UsageState } from "./usage.ts";
|
||||
import { scanAll } from "./util.ts";
|
||||
import { type ConversationView, ConversationViews } from "./view.ts";
|
||||
|
||||
const SCAN_PAGE_SIZE = 256;
|
||||
|
||||
type CreateTarget =
|
||||
| { readonly kind: "root" }
|
||||
| { readonly kind: "independent"; readonly ownership: ConversationOwnership }
|
||||
| {
|
||||
readonly kind: "fork";
|
||||
readonly parentId: ConversationId;
|
||||
readonly at: EntryId;
|
||||
readonly ownership: ConversationOwnership;
|
||||
};
|
||||
|
||||
/** What the conveniences apply in the creating commit, after the creation hook. */
|
||||
type CreateOptions = { readonly agent?: AgentChange; readonly init?: ConversationInit };
|
||||
|
||||
/** Harness-private services used by Conversation handles. */
|
||||
type ConversationHost<Tool extends ToolRegistration> = {
|
||||
readonly harness: HarnessImpl<Tool>;
|
||||
readonly storage: Storage;
|
||||
readonly tasks: TaskScheduler;
|
||||
readonly submissions: Submissions;
|
||||
readonly views: ConversationViews;
|
||||
readonly now: () => number;
|
||||
create(target: CreateTarget, options: CreateOptions, context: Context): Promise<Conversation>;
|
||||
};
|
||||
|
||||
/** Key of the Harness's internal index-size getter, for tests. */
|
||||
export const SCHEDULER_INDEX_SIZES = Symbol.for("pi-durable.schedulerIndexSizes");
|
||||
|
||||
class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
|
||||
readonly id: ConversationId;
|
||||
readonly #host: ConversationHost<Tool>;
|
||||
|
||||
constructor(id: ConversationId, host: ConversationHost<Tool>) {
|
||||
this.id = id;
|
||||
this.#host = host;
|
||||
}
|
||||
|
||||
agent(context: Context): Promise<Agent> {
|
||||
return this.#host.harness.resolveAgent(this.id, undefined, context);
|
||||
}
|
||||
|
||||
configure(change: AgentChange, context: Context): Promise<void> {
|
||||
return this.#host.harness.commitWith((tx) => configure(tx, this.id, change), context);
|
||||
}
|
||||
|
||||
submit(submission: SubmissionDraft, context: Context): Promise<Submission> {
|
||||
return this.#host.submissions.submit(this.id, submission, context);
|
||||
}
|
||||
|
||||
compact(instructions: string | undefined, context: Context): Promise<TaskId<CompactionResult>> {
|
||||
this.#host.tasks.resume();
|
||||
const input = { reason: "manual", ...(instructions === undefined ? {} : { instructions }) } as const;
|
||||
return this.#host.harness.commitWith((tx) => createCompaction(tx, this.id, input), context);
|
||||
}
|
||||
|
||||
async reset(handoff: string | undefined, context: Context): Promise<void> {
|
||||
const model =
|
||||
handoff === undefined
|
||||
? {}
|
||||
: { model: [{ role: "user", content: handoff, timestamp: this.#host.now() } as const] };
|
||||
const entry = { kind: ResetEntry.kind, head: "self", ...model } as const;
|
||||
await this.#host.submissions.submit(this.id, { type: "write", entry }, context);
|
||||
}
|
||||
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T> {
|
||||
return this.#host.harness.commitWith(change, context, { conversationId: this.id });
|
||||
}
|
||||
|
||||
context(context: Context, options?: { readonly at?: EntryId }): Promise<ContextView> {
|
||||
return readContext(this.#host.harness, this.#host.storage, this.id, context, options?.at);
|
||||
}
|
||||
|
||||
entries(
|
||||
query: Omit<EntryQuery, "conversationId">,
|
||||
limit: number,
|
||||
cursor: Cursor | undefined,
|
||||
context: Context,
|
||||
): Promise<Page<EntryRecord, Cursor>> {
|
||||
const bounded: EntryQuery = {
|
||||
conversationId: this.id,
|
||||
...(query.minEntryId === undefined ? {} : { minEntryId: query.minEntryId }),
|
||||
...(query.maxEntryId === undefined ? {} : { maxEntryId: query.maxEntryId }),
|
||||
...(query.order === undefined ? {} : { order: query.order }),
|
||||
};
|
||||
return this.#host.harness.readOnLine(() => this.#host.storage.scanEntries(bounded, limit, cursor, context));
|
||||
}
|
||||
|
||||
fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise<Conversation> {
|
||||
return this.#host.create({ kind: "fork", parentId: this.id, at, ownership: options.ownership }, options, context);
|
||||
}
|
||||
|
||||
abort(context: Context, options?: ConversationAbortOptions): Promise<void> {
|
||||
this.#host.tasks.resume();
|
||||
return this.#host.tasks.abortConversation(this.id, options?.background === true, context);
|
||||
}
|
||||
|
||||
waitForIdle(context: Context): Promise<void> {
|
||||
this.#host.tasks.resume();
|
||||
return this.#host.tasks.waitForIdle(this.id, context);
|
||||
}
|
||||
|
||||
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>> {
|
||||
return this.#host.views.state(this.id, context);
|
||||
}
|
||||
|
||||
watch(context: Context): Promise<ConversationWatch> {
|
||||
return this.#host.views.watch(this.id, context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Session kernel extended with conversation handles and a registry. */
|
||||
class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements HarnessType {
|
||||
readonly #options: HarnessOptions<Tool>;
|
||||
readonly #report: (error: unknown) => void;
|
||||
readonly #host: ConversationHost<Tool>;
|
||||
readonly #tasks: TaskScheduler;
|
||||
readonly #submissions: Submissions;
|
||||
readonly #taskGraph: TaskGraphView;
|
||||
|
||||
constructor(rawStorage: Storage, options: HarnessOptions<Tool>, context: Context) {
|
||||
const report = safeReport(options.onReport);
|
||||
const now = safeNow(options.now, report);
|
||||
// Read at each use, as all settings are, but only this field: it is read on every commit.
|
||||
super(rawStorage, now, () => options.settings?.documentRetentionMs);
|
||||
// Every component reads and writes through the Session's failure guard: `rawStorage` serves only `super()`.
|
||||
const storage = this.storage;
|
||||
this.#options = options;
|
||||
this.#report = report;
|
||||
const settings = () => resolveSettings(options.settings);
|
||||
this.#tasks = new TaskScheduler({
|
||||
session: this,
|
||||
storage,
|
||||
registry: options.registry,
|
||||
models: options.models,
|
||||
agent: (id, snapshot, callContext) => this.resolveAgent(id, snapshot as RegistrySnapshot<Tool>, callContext),
|
||||
settings,
|
||||
env: (id, callContext) => this.buildEnv(id, callContext),
|
||||
now,
|
||||
report: this.#report,
|
||||
settleOutcome: settleSchedulerOutcome,
|
||||
withdrawInputs: withdrawQueuedInputs,
|
||||
conversation: async (id, binding, callContext) => {
|
||||
const record = await this.readOnLine(() => storage.conversation(id, callContext));
|
||||
return record === undefined ? undefined : boundConversation(id, binding, this.#submissions, this.#tasks);
|
||||
},
|
||||
context: withoutAbortSignal(context),
|
||||
});
|
||||
this.#submissions = new Submissions(this, storage, now, settings, () => this.#tasks.resume());
|
||||
this.#taskGraph = new TaskGraphView(this, storage);
|
||||
this.#host = {
|
||||
harness: this,
|
||||
storage,
|
||||
tasks: this.#tasks,
|
||||
submissions: this.#submissions,
|
||||
views: new ConversationViews(this, storage),
|
||||
now,
|
||||
create: (target, createOptions, context) => this.#create(target, createOptions, context),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */
|
||||
async resolveAgent(
|
||||
id: ConversationId,
|
||||
snapshot: RegistrySnapshot<Tool> | undefined,
|
||||
context: Context,
|
||||
): Promise<Agent<Tool>> {
|
||||
const registry = snapshot ?? this.#options.registry.snapshot();
|
||||
const state = await this.snapshot(AgentDoc, id, context);
|
||||
return resolveAgent(state, registry, resolveSettings(this.#options.settings), this.#report);
|
||||
}
|
||||
|
||||
/** Build a conversation's environment from its current `cwd`; `undefined` without an `env` option. */
|
||||
async buildEnv(id: ConversationId, context: Context): Promise<ExecutionEnv | undefined> {
|
||||
const build = this.#options.env;
|
||||
if (build === undefined) return undefined;
|
||||
const cwd = (await this.snapshot(AgentDoc, id, context))?.cwd;
|
||||
return build({ conversationId: id, ...(cwd === undefined ? {} : { cwd }), read: this }, context);
|
||||
}
|
||||
|
||||
/** Reconcile surviving `running` tasks to `pending`; part of open. */
|
||||
openTasks(context: Context): Promise<void> {
|
||||
return this.#tasks.open(context);
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.assertUsable();
|
||||
this.#tasks.resume();
|
||||
}
|
||||
|
||||
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined> {
|
||||
return this.readOnLine(() => this.storage.task(id, context)) as Promise<
|
||||
TaskRecord<JsonValue, JsonValue, R> | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
/** Internal, for tests: sizes of the scheduler's ownership indexes. */
|
||||
get [SCHEDULER_INDEX_SIZES](): TaskScheduler["indexSizes"] {
|
||||
return this.#tasks.indexSizes;
|
||||
}
|
||||
|
||||
inspect(context: Context): Promise<HarnessInspection> {
|
||||
return this.readOnLine(async () => {
|
||||
const { scheduling, tasks } = await this.#tasks.inspect(this.#options.registry.snapshot());
|
||||
const scan = (status: "queued" | "placed") =>
|
||||
scanAll((cursor) => this.storage.scanSubmissions({ status }, SCAN_PAGE_SIZE, cursor, context));
|
||||
const submissions = [...(await scan("queued")), ...(await scan("placed"))].sort((a, b) => a.id - b.id);
|
||||
return { scheduling, tasks, submissions };
|
||||
});
|
||||
}
|
||||
|
||||
submission(id: SubmissionId, context: Context): Promise<Submission | undefined> {
|
||||
return this.#submissions.get(id, context);
|
||||
}
|
||||
|
||||
abortSubmission(
|
||||
id: SubmissionId,
|
||||
context: Context,
|
||||
conversationId?: ConversationId,
|
||||
): Promise<"aborted" | "already_placed" | "settled" | "not_found"> {
|
||||
return this.#submissions.abort(id, context, conversationId);
|
||||
}
|
||||
|
||||
abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal"> {
|
||||
return this.#tasks.abort(id, context);
|
||||
}
|
||||
|
||||
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>> {
|
||||
this.#tasks.resume();
|
||||
return this.#tasks.waitForTask(id, context) as Promise<SettledTask<R>>;
|
||||
}
|
||||
|
||||
waitForIdle(context: Context): Promise<void> {
|
||||
this.#tasks.resume();
|
||||
return this.#tasks.waitForIdle(undefined, context);
|
||||
}
|
||||
|
||||
/** Sum every conversation's committed `pi.usage`. Each document is read at its own point; totals only grow. */
|
||||
async usage(context: Context): Promise<UsageState> {
|
||||
const conversations = await this.readOnLine(() =>
|
||||
scanAll((cursor) => this.storage.scanConversations({}, SCAN_PAGE_SIZE, cursor, context)),
|
||||
);
|
||||
const total = UsageDoc.definition.initial();
|
||||
for (const { id } of conversations) {
|
||||
const state = await this.snapshot(UsageDoc, id, context);
|
||||
if (state !== undefined) addUsageState(total, state);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>> {
|
||||
return this.#taskGraph.state(context);
|
||||
}
|
||||
|
||||
watchTaskGraph(context: Context): Promise<TaskGraphWatch> {
|
||||
return this.#taskGraph.watch(context);
|
||||
}
|
||||
|
||||
root(
|
||||
context: Context,
|
||||
options?: { readonly agent?: AgentChange; readonly init?: ConversationInit },
|
||||
): Promise<Conversation> {
|
||||
return this.#create({ kind: "root" }, options ?? {}, context);
|
||||
}
|
||||
|
||||
async conversation(id: ConversationId, context: Context): Promise<Conversation | undefined> {
|
||||
this.assertUsable();
|
||||
const record = await this.readOnLine(() => this.storage.conversation(id, context));
|
||||
return record === undefined ? undefined : new ConversationImpl(record.id, this.#host);
|
||||
}
|
||||
|
||||
createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation> {
|
||||
return this.#create({ kind: "independent", ownership: options.ownership }, options, context);
|
||||
}
|
||||
|
||||
override report(error: unknown): void {
|
||||
this.#report(error);
|
||||
}
|
||||
|
||||
/** Signal and join task invocations after the close listeners and before Storage closes; writes no task outcome. */
|
||||
protected override beforeClose(): Promise<void> {
|
||||
return this.#tasks.join();
|
||||
}
|
||||
|
||||
async #create(target: CreateTarget, options: CreateOptions, context: Context): Promise<Conversation> {
|
||||
this.assertUsable();
|
||||
const id = await this.commitWith(async (tx) => {
|
||||
if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) {
|
||||
return ROOT_CONVERSATION_ID;
|
||||
}
|
||||
const record =
|
||||
target.kind === "root"
|
||||
? await tx.createRootConversation()
|
||||
: target.kind === "fork"
|
||||
? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership })
|
||||
: await tx.createConversation({ ownership: target.ownership });
|
||||
if (options.agent !== undefined) await configure(tx, record.id, options.agent);
|
||||
if (options.init !== undefined) await options.init(tx, record.id);
|
||||
return record.id;
|
||||
}, context);
|
||||
return new ConversationImpl(id, this.#host);
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and
|
||||
* `pi.usage`, a fresh `pi.provider`, the conversation's `pi.agent` (see `createAgent()`), then
|
||||
* `HarnessOptions.conversationCreated`.
|
||||
*/
|
||||
protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void> {
|
||||
await tx.doc(LiveDoc, record.id);
|
||||
await tx.doc(InboxDoc, record.id);
|
||||
await tx.doc(UsageDoc, record.id);
|
||||
await tx.doc(ProviderDoc, record.id);
|
||||
await createAgent(tx, record);
|
||||
await this.#options.conversationCreated?.(tx, record);
|
||||
}
|
||||
}
|
||||
|
||||
/** `onReport` that cannot fail: it is the last place an error goes, so its own throw or rejection is dropped. */
|
||||
function safeReport(onReport: ((error: unknown) => void) | undefined): (error: unknown) => void {
|
||||
return (error) =>
|
||||
contained(
|
||||
() => onReport?.(error),
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host clock, falling back to `Date.now` when it throws: a broken clock must not stop commits. The first throw is
|
||||
* reported; the clock is read in every task change, so later ones are not.
|
||||
*/
|
||||
export function safeNow(now: (() => number) | undefined, report: (error: unknown) => void): () => number {
|
||||
if (now === undefined) return Date.now;
|
||||
let reported = false;
|
||||
return () => {
|
||||
try {
|
||||
return now();
|
||||
} catch (error) {
|
||||
// Set first: a report handler that reads the clock again must not report again.
|
||||
if (!reported) {
|
||||
reported = true;
|
||||
report(error);
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invocation-bound handle for tasks and tools. Every operation, and every operation of a submission it returns, first
|
||||
* checks the invocation and runs under its signal, so it rejects once the invocation ends; admitted work stays durable.
|
||||
*/
|
||||
function boundConversation(
|
||||
id: ConversationId,
|
||||
binding: InvocationBinding,
|
||||
submissions: Submissions,
|
||||
tasks: TaskScheduler,
|
||||
): ConversationHandle {
|
||||
const bind = (context: Context): Context => withAbortSignal(binding.signal, context);
|
||||
const bound = <T>(operation: (context: Context) => Promise<T>) => {
|
||||
return async (context: Context): Promise<T> => {
|
||||
binding.check();
|
||||
return operation(bind(context));
|
||||
};
|
||||
};
|
||||
return {
|
||||
id,
|
||||
submit: async (draft, context) => {
|
||||
binding.check();
|
||||
const submission = await submissions.submit(id, draft, bind(context));
|
||||
return {
|
||||
id: submission.id,
|
||||
status: bound((callContext) => submission.status(callContext)),
|
||||
wait: bound((callContext) => submission.wait(callContext)),
|
||||
abort: bound((callContext) => submission.abort(callContext)),
|
||||
};
|
||||
},
|
||||
abort: async (context, options) => {
|
||||
binding.check();
|
||||
return tasks.abortConversation(id, options?.background === true, bind(context));
|
||||
},
|
||||
waitForIdle: bound((callContext) => tasks.waitForIdle(id, callContext)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Durable agent harness over one Session. */
|
||||
export type Harness = HarnessType;
|
||||
|
||||
export const Harness = {
|
||||
/** Open a Harness over storage. The registry may keep changing while the Harness runs. */
|
||||
async open<Tool extends ToolRegistration>(
|
||||
storage: Storage,
|
||||
options: HarnessOptions<Tool>,
|
||||
context: Context,
|
||||
): Promise<Harness> {
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const snapshot = options.registry.snapshot();
|
||||
const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined);
|
||||
if (missing.length > 0) {
|
||||
const names = missing.map((task) => task.definition.name).join(", ");
|
||||
throw new Error(`Registry lacks built-in tasks ${names}; create it with createRegistry()`);
|
||||
}
|
||||
const harness = new HarnessImpl(storage, options, context);
|
||||
try {
|
||||
await harness.openTasks(context);
|
||||
} catch (error) {
|
||||
// The caller's context may be what failed open: close without it, and rethrow the open error.
|
||||
// A failing Storage close fails the Session, which reports it.
|
||||
await harness.close(withoutAbortSignal(context)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return harness;
|
||||
},
|
||||
};
|
||||
168
third_party/pi/packages/durable/src/harness/inbox.ts
vendored
Normal file
168
third_party/pi/packages/durable/src/harness/inbox.ts
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
import { copyJson, type Draft, type JsonRepresentation } from "@earendil-works/chord";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import { UserEntry } from "../entries.ts";
|
||||
import type { ConversationId, EntryDraft, EntryId, EntryRecord, JsonObject, SubmissionId, Tx } from "../types.ts";
|
||||
import type { InputEntry, QueueMode, Settings, UserInput } from "./types.ts";
|
||||
|
||||
/** A queued submission: user input for a run, with the entry it is placed as when not `pi.user`, or an entry write. */
|
||||
export type InboxItem =
|
||||
| {
|
||||
id: SubmissionId;
|
||||
mode: "steer" | "followUp";
|
||||
content: JsonRepresentation<UserInput>;
|
||||
entry?: JsonRepresentation<InputEntry>;
|
||||
}
|
||||
/** `entry` is an `EntryDraft`, stored as plain JSON. */
|
||||
| { id: SubmissionId; mode: "write"; entry: JsonObject };
|
||||
|
||||
/** Built-in queue of one conversation's submissions waiting for a boundary, in ID order. */
|
||||
export type InboxState = { items: InboxItem[] };
|
||||
|
||||
export const InboxDoc = defineDoc<InboxState>({
|
||||
kind: "pi.inbox",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ items: [] }),
|
||||
checkpointWhen: (value) => value.items.length === 0,
|
||||
});
|
||||
|
||||
/** The settings a boundary reads, on the Session line. */
|
||||
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 conversationId: ConversationId;
|
||||
readonly inbox: Draft<InboxState>;
|
||||
readonly steeringMode: QueueMode;
|
||||
readonly followUpMode: QueueMode;
|
||||
/** Start of the active range, the newest head marker's `head`; advanced by heads written in this commit. */
|
||||
head: EntryId | undefined;
|
||||
};
|
||||
|
||||
/** Selected user items, in ID order, and whether a `head: "self"` write (a reset) was placed. */
|
||||
export type BoundaryResult = { readonly users: SubmissionId[]; readonly reset: boolean };
|
||||
|
||||
/**
|
||||
* 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: Tx, conversationId: ConversationId, modes: QueueModes): Promise<Boundary> {
|
||||
const head = (await tx.latestHeadMarker(conversationId))?.head;
|
||||
const inbox = await tx.doc(InboxDoc, conversationId);
|
||||
return {
|
||||
conversationId,
|
||||
inbox,
|
||||
steeringMode: modes.steeringMode,
|
||||
followUpMode: modes.followUpMode,
|
||||
head,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* items after them, each in ID order, so user items queued before a reset run in the new context. A write whose head
|
||||
* targets an entry before the active range, including a range started earlier in this commit, is stale. Selected and
|
||||
* stale items are removed positionally.
|
||||
*/
|
||||
export async function applyBoundary(
|
||||
tx: Tx,
|
||||
boundary: Boundary,
|
||||
at: "postTools" | "final",
|
||||
now: number,
|
||||
): Promise<BoundaryResult> {
|
||||
const { conversationId, inbox } = boundary;
|
||||
const items = inbox.items;
|
||||
const reset = hasQueuedReset(items);
|
||||
const final = at === "final" || reset;
|
||||
const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => {
|
||||
const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : []));
|
||||
return queueMode === "all" ? indexes : indexes.slice(0, 1);
|
||||
};
|
||||
const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));
|
||||
const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];
|
||||
users.sort((a, b) => a - b);
|
||||
|
||||
// `appendEntry()` copies the drafts' values; the items are removed only afterwards.
|
||||
for (const index of writes) {
|
||||
const item = items[index] as Draft<Extract<InboxItem, { mode: "write" }>>;
|
||||
const draft = item.entry as unknown as EntryDraft;
|
||||
if (isStale(boundary, draft)) {
|
||||
tx.settleSubmission(item.id, { status: "unanswered", reason: "stale" });
|
||||
continue;
|
||||
}
|
||||
const entry = await tx.appendEntry(conversationId, draft);
|
||||
if (draft.head !== undefined) boundary.head = draft.head === "self" ? entry.id : draft.head;
|
||||
tx.placeSubmission(item.id, entry.id);
|
||||
}
|
||||
const placed: SubmissionId[] = [];
|
||||
for (const index of users) {
|
||||
const item = items[index] as Draft<Extract<InboxItem, { mode: "steer" | "followUp" }>>;
|
||||
const entry = await appendInput(tx, conversationId, item.content as UserInput, item.entry as InputEntry, now);
|
||||
tx.placeSubmission(item.id, entry.id);
|
||||
placed.push(item.id);
|
||||
}
|
||||
const removed = [...writes, ...users].sort((a, b) => b - a);
|
||||
for (const index of removed) items.splice(index, 1);
|
||||
return { users: placed, reset };
|
||||
}
|
||||
|
||||
/** 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: Tx,
|
||||
conversationId: ConversationId,
|
||||
content: UserInput,
|
||||
entry: InputEntry | undefined,
|
||||
now: number,
|
||||
): Promise<EntryRecord> {
|
||||
const message = { role: "user", content, timestamp: now } as const;
|
||||
if (entry === undefined) return tx.appendEntry(UserEntry, conversationId, { model: [message] });
|
||||
return tx.appendEntry(conversationId, { ...pickInputEntry(entry), 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 }: InputEntry): JsonRepresentation<InputEntry> {
|
||||
const picked = data === undefined ? { kind } : { kind, data };
|
||||
return copyJson(picked, { omitUndefinedProperties: true }) as JsonRepresentation<InputEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a write is display-only: it has no `model`, `head`, or `edits` (present counts, even if empty), so it changes
|
||||
* nothing the model sees and needs no boundary.
|
||||
*/
|
||||
export function isDisplayOnly(entry: EntryDraft): boolean {
|
||||
return entry.model === undefined && entry.head === undefined && entry.edits === undefined;
|
||||
}
|
||||
|
||||
/** Whether a reset (`head: "self"` write) is queued; entries appended before it leave the active range when it lands. */
|
||||
export function hasQueuedReset(items: readonly InboxItem[]): boolean {
|
||||
return items.some((item) => item.mode === "write" && item.entry.head === "self");
|
||||
}
|
||||
|
||||
/** Whether a head write targets an entry before the active range, so placing it would bring back cut history. */
|
||||
export function isStale(boundary: Boundary, entry: EntryDraft): boolean {
|
||||
return typeof entry.head === "number" && boundary.head !== undefined && entry.head < boundary.head;
|
||||
}
|
||||
|
||||
/** Remove a withdrawn submission's item; the caller settles the submission. */
|
||||
export async function removeInboxItem(tx: Tx, conversationId: ConversationId, id: SubmissionId): Promise<void> {
|
||||
const items = (await tx.doc(InboxDoc, conversationId)).items;
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
if (index >= 0) items.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw every queued input of a conversation, as `Conversation.abort()` and abort cascades do: each settles
|
||||
* `unanswered` with `aborted` and leaves the inbox; queued writes stay for later placement.
|
||||
*/
|
||||
export async function withdrawQueuedInputs(tx: Tx, conversationId: ConversationId): Promise<void> {
|
||||
const items = (await tx.doc(InboxDoc, conversationId)).items;
|
||||
for (let index = items.length - 1; index >= 0; index--) {
|
||||
const item = items[index]!;
|
||||
if (item.mode === "write") continue;
|
||||
tx.settleSubmission(item.id, { status: "unanswered", reason: "aborted" });
|
||||
items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
31
third_party/pi/packages/durable/src/harness/json.ts
vendored
Normal file
31
third_party/pi/packages/durable/src/harness/json.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { JsonValue } from "@earendil-works/chord";
|
||||
|
||||
type JsonContainer = Record<string, JsonValue> | JsonValue[];
|
||||
|
||||
/**
|
||||
* Assign `value` at `target[key]` leaf by leaf. Chord records a container assignment as one full set and only emits an
|
||||
* append when a string leaf is reassigned with a longer string, so writing the partial whole would store and publish the
|
||||
* complete message on every flush.
|
||||
*/
|
||||
export function assignJson(target: JsonContainer, key: string | number, value: JsonValue): void {
|
||||
const slots = target as Record<string | number, JsonValue>;
|
||||
const current = slots[key];
|
||||
if (isRecord(current) && isRecord(value)) {
|
||||
for (const name of Object.keys(current)) if (!Object.hasOwn(value, name)) delete current[name];
|
||||
for (const [name, child] of Object.entries(value)) assignJson(current, name, child);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(current) && Array.isArray(value) && current.length <= value.length) {
|
||||
const items = current as JsonValue[];
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (index < items.length) assignJson(items, index, value[index]!);
|
||||
else items.push(value[index]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current !== value) slots[key] = value;
|
||||
}
|
||||
|
||||
function isRecord(value: JsonValue | undefined): value is Record<string, JsonValue> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
268
third_party/pi/packages/durable/src/harness/live.ts
vendored
Normal file
268
third_party/pi/packages/durable/src/harness/live.ts
vendored
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { Draft, JsonRepresentation, JsonValue } from "@earendil-works/chord";
|
||||
import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import type { Transaction } from "../session/transaction.ts";
|
||||
import type { EntryId, JsonObject, SubmissionId, SubmissionSettlement, TaskId, TaskRecord, Tx } from "../types.ts";
|
||||
import { convertPartial } from "./generation.ts";
|
||||
import type { SchedulerOutcome } from "./scheduler.ts";
|
||||
import type { CompactionReason, CompactionResult, ToolDiagnostic } from "./types.ts";
|
||||
|
||||
/** What a running tool call publishes, shared by model-issued and nested calls. */
|
||||
type SlotProgress = {
|
||||
callId: string;
|
||||
name: string;
|
||||
status: "pending" | "running" | "done";
|
||||
/** Retained running output and what the bounds dropped. */
|
||||
output?: string;
|
||||
droppedBytes?: number;
|
||||
droppedLines?: number;
|
||||
/** Last `details()` value. */
|
||||
details?: JsonValue;
|
||||
/** Diagnostics recorded through `api.diagnostic()`. */
|
||||
diagnostics?: ToolDiagnostic[];
|
||||
};
|
||||
|
||||
/** Presentation of one tool call of the current round. */
|
||||
export type ToolSlot = SlotProgress & {
|
||||
/**
|
||||
* Absent for a call not started yet (sequential round) and for a call its request did not offer, which starts `done`
|
||||
* with the `entry` generation wrote.
|
||||
*/
|
||||
taskId?: TaskId;
|
||||
/** Result entry once done; absent when the tool task faulted or was orphaned. */
|
||||
entry?: EntryId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Presentation of one nested call: a call a running tool made through `executeTool()`. `callId` is
|
||||
* `<parentCallId>/<key>`, the call's ID in tool events and unique because keys contain no `/`. The Harness relates
|
||||
* slots by task ID: `taskId` is the call's tool task, `parentTaskId` the calling one, model-issued or nested.
|
||||
*/
|
||||
export type NestedToolSlot = SlotProgress & {
|
||||
parentCallId: string;
|
||||
parentTaskId: TaskId;
|
||||
taskId: TaskId;
|
||||
/**
|
||||
* What the call was made with, replaced by the arguments it runs with, after repair, `beforeTool`, and coercion,
|
||||
* once it starts executing. A model-issued call's arguments are in its assistant entry instead.
|
||||
*/
|
||||
arguments: JsonObject;
|
||||
/** Once done: how the call ended, for a status line. The result itself is in the caller's `NestedCallDoc`. */
|
||||
summary?: NestedToolSummary;
|
||||
};
|
||||
|
||||
/** How a nested call ended: an error flag, its execution time, its spend, and up to 500 characters of error text. */
|
||||
export type NestedToolSummary = {
|
||||
isError: boolean;
|
||||
durationMs?: number;
|
||||
usage?: JsonRepresentation<Usage>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Presentation of one live compaction task (spec §8.7). */
|
||||
export type CompactionStatus = {
|
||||
taskId: TaskId<CompactionResult>;
|
||||
reason: CompactionReason;
|
||||
/** Whether a generation waits for it: a compaction the generation owns. */
|
||||
blocking: boolean;
|
||||
attempt: number;
|
||||
/** Durable backoff before the next summarization attempt. */
|
||||
retry?: { at: number; error: string };
|
||||
};
|
||||
|
||||
/** The newest compaction that failed since a compaction last produced a summary (spec §8.7). */
|
||||
export type CompactionFailure = {
|
||||
taskId: TaskId<CompactionResult>;
|
||||
reason: CompactionReason;
|
||||
/** The failed or faulted outcome's error message, or why the scheduler orphaned the task. */
|
||||
error: string;
|
||||
};
|
||||
|
||||
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */
|
||||
export type LiveState = {
|
||||
/** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */
|
||||
run?: { taskId: TaskId; inputs: SubmissionId[] };
|
||||
/** Presentation of the current generation attempt. */
|
||||
generation?: {
|
||||
attempt: number;
|
||||
/** Committed throttled partial of the in-flight response. */
|
||||
message?: JsonRepresentation<AssistantMessage>;
|
||||
/** Durable backoff before the next attempt. */
|
||||
retry?: { at: number; error: string };
|
||||
/** Provider-side deferred response being polled. */
|
||||
deferred?: { pollAt: number };
|
||||
};
|
||||
/** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */
|
||||
tools?: ToolSlot[];
|
||||
/**
|
||||
* Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A
|
||||
* call's nested slots are removed when the call settles.
|
||||
*/
|
||||
nestedTools?: NestedToolSlot[];
|
||||
/** Live compaction tasks in task ID order; absent when none. */
|
||||
compactions?: CompactionStatus[];
|
||||
/**
|
||||
* The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary
|
||||
* removes it; one that is aborted or finds nothing to compact leaves it.
|
||||
*/
|
||||
compactionFailure?: CompactionFailure;
|
||||
};
|
||||
|
||||
export const LiveDoc = defineDoc<LiveState>({
|
||||
kind: "pi.live",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({}),
|
||||
// REMINDER: a complete base whenever nothing runs (spec §8.2): no generation and no running tool slot. That holds
|
||||
// while idle, in the commit handing a generation over to its tool round, and between tools, so the delta chain
|
||||
// spans at most one generation or the overlapping execution of one round's tools. A slot holds output only while
|
||||
// running, so every base is small; a nested call's result lives in its caller's task documents, never here. Do not
|
||||
// add a delta-count bound; the tool output benchmark checks this rule.
|
||||
checkpointWhen: (value) =>
|
||||
value.generation === undefined &&
|
||||
!(value.tools ?? []).some((slot) => slot.status === "running") &&
|
||||
!(value.nestedTools ?? []).some((slot) => slot.status === "running"),
|
||||
});
|
||||
|
||||
/** Built-in task kinds that can own `pi.live.run`. */
|
||||
const RUN_TASK_KINDS: ReadonlySet<string> = new Set(["pi.generation"]);
|
||||
const TOOL_TASK_KIND = "pi.tool";
|
||||
const COMPACTION_TASK_KIND = "pi.compaction";
|
||||
|
||||
/**
|
||||
* End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation`, `tools`, and
|
||||
* `nestedTools`, whose presentation belongs to the ending run.
|
||||
*/
|
||||
export function endRun(tx: Tx, live: Draft<LiveState>, taskId: TaskId, settlement: SubmissionSettlement): void {
|
||||
if (live.run?.taskId === taskId) {
|
||||
for (const id of live.run.inputs) tx.settleSubmission(id, settlement);
|
||||
delete live.run;
|
||||
}
|
||||
delete live.generation;
|
||||
delete live.tools;
|
||||
delete live.nestedTools;
|
||||
}
|
||||
|
||||
/** Add the status of a compaction task created in this commit; statuses stay in task ID order. */
|
||||
export function addCompactionStatus(live: Draft<LiveState>, status: CompactionStatus): void {
|
||||
live.compactions ??= [];
|
||||
live.compactions.push(status);
|
||||
}
|
||||
|
||||
/** The status of compaction task `taskId`, if listed. */
|
||||
export function compactionStatus(live: Draft<LiveState>, taskId: TaskId): Draft<CompactionStatus> | undefined {
|
||||
return live.compactions?.find((status) => status.taskId === taskId);
|
||||
}
|
||||
|
||||
/** Remove the status of compaction task `taskId`, and the list once empty. */
|
||||
export function removeCompactionStatus(live: Draft<LiveState>, taskId: TaskId): void {
|
||||
const statuses = live.compactions;
|
||||
if (statuses === undefined) return;
|
||||
const index = statuses.findIndex((status) => status.taskId === taskId);
|
||||
if (index >= 0) statuses.splice(index, 1);
|
||||
if (statuses.length === 0) delete live.compactions;
|
||||
}
|
||||
|
||||
/** Remove the status of compaction task `taskId` and record that it failed with `error`. */
|
||||
export function failCompactionStatus(live: Draft<LiveState>, taskId: TaskId, error: string): void {
|
||||
const status = compactionStatus(live, taskId);
|
||||
if (status !== undefined) live.compactionFailure = { taskId: status.taskId, reason: status.reason, error };
|
||||
removeCompactionStatus(live, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot of tool task `taskId`, model-issued or nested, if `pi.live` still lists it. Nested slots are found by binary
|
||||
* search: a tool may make thousands of nested calls, each of which looks up its slot in several commits, and every
|
||||
* element a search reads costs a node in the draft's change tracker. Their task IDs ascend: each slot is appended in
|
||||
* the commit that creates its task, task IDs ascend in commit order, and removal keeps the order.
|
||||
*/
|
||||
export function toolSlot(live: Draft<LiveState>, taskId: TaskId): Draft<ToolSlot | NestedToolSlot> | undefined {
|
||||
const slot = live.tools?.find((each) => each.taskId === taskId);
|
||||
if (slot !== undefined || live.nestedTools === undefined) return slot;
|
||||
const nested = live.nestedTools;
|
||||
let low = 0;
|
||||
let high = nested.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const candidate = nested[middle]!;
|
||||
if (candidate.taskId === taskId) return candidate;
|
||||
if (candidate.taskId < taskId) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a slot done and clear its running output; the caller sets a model-issued call's result `entry`, or a nested
|
||||
* call's `summary`. Neither is set when the task faulted or was orphaned.
|
||||
*/
|
||||
export function finishSlot(slot: Draft<ToolSlot | NestedToolSlot>): void {
|
||||
slot.status = "done";
|
||||
clearProgress(slot);
|
||||
}
|
||||
|
||||
/** Remove the nested slots below tool task `taskId`, transitively; the list holds parents before their children. */
|
||||
export function removeNestedSlots(live: Draft<LiveState>, taskId: TaskId): void {
|
||||
const nested = live.nestedTools;
|
||||
if (nested === undefined) return;
|
||||
const removed = new Set<TaskId>([taskId]);
|
||||
for (const slot of nested) if (removed.has(slot.parentTaskId)) removed.add(slot.taskId);
|
||||
for (let index = nested.length - 1; index >= 0; index--) {
|
||||
if (removed.has(nested[index]!.parentTaskId)) nested.splice(index, 1);
|
||||
}
|
||||
if (nested.length === 0) delete live.nestedTools;
|
||||
}
|
||||
|
||||
/** Remove what a tool published while running; its result entry or a rerun replaces it. */
|
||||
export function clearProgress(slot: Draft<SlotProgress>): void {
|
||||
delete slot.output;
|
||||
delete slot.droppedBytes;
|
||||
delete slot.droppedLines;
|
||||
delete slot.details;
|
||||
delete slot.diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harness cleanup for a terminal outcome the scheduler writes itself (`faulted` or `orphaned`). A run task ends its
|
||||
* run; a tool task's slot is marked done without an entry, and context derivation synthesizes the missing result; a
|
||||
* compaction task's status is removed and its failure recorded.
|
||||
* Ignores other kinds so it never creates `pi.live` elsewhere. The scheduler calls this without knowing task kinds;
|
||||
* the Harness passes it in (spec §5.4).
|
||||
* REMINDER: a committed generation partial becomes an aborted assistant entry here, exactly as in the generation abort
|
||||
* handler, so the transcript keeps what the model produced and `pi.usage` counts its spend. The scheduler's commit has
|
||||
* no task scope, so that entry has no `byTaskId`. Faults come from task bugs
|
||||
* or malformed provider data (a non-JSON value in a response), or a commit the Storage rejected without effect; an
|
||||
* uncertain storage failure poisons the Session instead and writes no outcome.
|
||||
*/
|
||||
export async function settleSchedulerOutcome(
|
||||
tx: Transaction,
|
||||
record: TaskRecord<JsonValue, JsonValue, JsonValue>,
|
||||
outcome: SchedulerOutcome,
|
||||
): Promise<void> {
|
||||
if (record.kind === TOOL_TASK_KIND) {
|
||||
const live = await tx.doc(LiveDoc, record.conversationId);
|
||||
const slot = toolSlot(live, record.id);
|
||||
if (slot !== undefined) finishSlot(slot);
|
||||
removeNestedSlots(live, record.id);
|
||||
return;
|
||||
}
|
||||
if (record.kind === COMPACTION_TASK_KIND) {
|
||||
const error = outcome.status === "faulted" ? outcome.error.message : outcome.reason;
|
||||
failCompactionStatus(await tx.doc(LiveDoc, record.conversationId), record.id, error);
|
||||
return;
|
||||
}
|
||||
if (!RUN_TASK_KINDS.has(record.kind)) return;
|
||||
const live = await tx.doc(LiveDoc, record.conversationId);
|
||||
if (live.run?.taskId !== record.id) return;
|
||||
await convertPartial(tx, live, record.conversationId);
|
||||
endRun(
|
||||
tx,
|
||||
live,
|
||||
record.id,
|
||||
outcome.status === "faulted"
|
||||
? { status: "unanswered", reason: "faulted", detail: outcome.error.message }
|
||||
: { status: "unanswered", reason: outcome.reason },
|
||||
);
|
||||
}
|
||||
158
third_party/pi/packages/durable/src/harness/prompt.ts
vendored
Normal file
158
third_party/pi/packages/durable/src/harness/prompt.ts
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { Message, SystemMessage, Tool, ToolReference } from "@earendil-works/pi-ai";
|
||||
import { declarationsEqual, getCurrentTools, toToolDeclaration } from "@earendil-works/pi-ai/utils/transcript";
|
||||
import { SystemEntry } from "../entries.ts";
|
||||
import type { ContextEdit, TypedEntryDraft } from "../types.ts";
|
||||
import type { ContextView, PromptInput, PromptSection, ToolRegistration } from "./types.ts";
|
||||
|
||||
/** Sections in effect after replaying system messages in order: set in place, `null` deletes, re-adding appends. */
|
||||
export function replaySections(messages: readonly Message[]): Map<string, string> {
|
||||
const shown = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (message.role !== "system" || message.sections === undefined) continue;
|
||||
for (const [key, value] of Object.entries(message.sections)) {
|
||||
if (value === null) shown.delete(key);
|
||||
else shown.set(key, value);
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the agent's sections in order. `undefined` omits a section; tagged text is wrapped as `<key>\n...\n</key>`. A
|
||||
* section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate.
|
||||
*/
|
||||
export async function renderSections<Tool extends ToolRegistration>(
|
||||
sections: readonly PromptSection<Tool>[],
|
||||
input: PromptInput<Tool>,
|
||||
shown: ReadonlyMap<string, string>,
|
||||
report: (error: unknown) => void,
|
||||
context: Context,
|
||||
): Promise<Map<string, string>> {
|
||||
const desired = new Map<string, string>();
|
||||
for (const section of sections) {
|
||||
let text: string | undefined;
|
||||
try {
|
||||
text = await section.render(input, context);
|
||||
} catch (error) {
|
||||
if (context.abortSignal?.aborted) throw error;
|
||||
report(error);
|
||||
const kept = shown.get(section.key);
|
||||
if (kept !== undefined) desired.set(section.key, kept);
|
||||
continue;
|
||||
}
|
||||
if (text === undefined) continue;
|
||||
desired.set(section.key, section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`);
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
|
||||
type SystemDraft = TypedEntryDraft<never>;
|
||||
|
||||
type ToolChanges = { readonly toolsRemoved: ToolReference[]; readonly toolsAdded: Tool[] };
|
||||
|
||||
/**
|
||||
* Plan the `pi.system` entries that make the replayed sections and tools of `view` equal `desired` and `tools` in values
|
||||
* and order.
|
||||
*
|
||||
* - A head marker with no later `pi.system` entry in context: one complete baseline that omits every retained earlier
|
||||
* `pi.system` entry, written even when it restates the replayed values.
|
||||
* - Otherwise, when a minimal section patch would leave a different order: remove every shown section, then re-add
|
||||
* every desired section in order.
|
||||
* - Otherwise the minimal patch of changed values and `null` removals, or nothing.
|
||||
*
|
||||
* Tool changes ride on the last planned entry, or on one entry of their own.
|
||||
*/
|
||||
export function planSystemEntries(
|
||||
view: ContextView,
|
||||
desired: ReadonlyMap<string, string>,
|
||||
tools: readonly Tool[],
|
||||
timestamp: number,
|
||||
): SystemDraft[] {
|
||||
const head = view.head;
|
||||
if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) {
|
||||
const edits: ContextEdit[] = view.entries
|
||||
.filter((entry) => SystemEntry.is(entry))
|
||||
.map((entry) => ({ target: entry.id, action: "omit" }));
|
||||
const baseline = systemEntry(
|
||||
Object.fromEntries(desired),
|
||||
{ toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) },
|
||||
timestamp,
|
||||
);
|
||||
return [edits.length === 0 ? baseline : { ...baseline, edits }];
|
||||
}
|
||||
const sections = planSections(replaySections(view.messages), desired);
|
||||
const changes = planTools(getCurrentTools(view.messages), tools);
|
||||
if (changes.toolsRemoved.length === 0 && changes.toolsAdded.length === 0) {
|
||||
return sections.map((patch) => systemEntry(patch, undefined, timestamp));
|
||||
}
|
||||
if (sections.length === 0) return [systemEntry(undefined, changes, timestamp)];
|
||||
return sections.map((patch, index) =>
|
||||
systemEntry(patch, index === sections.length - 1 ? changes : undefined, timestamp),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool changes from `offered` to `desired`. A changed declaration is removed and re-added. Replay keeps retained tools
|
||||
* in place and appends additions; when that would not yield the desired order, every offered tool is removed and
|
||||
* every desired tool re-added in order.
|
||||
*/
|
||||
function planTools(offered: readonly Tool[], desired: readonly Tool[]): ToolChanges {
|
||||
const wanted = new Map(desired.map((tool) => [tool.name, tool]));
|
||||
const kept = offered.filter((tool) => {
|
||||
const next = wanted.get(tool.name);
|
||||
return next !== undefined && declarationsEqual(tool, next);
|
||||
});
|
||||
const keptNames = new Set(kept.map((tool) => tool.name));
|
||||
const added = desired.filter((tool) => !keptNames.has(tool.name));
|
||||
const replayed = [...kept, ...added];
|
||||
if (replayed.some((tool, index) => tool.name !== desired[index]!.name)) {
|
||||
return {
|
||||
toolsRemoved: offered.map((tool) => ({ name: tool.name })),
|
||||
toolsAdded: desired.map(toToolDeclaration),
|
||||
};
|
||||
}
|
||||
return {
|
||||
toolsRemoved: offered.filter((tool) => !keptNames.has(tool.name)).map((tool) => ({ name: tool.name })),
|
||||
toolsAdded: added.map(toToolDeclaration),
|
||||
};
|
||||
}
|
||||
|
||||
/** Section patches: none, the minimal patch, or a remove-all/re-add-all pair when the order would differ. */
|
||||
function planSections(
|
||||
shown: ReadonlyMap<string, string>,
|
||||
desired: ReadonlyMap<string, string>,
|
||||
): Record<string, string | null>[] {
|
||||
const patchedOrder = [
|
||||
...[...shown.keys()].filter((key) => desired.has(key)),
|
||||
...[...desired.keys()].filter((key) => !shown.has(key)),
|
||||
];
|
||||
const desiredOrder = [...desired.keys()];
|
||||
if (patchedOrder.some((key, index) => key !== desiredOrder[index])) {
|
||||
return [Object.fromEntries([...shown.keys()].map((key) => [key, null])), Object.fromEntries(desired)];
|
||||
}
|
||||
|
||||
const patch: Record<string, string | null> = {};
|
||||
for (const [key, value] of shown) {
|
||||
const next = desired.get(key);
|
||||
if (next !== value) patch[key] = next ?? null;
|
||||
}
|
||||
for (const [key, value] of desired) if (!shown.has(key)) patch[key] = value;
|
||||
return Object.keys(patch).length === 0 ? [] : [patch];
|
||||
}
|
||||
|
||||
function systemEntry(
|
||||
sections: Record<string, string | null> | undefined,
|
||||
tools: ToolChanges | undefined,
|
||||
timestamp: number,
|
||||
): SystemDraft {
|
||||
const message: SystemMessage = {
|
||||
role: "system",
|
||||
content: "",
|
||||
...(sections === undefined ? {} : { sections }),
|
||||
...(tools === undefined || tools.toolsRemoved.length === 0 ? {} : { toolsRemoved: tools.toolsRemoved }),
|
||||
...(tools === undefined || tools.toolsAdded.length === 0 ? {} : { toolsAdded: tools.toolsAdded }),
|
||||
timestamp,
|
||||
};
|
||||
return { model: [message] };
|
||||
}
|
||||
39
third_party/pi/packages/durable/src/harness/provider.ts
vendored
Normal file
39
third_party/pi/packages/durable/src/harness/provider.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { uuidv7 } from "@earendil-works/pi-ai/utils/uuid";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import type { TaskRuntime } from "../types.ts";
|
||||
|
||||
/** Stable provider-facing identity of one conversation. */
|
||||
export type ProviderState = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/** Built-in provider state; every fork starts with a fresh identity instead of copying its parent. */
|
||||
export const ProviderDoc = defineDoc<ProviderState>({
|
||||
kind: "pi.provider",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ sessionId: uuidv7() }),
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the persisted identity without writing in the normal path. A legacy conversation without `pi.provider` gets
|
||||
* one migration commit whose `tx.doc()` runs `initial()` before the provider request starts.
|
||||
*/
|
||||
export async function ensureProviderSessionId<I, S, R, H extends object>(
|
||||
runtime: TaskRuntime<I, S, R, H>,
|
||||
context: Context,
|
||||
): Promise<string> {
|
||||
const existing = await runtime.snapshot(ProviderDoc, runtime.conversationId, context);
|
||||
if (existing !== undefined) return existing.sessionId;
|
||||
let created: string | undefined;
|
||||
await runtime.commit(async (tx) => {
|
||||
created = (await tx.doc(ProviderDoc, runtime.conversationId)).sessionId;
|
||||
return undefined;
|
||||
}, context);
|
||||
if (created === undefined) throw new Error(`Conversation ${runtime.conversationId} has no provider session ID`);
|
||||
return created;
|
||||
}
|
||||
114
third_party/pi/packages/durable/src/harness/registry.ts
vendored
Normal file
114
third_party/pi/packages/durable/src/harness/registry.ts
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
import { INSTRUCTIONS_KEY } from "./agent.ts";
|
||||
import { CompactionTask } from "./compaction.ts";
|
||||
import { GenerationTask } from "./generation.ts";
|
||||
import { ToolTask } from "./tool.ts";
|
||||
import type { AnyTask, Extension, PromptSection, Registry, RegistrySnapshot, ToolRegistration } from "./types.ts";
|
||||
|
||||
const SECTION_KEY = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
/** Built-in task definitions every registry holds; they are not an extension and cannot be removed or replaced. */
|
||||
export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, CompactionTask];
|
||||
|
||||
/** Immutable published registry state. */
|
||||
class RegistryState<Tool extends ToolRegistration> implements RegistrySnapshot<Tool> {
|
||||
readonly #extensions: readonly Extension<Tool>[];
|
||||
readonly #byName: ReadonlyMap<string, Extension<Tool>>;
|
||||
readonly #tasks: ReadonlyMap<string, AnyTask>;
|
||||
|
||||
constructor(extensions: readonly Extension<Tool>[]) {
|
||||
this.#extensions = extensions;
|
||||
this.#byName = new Map(extensions.map((extension) => [extension.name, extension]));
|
||||
const tasks = new Map<string, AnyTask>();
|
||||
for (const task of BUILTIN_TASKS) tasks.set(task.definition.name, task);
|
||||
for (const extension of extensions) {
|
||||
for (const task of extension.tasks ?? []) {
|
||||
const name = task.definition.name;
|
||||
if (tasks.has(name)) throw new Error(`Task ${name} of extension ${extension.name} is already installed`);
|
||||
tasks.set(name, task);
|
||||
}
|
||||
}
|
||||
this.#tasks = tasks;
|
||||
}
|
||||
|
||||
installed(): readonly Extension<Tool>[] {
|
||||
return this.#extensions;
|
||||
}
|
||||
|
||||
extension(name: string): Extension<Tool> | undefined {
|
||||
return this.#byName.get(name);
|
||||
}
|
||||
|
||||
tools(): readonly { readonly extension: Extension<Tool>; readonly tool: Tool }[] {
|
||||
return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool })));
|
||||
}
|
||||
|
||||
sections(): readonly { readonly extension: Extension<Tool>; readonly section: PromptSection<Tool> }[] {
|
||||
return this.#extensions.flatMap((extension) =>
|
||||
(extension.sections ?? []).map((section) => ({ extension, section })),
|
||||
);
|
||||
}
|
||||
|
||||
tasks(): readonly AnyTask[] {
|
||||
return [...this.#tasks.values()];
|
||||
}
|
||||
|
||||
task(name: string): AnyTask | undefined {
|
||||
return this.#tasks.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> {
|
||||
#current = new RegistryState<Tool>([]);
|
||||
readonly #listeners = new Set<() => void>();
|
||||
|
||||
snapshot(): RegistrySnapshot<Tool> {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
install(extension: Extension<Tool>): void {
|
||||
validateExtension(extension);
|
||||
const current = this.#current.installed();
|
||||
const index = current.findIndex((installed) => installed.name === extension.name);
|
||||
const next =
|
||||
index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed));
|
||||
this.#publish(next);
|
||||
}
|
||||
|
||||
uninstall(extension: Extension): void {
|
||||
const current = this.#current.installed();
|
||||
if (!current.some((installed) => installed.name === extension.name)) return;
|
||||
this.#publish(current.filter((installed) => installed.name !== extension.name));
|
||||
}
|
||||
|
||||
/** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */
|
||||
#publish(extensions: readonly Extension<Tool>[]): void {
|
||||
this.#current = new RegistryState(extensions);
|
||||
for (const listener of [...this.#listeners]) listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Unique tool names and section keys within one extension; valid, unreserved section keys. */
|
||||
function validateExtension(extension: Extension): void {
|
||||
const tools = new Set<string>();
|
||||
for (const tool of extension.tools ?? []) {
|
||||
if (tools.has(tool.name)) throw new Error(`Extension ${extension.name} has two tools named ${tool.name}`);
|
||||
tools.add(tool.name);
|
||||
}
|
||||
const sections = new Set<string>();
|
||||
for (const { key } of extension.sections ?? []) {
|
||||
if (!SECTION_KEY.test(key)) throw new TypeError(`Section key ${JSON.stringify(key)} must match ${SECTION_KEY}`);
|
||||
if (key === INSTRUCTIONS_KEY) throw new Error(`Section key ${key} is reserved for the agent's instructions`);
|
||||
if (sections.has(key)) throw new Error(`Extension ${extension.name} has two sections with key ${key}`);
|
||||
sections.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an application-owned registry holding only the built-in tasks. */
|
||||
export function createRegistry<Tool extends ToolRegistration = ToolRegistration>(): Registry<Tool> {
|
||||
return new RegistryImpl<Tool>();
|
||||
}
|
||||
1953
third_party/pi/packages/durable/src/harness/scheduler.ts
vendored
Normal file
1953
third_party/pi/packages/durable/src/harness/scheduler.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
241
third_party/pi/packages/durable/src/harness/submissions.ts
vendored
Normal file
241
third_party/pi/packages/durable/src/harness/submissions.ts
vendored
Normal file
@@ -0,0 +1,241 @@
|
||||
import { type Context, copyJson, type JsonRepresentation } from "@earendil-works/chord";
|
||||
import { ConversationBusy } from "../errors.ts";
|
||||
import type { SessionImpl } from "../session/session.ts";
|
||||
import type {
|
||||
CommitPublication,
|
||||
ConversationId,
|
||||
JsonObject,
|
||||
Storage,
|
||||
SubmissionId,
|
||||
SubmissionRecord,
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import { startRun } from "./generation.ts";
|
||||
import {
|
||||
appendInput,
|
||||
applyBoundary,
|
||||
hasQueuedReset,
|
||||
InboxDoc,
|
||||
isDisplayOnly,
|
||||
isStale,
|
||||
pickInputEntry,
|
||||
prepareBoundary,
|
||||
type QueueModes,
|
||||
removeInboxItem,
|
||||
} from "./inbox.ts";
|
||||
import { LiveDoc } from "./live.ts";
|
||||
import type { InputEntry, SettledSubmissionRecord, Submission, SubmissionDraft, UserInput } from "./types.ts";
|
||||
import { closedError, Waiters } from "./util.ts";
|
||||
|
||||
type AbortResult = "aborted" | "already_placed" | "settled";
|
||||
|
||||
/** Admission, waits, and withdrawal of the durable submissions of one Harness. */
|
||||
export class Submissions {
|
||||
readonly #session: SessionImpl;
|
||||
readonly #storage: Storage;
|
||||
readonly #now: () => number;
|
||||
/** Read at each admission, on the Session line. */
|
||||
readonly #queueModes: () => QueueModes;
|
||||
/** Enable task scheduling; submitting or waiting asks for progress. */
|
||||
readonly #resume: () => void;
|
||||
readonly #waiters = new Waiters<SubmissionId, SettledSubmissionRecord>();
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
session: SessionImpl,
|
||||
storage: Storage,
|
||||
now: () => number,
|
||||
queueModes: () => QueueModes,
|
||||
resume: () => void,
|
||||
) {
|
||||
this.#session = session;
|
||||
this.#storage = storage;
|
||||
this.#now = now;
|
||||
this.#queueModes = queueModes;
|
||||
this.#resume = resume;
|
||||
session.observeCommits((publication) => this.#observe(publication));
|
||||
session.subscribeClose(() => {
|
||||
this.#closed = true;
|
||||
this.#waiters.rejectAll(closedError(this.#session));
|
||||
});
|
||||
}
|
||||
|
||||
/** Admit a submission in one commit; see `admitSubmission()`. */
|
||||
async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> {
|
||||
this.#resume();
|
||||
const id = await this.#session.commitWith(
|
||||
(tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes()),
|
||||
context,
|
||||
);
|
||||
return new SubmissionHandle(id, this);
|
||||
}
|
||||
|
||||
/** Handle for an existing submission, or `undefined`. */
|
||||
async get(id: SubmissionId, context: Context): Promise<Submission | undefined> {
|
||||
const record = await this.#session.readOnLine(() => this.#storage.submission(id, context));
|
||||
return record === undefined ? undefined : new SubmissionHandle(record.id, this);
|
||||
}
|
||||
|
||||
async status(id: SubmissionId, context: Context): Promise<SubmissionRecord> {
|
||||
const record = await this.#session.readOnLine(() => this.#storage.submission(id, context));
|
||||
if (record === undefined) throw new Error(`Submission ${id} does not exist`);
|
||||
return record;
|
||||
}
|
||||
|
||||
async wait(id: SubmissionId, context: Context): Promise<SettledSubmissionRecord> {
|
||||
this.#resume();
|
||||
// Check and register on the line so no settling publication falls between them.
|
||||
const found = await this.#session.readOnLine(async () => {
|
||||
const record = await this.#storage.submission(id, context);
|
||||
if (record === undefined) throw new Error(`Submission ${id} does not exist`);
|
||||
if (isSettled(record)) return { promise: Promise.resolve(record) };
|
||||
// Close rejects registered waiters synchronously and may begin during the read.
|
||||
if (this.#closed) throw closedError(this.#session);
|
||||
return { promise: this.#waiters.add(id, context) };
|
||||
});
|
||||
return found.promise;
|
||||
}
|
||||
|
||||
/** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */
|
||||
abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> {
|
||||
return this.#session.commitWith(async (tx) => {
|
||||
const record = await tx.submission(id);
|
||||
if (record === undefined || (conversationId !== undefined && record.conversationId !== conversationId)) {
|
||||
return "not_found";
|
||||
}
|
||||
if (record.status === "queued") {
|
||||
tx.settleSubmission(id, { status: "unanswered", reason: "aborted" });
|
||||
await removeInboxItem(tx, record.conversationId, id);
|
||||
return "aborted";
|
||||
}
|
||||
return record.status === "placed" ? "already_placed" : "settled";
|
||||
}, context);
|
||||
}
|
||||
|
||||
#observe(publication: CommitPublication): void {
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "submission" || !isSettled(change.value)) continue;
|
||||
this.#waiters.resolve(change.value.id, change.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SubmissionHandle implements Submission {
|
||||
readonly id: SubmissionId;
|
||||
readonly #submissions: Submissions;
|
||||
|
||||
constructor(id: SubmissionId, submissions: Submissions) {
|
||||
this.id = id;
|
||||
this.#submissions = submissions;
|
||||
}
|
||||
|
||||
status(context: Context): Promise<SubmissionRecord> {
|
||||
return this.#submissions.status(this.id, context);
|
||||
}
|
||||
|
||||
wait(context: Context): Promise<SettledSubmissionRecord> {
|
||||
return this.#submissions.wait(this.id, context);
|
||||
}
|
||||
|
||||
async abort(context: Context): Promise<AbortResult> {
|
||||
const result = await this.#submissions.abort(this.id, context);
|
||||
if (result === "not_found") throw new Error(`Submission ${this.id} does not exist`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function isSettled(record: SubmissionRecord): record is SettledSubmissionRecord {
|
||||
return record.status === "done" || record.status === "unanswered";
|
||||
}
|
||||
|
||||
/** Reject an input entry an untyped caller got wrong: its kind must be a non-empty string outside the `pi.` prefix. */
|
||||
function checkInputEntry(entry: InputEntry): void {
|
||||
const { kind } = entry;
|
||||
if (typeof kind !== "string" || kind.length === 0 || kind.startsWith("pi.")) {
|
||||
throw new TypeError(
|
||||
`An input entry's kind must be a non-empty string not starting with "pi.", got ${String(kind)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit a submission inside a commit (spec §6); `Conversation.submit()` and conversation-owned compactions share it. A
|
||||
* known request ID returns its existing submission without writing. A display-only write (only `kind` and `data`) is
|
||||
* appended and settled `done` at once, busy or not, as it changes nothing the model sees; only behind a queued reset it
|
||||
* queues, so it lands after the reset instead of before it in the context the reset ends. A busy conversation queues anything else in
|
||||
* `pi.inbox`, or rejects `whenBusy: "reject"` input with `ConversationBusy`. An idle conversation with queued items
|
||||
* queues it behind them and runs a final boundary. Otherwise idle input places its entry (`pi.user`, or the input's
|
||||
* own) and starts a run, and an idle write appends its entry and settles `done`, or `stale` when its head reaches
|
||||
* before the active range.
|
||||
*/
|
||||
export async function admitSubmission(
|
||||
tx: Tx,
|
||||
conversationId: ConversationId,
|
||||
draft: SubmissionDraft,
|
||||
now: number,
|
||||
queueModes: QueueModes,
|
||||
): Promise<SubmissionId> {
|
||||
if (draft.type === "input" && draft.entry !== undefined) checkInputEntry(draft.entry);
|
||||
if (draft.requestId !== undefined) {
|
||||
const existing = await tx.submissionByRequest(conversationId, draft.requestId);
|
||||
if (existing !== undefined) {
|
||||
if (existing.type !== draft.type) {
|
||||
throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
const requestId = draft.requestId === undefined ? {} : { requestId: draft.requestId };
|
||||
if (
|
||||
draft.type === "write" &&
|
||||
isDisplayOnly(draft.entry) &&
|
||||
!hasQueuedReset((await tx.doc(InboxDoc, conversationId)).items)
|
||||
) {
|
||||
const entry = await tx.appendEntry(conversationId, draft.entry);
|
||||
const write = { conversationId, ...requestId, type: "write", status: "done", entry: entry.id } as const;
|
||||
return (await tx.createSubmission(write)).id;
|
||||
}
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const busy = live.run !== undefined;
|
||||
if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId);
|
||||
// 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);
|
||||
if (boundary === undefined || boundary.inbox.items.length > 0) {
|
||||
const { id } = await tx.createSubmission({
|
||||
conversationId,
|
||||
...requestId,
|
||||
type: draft.type,
|
||||
status: "queued",
|
||||
});
|
||||
// Hosts may leave optional fields `undefined`; drafts take strict JSON.
|
||||
const value = copyJson(draft.type === "write" ? draft.entry : draft.content, {
|
||||
omitUndefinedProperties: true,
|
||||
});
|
||||
const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items;
|
||||
if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject });
|
||||
else {
|
||||
const mode = draft.whenBusy === "steer" ? "steer" : "followUp";
|
||||
const content = value as JsonRepresentation<UserInput>;
|
||||
const entry = draft.entry === undefined ? {} : { entry: pickInputEntry(draft.entry) };
|
||||
items.push({ id, mode, content, ...entry });
|
||||
}
|
||||
if (boundary === undefined) return id;
|
||||
const { users } = await applyBoundary(tx, boundary, "final", now);
|
||||
if (users.length > 0) await startRun(tx, conversationId, live, users);
|
||||
return id;
|
||||
}
|
||||
if (draft.type === "write") {
|
||||
if (isStale(boundary, draft.entry)) {
|
||||
const stale = { status: "unanswered", reason: "stale" } as const;
|
||||
return (await tx.createSubmission({ conversationId, ...requestId, type: "write", ...stale })).id;
|
||||
}
|
||||
const entry = await tx.appendEntry(conversationId, draft.entry);
|
||||
const write = { conversationId, ...requestId, type: "write", status: "done", entry: entry.id } as const;
|
||||
return (await tx.createSubmission(write)).id;
|
||||
}
|
||||
const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now);
|
||||
const input = { conversationId, ...requestId, type: "input", status: "placed", entry: entry.id } as const;
|
||||
const { id } = await tx.createSubmission(input);
|
||||
await startRun(tx, conversationId, live, [id]);
|
||||
return id;
|
||||
}
|
||||
236
third_party/pi/packages/durable/src/harness/task-graph.ts
vendored
Normal file
236
third_party/pi/packages/durable/src/harness/task-graph.ts
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
import { type AttachedReplicatedState, type Context, type JsonValue, replicatedState } from "@earendil-works/chord";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
|
||||
import { CommittedStateSource, CommittedWatch } from "../session/observation.ts";
|
||||
import { contained, type SessionImpl } from "../session/session.ts";
|
||||
import type {
|
||||
CommitPublication,
|
||||
ConversationId,
|
||||
JoinPolicy,
|
||||
Storage,
|
||||
TaskId,
|
||||
TaskOutcome,
|
||||
TaskRecord,
|
||||
WatchHandle,
|
||||
} from "../types.ts";
|
||||
import { closedError, scanAll } from "./util.ts";
|
||||
|
||||
/** A live task's durable status without its checkpoint and outcome payloads (spec §9.5). */
|
||||
export type TaskGraphState =
|
||||
| { readonly status: "pending" | "running"; readonly phase: string }
|
||||
| {
|
||||
readonly status: "waiting";
|
||||
readonly phase: string;
|
||||
readonly on: readonly TaskId[];
|
||||
readonly policy: JoinPolicy;
|
||||
}
|
||||
/** Outcome held until its ordinary owned work drains. */
|
||||
| { readonly status: "completing"; readonly outcome: TaskOutcome<JsonValue>["status"] };
|
||||
|
||||
export type TaskGraphNode = {
|
||||
readonly id: TaskId;
|
||||
readonly kind: string;
|
||||
readonly conversationId: ConversationId;
|
||||
/** Owner task; absent for a conversation-owned task. */
|
||||
readonly owner?: TaskId;
|
||||
readonly background: boolean;
|
||||
readonly abortRequested: boolean;
|
||||
readonly state: TaskGraphState;
|
||||
/** Conversations this task owns, in ID order. */
|
||||
readonly conversations: readonly ConversationId[];
|
||||
};
|
||||
|
||||
/** Every live task of the Session (spec §9.5). */
|
||||
export type TaskGraph = {
|
||||
/** Every live task, keyed by its decimal ID. */
|
||||
readonly tasks: Readonly<Record<string, TaskGraphNode>>;
|
||||
};
|
||||
|
||||
export type TaskGraphWatch = WatchHandle<TaskGraph>;
|
||||
|
||||
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
type Observer = CommittedStateSource<TaskGraph> | CommittedWatch<TaskGraph>;
|
||||
type Mount = { value: TaskGraph; readonly observers: Set<Observer> };
|
||||
|
||||
const LIVE_STATUSES = ["pending", "running", "waiting", "completing"] as const;
|
||||
const SCAN_PAGE_SIZE = 256;
|
||||
|
||||
/**
|
||||
* The Harness's task graph mount: built on the Session line by its first observer and dropped with its last. It
|
||||
* advances from the Session's commit publications, which are durable.
|
||||
*/
|
||||
export class TaskGraphView {
|
||||
readonly #session: SessionImpl;
|
||||
readonly #storage: Storage;
|
||||
#mount: Mount | undefined;
|
||||
#closed = false;
|
||||
|
||||
constructor(session: SessionImpl, storage: Storage) {
|
||||
this.#session = session;
|
||||
this.#storage = storage;
|
||||
session.observeCommits((publication, context) => {
|
||||
if (this.#mount !== undefined) advance(this.#mount, publication, context, (error) => session.report(error));
|
||||
});
|
||||
session.subscribeClose(() => {
|
||||
this.#closed = true;
|
||||
for (const observer of [...(this.#mount?.observers ?? [])]) {
|
||||
contained(
|
||||
() => observer.closeSession(this.#session.failure),
|
||||
(error) => session.report(error),
|
||||
);
|
||||
}
|
||||
this.#mount = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** A disposable read-only Chord state of the graph. */
|
||||
async state(context: Context): Promise<AttachedReplicatedState<TaskGraph>> {
|
||||
const { observer, detach } = await this.#attach(
|
||||
(value, release) => new CommittedStateSource<TaskGraph>(value, release),
|
||||
context,
|
||||
);
|
||||
try {
|
||||
return replicatedState(observer, { onError: (error) => this.#session.report(error) });
|
||||
} catch (error) {
|
||||
detach();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** A serialized exact-frame watch of the graph; cancelling `context` stops it. */
|
||||
async watch(context: Context): Promise<TaskGraphWatch> {
|
||||
const { observer } = await this.#attach(
|
||||
(value, release) => new CommittedWatch<TaskGraph>(value, release, (error) => this.#session.report(error)),
|
||||
context,
|
||||
);
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) {
|
||||
observer.cancel();
|
||||
throw signal.reason;
|
||||
}
|
||||
if (signal !== undefined) observer.observeCancellation(signal);
|
||||
return observer;
|
||||
}
|
||||
|
||||
/** Register an observer created from the current revision, atomically on the Session line. */
|
||||
#attach<O extends Observer>(
|
||||
create: (value: TaskGraph, release: () => void) => O,
|
||||
context: Context,
|
||||
): Promise<{ observer: O; detach: () => void }> {
|
||||
return this.#session.readOnLine(async () => {
|
||||
const mount = this.#mount ?? { value: await this.#build(context), observers: new Set<Observer>() };
|
||||
const detach = (): void => {
|
||||
mount.observers.delete(observer);
|
||||
if (mount.observers.size === 0 && this.#mount === mount) this.#mount = undefined;
|
||||
};
|
||||
const observer = create(mount.value, detach);
|
||||
// Close or cancellation may begin while the mount builds; register nothing then.
|
||||
if (this.#closed) throw closedError(this.#session);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
this.#mount = mount;
|
||||
mount.observers.add(observer);
|
||||
return { observer, detach };
|
||||
});
|
||||
}
|
||||
|
||||
async #build(context: Context): Promise<TaskGraph> {
|
||||
const tasks: Record<string, TaskGraphNode> = {};
|
||||
const records: AnyTaskRecord[] = [];
|
||||
for (const status of LIVE_STATUSES) {
|
||||
records.push(
|
||||
...(await scanAll((cursor) => this.#storage.scanTasks({ status }, SCAN_PAGE_SIZE, cursor, context))),
|
||||
);
|
||||
}
|
||||
records.sort((a, b) => a.id - b.id);
|
||||
for (const record of records) {
|
||||
const owned = await scanAll((cursor) =>
|
||||
this.#storage.scanConversations({ ownerTaskId: record.id }, SCAN_PAGE_SIZE, cursor, context),
|
||||
);
|
||||
const conversations = owned.map((conversation) => conversation.id).sort((a, b) => a - b);
|
||||
tasks[String(record.id)] = nodeOf(record, conversations);
|
||||
}
|
||||
return { tasks };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the mount's operations from one publication, apply them, and hand the revision to every observer; one that
|
||||
* throws is reported through `report`, and the others still get it.
|
||||
*/
|
||||
function advance(
|
||||
mount: Mount,
|
||||
publication: CommitPublication,
|
||||
context: Context,
|
||||
report: (error: unknown) => void,
|
||||
): void {
|
||||
const ops: Op[] = [];
|
||||
// Nodes this publication set or deleted, over the mount's value.
|
||||
const changed = new Map<string, TaskGraphNode | undefined>();
|
||||
const node = (key: string): TaskGraphNode | undefined =>
|
||||
changed.has(key) ? changed.get(key) : mount.value.tasks[key];
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "task") continue;
|
||||
const record = change.value;
|
||||
const key = String(record.id);
|
||||
const previous = node(key);
|
||||
if (record.state.status === "terminal") {
|
||||
if (previous === undefined) continue;
|
||||
ops.push(["d", ["tasks", key]]);
|
||||
changed.set(key, undefined);
|
||||
continue;
|
||||
}
|
||||
const next = nodeOf(record, previous?.conversations ?? []);
|
||||
if (previous !== undefined && JSON.stringify(next) === JSON.stringify(previous)) continue;
|
||||
ops.push(["s", ["tasks", key], next as unknown as JsonValue]);
|
||||
changed.set(key, next);
|
||||
}
|
||||
// After the tasks, so a conversation created with its owner task in one commit finds the owner's node. Change order
|
||||
// within a publication is unspecified, so each owner's list is sorted again.
|
||||
const created = new Map<string, ConversationId[]>();
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "conversation" || change.value.owner === undefined) continue;
|
||||
const key = String(change.value.owner.taskId);
|
||||
if (node(key) !== undefined) created.set(key, [...(created.get(key) ?? []), change.value.id]);
|
||||
}
|
||||
for (const [key, ids] of created) {
|
||||
const conversations = [...node(key)!.conversations, ...ids].sort((a, b) => a - b);
|
||||
ops.push(["s", ["tasks", key, "conversations"], conversations]);
|
||||
}
|
||||
if (ops.length === 0) return;
|
||||
mount.value = applyImmutable(mount.value, ops);
|
||||
const frameContext = withoutAbortSignal(context);
|
||||
for (const observer of [...mount.observers])
|
||||
contained(() => observer.advance(mount.value, ops, frameContext), report);
|
||||
}
|
||||
|
||||
function nodeOf(record: AnyTaskRecord, conversations: readonly ConversationId[]): TaskGraphNode {
|
||||
return {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
conversationId: record.conversationId,
|
||||
...(record.owner === undefined ? {} : { owner: record.owner }),
|
||||
background: record.background,
|
||||
abortRequested: record.abortRequested,
|
||||
state: stateOf(record),
|
||||
conversations,
|
||||
};
|
||||
}
|
||||
|
||||
function stateOf(record: AnyTaskRecord): TaskGraphState {
|
||||
const state = record.state;
|
||||
switch (state.status) {
|
||||
case "pending":
|
||||
case "running":
|
||||
return { status: state.status, phase: phaseOf(state.checkpoint) };
|
||||
case "waiting":
|
||||
return { status: "waiting", phase: phaseOf(state.checkpoint), on: [...state.on], policy: state.policy };
|
||||
// Terminal records never reach here: they leave the graph.
|
||||
case "completing":
|
||||
case "terminal":
|
||||
return { status: "completing", outcome: state.outcome.status };
|
||||
}
|
||||
}
|
||||
|
||||
function phaseOf(checkpoint: JsonValue): string {
|
||||
return (checkpoint as { readonly phase: string }).phase;
|
||||
}
|
||||
911
third_party/pi/packages/durable/src/harness/tool.ts
vendored
Normal file
911
third_party/pi/packages/durable/src/harness/tool.ts
vendored
Normal file
@@ -0,0 +1,911 @@
|
||||
import { type Context, copyJson, type Draft, type JsonRepresentation, type JsonValue } from "@earendil-works/chord";
|
||||
import { awaitWithContext } from "@earendil-works/chord/context";
|
||||
import { overlap } from "@earendil-works/chord/delta";
|
||||
import {
|
||||
boundOutput,
|
||||
OutputBuffer,
|
||||
type OutputLimits,
|
||||
PROGRESS_BYTES_PER_SECOND,
|
||||
Progress,
|
||||
} from "@earendil-works/chord/output";
|
||||
import type { ImageContent, TextContent, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import { validateToolArguments } from "@earendil-works/pi-ai/utils/validation";
|
||||
import { Compile } from "typebox/compile";
|
||||
import { defineDocFamily } from "../documents.ts";
|
||||
import { AssistantEntry, ToolResultEntry } from "../entries.ts";
|
||||
import { errorMessage } from "../errors.ts";
|
||||
import { defineTask } from "../tasks.ts";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, utf8ByteLength } from "../truncate.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
EntryId,
|
||||
JsonObject,
|
||||
Task,
|
||||
TaskId,
|
||||
TaskOptions,
|
||||
TaskOutcome,
|
||||
TaskRuntime,
|
||||
Tx,
|
||||
TypedEntry,
|
||||
} from "../types.ts";
|
||||
import { assignJson } from "./json.ts";
|
||||
import {
|
||||
clearProgress,
|
||||
finishSlot,
|
||||
LiveDoc,
|
||||
type NestedToolSlot,
|
||||
type NestedToolSummary,
|
||||
removeNestedSlots,
|
||||
type ToolSlot,
|
||||
toolSlot,
|
||||
} from "./live.ts";
|
||||
import type {
|
||||
NestedToolExecutionResult,
|
||||
ToolControl,
|
||||
ToolDiagnostic,
|
||||
ToolExecutionApi,
|
||||
ToolExecutionResult,
|
||||
ToolHookCall,
|
||||
ToolHooks,
|
||||
ToolRegistration,
|
||||
} from "./types.ts";
|
||||
import { recordUsage } from "./usage.ts";
|
||||
|
||||
/**
|
||||
* A model-issued call, read from its assistant entry, or a nested call a running tool made through `executeTool()`,
|
||||
* whose call the input carries because no entry holds it.
|
||||
*/
|
||||
export type ToolTaskInput =
|
||||
| { kind: "model"; assistant: EntryId; callId: string }
|
||||
| {
|
||||
kind: "nested";
|
||||
parent: TaskId;
|
||||
parentCallId: string;
|
||||
key: string;
|
||||
call: ToolCall;
|
||||
/** `false`: commit no running output, details, or diagnostics to the slot; the result still gets them. */
|
||||
progress?: false;
|
||||
};
|
||||
|
||||
export type ToolTaskCheckpoint =
|
||||
| { phase: "call" }
|
||||
/** Durable intent: the final arguments and the replay policy recorded before execution. */
|
||||
| { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };
|
||||
|
||||
/**
|
||||
* A model-issued call's result is its transcript entry; a nested call's result is in its caller's
|
||||
* `NestedCallDoc`, so the receipt stays small and the result retires with the caller. A version 1 task that was
|
||||
* already holding its outcome as `completing` when the Harness upgraded finishes with a version 1 result,
|
||||
* `{ entryId, control? }` without `kind`; treat a result without `kind` as a model-issued call's.
|
||||
*/
|
||||
export type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" };
|
||||
|
||||
/**
|
||||
* Memo of a tool task that made a nested call, in any attempt, committed before the first admission of each attempt:
|
||||
* only then can `pi.live` hold slots below it, which settling removes. A crash between the two only costs a scan.
|
||||
*/
|
||||
const MADE_NESTED_CALLS = "pi.tool.madeNestedCalls";
|
||||
|
||||
/** One nested call of a running tool: its task, and once it ended, its result. */
|
||||
export type NestedCallState = {
|
||||
/** Absent only inside the commit that creates the call; a creation that fails leaves no member. */
|
||||
taskId?: TaskId<ToolTaskResult>;
|
||||
/** Exactly what `executeTool()` returns; absent while running, and for a call the scheduler faulted or orphaned. */
|
||||
result?: JsonRepresentation<NestedToolExecutionResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The nested calls of a tool call, a task-scoped family of the calling task keyed by the nested call's key: so a rerun
|
||||
* finds the calls it already made, and the caller reads each result. Created with the nested task; the nested call's
|
||||
* terminal commit adds its result. One document per call, so no commit rewrites the others. Retires with the caller.
|
||||
*/
|
||||
export const NestedCallDoc = defineDocFamily<NestedCallState, null>({
|
||||
kind: "pi.tool.nested-call",
|
||||
version: 1,
|
||||
scope: "task",
|
||||
family: true,
|
||||
initial: () => ({}),
|
||||
});
|
||||
|
||||
type Runtime = TaskRuntime<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>;
|
||||
type Content = (TextContent | ImageContent)[];
|
||||
|
||||
/**
|
||||
* Built-in tool task: resolves the called tool among its phase agent's tools, validates, runs `beforeTool`, records intent,
|
||||
* executes, runs `afterTool`, and settles the result, all in one `call` handler so nothing separates resolution from
|
||||
* settlement. `execute` is reached only by recovery and applies the replay rule. A model-issued call settles by
|
||||
* appending its result entry; a nested call by writing its result to its caller's `NestedCallDoc`.
|
||||
*/
|
||||
export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>({
|
||||
name: "pi.tool",
|
||||
version: 2,
|
||||
initial: () => ({ phase: "call" }),
|
||||
// Version 1 had only model-issued calls.
|
||||
migrate: (input, checkpoint) => ({
|
||||
input: { kind: "model", ...(input as { assistant: EntryId; callId: string }) },
|
||||
checkpoint: checkpoint as ToolTaskCheckpoint,
|
||||
}),
|
||||
phases: {
|
||||
call: async (task, runtime, context) => {
|
||||
const call = await readCall(runtime, task.input, context);
|
||||
const tool = await resolveTool(runtime, task.input, call.name, context);
|
||||
if (tool === undefined) {
|
||||
const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
|
||||
return settle(runtime, task.input, call, COMPLETED, () => error, context);
|
||||
}
|
||||
const prepared = prepare(tool, call.arguments as JsonObject);
|
||||
const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args);
|
||||
if ("error" in checked)
|
||||
return settle(runtime, task.input, call, COMPLETED, () => invalid(checked.error), context);
|
||||
let args = checked.args;
|
||||
let block: string | undefined;
|
||||
await runtime.hooks.each("beforeTool", async (hook) => {
|
||||
if (block !== undefined) return;
|
||||
try {
|
||||
const decision = await hook({ ...call, arguments: args }, runtime, context);
|
||||
if (decision?.block !== undefined) block = decision.block;
|
||||
else if (decision?.arguments !== undefined) args = decision.arguments;
|
||||
} catch (error) {
|
||||
if (runtime.signal.aborted) throw error;
|
||||
block = errorMessage(error);
|
||||
}
|
||||
});
|
||||
if (block !== undefined) {
|
||||
const blocked = harnessError("blocked", `Tool call blocked: ${block}`);
|
||||
return settle(runtime, task.input, call, COMPLETED, () => blocked, context);
|
||||
}
|
||||
const validated = validate(tool, call, args);
|
||||
if ("error" in validated)
|
||||
return settle(runtime, task.input, call, COMPLETED, () => invalid(validated.error), context);
|
||||
// Strict JSON: repair may set optional properties to undefined, which the slot and checkpoint cannot hold.
|
||||
const final = copyJson(validated.args, { omitUndefinedProperties: true }) as JsonObject;
|
||||
await runtime.commit(async (tx) => {
|
||||
const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
|
||||
if (slot !== undefined) slot.status = "running";
|
||||
// A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed.
|
||||
// Compared with the input, which the slot got at admission: reading the draft would track every leaf.
|
||||
const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final);
|
||||
if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final;
|
||||
const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;
|
||||
return { status: "running", checkpoint: intent };
|
||||
}, context);
|
||||
await run(runtime, task.input, call, tool, final, context);
|
||||
},
|
||||
/** Recovery after intent: rerun only when the stored and the current policy both say `safe`. */
|
||||
execute: async (task, runtime, context) => {
|
||||
const { arguments: args, replay } = task.state.checkpoint;
|
||||
const call = await readCall(runtime, task.input, context);
|
||||
const tool = await resolveTool(runtime, task.input, call.name, context);
|
||||
if (replay === "safe" && tool?.replay === "safe") {
|
||||
// The rerun reports from scratch; clear what the interrupted attempt published.
|
||||
await runtime.commit(async (tx) => {
|
||||
const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
|
||||
if (slot !== undefined) clearProgress(slot);
|
||||
return undefined;
|
||||
}, context);
|
||||
return run(runtime, task.input, call, tool, args, context);
|
||||
}
|
||||
const message = `Tool ${call.name} was interrupted and may have partially run`;
|
||||
// `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted.
|
||||
const ending = { status: "failed", message } as const;
|
||||
await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);
|
||||
},
|
||||
},
|
||||
abort: async (task, runtime, context) => {
|
||||
const call = await readCall(runtime, task.input, context);
|
||||
// Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run.
|
||||
const [code, message] =
|
||||
task.abortReason !== "restart"
|
||||
? ["aborted", `Tool ${call.name} was aborted`]
|
||||
: task.state.checkpoint.phase === "execute"
|
||||
? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]
|
||||
: ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`];
|
||||
const ending = { status: "aborted" } as const;
|
||||
await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, code, message), context);
|
||||
},
|
||||
});
|
||||
|
||||
/** The called tool: among the tools the model is offered for a model-issued call, else among those tools may call. */
|
||||
async function resolveTool(
|
||||
runtime: Runtime,
|
||||
input: ToolTaskInput,
|
||||
name: string,
|
||||
context: Context,
|
||||
): Promise<ToolRegistration | undefined> {
|
||||
const agent = await runtime.agent(context);
|
||||
return (input.kind === "model" ? agent.tools : agent.callable).find((tool) => tool.name === name);
|
||||
}
|
||||
|
||||
/** The call: from the input for a nested call, which hooks see with its parent, else from the assistant entry. */
|
||||
async function readCall(runtime: Runtime, input: ToolTaskInput, context: Context): Promise<ToolHookCall> {
|
||||
if (input.kind === "nested") return { ...input.call, parent: { taskId: input.parent, callId: input.parentCallId } };
|
||||
const entry = await runtime.entry(AssistantEntry, input.assistant, context);
|
||||
const message = entry?.model?.[0];
|
||||
const call =
|
||||
message?.role === "assistant"
|
||||
? message.content.find(
|
||||
(content): content is ToolCall => content.type === "toolCall" && content.id === input.callId,
|
||||
)
|
||||
: undefined;
|
||||
if (call === undefined) throw new Error(`Entry ${input.assistant} has no tool call ${input.callId}`);
|
||||
return call;
|
||||
}
|
||||
|
||||
/** Arguments, or why they are invalid. */
|
||||
type Checked = { readonly args: JsonObject } | { readonly error: string };
|
||||
|
||||
/** The call's arguments as repaired by the tool; a throwing repair makes them invalid. */
|
||||
function prepare(tool: ToolRegistration, args: JsonObject): Checked {
|
||||
if (tool.prepareArguments === undefined) return { args };
|
||||
try {
|
||||
return { args: tool.prepareArguments(args) as JsonObject };
|
||||
} catch (error) {
|
||||
return { error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Arguments validated and coerced against the implementation's schema. */
|
||||
function validate(tool: ToolRegistration, call: ToolCall, args: JsonObject): Checked {
|
||||
try {
|
||||
return { args: validateToolArguments(tool, { ...call, arguments: args }) as JsonObject };
|
||||
} catch (error) {
|
||||
return { error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): ToolExecutionResult {
|
||||
return harnessError("invalid_arguments", message);
|
||||
}
|
||||
|
||||
/** What a running tool reported through its api: output, the last details, and diagnostics. */
|
||||
type Reported = {
|
||||
readonly output: OutputBuffer;
|
||||
readonly limits: OutputLimits;
|
||||
readonly diagnostics: ToolDiagnostic[];
|
||||
details: JsonValue | undefined;
|
||||
};
|
||||
|
||||
/** Execute with the resolved implementation, then settle its result. */
|
||||
async function run(
|
||||
runtime: Runtime,
|
||||
input: ToolTaskInput,
|
||||
call: ToolHookCall,
|
||||
tool: ToolRegistration,
|
||||
args: JsonObject,
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
const limits: OutputLimits = {
|
||||
maxBytes: tool.outputLimits?.maxBytes ?? DEFAULT_MAX_BYTES,
|
||||
maxLines: tool.outputLimits?.maxLines ?? DEFAULT_MAX_LINES,
|
||||
retain: tool.outputLimits?.retain ?? "head",
|
||||
};
|
||||
const reported: Reported = { output: new OutputBuffer(limits), limits, diagnostics: [], details: undefined };
|
||||
const streams = input.kind === "model" || input.progress !== false;
|
||||
const progress = publishProgress(runtime, reported, streams, context);
|
||||
/** Nested call admissions this invocation started; cleanup waits for them, so it sees every nested call. */
|
||||
const admissions: Promise<unknown>[] = [];
|
||||
// A caller that can rerun reattaches to its nested calls and child tasks after a restart; any other caller never
|
||||
// resumes, so they are abandoned (`TaskOptions.abandonOnRestart`).
|
||||
const resumes = tool.replay === "safe";
|
||||
/** Default keys: the order of this invocation's calls, so a rerun that calls in the same order reattaches. */
|
||||
let sequence = 0;
|
||||
/** This attempt's commit of the `MADE_NESTED_CALLS` memo. */
|
||||
let madeNestedMemo: Promise<unknown> | undefined;
|
||||
let ended = false;
|
||||
const assertLive = (): void => {
|
||||
if (ended) throw new Error(`Tool call ${call.id} has settled`);
|
||||
};
|
||||
const api: Omit<ToolExecutionApi, "env"> = {
|
||||
taskId: runtime.taskId,
|
||||
conversationId: runtime.conversationId,
|
||||
callId: call.id,
|
||||
registry: runtime.registry,
|
||||
agent: runtime.agent,
|
||||
models: runtime.models,
|
||||
output: (chunk, skipped) => {
|
||||
assertLive();
|
||||
if (reported.output.push(chunk, skipped)) progress.mark();
|
||||
},
|
||||
outputWindow:
|
||||
limits.retain === "tail"
|
||||
? {
|
||||
maxBytes: limits.maxBytes,
|
||||
maxLines: limits.maxLines,
|
||||
minIntervalMs: runtime.settings.progress.outputIntervalMs,
|
||||
bytesPerSecond: PROGRESS_BYTES_PER_SECOND,
|
||||
}
|
||||
: undefined,
|
||||
retainedOutput: () => {
|
||||
assertLive();
|
||||
const retained = reported.output.snapshot();
|
||||
return { text: retained.text, truncated: retained.droppedBytes > 0 };
|
||||
},
|
||||
diagnostic: (diagnostic) => {
|
||||
assertLive();
|
||||
reported.diagnostics.push(copyJson(diagnostic, { omitUndefinedProperties: true }) as ToolDiagnostic);
|
||||
progress.mark();
|
||||
},
|
||||
details: async (value, detailsContext) => {
|
||||
assertLive();
|
||||
detailsContext.abortSignal?.throwIfAborted();
|
||||
reported.details = copyJson(value, { omitUndefinedProperties: true });
|
||||
const committed = progress.markAndWait();
|
||||
// Cancelling the wait leaves the update in place; the commit's own outcome stays observed.
|
||||
committed.catch(() => {});
|
||||
return awaitWithContext(committed, detailsContext);
|
||||
},
|
||||
commit: async (change, commitContext) => {
|
||||
let result: Awaited<ReturnType<typeof change>> | undefined;
|
||||
await runtime.commit(async (tx) => {
|
||||
result = await change(tx);
|
||||
return undefined;
|
||||
}, commitContext);
|
||||
return result as Awaited<ReturnType<typeof change>>;
|
||||
},
|
||||
memo: runtime.memo,
|
||||
createTask: async <I, S extends { phase: string }, R, H extends object>(
|
||||
task: Task<I, S, R, H>,
|
||||
input: I,
|
||||
options: Omit<TaskOptions, "conversationId">,
|
||||
taskContext: Context,
|
||||
): Promise<TaskId<R>> => {
|
||||
const child = options.ownership.kind === "task" && !resumes ? { abandonOnRestart: true, ...options } : options;
|
||||
let id: TaskId<R> | undefined;
|
||||
await runtime.commit(async (tx) => {
|
||||
id = await tx.createTask(task, input, child);
|
||||
return undefined;
|
||||
}, taskContext);
|
||||
return id!;
|
||||
},
|
||||
getTask: runtime.getTask,
|
||||
waitForTask: runtime.waitForTask,
|
||||
conversation: runtime.conversation,
|
||||
snapshot: runtime.snapshot,
|
||||
snapshotAsOf: runtime.snapshotAsOf,
|
||||
watchDoc: runtime.watchDoc,
|
||||
executeTool: async (name, toolArgs, callContext, options = {}) => {
|
||||
assertLive();
|
||||
const key = options.key ?? String(++sequence);
|
||||
if (options.key !== undefined) checkKey(options.key);
|
||||
// The memo first, once per attempt; a failed one is tried again by the next call.
|
||||
madeNestedMemo ??= runtime.memo(MADE_NESTED_CALLS, true, callContext).catch((error: unknown) => {
|
||||
madeNestedMemo = undefined;
|
||||
throw error;
|
||||
});
|
||||
// Registered now, so cleanup waits for it.
|
||||
const admission = madeNestedMemo.then(() =>
|
||||
startNestedCall(
|
||||
runtime,
|
||||
call.id,
|
||||
name,
|
||||
toolArgs,
|
||||
{ key, progress: options.progress, abandonOnRestart: !resumes },
|
||||
callContext,
|
||||
),
|
||||
);
|
||||
admissions.push(admission);
|
||||
const id = await admission;
|
||||
const settled = await runtime.waitForTask(id, callContext);
|
||||
const stored = (await runtime.snapshot(NestedCallDoc, runtime.taskId, key, callContext))?.result;
|
||||
// A copy the tool may change; a nested call the scheduler faulted or orphaned stored none.
|
||||
return stored === undefined
|
||||
? fallbackResult(id, name, settled.state.outcome)
|
||||
: (copyJson(stored as unknown as JsonValue) as unknown as NestedToolExecutionResult);
|
||||
},
|
||||
};
|
||||
|
||||
let result: ToolExecutionResult;
|
||||
let ending = COMPLETED;
|
||||
// Execution time of this attempt; a rerun after recovery measures only itself.
|
||||
let durationMs: number | undefined;
|
||||
try {
|
||||
// Built for this call, so a rerun after recovery gets the conversation's environment at that time.
|
||||
const env = await runtime.env(context);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
result = await tool.execute(args, { ...api, env }, context);
|
||||
} finally {
|
||||
durationMs = Math.round(performance.now() - startedAt);
|
||||
}
|
||||
} catch (error) {
|
||||
if (runtime.signal.aborted) {
|
||||
ended = true;
|
||||
for (const waiter of await progress.stop()) waiter.reject(error);
|
||||
throw error;
|
||||
}
|
||||
result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorMessage(error))] };
|
||||
// A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call
|
||||
// owned; it no longer supervises it. The error text is already in the result.
|
||||
ending = { status: "failed", message: `Tool ${call.name} threw` };
|
||||
}
|
||||
ended = true;
|
||||
// No admission starts after `ended`; let those underway commit, so settlement aborts and lists their calls.
|
||||
await Promise.allSettled(admissions);
|
||||
reported.output.end();
|
||||
// Details still waiting for a progress commit settle with the terminal commit, the final flush.
|
||||
const pending = await progress.stop();
|
||||
try {
|
||||
const settled = await finalResult(runtime, input, call, tool, result, reported, context);
|
||||
await settle(runtime, input, call, ending, () => settled, context, durationMs);
|
||||
} catch (error) {
|
||||
for (const waiter of pending) waiter.reject(error);
|
||||
throw error;
|
||||
}
|
||||
for (const waiter of pending) waiter.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled commits of what the tool reported into its slot, in `pi.live.tools` or `pi.live.nestedTools`, each writing
|
||||
* only what changed since the last one; none when `enabled` is false.
|
||||
*/
|
||||
function publishProgress(runtime: Runtime, reported: Reported, enabled: boolean, context: Context): Progress {
|
||||
let written = { text: "", details: undefined as JsonValue | undefined, diagnostics: 0 };
|
||||
return new Progress(
|
||||
async () => {
|
||||
if (!enabled) return 0;
|
||||
// Capture everything synchronously: the tool keeps reporting while the commit is in flight.
|
||||
const snapshot = reported.output.snapshot();
|
||||
const current = { text: snapshot.text, details: reported.details, diagnostics: reported.diagnostics.length };
|
||||
const added = reported.diagnostics.slice(written.diagnostics, current.diagnostics);
|
||||
const detailsChanged = current.details !== written.details;
|
||||
// What the commit writes, as Chord diffs the string: an append, a trim plus an append of what follows the shared
|
||||
// part, or the whole window when its bounded overlap search finds nothing.
|
||||
let bytes = 0;
|
||||
if (snapshot.text !== written.text) {
|
||||
const shared = snapshot.text.startsWith(written.text)
|
||||
? written.text.length
|
||||
: overlap(written.text, snapshot.text, 65_536);
|
||||
bytes += utf8ByteLength(snapshot.text.slice(shared));
|
||||
}
|
||||
if (detailsChanged) bytes += utf8ByteLength(JSON.stringify(current.details ?? null));
|
||||
if (added.length > 0) bytes += utf8ByteLength(JSON.stringify(added));
|
||||
await runtime.commit(async (tx) => {
|
||||
const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
|
||||
if (slot === undefined) return undefined;
|
||||
// REMINDER: assign `output` as one string field. Chord then diffs it into an append, or a trim plus an
|
||||
// append for a sliding tail; replacing the slot object would record the whole window on every commit.
|
||||
if ((slot.output ?? "") !== snapshot.text) slot.output = snapshot.text;
|
||||
if (snapshot.droppedBytes > 0) slot.droppedBytes = snapshot.droppedBytes;
|
||||
if (snapshot.droppedLines > 0) slot.droppedLines = snapshot.droppedLines;
|
||||
// Diff details leaf by leaf and append new diagnostics, so each commit writes only what changed.
|
||||
if (detailsChanged && current.details !== undefined) {
|
||||
assignJson(slot as unknown as Record<string, JsonValue>, "details", current.details);
|
||||
}
|
||||
if (added.length > 0) {
|
||||
if (slot.diagnostics === undefined) slot.diagnostics = [];
|
||||
for (const diagnostic of added) slot.diagnostics.push(diagnostic);
|
||||
}
|
||||
return undefined;
|
||||
}, context);
|
||||
written = current;
|
||||
return bytes;
|
||||
},
|
||||
(error) => {
|
||||
// Rejections after an abort mark or close are expected; the committed state stays consistent.
|
||||
if (!runtime.signal.aborted) runtime.report(error);
|
||||
},
|
||||
runtime.settings.progress.outputIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settled result: the tool's result with the retained output and last details as fallbacks, its diagnostics after
|
||||
* those reported through the api, `afterTool` applied, explicit text bounded, and `structuredOutput` checked against the
|
||||
* tool's schema, with the Harness's diagnostics last. A nested call whose `structuredOutput` breaks the contract gets an
|
||||
* error result without it; a model-issued call only loses it, and the break is reported to the host.
|
||||
*/
|
||||
async function finalResult(
|
||||
runtime: Runtime,
|
||||
input: ToolTaskInput,
|
||||
call: ToolHookCall,
|
||||
tool: ToolRegistration,
|
||||
result: ToolExecutionResult,
|
||||
reported: Reported,
|
||||
context: Context,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const harness: ToolDiagnostic[] = [];
|
||||
const retained = result.output === undefined ? reported.output.snapshot() : undefined;
|
||||
const output: Content =
|
||||
retained === undefined ? result.output! : retained.text === "" ? [] : [{ type: "text", text: retained.text }];
|
||||
let final: ToolExecutionResult = {
|
||||
...result,
|
||||
output,
|
||||
details: result.details === undefined ? reported.details : result.details,
|
||||
diagnostics: [...reported.diagnostics, ...(result.diagnostics ?? [])],
|
||||
};
|
||||
await runtime.hooks.each("afterTool", async (hook) => {
|
||||
final = (await hook(call, final, runtime, context)) ?? final;
|
||||
});
|
||||
// The retained output's truncation applies only while afterTool kept that output.
|
||||
if (retained !== undefined && retained.droppedBytes > 0 && final.output === output) {
|
||||
harness.push(truncated(retained, reported.limits.retain));
|
||||
}
|
||||
const bounded = boundContent(final.output ?? [], reported.limits);
|
||||
if (bounded.droppedBytes > 0) harness.push(truncated(bounded, reported.limits.retain));
|
||||
const broken = structuredOutputError(tool, final);
|
||||
if (broken !== undefined) {
|
||||
const { structuredOutput: _dropped, ...rest } = final;
|
||||
if (input.kind === "nested") {
|
||||
final = { ...rest, isError: true };
|
||||
harness.push(toolDiagnostic("invalid_structured_output", broken));
|
||||
} else {
|
||||
// The model never sees structured output, so its call stands; the host learns of the broken tool.
|
||||
final = rest;
|
||||
runtime.report(new Error(broken));
|
||||
}
|
||||
}
|
||||
// Programs get the tool's own value: without a schema, its output. The model reads only `output`, so model-issued
|
||||
// calls skip both.
|
||||
if (input.kind === "nested" && tool.structuredOutputSchema === undefined) {
|
||||
final = { ...final, structuredOutput: outputValue(bounded.content) as JsonValue };
|
||||
}
|
||||
// A schema tool's error result without structured output has its text only in the output, which programs never
|
||||
// get: it becomes an error diagnostic, as a thrown error's message does, unless one says what went wrong already.
|
||||
if (
|
||||
input.kind === "nested" &&
|
||||
tool.structuredOutputSchema !== undefined &&
|
||||
final.isError === true &&
|
||||
final.structuredOutput === undefined &&
|
||||
![...(final.diagnostics ?? []), ...harness].some((diagnostic) => diagnostic.severity === "error")
|
||||
) {
|
||||
const text = bounded.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n");
|
||||
if (text !== "") harness.push(toolDiagnostic("tool_error", text));
|
||||
}
|
||||
return { ...final, output: bounded.content, diagnostics: [...(final.diagnostics ?? []), ...harness] };
|
||||
}
|
||||
|
||||
const structuredValidators = new WeakMap<object, ReturnType<typeof Compile>>();
|
||||
|
||||
/** Why a result's `structuredOutput` breaks the tool's contract, or `undefined` when it keeps it. */
|
||||
function structuredOutputError(tool: ToolRegistration, result: ToolExecutionResult): string | undefined {
|
||||
const schema = tool.structuredOutputSchema;
|
||||
const value = result.structuredOutput;
|
||||
if (schema === undefined) {
|
||||
return value === undefined
|
||||
? undefined
|
||||
: `Tool ${tool.name} returned structuredOutput but declares no structuredOutputSchema`;
|
||||
}
|
||||
if (value === undefined) {
|
||||
return result.isError === true ? undefined : `Tool ${tool.name} returned no structuredOutput`;
|
||||
}
|
||||
let validator = structuredValidators.get(schema);
|
||||
if (validator === undefined) {
|
||||
validator = Compile(schema);
|
||||
structuredValidators.set(schema, validator);
|
||||
}
|
||||
if (validator.Check(value)) return undefined;
|
||||
const [first] = validator.Errors(value);
|
||||
const path = first?.instancePath.replace(/^\//, "").replace(/\//g, ".") || "root";
|
||||
return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the tool's terminal state and mark its slot done. A model-issued call appends its result entry and ends with
|
||||
* the entry ID; a nested call writes its result to the caller's `NestedCallDoc`, records its usage, and ends with a
|
||||
* small receipt. Nested calls this call left running are aborted first, so they report into their slots before the
|
||||
* slots below the call leave `pi.live`. `build` receives the slot so interruption and abort can report the durable
|
||||
* partial output.
|
||||
*/
|
||||
async function settle(
|
||||
runtime: Runtime,
|
||||
input: ToolTaskInput,
|
||||
call: ToolHookCall,
|
||||
ending: Ending,
|
||||
build: (slot: Readonly<ToolSlot | NestedToolSlot> | undefined) => ToolExecutionResult,
|
||||
context: Context,
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
// A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.
|
||||
// Every live nested call the task owns, also those an earlier attempt made and this one never called again.
|
||||
const nestedIds = (await runtime.ownedTasks(context))
|
||||
.filter((record) => record.kind === ToolTask.definition.name && (record.input as ToolTaskInput).kind === "nested")
|
||||
.map((record) => record.id);
|
||||
// Read before the settling commit: no admission starts after `ended`, and those underway were awaited.
|
||||
const madeNested = (await runtime.memo(MADE_NESTED_CALLS, context)) === true;
|
||||
// Mark them all before waiting for any: one may run until a sibling is cancelled.
|
||||
await Promise.all(nestedIds.map((id) => runtime.abortOwned(id, context)));
|
||||
await runtime.commit(async (tx) => {
|
||||
const conversationId = runtime.conversationId;
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const slot = toolSlot(live, runtime.taskId);
|
||||
const result = build(slot);
|
||||
let settled: ToolTaskResult;
|
||||
if (input.kind === "nested") {
|
||||
const nested = nestedResult(runtime.taskId, result, durationMs);
|
||||
if (nested.usage !== undefined) await recordUsage(tx, conversationId, "tools", call.name, nested.usage);
|
||||
// The caller is never terminal before its owned nested calls, so its document is open; another task in it is a
|
||||
// bug, not a race.
|
||||
const state = await tx.doc(NestedCallDoc, input.parent, input.key, null);
|
||||
if (state.taskId !== runtime.taskId) throw new Error(`Nested call ${call.id} is not in its caller's index`);
|
||||
state.result = nested as unknown as Draft<JsonRepresentation<NestedToolExecutionResult>>;
|
||||
if (slot !== undefined && "parentTaskId" in slot) {
|
||||
finishSlot(slot);
|
||||
slot.summary = summaryOf(nested, result.output ?? []);
|
||||
}
|
||||
settled = { kind: "nested" };
|
||||
} else {
|
||||
const timing = { timestamp: runtime.now(), durationMs };
|
||||
const entry = await appendToolResult(tx, conversationId, call, result, timing);
|
||||
if (slot !== undefined && !("parentTaskId" in slot)) {
|
||||
finishSlot(slot);
|
||||
slot.entry = entry.id;
|
||||
}
|
||||
// Tools build control objects freely; drop keys set to undefined so the task result is strict JSON.
|
||||
const control =
|
||||
result.control === undefined || ending.status !== "completed"
|
||||
? {}
|
||||
: { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl };
|
||||
settled = { kind: "model", entryId: entry.id, ...control };
|
||||
}
|
||||
// A call that made no nested calls, in any attempt, has no slots below it; skipping the scan keeps many leaves
|
||||
// linear. Committed before the read: no nested call is admitted after `ended`.
|
||||
if (madeNested) removeNestedSlots(live, runtime.taskId);
|
||||
if (ending.status === "aborted") return { status: "terminal", outcome: { status: "aborted", result: settled } };
|
||||
if (ending.status === "failed") {
|
||||
const error = { message: ending.message };
|
||||
return { status: "terminal", outcome: { status: "failed", error, result: settled } };
|
||||
}
|
||||
return { status: "terminal", outcome: { status: "completed", result: settled } };
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* A nested call's result, as stored and returned: strict JSON, without `output`, which only the model reads, and
|
||||
* without `control`, which only model-issued calls apply.
|
||||
*/
|
||||
function nestedResult(
|
||||
taskId: TaskId,
|
||||
result: ToolExecutionResult,
|
||||
durationMs: number | undefined,
|
||||
): NestedToolExecutionResult {
|
||||
const nested = {
|
||||
taskId,
|
||||
structuredOutput: result.structuredOutput,
|
||||
isError: result.isError ?? false,
|
||||
details: result.details,
|
||||
diagnostics: result.diagnostics ?? [],
|
||||
usage: result.usage,
|
||||
durationMs,
|
||||
};
|
||||
return copyJson(nested as unknown as JsonValue, {
|
||||
omitUndefinedProperties: true,
|
||||
}) as unknown as NestedToolExecutionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create nested call `key` of the call `parentCallId` in one commit: its tool task, owned by the calling task, its slot,
|
||||
* and its `NestedCallDoc`. A key already made returns that call's task when it names the same tool and arguments.
|
||||
*/
|
||||
async function startNestedCall(
|
||||
runtime: Runtime,
|
||||
parentCallId: string,
|
||||
name: string,
|
||||
args: JsonObject,
|
||||
options: { readonly key: string; readonly progress: boolean | undefined; readonly abandonOnRestart: boolean },
|
||||
context: Context,
|
||||
): Promise<TaskId<ToolTaskResult>> {
|
||||
const key = options.key;
|
||||
const call: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: `${parentCallId}/${key}`,
|
||||
name,
|
||||
arguments: copyJson(args, { omitUndefinedProperties: true }) as JsonObject,
|
||||
};
|
||||
let id: TaskId<ToolTaskResult> | undefined;
|
||||
await runtime.commit(async (tx) => {
|
||||
// Opened, or created empty for a key not made yet.
|
||||
const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null);
|
||||
const existing = state.taskId;
|
||||
if (existing !== undefined) {
|
||||
const input = (await tx.task(existing))?.input as ToolTaskInput | undefined;
|
||||
const same =
|
||||
input?.kind === "nested" &&
|
||||
input.call.name === name &&
|
||||
jsonEqual(input.call.arguments as JsonValue, call.arguments as JsonValue);
|
||||
if (!same) throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`);
|
||||
id = existing as TaskId<ToolTaskResult>;
|
||||
return undefined;
|
||||
}
|
||||
const input: ToolTaskInput = {
|
||||
kind: "nested",
|
||||
parent: runtime.taskId,
|
||||
parentCallId,
|
||||
key,
|
||||
call,
|
||||
...(options.progress === false ? { progress: false } : {}),
|
||||
};
|
||||
const created = await tx.createTask(ToolTask, input, {
|
||||
ownership: { kind: "task", taskId: runtime.taskId },
|
||||
abandonOnRestart: options.abandonOnRestart,
|
||||
});
|
||||
state.taskId = created;
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
live.nestedTools ??= [];
|
||||
const slot: NestedToolSlot = {
|
||||
callId: call.id,
|
||||
parentCallId,
|
||||
parentTaskId: runtime.taskId,
|
||||
name,
|
||||
taskId: created,
|
||||
arguments: call.arguments as JsonObject,
|
||||
status: "pending",
|
||||
};
|
||||
live.nestedTools.push(slot as Draft<NestedToolSlot>);
|
||||
id = created;
|
||||
return undefined;
|
||||
}, context);
|
||||
return id!;
|
||||
}
|
||||
|
||||
/**
|
||||
* What programs get of a tool's output when it declares no schema: one text item as its string, one image as itself,
|
||||
* nothing as `""`, and anything else as the content list.
|
||||
*/
|
||||
function outputValue(output: Content): string | ImageContent | Content {
|
||||
if (output.length === 0) return "";
|
||||
if (output.length > 1) return output;
|
||||
const [only] = output;
|
||||
return only!.type === "text" ? only!.text : only!;
|
||||
}
|
||||
|
||||
/** What the caller gets for a nested call without a stored result: one the scheduler faulted or orphaned. */
|
||||
function fallbackResult(taskId: TaskId, name: string, outcome: TaskOutcome<ToolTaskResult>): NestedToolExecutionResult {
|
||||
const message =
|
||||
outcome.status === "faulted"
|
||||
? `Tool ${name} failed: ${outcome.error.message}`
|
||||
: outcome.status === "orphaned"
|
||||
? `Tool ${name} could not resume: ${outcome.reason}`
|
||||
: `Tool ${name} ended without a result`;
|
||||
const error = harnessError(outcome.status, message);
|
||||
return { taskId, isError: true, diagnostics: error.diagnostics ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool task ends; the result entry is appended either way. `failed` (execution threw or was interrupted)
|
||||
* records cancellation intent for the conversations the call owns; a result with `isError` still completes.
|
||||
*/
|
||||
type Ending = { readonly status: "completed" | "aborted" } | { readonly status: "failed"; readonly message: string };
|
||||
|
||||
const COMPLETED: Ending = { status: "completed" };
|
||||
|
||||
/** An error result from the slot's durable partial output, details, and diagnostics. */
|
||||
function fromSlot(
|
||||
slot: Readonly<ToolSlot | NestedToolSlot> | undefined,
|
||||
code: string,
|
||||
message: string,
|
||||
): ToolExecutionResult {
|
||||
const diagnostics = [...(slot?.diagnostics ?? [])];
|
||||
const droppedBytes = slot?.droppedBytes ?? 0;
|
||||
if (droppedBytes > 0) diagnostics.push(truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }));
|
||||
diagnostics.push(toolDiagnostic(code, message));
|
||||
return {
|
||||
output: slot?.output === undefined || slot.output === "" ? [] : [{ type: "text", text: slot.output }],
|
||||
isError: true,
|
||||
...(slot?.details === undefined ? {} : { details: slot.details }),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** An error result the Harness writes itself: no content and one `error` diagnostic with `code`. */
|
||||
export function harnessError(code: string, message: string): ToolExecutionResult {
|
||||
return { output: [], isError: true, diagnostics: [toolDiagnostic(code, message)] };
|
||||
}
|
||||
|
||||
function toolDiagnostic(code: string, message: string): ToolDiagnostic {
|
||||
return { severity: "error", code, message };
|
||||
}
|
||||
|
||||
/** The Harness's truncation diagnostic; `retain` is unknown when rebuilt from a slot after recovery. */
|
||||
function truncated(
|
||||
dropped: { readonly droppedLines: number; readonly droppedBytes: number },
|
||||
retain?: "head" | "tail",
|
||||
): ToolDiagnostic {
|
||||
const kept = retain === undefined ? "" : ` to its ${retain === "head" ? "beginning" : "end"}`;
|
||||
return {
|
||||
severity: "warn",
|
||||
code: "truncated",
|
||||
message: `Output truncated${kept}: ${dropped.droppedLines} lines, ${dropped.droppedBytes} bytes dropped`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Error text a nested call's summary keeps. */
|
||||
const MAX_SUMMARY_ERROR_CHARS = 500;
|
||||
|
||||
/** How a nested call ended, for its slot: error text, from its diagnostics or else its `output`, bounded. */
|
||||
function summaryOf(result: NestedToolExecutionResult, output: Content): NestedToolSummary {
|
||||
const error = result.isError ? errorTextOf(result, output).slice(0, MAX_SUMMARY_ERROR_CHARS) : "";
|
||||
return {
|
||||
isError: result.isError,
|
||||
...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
|
||||
...(result.usage === undefined ? {} : { usage: result.usage }),
|
||||
...(error === "" ? {} : { error }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The error messages of a nested result, else the text items of its output, joined with newlines. */
|
||||
function errorTextOf(result: NestedToolExecutionResult, output: Content): string {
|
||||
const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
if (errors.length > 0) return errors.map((diagnostic) => diagnostic.message).join("\n");
|
||||
return output.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a `pi.tool-result` entry. The content ends with the rendered diagnostics, so the stored message is exactly
|
||||
* what the model sees; `data` keeps the structured list. A result's usage is added to `pi.usage` in the same commit.
|
||||
*/
|
||||
export async function appendToolResult(
|
||||
tx: Tx,
|
||||
conversationId: ConversationId,
|
||||
call: ToolCall,
|
||||
result: ToolExecutionResult,
|
||||
meta: { readonly timestamp: number; readonly durationMs?: number },
|
||||
): Promise<TypedEntry<{ diagnostics: ToolDiagnostic[] }>> {
|
||||
const { timestamp, durationMs } = meta;
|
||||
const diagnostics = [...(result.diagnostics ?? [])];
|
||||
const content: Content = [...(result.output ?? [])];
|
||||
if (diagnostics.length > 0) content.push({ type: "text", text: renderDiagnostics(diagnostics) });
|
||||
const message = {
|
||||
role: "toolResult",
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
content,
|
||||
...(result.details === undefined ? {} : { details: result.details }),
|
||||
...(result.usage === undefined ? {} : { usage: result.usage }),
|
||||
isError: result.isError ?? false,
|
||||
...(durationMs === undefined ? {} : { durationMs }),
|
||||
timestamp,
|
||||
} as ToolResultMessage;
|
||||
if (result.usage !== undefined) await recordUsage(tx, conversationId, "tools", call.name, result.usage);
|
||||
return tx.appendEntry(ToolResultEntry, conversationId, { model: [message], data: { diagnostics } });
|
||||
}
|
||||
|
||||
function renderDiagnostics(diagnostics: readonly ToolDiagnostic[]): string {
|
||||
return `<harness>\n${diagnostics.map((diagnostic) => `[${diagnostic.severity}] ${diagnostic.message}`).join("\n")}\n</harness>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the text of result content. When the joined text exceeds the limits, the text items are replaced by one bounded
|
||||
* item at the position of the first (head) or last (tail) text item; other content is kept.
|
||||
*/
|
||||
function boundContent(
|
||||
content: Content,
|
||||
limits: OutputLimits,
|
||||
): { content: Content; droppedBytes: number; droppedLines: number } {
|
||||
const texts = content.filter((item): item is TextContent => item.type === "text");
|
||||
const bounded = boundOutput(texts.map((item) => item.text).join(""), limits);
|
||||
if (bounded.droppedBytes === 0) return { content, droppedBytes: 0, droppedLines: 0 };
|
||||
const keep = limits.retain === "head" ? texts[0] : texts.at(-1);
|
||||
const result: Content = [];
|
||||
for (const item of content) {
|
||||
if (item.type !== "text") result.push(item);
|
||||
else if (item === keep) result.push({ ...item, text: bounded.text });
|
||||
}
|
||||
return { content: result, droppedBytes: bounded.droppedBytes, droppedLines: bounded.droppedLines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit nested call keys are path segments of the call ID, so IDs of different nested calls never collide, and are
|
||||
* never plain positive integers, which default keys use. `__proto__` stays reserved, as it was when keys indexed a JSON object.
|
||||
*/
|
||||
function checkKey(key: string): void {
|
||||
if (key === "" || key.includes("/") || key === "__proto__" || /^[1-9][0-9]*$/.test(key)) {
|
||||
throw new Error(
|
||||
`Nested call key ${JSON.stringify(key)} must be non-empty, without "/", not "__proto__", and not a positive integer`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural JSON equality; object key order does not matter. */
|
||||
function jsonEqual(a: JsonValue, b: JsonValue): boolean {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return (
|
||||
Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]!))
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key]!, b[key]!))
|
||||
);
|
||||
}
|
||||
812
third_party/pi/packages/durable/src/harness/types.ts
vendored
Normal file
812
third_party/pi/packages/durable/src/harness/types.ts
vendored
Normal file
@@ -0,0 +1,812 @@
|
||||
import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
Message,
|
||||
Models,
|
||||
ModelThinkingLevel,
|
||||
Static,
|
||||
Tool,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
Transport,
|
||||
TSchema,
|
||||
Usage,
|
||||
UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { ExecutionEnv, ShellOutputSkip, ShellOutputWindow } from "../env/index.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
ConversationOwnership,
|
||||
ConversationRecord,
|
||||
Cursor,
|
||||
DocumentObserver,
|
||||
DocumentReader,
|
||||
EntryDraft,
|
||||
EntryId,
|
||||
EntryQuery,
|
||||
EntryRecord,
|
||||
JsonObject,
|
||||
Page,
|
||||
Session,
|
||||
SubmissionId,
|
||||
SubmissionRecord,
|
||||
Task,
|
||||
TaskId,
|
||||
TaskOptions,
|
||||
TaskRecord,
|
||||
TaskState,
|
||||
Tx,
|
||||
WatchHandle,
|
||||
} from "../types.ts";
|
||||
import type { TaskGraph, TaskGraphWatch } from "./task-graph.ts";
|
||||
import type { UsageState } from "./usage.ts";
|
||||
import type { ConversationView } from "./view.ts";
|
||||
|
||||
/** Provider and model ID resolved through pi-ai `Models`. */
|
||||
export type ModelRef = {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
};
|
||||
|
||||
export type UserInput = UserMessage["content"];
|
||||
|
||||
/**
|
||||
* The entry an input is placed as, instead of `pi.user`: an application kind, and data the transcript shows, such as its
|
||||
* author. Its model message is still the input's user message.
|
||||
*/
|
||||
export type InputEntry = { readonly kind: string; readonly data?: JsonValue };
|
||||
|
||||
/** Host submission: user input that may start a run, or a passive entry write. */
|
||||
export type SubmissionDraft = {
|
||||
readonly requestId?: string;
|
||||
} & (
|
||||
| {
|
||||
readonly type: "input";
|
||||
readonly content: UserInput;
|
||||
readonly whenBusy?: "steer" | "followUp" | "reject";
|
||||
/** Place the input as this entry; default `pi.user` without data. The kind must not start with `pi.`. */
|
||||
readonly entry?: InputEntry;
|
||||
}
|
||||
| {
|
||||
readonly type: "write";
|
||||
readonly entry: EntryDraft;
|
||||
readonly content?: never;
|
||||
readonly whenBusy?: never;
|
||||
}
|
||||
);
|
||||
|
||||
export type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;
|
||||
|
||||
export type SettledSubmissionRecord = SubmissionRecord & {
|
||||
readonly status: "done" | "unanswered";
|
||||
};
|
||||
|
||||
/** Awaitable host object for one durably admitted submission. */
|
||||
export interface Submission {
|
||||
readonly id: SubmissionId;
|
||||
status(context: Context): Promise<SubmissionRecord>;
|
||||
wait(context: Context): Promise<SettledSubmissionRecord>;
|
||||
abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;
|
||||
}
|
||||
|
||||
export type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
|
||||
readonly state: Extract<TaskState<JsonValue, R>, { readonly status: "terminal" }>;
|
||||
};
|
||||
|
||||
/** Erased executable task definition stored in the registry. */
|
||||
export type AnyTask = {
|
||||
readonly definition: {
|
||||
readonly name: string;
|
||||
readonly version: number;
|
||||
readonly initial: unknown;
|
||||
readonly phases: Readonly<Record<string, unknown>>;
|
||||
readonly abort: unknown;
|
||||
readonly migrate?: unknown;
|
||||
readonly hooks?: object;
|
||||
};
|
||||
};
|
||||
|
||||
/** Options of `Conversation.abort()`. */
|
||||
export type ConversationAbortOptions = {
|
||||
/**
|
||||
* Cross background boundaries: mark every live task reached ignoring the background flag when the abort is admitted,
|
||||
* withdraw the queued inputs of every conversation reached, and wait until those tasks are terminal and the
|
||||
* conversation is ordinarily idle. Background work created afterwards is neither marked nor awaited.
|
||||
*/
|
||||
readonly background?: boolean;
|
||||
};
|
||||
|
||||
/** Hook handler map declared by a task definition. */
|
||||
export type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H> ? H : never;
|
||||
|
||||
/**
|
||||
* Invocation-bound conversation operations for tasks and tools. Rejects after the invocation ends; passive entries are
|
||||
* written with ordinary transaction writes instead.
|
||||
*/
|
||||
export interface ConversationHandle {
|
||||
readonly id: ConversationId;
|
||||
submit(submission: InputSubmissionDraft, context: Context): Promise<Submission>;
|
||||
/** `Conversation.abort()`: withdraw queued inputs, abort the ordinary ownership scope, and wait until it is idle. */
|
||||
abort(context: Context, options?: ConversationAbortOptions): Promise<void>;
|
||||
/** Resolve when the conversation's ordinary ownership scope has no live non-background task. */
|
||||
waitForIdle(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** Post-tools controls requested by a tool result. */
|
||||
export type ToolControl = {
|
||||
readonly addTools?: readonly string[];
|
||||
readonly terminate?: true;
|
||||
readonly handoff?: string;
|
||||
};
|
||||
|
||||
/** Remark about a call for the model and the UI, such as truncation or a spill path; never part of the tool's data. */
|
||||
export type ToolDiagnostic = {
|
||||
readonly severity: "info" | "warn" | "error";
|
||||
readonly message: string;
|
||||
readonly code?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a tool returns, on three channels: `output` for the model, `structuredOutput` for programs that call the tool
|
||||
* (`executeTool()` callers, code mode scripts), and `details` for UIs.
|
||||
*/
|
||||
export type ToolExecutionResult<TDetails extends JsonValue = JsonValue> = {
|
||||
/** For the model. Omitted: the retained `output()` text. */
|
||||
readonly output?: ToolResultMessage["content"];
|
||||
/**
|
||||
* For programs. Required unless `isError` when the tool declares `structuredOutputSchema`, and validated against it;
|
||||
* not allowed without one, where programs get the output itself (`NestedToolExecutionResult.structuredOutput`).
|
||||
*/
|
||||
readonly structuredOutput?: JsonValue;
|
||||
readonly isError?: boolean;
|
||||
/** Omitted: the last `details()` value becomes the details. */
|
||||
readonly details?: TDetails;
|
||||
/** Added after those recorded through `api.diagnostic()`. */
|
||||
readonly diagnostics?: readonly ToolDiagnostic[];
|
||||
/** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */
|
||||
readonly usage?: Usage;
|
||||
readonly control?: ToolControl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of a nested call, as `executeTool()` returns it. Programs read `structuredOutput`; the output for the model
|
||||
* is not kept, and `control` does not apply.
|
||||
*/
|
||||
export type NestedToolExecutionResult = {
|
||||
/** The nested call's tool task. */
|
||||
readonly taskId: TaskId;
|
||||
/**
|
||||
* The tool's value for programs. A tool with `structuredOutputSchema`: its validated `structuredOutput`, also on an
|
||||
* error result that has one; absent otherwise, and an error's text is then in an error diagnostic. A tool without:
|
||||
* its bounded output, success or error, as a string for one text item, an `ImageContent` for one image, `""` for
|
||||
* none, and the content list otherwise. Absent on an error result the Harness wrote itself (unavailable, invalid,
|
||||
* blocked, interrupted, aborted); `diagnostics` say what went wrong.
|
||||
*/
|
||||
readonly structuredOutput?: JsonValue;
|
||||
readonly isError: boolean;
|
||||
readonly details?: JsonValue;
|
||||
readonly diagnostics: readonly ToolDiagnostic[];
|
||||
readonly usage?: Usage;
|
||||
/** Execution time of the attempt that produced the result; absent when it never executed. */
|
||||
readonly durationMs?: number;
|
||||
};
|
||||
|
||||
/** Whether the tools of one round run at once or one after another in call order. */
|
||||
export type ToolExecutionMode = "parallel" | "sequential";
|
||||
|
||||
/** How many queued items of one mode a boundary places: the first, or all of them. */
|
||||
export type QueueMode = "all" | "one-at-a-time";
|
||||
|
||||
/**
|
||||
* Operations available to one tool invocation. A plain object, so a wrapper can spread it. Every operation rejects after
|
||||
* the invocation ends.
|
||||
*/
|
||||
export interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extends DocumentObserver, DocumentReader {
|
||||
readonly taskId: TaskId;
|
||||
readonly conversationId: ConversationId;
|
||||
readonly callId: string;
|
||||
/** The tool task's phase snapshot. */
|
||||
readonly registry: RegistrySnapshot;
|
||||
/** The calling conversation's agent, as the tool task's phase resolved it. */
|
||||
agent(context: Context): Promise<Agent>;
|
||||
/** `HarnessOptions.models`: the catalog, credentials, and request transforms generation uses. */
|
||||
readonly models: Models;
|
||||
/** Built by `HarnessOptions.env` for this call; `undefined` without an environment. */
|
||||
readonly env: ExecutionEnv | undefined;
|
||||
/**
|
||||
* Append running output; it becomes the result's `output` when the result omits it. `skipped` counts output
|
||||
* omitted before the chunk, as reported by an environment given `outputWindow` (`ShellOutputInfo.skipped`).
|
||||
*/
|
||||
output(chunk: string | Uint8Array, skipped?: ShellOutputSkip): void;
|
||||
/**
|
||||
* The tail this call's output keeps and the pace of its progress commits, for `ShellExecOptions.window`; `undefined`
|
||||
* when the tool keeps the head of its output, which cannot accept skips. A wrapper that replaces `output` and
|
||||
* transforms text must also replace this with `undefined`, so skipped text cannot bypass its transform.
|
||||
*/
|
||||
readonly outputWindow: ShellOutputWindow | undefined;
|
||||
/**
|
||||
* The output retained so far, as the model will see it when the result omits `output`, and whether earlier output was
|
||||
* dropped; for a tool that also returns that text in its `structuredOutput`.
|
||||
*/
|
||||
retainedOutput(): { readonly text: string; readonly truncated: boolean };
|
||||
/** Record a model-visible remark about this call. */
|
||||
diagnostic(diagnostic: ToolDiagnostic): void;
|
||||
/** Replace running details; the last value becomes the result details when the result omits `details`. */
|
||||
details(value: TDetails, context: Context): Promise<void>;
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
|
||||
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
|
||||
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
|
||||
/**
|
||||
* Create a task in one commit. A child task of a tool that is not replay-safe defaults to `abandonOnRestart`: the
|
||||
* tool never resumes after a restart, so nothing awaits the child then.
|
||||
*/
|
||||
createTask<I, S extends { phase: string }, R, H extends object>(
|
||||
task: Task<I, S, R, H>,
|
||||
input: I,
|
||||
options: Omit<TaskOptions, "conversationId">,
|
||||
context: Context,
|
||||
): Promise<TaskId<R>>;
|
||||
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
|
||||
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
|
||||
/** Invocation-bound handle of an existing conversation, such as one this tool created in `commit()`. */
|
||||
conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
|
||||
/**
|
||||
* Run tool `name` as a nested call of this one and wait for its result. The nested call is its own `pi.tool` task,
|
||||
* owned by this call: it resolves the tool among the conversation's tools, validates, runs the `ToolTask` hooks (which
|
||||
* see `call.parent`), applies output limits and the tool's replay policy, and reports progress in `pi.live.nestedTools`.
|
||||
* Its result returns here instead of entering the transcript. A call that is blocked, invalid, throws, is interrupted,
|
||||
* or is aborted returns an `isError` result.
|
||||
*
|
||||
* The nested call's key names it within this call; its call ID is `<callId>/<key>`. By default the key is the call's
|
||||
* position among this invocation's nested calls, `1`, `2`, ... An explicit `key` must be non-empty, without `/`, not
|
||||
* `__proto__`, and not a positive integer, so it never collides with a default one. A rerun of a replay-safe tool
|
||||
* that makes a call with a used key, by calling in the same order or by passing the same `key`, gets that nested call
|
||||
* back, finished or still running, instead of starting another. A nested call a crash interrupted before its result
|
||||
* committed follows its own replay policy, so this is not exactly-once execution. Reusing a key with another tool or
|
||||
* other arguments throws. A tool that is not replay-safe never reruns: after a restart, its unfinished nested calls
|
||||
* are abandoned (`TaskOptions.abandonOnRestart`) before they run again.
|
||||
*
|
||||
* `progress: false` commits no running output, details, or diagnostics to the nested call's slot, only its status;
|
||||
* the result still carries its details and diagnostics, and a schema-less tool's output as `structuredOutput`. An
|
||||
* interrupted or aborted call's result is built from its slot, so it then has none of them. Either way, programs never
|
||||
* get the partial output of an interrupted or aborted call.
|
||||
*
|
||||
* Nested calls run as soon as they are made, whatever their `executionMode`; the caller decides what runs at once by
|
||||
* what it awaits. Cancelling `context` stops only the wait; the nested call runs on. When this call settles, nested
|
||||
* calls it left running are aborted first. A nested result's `usage` is already counted under its tool; a result
|
||||
* that passes it on counts it again. Rejects, instead of returning a result, once `execute()` has returned, when this
|
||||
* call is aborted, or when the Harness closes.
|
||||
*/
|
||||
executeTool(
|
||||
name: string,
|
||||
args: JsonObject,
|
||||
context: Context,
|
||||
options?: { readonly key?: string; readonly progress?: boolean },
|
||||
): Promise<NestedToolExecutionResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executable tool registered in a registry. Only pi-ai `Tool` fields enter the transcript. `args` are typed by
|
||||
* `parameters`, which the Harness validates them against before `execute()`; `defineTool()` infers both generics.
|
||||
*/
|
||||
export type ToolRegistration<
|
||||
TParameters extends TSchema = TSchema,
|
||||
TDetails extends JsonValue = JsonValue,
|
||||
> = Tool<TParameters> & {
|
||||
/** Whether an interrupted execution may rerun on recovery. Default `unsafe`. */
|
||||
readonly replay?: "safe" | "unsafe";
|
||||
/** Default: the settings' `toolExecution`. One sequential call makes its whole round sequential. */
|
||||
readonly executionMode?: ToolExecutionMode;
|
||||
/**
|
||||
* Repair arguments models commonly get wrong before validation, such as a JSON string where an array belongs. Must be
|
||||
* pure and must not mutate `args`: it runs again when a call is retried before its intent is recorded. Its result is
|
||||
* still validated against `parameters`.
|
||||
*/
|
||||
prepareArguments?(args: unknown): Static<TParameters>;
|
||||
/**
|
||||
* Schema of `structuredOutput`, which programs that call the tool receive. Omitted: they receive the result's bounded
|
||||
* output, success or error, as `NestedToolExecutionResult.structuredOutput` describes.
|
||||
*/
|
||||
readonly structuredOutputSchema?: TSchema;
|
||||
/**
|
||||
* Who may call the tool: the model, other tools through `executeTool()`, or both (default). A code mode tool is
|
||||
* `["model"]`, so scripts cannot start scripts; tools behind code mode, such as MCP tools, can be `["tools"]`, so the
|
||||
* model is never offered them.
|
||||
*/
|
||||
readonly callers?: readonly ("model" | "tools")[];
|
||||
/**
|
||||
* Not offered to the model until loaded: the model gets the tool only when `modelTools` names it, in its array or its
|
||||
* `add`, which `control.addTools` (from a tool such as `tool_search`) writes. Other tools can call it any time.
|
||||
*/
|
||||
readonly deferred?: true;
|
||||
readonly outputLimits?: {
|
||||
readonly maxBytes?: number;
|
||||
readonly maxLines?: number;
|
||||
readonly retain?: "head" | "tail";
|
||||
};
|
||||
execute(
|
||||
args: Static<TParameters>,
|
||||
api: ToolExecutionApi<TDetails>,
|
||||
context: Context,
|
||||
): Promise<ToolExecutionResult<TDetails>>;
|
||||
};
|
||||
|
||||
/** Input to system prompt section rendering for one request preparation. */
|
||||
export type PromptInput<Tool extends ToolRegistration = ToolRegistration> = {
|
||||
readonly conversationId: ConversationId;
|
||||
/** The request's resolution; `agent.tools` are the tools offered in this request. */
|
||||
readonly agent: Agent<Tool>;
|
||||
/** Built by `HarnessOptions.env` for this preparation; `undefined` without an environment. */
|
||||
readonly env: ExecutionEnv | undefined;
|
||||
/** Sections already in effect after replaying the active transcript. */
|
||||
readonly shown: Readonly<Record<string, string>>;
|
||||
/** Committed document reads. */
|
||||
readonly read: DocumentReader;
|
||||
};
|
||||
|
||||
/** One system prompt section; the agent's sections render in order before each request. */
|
||||
export type PromptSection<Tool extends ToolRegistration = ToolRegistration> = {
|
||||
readonly key: string;
|
||||
render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>;
|
||||
/** Default true: wrap the text as `<key>\n...\n</key>`. */
|
||||
readonly tag?: boolean;
|
||||
};
|
||||
|
||||
/** Built by `hook()`; matches tasks by name. */
|
||||
export type HookRegistration = { readonly task: string; readonly handlers: object };
|
||||
|
||||
/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. Wrappers are pure. */
|
||||
export type Wrap<Tool extends ToolRegistration = ToolRegistration> =
|
||||
| { readonly tool: string; wrap(tool: Tool): Tool }
|
||||
| { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };
|
||||
|
||||
/** Named bundle of code; installed in a registry and selected by conversations by name. */
|
||||
export interface Extension<Tool extends ToolRegistration = ToolRegistration> {
|
||||
readonly name: string;
|
||||
readonly tools?: readonly Tool[];
|
||||
readonly sections?: readonly PromptSection<Tool>[];
|
||||
readonly hooks?: readonly HookRegistration[];
|
||||
/** Apply where this extension is selected, in order. */
|
||||
readonly wraps?: readonly Wrap<Tool>[];
|
||||
/** Resolved by name for every task, whichever conversations select this extension. */
|
||||
readonly tasks?: readonly AnyTask[];
|
||||
}
|
||||
|
||||
/** Immutable view of one published registry state. */
|
||||
export interface RegistrySnapshot<Tool extends ToolRegistration = ToolRegistration> {
|
||||
installed(): readonly Extension<Tool>[];
|
||||
extension(name: string): Extension<Tool> | undefined;
|
||||
/** Every installed tool with its extension, in install order. Names may repeat across extensions. */
|
||||
tools(): readonly { readonly extension: Extension<Tool>; readonly tool: Tool }[];
|
||||
sections(): readonly { readonly extension: Extension<Tool>; readonly section: PromptSection<Tool> }[];
|
||||
/** Built-in and installed task definitions. */
|
||||
tasks(): readonly AnyTask[];
|
||||
task(name: string): AnyTask | undefined;
|
||||
}
|
||||
|
||||
/** Read side of a registry consumed by a Harness. */
|
||||
export interface RegistryReader<Tool extends ToolRegistration = ToolRegistration> {
|
||||
/** Immutable view of the whole current registry. */
|
||||
snapshot(): RegistrySnapshot<Tool>;
|
||||
/** Called synchronously after every publication; wakes the scheduler to reconsider blocked tasks. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
/** Application-owned registry of extensions. */
|
||||
export interface Registry<Tool extends ToolRegistration = ToolRegistration> extends RegistryReader<Tool> {
|
||||
/** Install `extension`, or replace the installed extension with its name in place. Publishes at once. */
|
||||
install(extension: Extension<Tool>): void;
|
||||
/** Remove the installed extension with `extension.name`, whichever object it is. A later install appends. */
|
||||
uninstall(extension: Extension): void;
|
||||
}
|
||||
|
||||
/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */
|
||||
export type AgentState = {
|
||||
model?: ModelRef;
|
||||
thinkingLevel?: ModelThinkingLevel;
|
||||
/** An array selects exactly these extensions, in order. An object edits the host default selection. */
|
||||
extensions?: string[] | { add?: string[]; remove?: string[] };
|
||||
/** Filters the selected extensions' tools: the enabled tools. An array enables exactly these, in order. */
|
||||
tools?: string[] | { remove: string[] };
|
||||
/**
|
||||
* Filters the enabled tools the model may call (`callers` includes `model`): the tools offered to it. Never widens:
|
||||
* other tools can still call the ones it leaves out.
|
||||
* - Unset: every such tool except deferred ones.
|
||||
* - An array: exactly these, in order, deferred ones too.
|
||||
* - An object: every such tool except deferred ones and those in `remove`, in registry order, followed by the
|
||||
* deferred tools in `add` that are not in `remove`, in `add` order. Names of tools that are not deferred or not
|
||||
* registered have no effect in `add`.
|
||||
*/
|
||||
modelTools?: string[] | { remove?: string[]; add?: string[] };
|
||||
/** Rendered after every extension section, as the section `instructions`. */
|
||||
instructions?: string;
|
||||
/** Directory within the environment's file system, passed to `HarnessOptions.env`. */
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
/** A change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */
|
||||
export type AgentChange = {
|
||||
readonly model?: ModelRef | null;
|
||||
readonly thinkingLevel?: ModelThinkingLevel | null;
|
||||
readonly extensions?:
|
||||
| readonly Extension[]
|
||||
| { readonly add?: readonly Extension[]; readonly remove?: readonly Extension[] }
|
||||
| null;
|
||||
readonly tools?: readonly ToolRegistration[] | { readonly remove: readonly ToolRegistration[] } | null;
|
||||
/** Replaces the stored filter as a whole; tools loaded with `control.addTools` are in its `add`. */
|
||||
readonly modelTools?:
|
||||
| readonly ToolRegistration[]
|
||||
| { readonly remove?: readonly ToolRegistration[]; readonly add?: readonly ToolRegistration[] }
|
||||
| null;
|
||||
readonly instructions?: string | null;
|
||||
readonly cwd?: string | null;
|
||||
};
|
||||
|
||||
/** A conversation's agent resolved against a registry snapshot and the settings. */
|
||||
export type Agent<Tool extends ToolRegistration = ToolRegistration> = {
|
||||
readonly model?: ModelRef;
|
||||
readonly thinkingLevel: ModelThinkingLevel;
|
||||
readonly extensions: readonly Extension<Tool>[];
|
||||
/** The tools a request offers, in order: enabled, callable by the model, and selected by `modelTools`. */
|
||||
readonly tools: readonly Tool[];
|
||||
/** The tools nested calls (`executeTool()`) resolve among, in order: enabled and callable by tools. */
|
||||
readonly callable: readonly Tool[];
|
||||
/** Enabled deferred tools the model may call and is not offered yet, in registry order: what `tool_search` loads. */
|
||||
readonly deferred: readonly Tool[];
|
||||
/** Extension sections, then `instructions` when set. */
|
||||
readonly sections: readonly PromptSection<Tool>[];
|
||||
readonly instructions?: string;
|
||||
readonly cwd?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs inside the creating commit, after the creation hook and the `agent` change. The conversation creation is already
|
||||
* a table write, so table reads here throw `ReadAfterWrite`; document access remains available.
|
||||
*/
|
||||
export type ConversationInit = (tx: Tx, conversationId: ConversationId) => void | Promise<void>;
|
||||
|
||||
export type ConversationCreateOptions = {
|
||||
readonly ownership: ConversationOwnership;
|
||||
/** Applied in the creating commit after the creation hook's copy, before `init`. */
|
||||
readonly agent?: AgentChange;
|
||||
readonly init?: ConversationInit;
|
||||
};
|
||||
|
||||
/** Curated pi-ai request options; absent fields use pi-ai defaults. */
|
||||
export type ConversationStreamOptions = {
|
||||
transport?: Transport;
|
||||
timeoutMs?: number;
|
||||
/** Provider/SDK retries inside one request attempt. */
|
||||
maxRetries?: number;
|
||||
maxRetryDelayMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
metadata?: JsonObject;
|
||||
cacheRetention?: CacheRetention;
|
||||
deferred?: boolean | { window?: "15m" | "1h" | "24h" };
|
||||
};
|
||||
|
||||
/** Durable generation attempt retries; the JSON shape of pi-ai `RetryPolicy`. */
|
||||
export type ConversationRetryPolicy = {
|
||||
enabled: boolean;
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxAgentDelayMs?: number;
|
||||
};
|
||||
|
||||
/** Automatic compaction thresholds (spec §8.7); manual compaction ignores `enabled`. */
|
||||
export type CompactionPolicy = {
|
||||
/** Threshold and overflow compaction. */
|
||||
enabled: boolean;
|
||||
/** Room kept free for the answer: generation blocks to compact above `contextWindow - reserveTokens`. */
|
||||
reserveTokens: number;
|
||||
/** Approximate size of the recent context a summary keeps verbatim. */
|
||||
keepRecentTokens: number;
|
||||
/** Background compaction starts `backgroundTokens` below the blocking threshold; `0` disables it. */
|
||||
backgroundTokens: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* How often running progress is committed. Each progress commit is a storage write; a host whose storage is remote can
|
||||
* commit less often, so live answers and tool output appear in larger steps.
|
||||
*/
|
||||
export type ProgressPolicy = {
|
||||
/** Minimum pause between commits of the answer being generated. */
|
||||
partialIntervalMs: number;
|
||||
/** Minimum pause between commits of running tool output; large commits also pause in proportion to their size. */
|
||||
outputIntervalMs: number;
|
||||
};
|
||||
|
||||
/** Why a compaction runs: `compact()`, a threshold in generation preparation, or a context overflow. */
|
||||
export type CompactionReason = "manual" | "threshold" | "overflow";
|
||||
|
||||
/**
|
||||
* `entryId` of a blocking compaction's summary, or the `submissionId` of a conversation-owned compaction's summary
|
||||
* write; both absent when nothing was compacted.
|
||||
*/
|
||||
export type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId };
|
||||
|
||||
/** Harness-wide run policy. Read at every resolution and never copied; getters are fine. Synchronous: some readers run on the Session line. */
|
||||
export type HarnessSettings = {
|
||||
/** Default extension selection; absent: every installed extension, in install order. */
|
||||
readonly extensions?: readonly Extension[];
|
||||
readonly stream?: ConversationStreamOptions;
|
||||
readonly retry?: Partial<ConversationRetryPolicy>;
|
||||
readonly compaction?: Partial<CompactionPolicy>;
|
||||
readonly progress?: Partial<ProgressPolicy>;
|
||||
readonly toolExecution?: ToolExecutionMode;
|
||||
readonly steeringMode?: QueueMode;
|
||||
readonly followUpMode?: QueueMode;
|
||||
/**
|
||||
* How long an idle conversation keeps its last context read in memory, so its next run reads only newer entries.
|
||||
* Busy conversations always keep it; `0` drops it once the conversation is idle.
|
||||
*/
|
||||
readonly contextRetentionMs?: number;
|
||||
/**
|
||||
* How long a loaded document that no watch or document state observes stays in memory after its last read or write;
|
||||
* the next access loads it from Storage. Observed documents always stay. Default ten minutes; `0` drops one right
|
||||
* after use; `Infinity` keeps them. Anything else that is not a number of milliseconds means the default.
|
||||
*/
|
||||
readonly documentRetentionMs?: number;
|
||||
};
|
||||
|
||||
/** Resolved settings: every field over its built-in default, object fields merged. */
|
||||
export type Settings = {
|
||||
/** Absent: every installed extension, in install order. */
|
||||
readonly extensions?: readonly Extension[];
|
||||
readonly stream: ConversationStreamOptions;
|
||||
readonly retry: ConversationRetryPolicy;
|
||||
readonly compaction: CompactionPolicy;
|
||||
readonly progress: ProgressPolicy;
|
||||
readonly toolExecution: ToolExecutionMode;
|
||||
readonly steeringMode: QueueMode;
|
||||
readonly followUpMode: QueueMode;
|
||||
readonly contextRetentionMs: number;
|
||||
readonly documentRetentionMs: number;
|
||||
};
|
||||
|
||||
/** What `HarnessOptions.env` builds an environment for. */
|
||||
export type EnvTarget = {
|
||||
readonly conversationId: ConversationId;
|
||||
/** The conversation's agent `cwd`. */
|
||||
readonly cwd?: string;
|
||||
readonly read: DocumentReader;
|
||||
};
|
||||
|
||||
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
|
||||
/** pi-ai model access used by generation. */
|
||||
readonly models: Models;
|
||||
readonly registry: RegistryReader<Tool>;
|
||||
readonly settings?: HarnessSettings;
|
||||
/** Builds a conversation's environment at each use. Never called on the Session line; may be async. */
|
||||
readonly env?: (target: EnvTarget, context: Context) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>;
|
||||
/**
|
||||
* Runs in every commit that creates or forks a conversation, raw `tx.createConversation()` included, after the
|
||||
* built-in `pi.*` documents and before the conveniences apply `agent` and run `init`. A fork already has its copies.
|
||||
* Table reads throw `ReadAfterWrite`, as in `init`; a throw fails the creating commit.
|
||||
*/
|
||||
readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;
|
||||
readonly now?: () => number;
|
||||
/**
|
||||
* Receives failures that do not fail the calling operation, such as a throwing hook or listener, and the error that
|
||||
* fails the Session, once. Its own throw or rejection is dropped.
|
||||
*/
|
||||
readonly onReport?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Live task and what the scheduler would do with it under the current registry. */
|
||||
export type TaskInspection = {
|
||||
readonly record: TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
readonly state: /** An invocation is active. */
|
||||
| { readonly kind: "running" }
|
||||
/** The next scheduling pass reserves it; `migrates` when its definition is newer and has `migrate`. */
|
||||
| { readonly kind: "ready"; readonly migrates: boolean }
|
||||
/**
|
||||
* Waits for these live tasks: the live part of its `on`, or, when abort-marked, its live ordinary owned work, which
|
||||
* must end before its abort handler starts.
|
||||
*/
|
||||
| { readonly kind: "waiting"; readonly on: readonly TaskId[] }
|
||||
/** Outcome held until its ordinary owned work drains. */
|
||||
| { readonly kind: "completing" }
|
||||
/** No registered definition can take it; aborting it settles it as `orphaned`. */
|
||||
| {
|
||||
readonly kind: "blocked";
|
||||
readonly reason: "missing_task" | "task_too_old" | "migration_failed";
|
||||
readonly error?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/** Point-in-time view of live work: unfinished tasks and submissions, read on the Session line. */
|
||||
export type HarnessInspection = {
|
||||
readonly scheduling: "paused" | "running" | "closing";
|
||||
readonly tasks: readonly TaskInspection[];
|
||||
/** Queued and placed submissions, in ID order. */
|
||||
readonly submissions: readonly SubmissionRecord[];
|
||||
};
|
||||
|
||||
/** Raw active transcript and derived model context. */
|
||||
export type ContextView = {
|
||||
/** Newest applicable head marker, if any. */
|
||||
readonly head: EntryRecord | undefined;
|
||||
/** Raw active entries: the head marker followed by non-head entries from its head through the tail. */
|
||||
readonly entries: readonly EntryRecord[];
|
||||
/** Per entry of `entries`, its model messages after edits and excluded stop reasons, before tool result ordering. */
|
||||
readonly contributions: readonly (readonly Message[])[];
|
||||
/** Model context for the next provider request. */
|
||||
readonly messages: readonly Message[];
|
||||
};
|
||||
|
||||
/** Stateless handle for one conversation, bound to the Harness that returned it. Compare handles by `id`. */
|
||||
export interface Conversation {
|
||||
readonly id: ConversationId;
|
||||
|
||||
/** Resolved with the current registry snapshot and settings. */
|
||||
agent(context: Context): Promise<Agent>;
|
||||
/** `configure()` in its own commit. */
|
||||
configure(change: AgentChange, context: Context): Promise<void>;
|
||||
|
||||
/**
|
||||
* Durably admit user input or a passive entry write. A busy conversation, or one with queued items, queues input and
|
||||
* writes that affect model context in `pi.inbox`; `whenBusy: "reject"` rejects input with `ConversationBusy` instead
|
||||
* and writes nothing. A write of only `kind` and `data` (no `model`, `head`, or `edits`) is appended at once,
|
||||
* even mid-run, as it changes nothing the model sees.
|
||||
*/
|
||||
submit(submission: SubmissionDraft, context: Context): Promise<Submission>;
|
||||
/**
|
||||
* Admit a write of a `pi.reset` entry that starts a new context, carrying `handoff` as a user message when given.
|
||||
* Resolves after admission; while busy, it is placed at the next boundary.
|
||||
*/
|
||||
reset(handoff: string | undefined, context: Context): Promise<void>;
|
||||
/**
|
||||
* Admit a manual compaction task and return its ID. It summarizes while the conversation keeps working and places its
|
||||
* summary through a write submission: at once when idle, otherwise at the next boundary (spec §8.7).
|
||||
*/
|
||||
compact(instructions: string | undefined, context: Context): Promise<TaskId<CompactionResult>>;
|
||||
|
||||
/** Session commit whose `tx.createTask()` defaults to this conversation. */
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
|
||||
/**
|
||||
* Committed raw active transcript and model context. With `at`, the context as of that visible entry: the same view
|
||||
* `fork(at)` would start with, without creating a conversation.
|
||||
*/
|
||||
context(context: Context, options?: { readonly at?: EntryId }): Promise<ContextView>;
|
||||
/** Fork-aware history of this conversation, newest first unless `query.order` is `ascending`. */
|
||||
entries(
|
||||
query: Omit<EntryQuery, "conversationId">,
|
||||
limit: number,
|
||||
cursor: Cursor | undefined,
|
||||
context: Context,
|
||||
): Promise<Page<EntryRecord, Cursor>>;
|
||||
fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise<Conversation>;
|
||||
/**
|
||||
* Withdraw queued inputs (queued writes stay), mark every live non-background task of the ordinary ownership scope,
|
||||
* signal them, and resolve once the scope is idle. Background subtrees survive unless `background` is set.
|
||||
*/
|
||||
abort(context: Context, options?: ConversationAbortOptions): Promise<void>;
|
||||
/**
|
||||
* Resolve when the ordinary ownership scope has no live non-background task: this conversation and the conversations
|
||||
* owned, transitively, by its non-background tasks.
|
||||
*/
|
||||
waitForIdle(context: Context): Promise<void>;
|
||||
/** The structural view (spec §9.3) as a disposable read-only Chord state. */
|
||||
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
|
||||
/** The structural view as a serialized exact-frame watch with bounded pending frames. */
|
||||
watch(context: Context): Promise<ConversationWatch>;
|
||||
}
|
||||
|
||||
export type ConversationWatch = WatchHandle<ConversationView>;
|
||||
|
||||
/** Durable agent harness over one Session. */
|
||||
export interface Harness extends Session {
|
||||
/**
|
||||
* Enable task scheduling. Idempotent; throws after close. Calls that ask for progress enable it too:
|
||||
* `Conversation.submit()`, `Conversation.compact()`, `Conversation.abort()`, `Submission.wait()`, `waitForTask()`,
|
||||
* `Harness.waitForIdle()`, and `Conversation.waitForIdle()`. Read-only viewers never do.
|
||||
*/
|
||||
resume(): void;
|
||||
|
||||
/** Return the reserved root conversation, creating it with `agent` and `init` in one commit when absent. */
|
||||
root(
|
||||
context: Context,
|
||||
options?: { readonly agent?: AgentChange; readonly init?: ConversationInit },
|
||||
): Promise<Conversation>;
|
||||
conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
|
||||
createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;
|
||||
|
||||
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
|
||||
/** Live tasks and unsettled submissions. Writes nothing and runs no task code. */
|
||||
inspect(context: Context): Promise<HarnessInspection>;
|
||||
/** Reacquire a submission, for example after reopen. */
|
||||
submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
|
||||
/** `not_found` for an unknown submission or one of another conversation than `conversationId`. */
|
||||
abortSubmission(
|
||||
id: SubmissionId,
|
||||
context: Context,
|
||||
conversationId?: ConversationId,
|
||||
): Promise<"aborted" | "already_placed" | "settled" | "not_found">;
|
||||
/**
|
||||
* Commit the abort mark, signal and join an active run invocation, and schedule the abort invocation. A task whose
|
||||
* definition cannot take it settles as `orphaned` instead.
|
||||
*/
|
||||
abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
|
||||
/** Resolve with the terminal receipt; cancelling `context` cancels only this wait. */
|
||||
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
|
||||
/** Resolve when the ordinary ownership scope of every ownerless conversation has no live non-background task. */
|
||||
waitForIdle(context: Context): Promise<void>;
|
||||
/** Session total: every conversation's `pi.usage` summed. */
|
||||
usage(context: Context): Promise<UsageState>;
|
||||
/** Every live task with its owner edge, status, and owned conversations (spec §9.5), as a disposable Chord state. */
|
||||
taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
|
||||
/** The task graph as a serialized exact-frame watch with bounded pending frames. */
|
||||
watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
|
||||
}
|
||||
|
||||
/** What a hook may use: committed reads and the asking task's memos, which hooks and the task share. */
|
||||
export interface HookApi extends DocumentReader {
|
||||
readonly taskId: TaskId;
|
||||
readonly conversationId: ConversationId;
|
||||
/** `HarnessOptions.models`. */
|
||||
readonly models: Models;
|
||||
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
|
||||
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
|
||||
}
|
||||
|
||||
export type HookResult<T> = T | undefined | Promise<T | undefined>;
|
||||
|
||||
/** Hooks of the built-in generation task. */
|
||||
export interface GenerationHooks {
|
||||
/** Before every request attempt, including recovery; the result is used for that request only. */
|
||||
beforeRequest(
|
||||
request: { readonly messages: readonly Message[] },
|
||||
api: HookApi,
|
||||
context: Context,
|
||||
): HookResult<{ readonly messages: readonly Message[] }>;
|
||||
/** Every terminal provider message, before classification. */
|
||||
afterResponse(message: AssistantMessage, api: HookApi, context: Context): void | Promise<void>;
|
||||
/** A final answer; the first `continue` appends a user message and continues the run. */
|
||||
onYield(answer: AssistantMessage, api: HookApi, context: Context): HookResult<{ readonly continue: UserInput }>;
|
||||
/** After every tool of the round is terminal; `results` are the round's result entries in call order. */
|
||||
afterTools(assistant: EntryId, results: readonly EntryId[], api: HookApi, context: Context): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** A call as tool hooks see it; `parent` is set for a nested call, one a running tool made through `executeTool()`. */
|
||||
export type ToolHookCall = ToolCall & {
|
||||
readonly parent?: { readonly taskId: TaskId; readonly callId: string };
|
||||
};
|
||||
|
||||
/** Hooks of the built-in tool task, for model-issued and nested calls. */
|
||||
export interface ToolHooks {
|
||||
/** Before intent; the first `block` wins, otherwise `arguments` replace the call's arguments. A throw blocks. */
|
||||
beforeTool(
|
||||
call: ToolHookCall,
|
||||
api: HookApi,
|
||||
context: Context,
|
||||
): HookResult<{ readonly arguments?: JsonObject; readonly block?: string }>;
|
||||
/** After execution, before the result is committed; replaces the result. */
|
||||
afterTool(
|
||||
call: ToolHookCall,
|
||||
result: ToolExecutionResult,
|
||||
api: HookApi,
|
||||
context: Context,
|
||||
): HookResult<ToolExecutionResult>;
|
||||
}
|
||||
|
||||
/** Hooks of the built-in compaction task. */
|
||||
export interface CompactionHooks {
|
||||
/**
|
||||
* After range selection, before summarizing; the first decision wins. `entries` are the active entries the summary
|
||||
* replaces, the head marker first, and `messages` their model context, the summarizer's source; `firstKept` is the
|
||||
* first entry kept verbatim.
|
||||
*/
|
||||
beforeCompact(
|
||||
compaction: {
|
||||
readonly reason: CompactionReason;
|
||||
readonly entries: readonly EntryRecord[];
|
||||
readonly messages: readonly Message[];
|
||||
readonly firstKept: EntryId;
|
||||
readonly instructions?: string;
|
||||
},
|
||||
api: HookApi,
|
||||
context: Context,
|
||||
): HookResult<{ readonly decline: true } | { readonly summary: string }>;
|
||||
}
|
||||
72
third_party/pi/packages/durable/src/harness/usage.ts
vendored
Normal file
72
third_party/pi/packages/durable/src/harness/usage.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
import { copyJson, type Draft, type JsonRepresentation } from "@earendil-works/chord";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import type { ConversationId, Tx } from "../types.ts";
|
||||
|
||||
/** Ledger of one conversation's own spend: its entries, and compaction summarization attempts, which have none. */
|
||||
export type UsageState = {
|
||||
/** Assistant entries and summarization attempts, keyed `provider/modelId`. */
|
||||
models: Record<string, JsonRepresentation<Usage>>;
|
||||
/** Tool results, keyed by tool name; their usage has no model identity. */
|
||||
tools: Record<string, JsonRepresentation<Usage>>;
|
||||
};
|
||||
|
||||
export const UsageDoc = defineDoc<UsageState>({
|
||||
kind: "pi.usage",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ models: {}, tools: {} }),
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
/** Add `usage` to one bucket of the conversation's `pi.usage`, in the commit that records the response. */
|
||||
export async function recordUsage(
|
||||
tx: Tx,
|
||||
conversationId: ConversationId,
|
||||
bucket: keyof UsageState,
|
||||
key: string,
|
||||
usage: Usage,
|
||||
): Promise<void> {
|
||||
const totals = (await tx.doc(UsageDoc, conversationId))[bucket];
|
||||
// Own keys only: a tool may be called `toString`.
|
||||
const total = Object.hasOwn(totals, key) ? totals[key] : undefined;
|
||||
// Providers may leave optional counters `undefined`; drafts take strict JSON.
|
||||
if (total === undefined)
|
||||
totals[key] = copyJson(usage, { omitUndefinedProperties: true }) as JsonRepresentation<Usage>;
|
||||
else addUsage(total, usage);
|
||||
}
|
||||
|
||||
/** Add every counter of `usage` to `total`; optional counters are added once either side reports them. */
|
||||
export function addUsage(total: Draft<Usage> | Usage, usage: Usage): void {
|
||||
total.input += usage.input;
|
||||
total.output += usage.output;
|
||||
total.cacheRead += usage.cacheRead;
|
||||
total.cacheWrite += usage.cacheWrite;
|
||||
total.totalTokens += usage.totalTokens;
|
||||
if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
|
||||
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
|
||||
total.cost.input += usage.cost.input;
|
||||
total.cost.output += usage.cost.output;
|
||||
total.cost.cacheRead += usage.cost.cacheRead;
|
||||
total.cost.cacheWrite += usage.cost.cacheWrite;
|
||||
total.cost.total += usage.cost.total;
|
||||
}
|
||||
|
||||
/** Add every bucket of `state` into `sum`. */
|
||||
export function addUsageState(sum: UsageState, state: Readonly<UsageState>): void {
|
||||
for (const bucket of ["models", "tools"] as const) {
|
||||
for (const [key, usage] of Object.entries(state[bucket])) {
|
||||
const total = Object.hasOwn(sum[bucket], key) ? sum[bucket][key] : undefined;
|
||||
// Define rather than assign: assigning a tool named `__proto__` would set the prototype.
|
||||
if (total !== undefined) addUsage(total, usage);
|
||||
else
|
||||
Object.defineProperty(sum[bucket], key, {
|
||||
value: structuredClone(usage),
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
61
third_party/pi/packages/durable/src/harness/util.ts
vendored
Normal file
61
third_party/pi/packages/durable/src/harness/util.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { SessionFailed } from "../errors.ts";
|
||||
import type { Cursor, Page } from "../types.ts";
|
||||
|
||||
/** Pending waits by key. Each settles once: through `resolve`, `rejectAll`, or cancellation of its context. */
|
||||
export class Waiters<K, T> {
|
||||
readonly #sets = new Map<K, Set<PromiseWithResolvers<T>>>();
|
||||
|
||||
add(key: K, context: Context): Promise<T> {
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
const waiter = Promise.withResolvers<T>();
|
||||
const set = this.#sets.get(key) ?? new Set();
|
||||
this.#sets.set(key, set.add(waiter));
|
||||
if (signal !== undefined) {
|
||||
const onAbort = (): void => {
|
||||
set.delete(waiter);
|
||||
if (set.size === 0 && this.#sets.get(key) === set) this.#sets.delete(key);
|
||||
waiter.reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
const detach = (): void => signal.removeEventListener("abort", onAbort);
|
||||
waiter.promise.then(detach, detach);
|
||||
}
|
||||
return waiter.promise;
|
||||
}
|
||||
|
||||
keys(): K[] {
|
||||
return [...this.#sets.keys()];
|
||||
}
|
||||
|
||||
resolve(key: K, value: T): void {
|
||||
const set = this.#sets.get(key);
|
||||
this.#sets.delete(key);
|
||||
for (const waiter of set ?? []) waiter.resolve(value);
|
||||
}
|
||||
|
||||
rejectAll(error: unknown): void {
|
||||
const sets = [...this.#sets.values()];
|
||||
this.#sets.clear();
|
||||
for (const set of sets) for (const waiter of set) waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every item of a paginated scan, in page order. */
|
||||
export async function scanAll<T>(scan: (cursor: Cursor | undefined) => Promise<Page<T, Cursor>>): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let cursor: Cursor | undefined;
|
||||
do {
|
||||
const page = await scan(cursor);
|
||||
items.push(...page.items);
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
return items;
|
||||
}
|
||||
|
||||
/** What a closed Harness rejects with: `SessionFailed` with the cause when it closed because the Session failed. */
|
||||
export function closedError(session: { readonly failure: { readonly error: unknown } | undefined }): Error {
|
||||
const failure = session.failure;
|
||||
return failure === undefined ? new Error("Harness is closed") : new SessionFailed(failure.error);
|
||||
}
|
||||
267
third_party/pi/packages/durable/src/harness/view.ts
vendored
Normal file
267
third_party/pi/packages/durable/src/harness/view.ts
vendored
Normal file
@@ -0,0 +1,267 @@
|
||||
import { type AttachedReplicatedState, type Context, type JsonValue, replicatedState } from "@earendil-works/chord";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import { applyImmutable, type NonEmptyPath, type Op, type Path } from "@earendil-works/chord/delta";
|
||||
import { CommittedStateSource, CommittedWatch } from "../session/observation.ts";
|
||||
import { contained, type SessionImpl } from "../session/session.ts";
|
||||
import type {
|
||||
CommitPublication,
|
||||
ConversationDocToken,
|
||||
ConversationId,
|
||||
ConversationRecord,
|
||||
DocumentId,
|
||||
EntryRecord,
|
||||
JsonObject,
|
||||
Storage,
|
||||
WatchHandle,
|
||||
} from "../types.ts";
|
||||
import { AgentDoc } from "./agent.ts";
|
||||
import { activeEntries, captureContextBounds } from "./context.ts";
|
||||
import { InboxDoc } from "./inbox.ts";
|
||||
import { LiveDoc } from "./live.ts";
|
||||
import { ProviderDoc } from "./provider.ts";
|
||||
import { UsageDoc } from "./usage.ts";
|
||||
import { closedError } from "./util.ts";
|
||||
|
||||
/** Structural mount of one conversation's active transcript and built-in documents (spec §9.3). */
|
||||
export type ConversationView = {
|
||||
readonly conversation: ConversationRecord;
|
||||
/** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */
|
||||
readonly entries: readonly EntryRecord[];
|
||||
/** Built-in conversation documents keyed by kind; absent documents are absent. */
|
||||
readonly docs: Readonly<Record<string, JsonObject>>;
|
||||
};
|
||||
|
||||
/** Receives each next revision of a mount, and the Session's close. */
|
||||
export type ViewObserver = {
|
||||
advance?(value: ConversationView, ops: readonly Op[], context: Context): void;
|
||||
/** Every publication, after the mount took it; `ops` are the mount's, possibly none. */
|
||||
publication?(
|
||||
before: ConversationView,
|
||||
after: ConversationView,
|
||||
ops: readonly Op[],
|
||||
publication: CommitPublication,
|
||||
context: Context,
|
||||
): void;
|
||||
closeSession(failure: { readonly error: unknown } | undefined): void;
|
||||
};
|
||||
|
||||
const MOUNTED = [
|
||||
AgentDoc,
|
||||
LiveDoc,
|
||||
InboxDoc,
|
||||
ProviderDoc,
|
||||
UsageDoc,
|
||||
] as unknown as readonly ConversationDocToken<JsonObject>[];
|
||||
const MOUNTED_KINDS: ReadonlySet<string> = new Set(MOUNTED.map((token) => token.definition.kind));
|
||||
|
||||
/** One conversation's mount: its current revision, the document incarnations it shows, and its observers. */
|
||||
type Mount = {
|
||||
value: ConversationView;
|
||||
/** Mounted incarnation and definition version per kind; another incarnation or version is set whole. */
|
||||
readonly docs: Map<string, { id: DocumentId; version: number }>;
|
||||
readonly observers: Set<ViewObserver>;
|
||||
};
|
||||
|
||||
const VIEWS = new WeakMap<object, ConversationViews>();
|
||||
|
||||
/** The view mounts of a Harness, for adapters built on them. */
|
||||
export function conversationViews(harness: object): ConversationViews {
|
||||
const views = VIEWS.get(harness);
|
||||
if (views === undefined) throw new Error("Not a Harness");
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Harness's conversation view mounts: at most one per conversation, built on the Session line by its first
|
||||
* observer and dropped with its last. Each mount advances from the Session's commit publications, which are durable.
|
||||
*/
|
||||
export class ConversationViews {
|
||||
readonly #session: SessionImpl;
|
||||
readonly #storage: Storage;
|
||||
readonly #mounts = new Map<ConversationId, Mount>();
|
||||
#closed = false;
|
||||
|
||||
constructor(session: SessionImpl, storage: Storage) {
|
||||
this.#session = session;
|
||||
this.#storage = storage;
|
||||
VIEWS.set(session, this);
|
||||
session.observeCommits((publication, context) => {
|
||||
for (const [id, mount] of this.#mounts)
|
||||
advance(id, mount, publication, context, (error) => this.report(error));
|
||||
});
|
||||
session.subscribeClose(() => {
|
||||
this.#closed = true;
|
||||
for (const mount of this.#mounts.values())
|
||||
for (const observer of [...mount.observers]) {
|
||||
contained(
|
||||
() => observer.closeSession(this.#session.failure),
|
||||
(error) => this.report(error),
|
||||
);
|
||||
}
|
||||
this.#mounts.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** Report an observer's listener error through the Harness. */
|
||||
report(error: unknown): void {
|
||||
this.#session.report(error);
|
||||
}
|
||||
|
||||
/** A disposable read-only Chord state of the view. */
|
||||
async state(id: ConversationId, context: Context): Promise<AttachedReplicatedState<ConversationView>> {
|
||||
const { observer, detach } = await this.attach(
|
||||
id,
|
||||
(value, release) => new CommittedStateSource<ConversationView>(value, release),
|
||||
context,
|
||||
);
|
||||
try {
|
||||
return replicatedState(observer, { onError: (error) => this.#session.report(error) });
|
||||
} catch (error) {
|
||||
detach();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** A serialized exact-frame watch of the view; cancelling `context` stops it. */
|
||||
async watch(id: ConversationId, context: Context): Promise<WatchHandle<ConversationView>> {
|
||||
const { observer } = await this.attach(
|
||||
id,
|
||||
(value, release) =>
|
||||
new CommittedWatch<ConversationView>(value, release, (error) => this.#session.report(error)),
|
||||
context,
|
||||
);
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) {
|
||||
observer.cancel();
|
||||
throw signal.reason;
|
||||
}
|
||||
if (signal !== undefined) observer.observeCancellation(signal);
|
||||
return observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an observer created from the current revision, atomically on the Session line: it sees every later
|
||||
* publication and nothing earlier. `create` may read committed Storage, still on the line. `release` drops it, and
|
||||
* the mount with its last observer.
|
||||
*/
|
||||
attach<O extends ViewObserver>(
|
||||
id: ConversationId,
|
||||
create: (value: ConversationView, release: () => void, storage: Storage) => O | Promise<O>,
|
||||
context: Context,
|
||||
): Promise<{ observer: O; detach: () => void }> {
|
||||
return this.#session.readOnLine(async () => {
|
||||
const mount = this.#mounts.get(id) ?? (await this.#build(id, context));
|
||||
const detach = (): void => {
|
||||
mount.observers.delete(observer);
|
||||
if (mount.observers.size === 0 && this.#mounts.get(id) === mount) this.#mounts.delete(id);
|
||||
};
|
||||
const observer = await create(mount.value, detach, this.#storage);
|
||||
// Close or cancellation may begin while the mount hydrates; register nothing then.
|
||||
if (this.#closed) throw closedError(this.#session);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
this.#mounts.set(id, mount);
|
||||
mount.observers.add(observer);
|
||||
return { observer, detach };
|
||||
});
|
||||
}
|
||||
|
||||
async #build(id: ConversationId, context: Context): Promise<Mount> {
|
||||
const conversation = await this.#storage.conversation(id, context);
|
||||
if (conversation === undefined) throw new Error(`Conversation ${id} does not exist`);
|
||||
const bounds = await captureContextBounds(this.#storage, id, context);
|
||||
const entries = await activeEntries(this.#storage, id, bounds, context);
|
||||
const docs: Record<string, JsonObject> = {};
|
||||
const incarnations = new Map<string, { id: DocumentId; version: number }>();
|
||||
for (const token of MOUNTED) {
|
||||
const loaded = await this.#session.conversationDocumentOnLine(token, id, context);
|
||||
if (loaded === undefined) continue;
|
||||
docs[token.definition.kind] = loaded.value;
|
||||
incarnations.set(token.definition.kind, { id: loaded.record.id, version: loaded.version });
|
||||
}
|
||||
return { value: { conversation, entries, docs }, docs: incarnations, observers: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the mount's operations from one publication, apply them, and hand the revision to every observer; one that
|
||||
* throws is reported through `report`, and the others still get it.
|
||||
*/
|
||||
function advance(
|
||||
id: ConversationId,
|
||||
mount: Mount,
|
||||
publication: CommitPublication,
|
||||
context: Context,
|
||||
report: (error: unknown) => void,
|
||||
): void {
|
||||
const docOps: Op[] = [];
|
||||
const entryOps: Op[] = [];
|
||||
let entries = mount.value.entries;
|
||||
// Entry writes are published in ID order.
|
||||
for (const change of publication.changes) {
|
||||
if (change.type === "entry" && change.value.conversationId === id) {
|
||||
const entry = change.value;
|
||||
const value = entry as unknown as JsonValue;
|
||||
if (entry.head === undefined) {
|
||||
entryOps.push(["p", ["entries"], entries.length, 0, [value]]);
|
||||
entries = [...entries, entry];
|
||||
continue;
|
||||
}
|
||||
// A head marker keeps the non-head entries from its head, which are always a suffix, and goes in front.
|
||||
const target = entry.head;
|
||||
let kept = entries.findIndex((candidate) => candidate.head === undefined && candidate.id >= target);
|
||||
if (kept < 0) kept = entries.length;
|
||||
entryOps.push(["p", ["entries"], 0, kept, [value]]);
|
||||
entries = [entry, ...entries.slice(kept)];
|
||||
continue;
|
||||
}
|
||||
if (change.type !== "document" || change.conversationId !== id) continue;
|
||||
const kind = change.record.kind;
|
||||
if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue;
|
||||
const path: NonEmptyPath = ["docs", kind];
|
||||
const mounted = mount.docs.get(kind);
|
||||
if (change.value === null) {
|
||||
if (mounted?.id !== change.record.id) continue;
|
||||
mount.docs.delete(kind);
|
||||
docOps.push(["d", path]);
|
||||
} else if (mounted?.id === change.record.id && mounted.version === change.version) {
|
||||
for (const op of change.ops) docOps.push(prefixed(op, path));
|
||||
} else {
|
||||
mount.docs.set(kind, { id: change.record.id, version: change.version! });
|
||||
docOps.push(["s", path, change.value]);
|
||||
}
|
||||
}
|
||||
const before = mount.value;
|
||||
const ops = [...docOps, ...entryOps];
|
||||
const frameContext = withoutAbortSignal(context);
|
||||
if (ops.length > 0) {
|
||||
const value = docOps.length === 0 ? mount.value : applyImmutable(mount.value, docOps);
|
||||
mount.value = entries === value.entries ? value : { ...value, entries };
|
||||
for (const observer of [...mount.observers]) {
|
||||
contained(() => observer.advance?.(mount.value, ops, frameContext), report);
|
||||
}
|
||||
}
|
||||
for (const observer of [...mount.observers]) {
|
||||
contained(() => observer.publication?.(before, mount.value, ops, publication, frameContext), report);
|
||||
}
|
||||
}
|
||||
|
||||
/** `op` moved under `prefix`; a root replacement becomes a set of the prefix. */
|
||||
function prefixed(op: Op, prefix: NonEmptyPath): Op {
|
||||
const at = (path: Path): NonEmptyPath => [...prefix, ...path] as unknown as NonEmptyPath;
|
||||
switch (op[0]) {
|
||||
case "r":
|
||||
return ["s", prefix, op[1]];
|
||||
case "p":
|
||||
return ["p", at(op[1]), op[2], op[3], op[4]];
|
||||
case "m":
|
||||
return ["m", at(op[1]), op[2]];
|
||||
case "s":
|
||||
return ["s", at(op[1]), op[2]];
|
||||
case "d":
|
||||
return ["d", at(op[1])];
|
||||
case "a":
|
||||
return ["a", at(op[1]), op[2]];
|
||||
case "t":
|
||||
return ["t", at(op[1]), op[2]];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user