@earendil-works/pi-durable 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +186 -0
- package/README.md +181 -84
- package/dist/codemode/codemode.d.ts +4 -4
- package/dist/codemode/codemode.d.ts.map +1 -1
- package/dist/codemode/codemode.js +9 -12
- package/dist/codemode/codemode.js.map +1 -1
- package/dist/codemode/models.js +4 -4
- package/dist/codemode/models.js.map +1 -1
- package/dist/env/index.d.ts +12 -10
- package/dist/env/index.d.ts.map +1 -1
- package/dist/env/index.js.map +1 -1
- package/dist/env/node-watch.d.ts +3 -3
- package/dist/env/node-watch.d.ts.map +1 -1
- package/dist/env/node-watch.js +18 -16
- package/dist/env/node-watch.js.map +1 -1
- package/dist/env/node.d.ts +2 -2
- package/dist/env/node.d.ts.map +1 -1
- package/dist/env/node.js +1 -1
- package/dist/env/node.js.map +1 -1
- package/dist/errors.d.ts +18 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +26 -3
- package/dist/errors.js.map +1 -1
- package/dist/harness/agent.d.ts.map +1 -1
- package/dist/harness/agent.js +10 -2
- package/dist/harness/agent.js.map +1 -1
- package/dist/harness/compaction.d.ts.map +1 -1
- package/dist/harness/compaction.js +19 -2
- package/dist/harness/compaction.js.map +1 -1
- package/dist/harness/context.d.ts +23 -7
- package/dist/harness/context.d.ts.map +1 -1
- package/dist/harness/context.js +71 -25
- package/dist/harness/context.js.map +1 -1
- package/dist/harness/define.d.ts +7 -3
- package/dist/harness/define.d.ts.map +1 -1
- package/dist/harness/define.js +4 -0
- package/dist/harness/define.js.map +1 -1
- package/dist/harness/generation.d.ts +38 -5
- package/dist/harness/generation.d.ts.map +1 -1
- package/dist/harness/generation.js +445 -96
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts +2 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +87 -62
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/inbox.d.ts +5 -1
- package/dist/harness/inbox.d.ts.map +1 -1
- package/dist/harness/inbox.js +4 -1
- package/dist/harness/inbox.js.map +1 -1
- package/dist/harness/json.d.ts +20 -4
- package/dist/harness/json.d.ts.map +1 -1
- package/dist/harness/json.js +23 -5
- package/dist/harness/json.js.map +1 -1
- package/dist/harness/live.d.ts +122 -16
- package/dist/harness/live.d.ts.map +1 -1
- package/dist/harness/live.js +93 -12
- package/dist/harness/live.js.map +1 -1
- package/dist/harness/partial-changes.d.ts +54 -0
- package/dist/harness/partial-changes.d.ts.map +1 -0
- package/dist/harness/partial-changes.js +162 -0
- package/dist/harness/partial-changes.js.map +1 -0
- package/dist/harness/registry-reader.d.ts +11 -0
- package/dist/harness/registry-reader.d.ts.map +1 -0
- package/dist/harness/registry-reader.js +25 -0
- package/dist/harness/registry-reader.js.map +1 -0
- package/dist/harness/scheduler.d.ts +46 -19
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +973 -376
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +17 -10
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +62 -25
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/task-graph.d.ts +12 -9
- package/dist/harness/task-graph.d.ts.map +1 -1
- package/dist/harness/task-graph.js +38 -28
- package/dist/harness/task-graph.js.map +1 -1
- package/dist/harness/telemetry.d.ts +11 -11
- package/dist/harness/telemetry.d.ts.map +1 -1
- package/dist/harness/telemetry.js +7 -7
- package/dist/harness/telemetry.js.map +1 -1
- package/dist/harness/tool.d.ts +2 -0
- package/dist/harness/tool.d.ts.map +1 -1
- package/dist/harness/tool.js +171 -55
- package/dist/harness/tool.js.map +1 -1
- package/dist/harness/transcript.d.ts +72 -0
- package/dist/harness/transcript.d.ts.map +1 -0
- package/dist/harness/transcript.js +264 -0
- package/dist/harness/transcript.js.map +1 -0
- package/dist/harness/types.d.ts +218 -59
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/util.d.ts +3 -1
- package/dist/harness/util.d.ts.map +1 -1
- package/dist/harness/util.js +9 -0
- package/dist/harness/util.js.map +1 -1
- package/dist/harness/view.d.ts +43 -22
- package/dist/harness/view.d.ts.map +1 -1
- package/dist/harness/view.js +130 -71
- package/dist/harness/view.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/search.d.ts +19 -3
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +107 -44
- package/dist/search.js.map +1 -1
- package/dist/session/cached-storage.d.ts +71 -0
- package/dist/session/cached-storage.d.ts.map +1 -0
- package/dist/session/cached-storage.js +269 -0
- package/dist/session/cached-storage.js.map +1 -0
- package/dist/session/conversation-tails.d.ts +33 -0
- package/dist/session/conversation-tails.d.ts.map +1 -0
- package/dist/session/conversation-tails.js +101 -0
- package/dist/session/conversation-tails.js.map +1 -0
- package/dist/session/keys.d.ts +68 -0
- package/dist/session/keys.d.ts.map +1 -0
- package/dist/session/keys.js +161 -0
- package/dist/session/keys.js.map +1 -0
- package/dist/session/session.d.ts +167 -26
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +1029 -171
- package/dist/session/session.js.map +1 -1
- package/dist/session/transaction.d.ts +81 -3
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +224 -37
- package/dist/session/transaction.js.map +1 -1
- package/dist/session/writer.d.ts +74 -0
- package/dist/session/writer.d.ts.map +1 -0
- package/dist/session/writer.js +294 -0
- package/dist/session/writer.js.map +1 -0
- package/dist/storage/indexeddb.d.ts +10 -2
- package/dist/storage/indexeddb.d.ts.map +1 -1
- package/dist/storage/indexeddb.js +91 -41
- package/dist/storage/indexeddb.js.map +1 -1
- package/dist/storage/jsonl/storage.d.ts +14 -2
- package/dist/storage/jsonl/storage.d.ts.map +1 -1
- package/dist/storage/jsonl/storage.js +88 -24
- package/dist/storage/jsonl/storage.js.map +1 -1
- package/dist/storage/memory.d.ts +7 -2
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +28 -2
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/storage.d.ts +14 -2
- package/dist/storage/sqlite/storage.d.ts.map +1 -1
- package/dist/storage/sqlite/storage.js +110 -36
- package/dist/storage/sqlite/storage.js.map +1 -1
- package/dist/storage/store/commit.d.ts +33 -0
- package/dist/storage/store/commit.d.ts.map +1 -0
- package/dist/storage/store/commit.js +286 -0
- package/dist/storage/store/commit.js.map +1 -0
- package/dist/storage/store/documents.d.ts +43 -0
- package/dist/storage/store/documents.d.ts.map +1 -0
- package/dist/storage/store/documents.js +102 -0
- package/dist/storage/store/documents.js.map +1 -0
- package/dist/storage/store/index.d.ts +2 -0
- package/dist/storage/store/index.d.ts.map +1 -0
- package/dist/storage/store/index.js +2 -0
- package/dist/storage/store/index.js.map +1 -0
- package/dist/storage/store/queries.d.ts +49 -0
- package/dist/storage/store/queries.d.ts.map +1 -0
- package/dist/storage/store/queries.js +93 -0
- package/dist/storage/store/queries.js.map +1 -0
- package/dist/storage/store/storage.d.ts +59 -0
- package/dist/storage/store/storage.d.ts.map +1 -0
- package/dist/storage/store/storage.js +438 -0
- package/dist/storage/store/storage.js.map +1 -0
- package/dist/storage/store/tables.d.ts +54 -0
- package/dist/storage/store/tables.d.ts.map +1 -0
- package/dist/storage/store/tables.js +108 -0
- package/dist/storage/store/tables.js.map +1 -0
- package/dist/testing/commit-writes.d.ts +5 -0
- package/dist/testing/commit-writes.d.ts.map +1 -0
- package/dist/testing/commit-writes.js +12 -0
- package/dist/testing/commit-writes.js.map +1 -0
- package/dist/testing/env-conformance.js +7 -7
- package/dist/testing/env-conformance.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/storage-benchmark.d.ts.map +1 -1
- package/dist/testing/storage-benchmark.js +18 -17
- package/dist/testing/storage-benchmark.js.map +1 -1
- package/dist/testing/storage-conformance.d.ts.map +1 -1
- package/dist/testing/storage-conformance.js +354 -69
- package/dist/testing/storage-conformance.js.map +1 -1
- package/dist/tool-search/tool-search.d.ts +6 -12
- package/dist/tool-search/tool-search.d.ts.map +1 -1
- package/dist/tool-search/tool-search.js +7 -7
- package/dist/tool-search/tool-search.js.map +1 -1
- package/dist/tools/bash.d.ts +12 -12
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +16 -15
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +7 -7
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +9 -9
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/index.d.ts +32 -32
- package/dist/tools/read.d.ts +6 -6
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +19 -8
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/write.d.ts +4 -4
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +5 -5
- package/dist/tools/write.js.map +1 -1
- package/dist/types.d.ts +223 -77
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +19 -12
- package/dist/harness/events.d.ts +0 -186
- package/dist/harness/events.d.ts.map +0 -1
- package/dist/harness/events.js +0 -373
- package/dist/harness/events.js.map +0 -1
- package/dist/session/observation.d.ts +0 -42
- package/dist/session/observation.d.ts.map +0 -1
- package/dist/session/observation.js +0 -281
- package/dist/session/observation.js.map +0 -1
|
@@ -4,13 +4,14 @@ import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/u
|
|
|
4
4
|
import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
|
5
5
|
import { AssistantEntry, ResetEntry, SystemEntry, UserEntry } from "../entries.js";
|
|
6
6
|
import { defineTask } from "../tasks.js";
|
|
7
|
-
import { addTools, sectionExtension } from "./agent.js";
|
|
7
|
+
import { addTools, agentHooks, sectionExtension } from "./agent.js";
|
|
8
8
|
import { compactionPolicy, createCompaction, estimateContext, selectCut } from "./compaction.js";
|
|
9
|
-
import { applyBoundary, prepareBoundary, withdrawQueuedInputs } from "./inbox.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { applyBoundary, hasQueuedReset, prepareBoundary, withdrawQueuedInputs } from "./inbox.js";
|
|
10
|
+
import { cutOffCount, cutOffTooOften, endRun, LiveDoc, PROGRESS_COMMIT, salvageable, } from "./live.js";
|
|
11
|
+
import { PartialChanges } from "./partial-changes.js";
|
|
12
12
|
import { planSystemEntries, renderSections, replaySections } from "./prompt.js";
|
|
13
13
|
import { ensureProviderSessionId } from "./provider.js";
|
|
14
|
+
import { endedByCrashes } from "./scheduler.js";
|
|
14
15
|
import { appendToolResult, harnessError, ToolTask } from "./tool.js";
|
|
15
16
|
import { recordUsage } from "./usage.js";
|
|
16
17
|
import { optionalExtension } from "./util.js";
|
|
@@ -21,8 +22,15 @@ const DEFAULT_POLL_AFTER_MS = 5000;
|
|
|
21
22
|
*/
|
|
22
23
|
export const GenerationTask = defineTask({
|
|
23
24
|
name: "pi.generation",
|
|
24
|
-
version:
|
|
25
|
+
version: 2,
|
|
25
26
|
initial: () => ({ phase: "prepare", attempt: 1 }),
|
|
27
|
+
// Version 2 leaves calls to tools not offered in a sequential round's `pending`, flagged `unoffered` in their slots.
|
|
28
|
+
// A version 1 Harness would start their tool tasks unflagged, resolving the name, so it leaves version 2 blocked
|
|
29
|
+
// instead. A version 1 checkpoint has no such calls and is kept as it is.
|
|
30
|
+
migrate: (input, checkpoint) => ({
|
|
31
|
+
input: input,
|
|
32
|
+
checkpoint: checkpoint,
|
|
33
|
+
}),
|
|
26
34
|
phases: {
|
|
27
35
|
/**
|
|
28
36
|
* Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to
|
|
@@ -31,6 +39,8 @@ export const GenerationTask = defineTask({
|
|
|
31
39
|
* may land meanwhile.
|
|
32
40
|
*/
|
|
33
41
|
prepare: async (task, runtime, context) => {
|
|
42
|
+
if (cutOffTooOften(task, runtime))
|
|
43
|
+
return endCutOff(runtime, task, context);
|
|
34
44
|
const { conversationId } = runtime;
|
|
35
45
|
const agent = await runtime.agent(context);
|
|
36
46
|
const settings = runtime.settings;
|
|
@@ -54,6 +64,12 @@ export const GenerationTask = defineTask({
|
|
|
54
64
|
}
|
|
55
65
|
}
|
|
56
66
|
const view = await runtime.context(conversationId, context);
|
|
67
|
+
// The model owes an answer only to a context ending in a user message or tool results; system messages say
|
|
68
|
+
// nothing it answers. Otherwise, as for a continue after an answer or in an empty conversation, end the run
|
|
69
|
+
// before any request.
|
|
70
|
+
const last = view.messages.findLast((message) => message.role !== "system");
|
|
71
|
+
if (last?.role !== "user" && last?.role !== "toolResult")
|
|
72
|
+
return failNothingToAnswer(runtime, context);
|
|
57
73
|
const shown = replaySections(view.messages);
|
|
58
74
|
let env;
|
|
59
75
|
try {
|
|
@@ -62,11 +78,11 @@ export const GenerationTask = defineTask({
|
|
|
62
78
|
catch (error) {
|
|
63
79
|
if (context.abortSignal?.aborted)
|
|
64
80
|
throw error;
|
|
65
|
-
runtime.report(error, {
|
|
81
|
+
runtime.report(error, { kind: "host", callback: "env" });
|
|
66
82
|
}
|
|
67
83
|
const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };
|
|
68
84
|
const desired = await renderSections(agent.sections, input, shown, (error, section) => runtime.report(error, {
|
|
69
|
-
|
|
85
|
+
kind: "extension",
|
|
70
86
|
...optionalExtension(sectionExtension(agent, section.key)),
|
|
71
87
|
}), context);
|
|
72
88
|
const entries = planSystemEntries(view, desired, agent.tools, runtime.now());
|
|
@@ -83,11 +99,10 @@ export const GenerationTask = defineTask({
|
|
|
83
99
|
return;
|
|
84
100
|
}
|
|
85
101
|
await runtime.commit(async (tx) => {
|
|
86
|
-
|
|
102
|
+
// The context ends in a message the model answers, so the conversation has an entry.
|
|
103
|
+
let cutoff = (await tx.latestEntryId(conversationId));
|
|
87
104
|
for (const entry of entries)
|
|
88
105
|
cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;
|
|
89
|
-
if (cutoff === undefined)
|
|
90
|
-
throw new Error(`Conversation ${conversationId} has no entries to send`);
|
|
91
106
|
// Checked in this commit, so a compaction admitted during preparation counts.
|
|
92
107
|
if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {
|
|
93
108
|
await createCompaction(tx, conversationId, { reason: "threshold" });
|
|
@@ -104,14 +119,22 @@ export const GenerationTask = defineTask({
|
|
|
104
119
|
}, context);
|
|
105
120
|
},
|
|
106
121
|
request: async (task, runtime, context) => {
|
|
122
|
+
// Charging the attempt may itself be what keeps failing, such as converting a huge partial: the limit ends it.
|
|
123
|
+
if (cutOffTooOften(task, runtime))
|
|
124
|
+
return endCutOff(runtime, task, context);
|
|
125
|
+
// The host stopped while this request was in flight: a failed attempt, not a resend (spec §8.3).
|
|
126
|
+
if (task.restarts !== undefined)
|
|
127
|
+
return interrupted(runtime, task.state.checkpoint, context);
|
|
107
128
|
const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;
|
|
108
129
|
const conversationId = runtime.conversationId;
|
|
130
|
+
// The attempt shown is presentation, stored like progress (`PROGRESS_COMMIT`); converting a partial left by an
|
|
131
|
+
// interrupted attempt appends an entry, which makes the commit immediate.
|
|
109
132
|
await runtime.commit(async (tx) => {
|
|
110
133
|
const live = await tx.doc(LiveDoc, conversationId);
|
|
111
134
|
await convertPartial(tx, live, conversationId);
|
|
112
135
|
live.generation = { attempt };
|
|
113
136
|
return undefined;
|
|
114
|
-
}, context);
|
|
137
|
+
}, context, PROGRESS_COMMIT);
|
|
115
138
|
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
|
116
139
|
if (model === undefined)
|
|
117
140
|
return failNoModel(runtime, ref, context);
|
|
@@ -128,29 +151,44 @@ export const GenerationTask = defineTask({
|
|
|
128
151
|
telemetry: context.telemetry,
|
|
129
152
|
sessionId: await ensureProviderSessionId(runtime, context),
|
|
130
153
|
...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),
|
|
154
|
+
onPayload: (payload) => providerPayload(runtime, model, payload, context),
|
|
131
155
|
};
|
|
132
156
|
const message = await streamResponse(runtime, model, messages, options, attempt, context);
|
|
133
|
-
const request = { attempt, compacted, model: ref, cutoff, messages: view.messages };
|
|
157
|
+
const request = { attempt, compacted, model: ref, thinkingLevel, cutoff, messages: view.messages };
|
|
134
158
|
await classify(runtime, request, message, context);
|
|
135
159
|
},
|
|
136
160
|
/** After the backoff's sleep; a run before `until` (an early wake, or a record of an earlier version) sleeps again. */
|
|
137
161
|
retry: async (task, runtime, context) => {
|
|
138
|
-
const { attempt, compacted, until } = task.state.checkpoint;
|
|
162
|
+
const { attempt, compacted, until, poll } = task.state.checkpoint;
|
|
163
|
+
if (cutOffTooOften(task, runtime))
|
|
164
|
+
return endCutOff(runtime, task, context);
|
|
139
165
|
if (until > runtime.now())
|
|
140
166
|
return runtime.sleepUntil(until, task.state.checkpoint, context);
|
|
141
167
|
await runtime.commit(async (tx) => {
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
169
|
+
const kept = compacted === undefined ? {} : { compacted };
|
|
170
|
+
// A poll the host died during polls its response again, as the next attempt.
|
|
171
|
+
if (poll !== undefined) {
|
|
172
|
+
const pollAt = runtime.now();
|
|
173
|
+
live.generation = { attempt: attempt + 1, deferred: { pollAt } };
|
|
174
|
+
return {
|
|
175
|
+
status: "running",
|
|
176
|
+
checkpoint: { phase: "poll", attempt: attempt + 1, ...kept, ...poll, pollAt },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
live.generation = { attempt: attempt + 1 };
|
|
180
|
+
return { status: "running", checkpoint: { phase: "prepare", attempt: attempt + 1, ...kept } };
|
|
149
181
|
}, context);
|
|
150
182
|
},
|
|
151
183
|
/** After the sleep until `pollAt`; a run before it (an early wake, or a record of an earlier version) sleeps again. */
|
|
152
184
|
poll: async (task, runtime, context) => {
|
|
153
|
-
const { attempt, compacted, model: ref, cutoff, handle, pollAt } = task.state.checkpoint;
|
|
185
|
+
const { attempt, compacted, model: ref, thinkingLevel, cutoff, handle, pollAt } = task.state.checkpoint;
|
|
186
|
+
if (cutOffTooOften(task, runtime))
|
|
187
|
+
return endCutOff(runtime, task, context);
|
|
188
|
+
// The host stopped while the poll's fetch was in flight: a failed attempt, as for a request. Checked before the
|
|
189
|
+
// time, which may have moved backwards on a replacement host; a sleeping poll is never cut off.
|
|
190
|
+
if (task.restarts !== undefined)
|
|
191
|
+
return interrupted(runtime, task.state.checkpoint, context);
|
|
154
192
|
if (pollAt > runtime.now())
|
|
155
193
|
return runtime.sleepUntil(pollAt, task.state.checkpoint, context);
|
|
156
194
|
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
|
@@ -160,10 +198,12 @@ export const GenerationTask = defineTask({
|
|
|
160
198
|
signal: runtime.signal,
|
|
161
199
|
telemetry: context.telemetry,
|
|
162
200
|
});
|
|
163
|
-
const request = { attempt, compacted, model: ref, cutoff, pollAt };
|
|
201
|
+
const request = { attempt, compacted, model: ref, thinkingLevel, cutoff, pollAt };
|
|
164
202
|
await classify(runtime, request, message, context);
|
|
165
203
|
},
|
|
166
204
|
tools: async (task, runtime, context) => {
|
|
205
|
+
if (cutOffTooOften(task, runtime))
|
|
206
|
+
return endCutOff(runtime, task, context);
|
|
167
207
|
const { assistant, tools, pending } = task.state.checkpoint;
|
|
168
208
|
const [next, ...rest] = pending;
|
|
169
209
|
if (next === undefined)
|
|
@@ -171,8 +211,8 @@ export const GenerationTask = defineTask({
|
|
|
171
211
|
// Sequential round: start the next call and wait for it.
|
|
172
212
|
await runtime.commit(async (tx) => {
|
|
173
213
|
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
174
|
-
const taskId = await createToolTask(tx, runtime, assistant, next);
|
|
175
214
|
const slot = live.tools?.find((slot) => slot.callId === next && slot.taskId === undefined);
|
|
215
|
+
const taskId = await createToolTask(tx, runtime, assistant, next, slot?.unoffered);
|
|
176
216
|
if (slot !== undefined)
|
|
177
217
|
slot.taskId = taskId;
|
|
178
218
|
const checkpoint = {
|
|
@@ -184,25 +224,50 @@ export const GenerationTask = defineTask({
|
|
|
184
224
|
return { status: "waiting", checkpoint, on: [taskId], policy: "allSettled" };
|
|
185
225
|
}, context);
|
|
186
226
|
},
|
|
227
|
+
/**
|
|
228
|
+
* Ask the `afterRun` hooks for tasks and create them, as the generation's children, in the commit that moves to
|
|
229
|
+
* `drain`; without any, end the run now.
|
|
230
|
+
*/
|
|
231
|
+
finish: async (task, runtime, context) => {
|
|
232
|
+
const { run } = task.state.checkpoint;
|
|
233
|
+
if (cutOffTooOften(task, runtime))
|
|
234
|
+
return endAnsweredCutOff(runtime, task, run.answer, context);
|
|
235
|
+
const prepared = [];
|
|
236
|
+
await runtime.hooks.each("afterRun", async (hook) => {
|
|
237
|
+
const asked = await hook(run, runtime, context);
|
|
238
|
+
if (asked !== undefined)
|
|
239
|
+
prepareRunEndTasks(runtime, asked.tasks, prepared);
|
|
240
|
+
});
|
|
241
|
+
if (prepared.length === 0)
|
|
242
|
+
return endAnsweredRun(runtime, run.answer, context);
|
|
243
|
+
await runtime.commit(async (tx) => {
|
|
244
|
+
const tasks = [];
|
|
245
|
+
for (const { task, input } of prepared) {
|
|
246
|
+
tasks.push(await tx.createTask(task, input, { ownership: { kind: "task", taskId: runtime.taskId } }));
|
|
247
|
+
}
|
|
248
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
249
|
+
if (live.run?.taskId === runtime.taskId && live.run.afterRun !== undefined)
|
|
250
|
+
live.run.afterRun.tasks = tasks;
|
|
251
|
+
const checkpoint = { phase: "drain", answer: run.answer, tasks };
|
|
252
|
+
return { status: "waiting", checkpoint, on: tasks, policy: "allSettled" };
|
|
253
|
+
}, context);
|
|
254
|
+
},
|
|
255
|
+
/** The after-run tasks are terminal, whatever their outcomes: end the run. */
|
|
256
|
+
drain: async (task, runtime, context) => {
|
|
257
|
+
const { answer } = task.state.checkpoint;
|
|
258
|
+
if (cutOffTooOften(task, runtime))
|
|
259
|
+
return endAnsweredCutOff(runtime, task, answer, context);
|
|
260
|
+
return endAnsweredRun(runtime, answer, context);
|
|
261
|
+
},
|
|
187
262
|
},
|
|
188
263
|
abort: async (task, runtime, context) => {
|
|
189
264
|
const checkpoint = task.state.checkpoint;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
await runtime.models.cancelDeferred(model, checkpoint.handle, {
|
|
195
|
-
signal: runtime.signal,
|
|
196
|
-
telemetry: context.telemetry,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
catch (error) {
|
|
200
|
-
runtime.report(error, { type: "host", callback: "models" });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
265
|
+
const polled = deferredOf(checkpoint);
|
|
266
|
+
if (polled !== undefined)
|
|
267
|
+
await cancelDeferred(runtime, polled, context);
|
|
204
268
|
const conversationId = runtime.conversationId;
|
|
205
|
-
// Runs after the round's tool tasks are terminal; calls never started get `aborted` results (spec §8.5)
|
|
269
|
+
// Runs after the round's tool tasks are terminal; calls never started get `aborted` results (spec §8.5), unoffered
|
|
270
|
+
// ones `tool_unavailable`.
|
|
206
271
|
const unstarted = checkpoint.phase === "tools"
|
|
207
272
|
? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context)
|
|
208
273
|
: [];
|
|
@@ -210,9 +275,14 @@ export const GenerationTask = defineTask({
|
|
|
210
275
|
const live = await tx.doc(LiveDoc, conversationId);
|
|
211
276
|
await convertPartial(tx, live, conversationId);
|
|
212
277
|
for (const call of unstarted) {
|
|
213
|
-
|
|
278
|
+
// A call its request did not offer could not have run either way.
|
|
279
|
+
const unoffered = live.tools?.find((slot) => slot.callId === call.id)?.unoffered === true;
|
|
280
|
+
const result = unoffered
|
|
281
|
+
? harnessError("tool_unavailable", `Tool ${call.name} is not available`)
|
|
282
|
+
: harnessError("aborted", `Tool ${call.name} was aborted`);
|
|
214
283
|
await appendToolResult(tx, conversationId, call, result, { timestamp: runtime.now() });
|
|
215
284
|
}
|
|
285
|
+
// In `finish` and `drain` the run answered already: it ends without a failure, its inputs stay `done`.
|
|
216
286
|
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" });
|
|
217
287
|
return { status: "terminal", outcome: { status: "aborted" } };
|
|
218
288
|
}, context);
|
|
@@ -224,9 +294,9 @@ async function readCalls(runtime, assistant, callIds, context) {
|
|
|
224
294
|
const calls = message?.role === "assistant" ? message.content.filter((content) => content.type === "toolCall") : [];
|
|
225
295
|
return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1));
|
|
226
296
|
}
|
|
227
|
-
/** A tool task for call `callId`, owned by the generation. */
|
|
228
|
-
function createToolTask(tx, runtime, assistant, callId) {
|
|
229
|
-
return tx.createTask(ToolTask, { kind: "model", assistant, callId }, { ownership: { kind: "task", taskId: runtime.taskId } });
|
|
297
|
+
/** A tool task for call `callId`, owned by the generation; `unoffered` for a call to a tool its request did not offer. */
|
|
298
|
+
function createToolTask(tx, runtime, assistant, callId, unoffered) {
|
|
299
|
+
return tx.createTask(ToolTask, { kind: "model", assistant, callId, ...(unoffered === undefined ? {} : { unoffered }) }, { ownership: { kind: "task", taskId: runtime.taskId } });
|
|
230
300
|
}
|
|
231
301
|
/**
|
|
232
302
|
* Which threshold compaction preparation starts before its request (spec §8.3): `blocking` above
|
|
@@ -268,16 +338,121 @@ async function failModelError(runtime, text, context) {
|
|
|
268
338
|
};
|
|
269
339
|
}, context);
|
|
270
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* The host stopped while this attempt's request or poll was in flight (`TaskRecord.restarts`): a failed attempt under
|
|
343
|
+
* `settings.retry` (spec §8.3). The committed partial becomes an aborted entry; the next attempt follows the backoff, a
|
|
344
|
+
* poll's by polling its deferred response again, or, with no retry left, the run ends `interrupted`.
|
|
345
|
+
*/
|
|
346
|
+
async function interrupted(runtime, checkpoint, context) {
|
|
347
|
+
const { attempt, compacted } = checkpoint;
|
|
348
|
+
const policy = runtime.settings.retry;
|
|
349
|
+
const retry = policy.enabled && attempt <= policy.maxRetries;
|
|
350
|
+
const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;
|
|
351
|
+
const polled = checkpoint.phase === "poll" ? checkpoint : undefined;
|
|
352
|
+
const text = polled === undefined
|
|
353
|
+
? `The host stopped while the model was answering, on attempt ${attempt}`
|
|
354
|
+
: `The host stopped while the deferred answer was fetched, on attempt ${attempt}`;
|
|
355
|
+
// A run that ends does not leave the provider's job running.
|
|
356
|
+
if (!retry && polled !== undefined)
|
|
357
|
+
await cancelDeferred(runtime, polled, context);
|
|
358
|
+
await runtime.commit(async (tx) => {
|
|
359
|
+
// Read before the first table write; only a failure uses it.
|
|
360
|
+
const failure = retry ? undefined : await prepareFailure(tx, runtime);
|
|
361
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
362
|
+
await convertPartial(tx, live, runtime.conversationId);
|
|
363
|
+
if (failure === undefined) {
|
|
364
|
+
live.generation = { attempt, retry: { at: until, error: text } };
|
|
365
|
+
const poll = polled === undefined
|
|
366
|
+
? {}
|
|
367
|
+
: {
|
|
368
|
+
poll: {
|
|
369
|
+
model: polled.model,
|
|
370
|
+
...(polled.thinkingLevel === undefined ? {} : { thinkingLevel: polled.thinkingLevel }),
|
|
371
|
+
cutoff: polled.cutoff,
|
|
372
|
+
handle: polled.handle,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
const next = { phase: "retry", attempt, ...(compacted === undefined ? {} : { compacted }), until, ...poll };
|
|
376
|
+
return { status: "sleeping", checkpoint: next, until };
|
|
377
|
+
}
|
|
378
|
+
await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "interrupted", detail: text });
|
|
379
|
+
return {
|
|
380
|
+
status: "terminal",
|
|
381
|
+
outcome: { status: "failed", error: { message: text, detail: { reason: "interrupted" } } },
|
|
382
|
+
};
|
|
383
|
+
}, context);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* End a run whose generation was cut off `settings.restartLimit` times in a row (spec §5.4). At the limit it still
|
|
387
|
+
* keeps a committed partial as an aborted entry and cancels a deferred response it polled; past the limit that cleanup
|
|
388
|
+
* may be what cut it off, so it ends without it.
|
|
389
|
+
*/
|
|
390
|
+
async function endCutOff(runtime, task, context) {
|
|
391
|
+
const checkpoint = task.state.checkpoint;
|
|
392
|
+
const text = `The generation was interrupted ${cutOffCount(task)} in its ${checkpoint.phase} phase`;
|
|
393
|
+
const salvage = salvageable(task, runtime);
|
|
394
|
+
const polled = deferredOf(checkpoint);
|
|
395
|
+
if (salvage && polled !== undefined)
|
|
396
|
+
await cancelDeferred(runtime, polled, context);
|
|
397
|
+
await runtime.commit(async (tx) => {
|
|
398
|
+
const failure = await prepareFailure(tx, runtime);
|
|
399
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
400
|
+
if (salvage)
|
|
401
|
+
await convertPartial(tx, live, runtime.conversationId);
|
|
402
|
+
await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "restart_limit", detail: text });
|
|
403
|
+
return {
|
|
404
|
+
status: "terminal",
|
|
405
|
+
outcome: { status: "failed", error: { message: text, detail: { reason: "restart_limit" } } },
|
|
406
|
+
};
|
|
407
|
+
}, context);
|
|
408
|
+
}
|
|
409
|
+
/** The deferred response a checkpoint polls, or will poll again after a retry's backoff. */
|
|
410
|
+
function deferredOf(checkpoint) {
|
|
411
|
+
if (checkpoint.phase === "poll")
|
|
412
|
+
return checkpoint;
|
|
413
|
+
return checkpoint.phase === "retry" ? checkpoint.poll : undefined;
|
|
414
|
+
}
|
|
415
|
+
/** Cancel a deferred response no attempt will poll again; a failure is reported, never thrown. */
|
|
416
|
+
async function cancelDeferred(runtime, checkpoint, context) {
|
|
417
|
+
const model = runtime.models.getModel(checkpoint.model.provider, checkpoint.model.modelId);
|
|
418
|
+
if (model === undefined)
|
|
419
|
+
return;
|
|
420
|
+
try {
|
|
421
|
+
await runtime.models.cancelDeferred(model, checkpoint.handle, {
|
|
422
|
+
signal: runtime.signal,
|
|
423
|
+
telemetry: context.telemetry,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
runtime.report(error, { kind: "host", callback: "models" });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
271
430
|
/** Settle the run's inputs `unanswered` with `no_model` and fail. */
|
|
272
431
|
async function failNoModel(runtime, ref, context) {
|
|
273
432
|
const message = ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
|
|
274
433
|
await runtime.commit(async (tx) => {
|
|
275
434
|
const failure = await prepareFailure(tx, runtime);
|
|
276
435
|
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
277
|
-
await endFailedRun(tx, runtime, live, failure, {
|
|
436
|
+
await endFailedRun(tx, runtime, live, failure, {
|
|
437
|
+
status: "unanswered",
|
|
438
|
+
reason: "no_model",
|
|
439
|
+
detail: message,
|
|
440
|
+
settlement: { status: "unanswered", reason: "no_model" },
|
|
441
|
+
});
|
|
278
442
|
return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
|
|
279
443
|
}, context);
|
|
280
444
|
}
|
|
445
|
+
/** End the run without a request: its context has nothing the model would answer. */
|
|
446
|
+
async function failNothingToAnswer(runtime, context) {
|
|
447
|
+
const message = "The conversation has nothing to answer";
|
|
448
|
+
await runtime.commit(async (tx) => {
|
|
449
|
+
const failure = await prepareFailure(tx, runtime);
|
|
450
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
451
|
+
await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "nothing_to_answer" });
|
|
452
|
+
const error = { message, detail: { reason: "nothing_to_answer" } };
|
|
453
|
+
return { status: "terminal", outcome: { status: "failed", error } };
|
|
454
|
+
}, context);
|
|
455
|
+
}
|
|
281
456
|
async function prepareFailure(tx, runtime) {
|
|
282
457
|
const settings = runtime.settings;
|
|
283
458
|
const policy = settings.afterFailedRun;
|
|
@@ -289,16 +464,16 @@ async function prepareFailure(tx, runtime) {
|
|
|
289
464
|
* End a failed run, settling its inputs with `settlement`, and do with the inputs queued behind it what
|
|
290
465
|
* `settings.afterFailedRun` says (spec §6): keep them, start the next run with them, or withdraw them.
|
|
291
466
|
*/
|
|
292
|
-
async function endFailedRun(tx, runtime, live, failure,
|
|
467
|
+
async function endFailedRun(tx, runtime, live, failure, end) {
|
|
293
468
|
const conversationId = runtime.conversationId;
|
|
294
|
-
endRun(tx, live, runtime.taskId,
|
|
469
|
+
endRun(tx, live, runtime.taskId, end);
|
|
295
470
|
if (failure.policy === "withdraw")
|
|
296
471
|
await withdrawQueuedInputs(tx, conversationId, "run_failed");
|
|
297
472
|
if (failure.boundary === undefined)
|
|
298
473
|
return;
|
|
299
|
-
const { users } = await applyBoundary(tx, failure.boundary, "final", runtime.now());
|
|
474
|
+
const { users, first } = await applyBoundary(tx, failure.boundary, "final", runtime.now());
|
|
300
475
|
if (users.length > 0)
|
|
301
|
-
await startRun(tx, conversationId, live, users);
|
|
476
|
+
await startRun(tx, conversationId, live, { inputs: users, first }, { keepFailure: true });
|
|
302
477
|
}
|
|
303
478
|
/**
|
|
304
479
|
* Append a committed partial left by an interrupted, aborted, faulted, or orphaned attempt as an aborted assistant
|
|
@@ -311,10 +486,22 @@ export async function convertPartial(tx, live, conversationId) {
|
|
|
311
486
|
const message = copyJson(partial);
|
|
312
487
|
await appendAssistant(tx, conversationId, { ...message, stopReason: "aborted" });
|
|
313
488
|
}
|
|
489
|
+
/** The `beforeProviderRequest` replacement chain over one provider request body. */
|
|
490
|
+
async function providerPayload(runtime, model, payload, context) {
|
|
491
|
+
let current = payload;
|
|
492
|
+
await runtime.hooks.each("beforeProviderRequest", async (hook) => {
|
|
493
|
+
const replaced = await hook({ model, payload: current }, runtime, context);
|
|
494
|
+
if (replaced !== undefined)
|
|
495
|
+
current = replaced.payload;
|
|
496
|
+
});
|
|
497
|
+
return current;
|
|
498
|
+
}
|
|
314
499
|
/**
|
|
315
500
|
* Stream one request and return the terminal message. Partials commit as trailing writes at most every
|
|
316
|
-
* `progress.partialIntervalMs` (default 100 ms) with one commit in flight; `finally` stops
|
|
317
|
-
* commit, so no stale partial lands after the outcome.
|
|
501
|
+
* `progress.partialIntervalMs` (default 100 ms) with one commit in flight, deferred (`PROGRESS_COMMIT`); `finally` stops
|
|
502
|
+
* the throttle and awaits that commit, so no stale partial lands after the outcome. A commit appends what text and
|
|
503
|
+
* thinking grew by and applies the argument changes since the last one (`PartialChanges`), so its cost follows the new
|
|
504
|
+
* text, not the strings' length.
|
|
318
505
|
*/
|
|
319
506
|
async function streamResponse(runtime, model, messages, options, attempt, context) {
|
|
320
507
|
const interval = runtime.settings.progress.partialIntervalMs;
|
|
@@ -322,6 +509,7 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
|
|
|
322
509
|
let timer;
|
|
323
510
|
let inFlight;
|
|
324
511
|
let stopped = false;
|
|
512
|
+
const changes = new PartialChanges();
|
|
325
513
|
const flush = () => {
|
|
326
514
|
timer = undefined;
|
|
327
515
|
const partial = pending;
|
|
@@ -329,14 +517,22 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
|
|
|
329
517
|
if (partial === undefined || stopped)
|
|
330
518
|
return;
|
|
331
519
|
inFlight = (async () => {
|
|
332
|
-
// Copy synchronously: the provider keeps mutating its partial.
|
|
520
|
+
// Copy synchronously: the provider keeps mutating its partial. The changes since the last commit belong to it.
|
|
521
|
+
// `copyJson()` returns untyped JSON; a copy of a message is that message's JSON representation.
|
|
333
522
|
const message = copyJson(partial, { omitUndefinedProperties: true });
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
523
|
+
const taken = changes.take(message);
|
|
524
|
+
try {
|
|
525
|
+
await runtime.commit(async (tx) => {
|
|
526
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
527
|
+
live.generation ??= { attempt };
|
|
528
|
+
changes.commit(live.generation, message, taken);
|
|
529
|
+
return undefined;
|
|
530
|
+
}, context, PROGRESS_COMMIT);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
changes.restore(taken);
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
340
536
|
})()
|
|
341
537
|
.catch((error) => {
|
|
342
538
|
// Rejections after an abort mark or close are expected; the committed state stays consistent.
|
|
@@ -356,6 +552,7 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
|
|
|
356
552
|
// never gets past it, so it never leaves a partial.
|
|
357
553
|
if (event.type === "done" || event.type === "error" || event.partial.content.length === 0)
|
|
358
554
|
continue;
|
|
555
|
+
changes.observe(event);
|
|
359
556
|
pending = event.partial;
|
|
360
557
|
if (timer === undefined && inFlight === undefined)
|
|
361
558
|
timer = runtime.setTimer(flush, interval);
|
|
@@ -373,7 +570,10 @@ async function classify(runtime, request, message, context) {
|
|
|
373
570
|
// An abort mark or close: the abort invocation or the reopened run handles the committed state.
|
|
374
571
|
runtime.signal.throwIfAborted();
|
|
375
572
|
const conversationId = runtime.conversationId;
|
|
376
|
-
const { attempt, compacted, model: ref, cutoff } = request;
|
|
573
|
+
const { attempt, compacted, model: ref, thinkingLevel, cutoff } = request;
|
|
574
|
+
// Record the requested level, as pi-agent-core's agent loop does.
|
|
575
|
+
if (thinkingLevel !== undefined)
|
|
576
|
+
message.thinkingLevel = thinkingLevel;
|
|
377
577
|
if (message.stopReason === "deferred" && message.deferred !== undefined) {
|
|
378
578
|
const handle = message.deferred;
|
|
379
579
|
const pollAt = Math.max(runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS), request.pollAt === undefined ? Number.NEGATIVE_INFINITY : request.pollAt + 1);
|
|
@@ -384,6 +584,7 @@ async function classify(runtime, request, message, context) {
|
|
|
384
584
|
attempt,
|
|
385
585
|
...(compacted === undefined ? {} : { compacted }),
|
|
386
586
|
model: ref,
|
|
587
|
+
...(thinkingLevel === undefined ? {} : { thinkingLevel }),
|
|
387
588
|
cutoff,
|
|
388
589
|
handle,
|
|
389
590
|
pollAt,
|
|
@@ -452,8 +653,9 @@ async function classify(runtime, request, message, context) {
|
|
|
452
653
|
}
|
|
453
654
|
/**
|
|
454
655
|
* A final answer; the final boundary places queued items (spec §6). The first `onYield` continuation appends a user
|
|
455
|
-
* message and hands the run to a successor generation, but only when the
|
|
456
|
-
* Otherwise the run's inputs settle `done
|
|
656
|
+
* message and hands the run to a successor generation, but only when the inbox holds no user item and no reset.
|
|
657
|
+
* Otherwise the run's inputs settle `done`; with `afterRun` hooks selected the run waits for the tasks they ask for,
|
|
658
|
+
* in `finish` and `drain`, before the final boundary; without, selected user items start the next run now.
|
|
457
659
|
*/
|
|
458
660
|
async function answer(runtime, message, context) {
|
|
459
661
|
let continuation;
|
|
@@ -462,6 +664,7 @@ async function answer(runtime, message, context) {
|
|
|
462
664
|
return;
|
|
463
665
|
continuation = (await hook(message, runtime, context))?.continue;
|
|
464
666
|
});
|
|
667
|
+
const afterRun = await asksAfterRun(runtime, context);
|
|
465
668
|
const conversationId = runtime.conversationId;
|
|
466
669
|
await runtime.commit(async (tx) => {
|
|
467
670
|
// Queue modes are read on the Session line, when the boundary is decided.
|
|
@@ -469,24 +672,135 @@ async function answer(runtime, message, context) {
|
|
|
469
672
|
const live = await tx.doc(LiveDoc, conversationId);
|
|
470
673
|
const entry = await appendAssistant(tx, conversationId, message);
|
|
471
674
|
const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
const
|
|
675
|
+
const continues = takesContinuation(continuation, boundary) ? continuation : undefined;
|
|
676
|
+
if (continues === undefined && afterRun) {
|
|
677
|
+
const held = holdRun(tx, live, runtime, { reason: "answer", answer: entry.id, last: entry.id });
|
|
678
|
+
if (held !== undefined)
|
|
679
|
+
return held;
|
|
680
|
+
}
|
|
681
|
+
const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now());
|
|
682
|
+
if (continues !== undefined) {
|
|
683
|
+
const user = { role: "user", content: continues, timestamp: runtime.now() };
|
|
475
684
|
await tx.appendEntry(UserEntry, conversationId, { model: [user] });
|
|
685
|
+
// The continuation starts the run's turn count over: bounding `onYield` loops is the extension's job.
|
|
686
|
+
if (live.run?.taskId === runtime.taskId)
|
|
687
|
+
live.run.turns = 0;
|
|
476
688
|
handOver(live, runtime.taskId, await createGeneration(tx, conversationId));
|
|
477
689
|
delete live.generation;
|
|
478
690
|
return result;
|
|
479
691
|
}
|
|
480
692
|
endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
|
|
481
693
|
if (users.length > 0)
|
|
482
|
-
await startRun(tx, conversationId, live, users);
|
|
694
|
+
await startRun(tx, conversationId, live, { inputs: users, first });
|
|
483
695
|
return result;
|
|
484
696
|
}, context);
|
|
485
697
|
}
|
|
698
|
+
/** Whether the phase's agent selects an `afterRun` hook, so a run that answers waits for its tasks (spec §8.3). */
|
|
699
|
+
async function asksAfterRun(runtime, context) {
|
|
700
|
+
const hooks = agentHooks(await runtime.agent(context), GenerationTask.definition.name);
|
|
701
|
+
return hooks.some(({ handlers }) => typeof handlers.afterRun === "function");
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Commit A of a run that answered with `afterRun` hooks selected (spec §8.3): settle the run's inputs `done` with the
|
|
705
|
+
* answer and keep `pi.live.run` for the after-run tasks, then ask the hooks in `finish`. The final boundary waits for
|
|
706
|
+
* the run's end, so queued items stay queued.
|
|
707
|
+
*/
|
|
708
|
+
function holdRun(tx, live, runtime, end) {
|
|
709
|
+
const run = live.run;
|
|
710
|
+
if (run?.taskId !== runtime.taskId)
|
|
711
|
+
return undefined;
|
|
712
|
+
for (const id of run.inputs)
|
|
713
|
+
tx.settleSubmission(id, { status: "done", answer: end.answer });
|
|
714
|
+
run.inputs = [];
|
|
715
|
+
run.afterRun = {};
|
|
716
|
+
delete live.generation;
|
|
717
|
+
delete live.tools;
|
|
718
|
+
delete live.nestedTools;
|
|
719
|
+
// A run started before `first` was recorded has its answer as its first entry.
|
|
720
|
+
const summary = { ...end, first: run.first ?? end.answer };
|
|
721
|
+
return { status: "running", checkpoint: { phase: "finish", run: summary } };
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Check the tasks one `afterRun` hook asked for, off the line, and add those that pass to `prepared`, so a broken request
|
|
725
|
+
* is skipped instead of failing the creating commit: its kind must be an installed task that is not built-in, its input
|
|
726
|
+
* JSON, and its definition's `initial()` must return a JSON checkpoint of one of its phases. The task is created from
|
|
727
|
+
* the installed definition, as every task runs under it, with that checkpoint, so `initial()` runs once. Then throws
|
|
728
|
+
* the rejections, which `hooks.each` reports against the hook's extension.
|
|
729
|
+
*/
|
|
730
|
+
function prepareRunEndTasks(runtime, requests, prepared) {
|
|
731
|
+
const rejected = [];
|
|
732
|
+
for (const request of requests) {
|
|
733
|
+
try {
|
|
734
|
+
const name = request.task.definition.name;
|
|
735
|
+
if (name.startsWith("pi."))
|
|
736
|
+
throw new Error(`An afterRun hook cannot start the built-in task ${name}`);
|
|
737
|
+
const installed = runtime.registry.task(name);
|
|
738
|
+
if (installed === undefined)
|
|
739
|
+
throw new Error(`Task ${name} is not installed`);
|
|
740
|
+
const input = copyJson(request.input, { omitUndefinedProperties: true });
|
|
741
|
+
// The registry erases definitions; an installed one is a task definition of its kind.
|
|
742
|
+
const definition = installed.definition;
|
|
743
|
+
const checkpoint = copyJson(definition.initial(input), { omitUndefinedProperties: true });
|
|
744
|
+
if (!Object.hasOwn(definition.phases, checkpoint.phase)) {
|
|
745
|
+
throw new Error(`Task ${name} starts at the unknown phase ${String(checkpoint.phase)}`);
|
|
746
|
+
}
|
|
747
|
+
prepared.push({ task: { definition: { ...definition, initial: () => checkpoint } }, input });
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
rejected.push(error);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (rejected.length === 1)
|
|
754
|
+
throw rejected[0];
|
|
755
|
+
if (rejected.length > 1)
|
|
756
|
+
throw new AggregateError(rejected, `${rejected.length} afterRun task requests were rejected`);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Commit B of a run that answered (spec §8.3): its after-run tasks are terminal, or there were none. Place what the
|
|
760
|
+
* final boundary selects, end the run, and start the next one for selected user items.
|
|
761
|
+
*/
|
|
762
|
+
async function endAnsweredRun(runtime, answer, context) {
|
|
763
|
+
const conversationId = runtime.conversationId;
|
|
764
|
+
await runtime.commit(async (tx) => {
|
|
765
|
+
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
|
766
|
+
const live = await tx.doc(LiveDoc, conversationId);
|
|
767
|
+
const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now());
|
|
768
|
+
endRun(tx, live, runtime.taskId, { status: "done", answer });
|
|
769
|
+
if (users.length > 0)
|
|
770
|
+
await startRun(tx, conversationId, live, { inputs: users, first });
|
|
771
|
+
return { status: "terminal", outcome: { status: "completed", result: { entryId: answer } } };
|
|
772
|
+
}, context);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* End a run that answered once its `finish` or `drain` phase was cut off `settings.restartLimit` times (spec §5.4):
|
|
776
|
+
* the run answered, so it ends `completed`, without a failure and without the tasks it would still have waited for,
|
|
777
|
+
* marked so the scheduler reports it `task_interrupted`. Queued items stay queued, as after a failed run.
|
|
778
|
+
*/
|
|
779
|
+
async function endAnsweredCutOff(runtime, task, answer, context) {
|
|
780
|
+
const text = `The generation was interrupted ${cutOffCount(task)} in its ${task.state.checkpoint.phase} phase`;
|
|
781
|
+
await runtime.commit(async (tx) => {
|
|
782
|
+
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
|
783
|
+
endRun(tx, live, runtime.taskId, { status: "done", answer });
|
|
784
|
+
const completed = { status: "terminal", outcome: { status: "completed", result: { entryId: answer } } };
|
|
785
|
+
return endedByCrashes(completed, text);
|
|
786
|
+
}, context);
|
|
787
|
+
}
|
|
486
788
|
/**
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
789
|
+
* Whether an answer's `onYield` continuation is taken (spec §8.3), decided from the inbox before the final boundary
|
|
790
|
+
* places anything: only when it holds no user item, of which the boundary would select one, and no reset, which would
|
|
791
|
+
* otherwise run first. A continuation starts the turn count over.
|
|
792
|
+
*/
|
|
793
|
+
function takesContinuation(continuation, boundary) {
|
|
794
|
+
const items = boundary.inbox.items;
|
|
795
|
+
return (continuation !== undefined &&
|
|
796
|
+
!items.some((item) => item.mode === "steer" || item.mode === "followUp") &&
|
|
797
|
+
!hasQueuedReset(items));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Append the tool-calling answer and start its tool round in one commit (spec §8.3). Every call gets a tool task owned by
|
|
801
|
+
* the generation, in call order, only the first one now when the round is sequential; a call to a tool the request did
|
|
802
|
+
* not offer is flagged, and its task settles `tool_unavailable`. The generation then waits for them in its `tools`
|
|
803
|
+
* phase, keeping the run.
|
|
490
804
|
*/
|
|
491
805
|
async function startToolRound(runtime, request, message, calls, context) {
|
|
492
806
|
const conversationId = runtime.conversationId;
|
|
@@ -503,30 +817,33 @@ async function startToolRound(runtime, request, message, calls, context) {
|
|
|
503
817
|
const tools = [];
|
|
504
818
|
const pending = [];
|
|
505
819
|
for (const call of calls) {
|
|
506
|
-
|
|
507
|
-
const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
|
|
508
|
-
const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });
|
|
509
|
-
slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
820
|
+
const unoffered = offered.has(call.name) ? {} : { unoffered: true };
|
|
512
821
|
if (sequential && tools.length > 0) {
|
|
513
822
|
pending.push(call.id);
|
|
514
|
-
slots.push({ callId: call.id, name: call.name, status: "pending" });
|
|
823
|
+
slots.push({ callId: call.id, name: call.name, status: "pending", ...unoffered });
|
|
515
824
|
continue;
|
|
516
825
|
}
|
|
517
|
-
const taskId = await createToolTask(tx, runtime, entry.id, call.id);
|
|
826
|
+
const taskId = await createToolTask(tx, runtime, entry.id, call.id, unoffered.unoffered);
|
|
518
827
|
tools.push(taskId);
|
|
519
|
-
slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });
|
|
828
|
+
slots.push({ callId: call.id, name: call.name, taskId, status: "pending", ...unoffered });
|
|
520
829
|
}
|
|
521
830
|
delete live.generation;
|
|
522
831
|
live.tools = slots;
|
|
832
|
+
if (live.run?.taskId === runtime.taskId) {
|
|
833
|
+
// A tool round is one turn of the run (`AgentState.maxTurns`).
|
|
834
|
+
live.run.turns += 1;
|
|
835
|
+
// A run its boundary started without placing anything, as a continue after an answer, begins here.
|
|
836
|
+
live.run.first ??= entry.id;
|
|
837
|
+
}
|
|
523
838
|
const checkpoint = { phase: "tools", assistant: entry.id, tools, pending };
|
|
524
839
|
return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };
|
|
525
840
|
}, context);
|
|
526
841
|
}
|
|
527
842
|
/**
|
|
528
|
-
* The round's tools are terminal: apply their controls and
|
|
529
|
-
* `
|
|
843
|
+
* The round's tools are terminal: apply their controls, append a `handoff`'s reset, and then end the run at the final
|
|
844
|
+
* boundary (`terminate`), end it like a failure at the conversation's turn limit (`turn_limit`), end it at a queued
|
|
845
|
+
* reset, or hand it to the next generation at the `postTools` boundary, in the handoff's new context when there is one
|
|
846
|
+
* (spec §8.5).
|
|
530
847
|
*/
|
|
531
848
|
async function finishToolRound(runtime, assistant, tools, context) {
|
|
532
849
|
const conversationId = runtime.conversationId;
|
|
@@ -540,36 +857,60 @@ async function finishToolRound(runtime, assistant, tools, context) {
|
|
|
540
857
|
const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
|
|
541
858
|
const results = slots.flatMap((slot) => (slot.entry === undefined ? [] : [slot.entry]));
|
|
542
859
|
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
|
|
543
|
-
//
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
const
|
|
860
|
+
// In call order, the slots' order; a slot without a task (faulted or orphaned before it got one) has no control.
|
|
861
|
+
const ordered = slots.map((slot) => (slot.taskId === undefined ? undefined : controls.get(slot.taskId)));
|
|
862
|
+
// Every call of the round must ask to terminate.
|
|
863
|
+
const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true);
|
|
864
|
+
const added = ordered.flatMap((control) => control?.addTools ?? []);
|
|
547
865
|
// The last handoff in call order wins.
|
|
548
|
-
const handoff =
|
|
866
|
+
const handoff = ordered.findLast((control) => control?.handoff !== undefined)?.handoff;
|
|
867
|
+
// Read from the phase's agent, as every agent field; a `configure()` during the round applies at its end.
|
|
868
|
+
const maxTurns = (await runtime.agent(context)).maxTurns;
|
|
869
|
+
const afterRun = terminate && (await asksAfterRun(runtime, context));
|
|
549
870
|
await runtime.commit(async (tx) => {
|
|
550
871
|
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
|
551
872
|
if (added.length > 0)
|
|
552
873
|
await addTools(tx, conversationId, added);
|
|
553
874
|
const live = await tx.doc(LiveDoc, conversationId);
|
|
554
875
|
const now = runtime.now();
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
|
|
876
|
+
// The run's last entry so far: the newest result, or the assistant when no result has an entry.
|
|
877
|
+
let last = results.reduce((newest, id) => (id > newest ? id : newest), assistant);
|
|
878
|
+
if (handoff !== undefined) {
|
|
879
|
+
// The handoff's reset precedes every queued item, so queued writes and selected steers land in its context.
|
|
880
|
+
const message = { role: "user", content: handoff, timestamp: now };
|
|
881
|
+
const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });
|
|
882
|
+
boundary.head = entry.id;
|
|
883
|
+
last = entry.id;
|
|
884
|
+
}
|
|
885
|
+
const turns = live.run?.taskId === runtime.taskId ? live.run.turns : 0;
|
|
886
|
+
const held = afterRun ? holdRun(tx, live, runtime, { reason: "terminate", answer: assistant, last }) : undefined;
|
|
887
|
+
if (held !== undefined)
|
|
888
|
+
return held;
|
|
889
|
+
if (terminate) {
|
|
890
|
+
const { users, first } = await applyBoundary(tx, boundary, "final", now);
|
|
562
891
|
endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });
|
|
563
892
|
if (users.length > 0)
|
|
564
|
-
await startRun(tx, conversationId, live, users);
|
|
893
|
+
await startRun(tx, conversationId, live, { inputs: users, first });
|
|
894
|
+
}
|
|
895
|
+
else if (maxTurns !== undefined && turns >= maxTurns) {
|
|
896
|
+
// The turn limit ends the run like a failure: `afterFailedRun` decides the queued items, and a continue resumes
|
|
897
|
+
// it on the round's results. The table was read for the boundary already, before any write.
|
|
898
|
+
const policy = runtime.settings.afterFailedRun;
|
|
899
|
+
const failure = { policy, ...(policy === "continue" ? { boundary } : {}) };
|
|
900
|
+
await endFailedRun(tx, runtime, live, failure, {
|
|
901
|
+
status: "unanswered",
|
|
902
|
+
reason: "turn_limit",
|
|
903
|
+
detail: turns === 1 ? "1 turn" : `${turns} turns`,
|
|
904
|
+
});
|
|
565
905
|
}
|
|
566
906
|
else {
|
|
567
|
-
const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);
|
|
907
|
+
const { users, first, reset } = await applyBoundary(tx, boundary, "postTools", now);
|
|
568
908
|
if (reset) {
|
|
569
909
|
// The queued reset cut the run's context before an answer.
|
|
570
910
|
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });
|
|
571
|
-
if (users.length > 0)
|
|
572
|
-
await startRun(tx, conversationId, live, users);
|
|
911
|
+
if (users.length > 0) {
|
|
912
|
+
await startRun(tx, conversationId, live, { inputs: users, first }, { keepFailure: true });
|
|
913
|
+
}
|
|
573
914
|
}
|
|
574
915
|
else {
|
|
575
916
|
delete live.tools;
|
|
@@ -590,9 +931,17 @@ async function appendAssistant(tx, conversationId, message) {
|
|
|
590
931
|
await recordUsage(tx, conversationId, "models", `${message.provider}/${message.model}`, message.usage);
|
|
591
932
|
return tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
|
|
592
933
|
}
|
|
593
|
-
/**
|
|
594
|
-
|
|
595
|
-
|
|
934
|
+
/**
|
|
935
|
+
* Start a run for `inputs`, placed input submissions, whose first entry is `first` (`LiveRun.first`): a new generation
|
|
936
|
+
* takes `pi.live.run`. Removes `runFailure`, unless `keepFailure`: a run started in the commit that ended the failed one
|
|
937
|
+
* keeps its failure.
|
|
938
|
+
*/
|
|
939
|
+
export async function startRun(tx, conversationId, live, run, options) {
|
|
940
|
+
const { inputs, first } = run;
|
|
941
|
+
const taskId = await createGeneration(tx, conversationId, { submissionIds: inputs });
|
|
942
|
+
live.run = { taskId, inputs, turns: 0, ...(first === undefined ? {} : { first }) };
|
|
943
|
+
if (options?.keepFailure !== true)
|
|
944
|
+
delete live.runFailure;
|
|
596
945
|
}
|
|
597
946
|
/** A generation owned by its conversation. */
|
|
598
947
|
function createGeneration(tx, conversationId, telemetry = {}) {
|