@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.
Files changed (221) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/README.md +181 -84
  3. package/dist/codemode/codemode.d.ts +4 -4
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +9 -12
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.js +4 -4
  8. package/dist/codemode/models.js.map +1 -1
  9. package/dist/env/index.d.ts +12 -10
  10. package/dist/env/index.d.ts.map +1 -1
  11. package/dist/env/index.js.map +1 -1
  12. package/dist/env/node-watch.d.ts +3 -3
  13. package/dist/env/node-watch.d.ts.map +1 -1
  14. package/dist/env/node-watch.js +18 -16
  15. package/dist/env/node-watch.js.map +1 -1
  16. package/dist/env/node.d.ts +2 -2
  17. package/dist/env/node.d.ts.map +1 -1
  18. package/dist/env/node.js +1 -1
  19. package/dist/env/node.js.map +1 -1
  20. package/dist/errors.d.ts +18 -2
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +26 -3
  23. package/dist/errors.js.map +1 -1
  24. package/dist/harness/agent.d.ts.map +1 -1
  25. package/dist/harness/agent.js +10 -2
  26. package/dist/harness/agent.js.map +1 -1
  27. package/dist/harness/compaction.d.ts.map +1 -1
  28. package/dist/harness/compaction.js +19 -2
  29. package/dist/harness/compaction.js.map +1 -1
  30. package/dist/harness/context.d.ts +23 -7
  31. package/dist/harness/context.d.ts.map +1 -1
  32. package/dist/harness/context.js +71 -25
  33. package/dist/harness/context.js.map +1 -1
  34. package/dist/harness/define.d.ts +7 -3
  35. package/dist/harness/define.d.ts.map +1 -1
  36. package/dist/harness/define.js +4 -0
  37. package/dist/harness/define.js.map +1 -1
  38. package/dist/harness/generation.d.ts +38 -5
  39. package/dist/harness/generation.d.ts.map +1 -1
  40. package/dist/harness/generation.js +445 -96
  41. package/dist/harness/generation.js.map +1 -1
  42. package/dist/harness/harness.d.ts +2 -0
  43. package/dist/harness/harness.d.ts.map +1 -1
  44. package/dist/harness/harness.js +87 -62
  45. package/dist/harness/harness.js.map +1 -1
  46. package/dist/harness/inbox.d.ts +5 -1
  47. package/dist/harness/inbox.d.ts.map +1 -1
  48. package/dist/harness/inbox.js +4 -1
  49. package/dist/harness/inbox.js.map +1 -1
  50. package/dist/harness/json.d.ts +20 -4
  51. package/dist/harness/json.d.ts.map +1 -1
  52. package/dist/harness/json.js +23 -5
  53. package/dist/harness/json.js.map +1 -1
  54. package/dist/harness/live.d.ts +122 -16
  55. package/dist/harness/live.d.ts.map +1 -1
  56. package/dist/harness/live.js +93 -12
  57. package/dist/harness/live.js.map +1 -1
  58. package/dist/harness/partial-changes.d.ts +54 -0
  59. package/dist/harness/partial-changes.d.ts.map +1 -0
  60. package/dist/harness/partial-changes.js +162 -0
  61. package/dist/harness/partial-changes.js.map +1 -0
  62. package/dist/harness/registry-reader.d.ts +11 -0
  63. package/dist/harness/registry-reader.d.ts.map +1 -0
  64. package/dist/harness/registry-reader.js +25 -0
  65. package/dist/harness/registry-reader.js.map +1 -0
  66. package/dist/harness/scheduler.d.ts +46 -19
  67. package/dist/harness/scheduler.d.ts.map +1 -1
  68. package/dist/harness/scheduler.js +973 -376
  69. package/dist/harness/scheduler.js.map +1 -1
  70. package/dist/harness/submissions.d.ts +17 -10
  71. package/dist/harness/submissions.d.ts.map +1 -1
  72. package/dist/harness/submissions.js +62 -25
  73. package/dist/harness/submissions.js.map +1 -1
  74. package/dist/harness/task-graph.d.ts +12 -9
  75. package/dist/harness/task-graph.d.ts.map +1 -1
  76. package/dist/harness/task-graph.js +38 -28
  77. package/dist/harness/task-graph.js.map +1 -1
  78. package/dist/harness/telemetry.d.ts +11 -11
  79. package/dist/harness/telemetry.d.ts.map +1 -1
  80. package/dist/harness/telemetry.js +7 -7
  81. package/dist/harness/telemetry.js.map +1 -1
  82. package/dist/harness/tool.d.ts +2 -0
  83. package/dist/harness/tool.d.ts.map +1 -1
  84. package/dist/harness/tool.js +171 -55
  85. package/dist/harness/tool.js.map +1 -1
  86. package/dist/harness/transcript.d.ts +72 -0
  87. package/dist/harness/transcript.d.ts.map +1 -0
  88. package/dist/harness/transcript.js +264 -0
  89. package/dist/harness/transcript.js.map +1 -0
  90. package/dist/harness/types.d.ts +218 -59
  91. package/dist/harness/types.d.ts.map +1 -1
  92. package/dist/harness/types.js.map +1 -1
  93. package/dist/harness/util.d.ts +3 -1
  94. package/dist/harness/util.d.ts.map +1 -1
  95. package/dist/harness/util.js +9 -0
  96. package/dist/harness/util.js.map +1 -1
  97. package/dist/harness/view.d.ts +43 -22
  98. package/dist/harness/view.d.ts.map +1 -1
  99. package/dist/harness/view.js +130 -71
  100. package/dist/harness/view.js.map +1 -1
  101. package/dist/index.d.ts +9 -7
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +4 -3
  104. package/dist/index.js.map +1 -1
  105. package/dist/search.d.ts +19 -3
  106. package/dist/search.d.ts.map +1 -1
  107. package/dist/search.js +107 -44
  108. package/dist/search.js.map +1 -1
  109. package/dist/session/cached-storage.d.ts +71 -0
  110. package/dist/session/cached-storage.d.ts.map +1 -0
  111. package/dist/session/cached-storage.js +269 -0
  112. package/dist/session/cached-storage.js.map +1 -0
  113. package/dist/session/conversation-tails.d.ts +33 -0
  114. package/dist/session/conversation-tails.d.ts.map +1 -0
  115. package/dist/session/conversation-tails.js +101 -0
  116. package/dist/session/conversation-tails.js.map +1 -0
  117. package/dist/session/keys.d.ts +68 -0
  118. package/dist/session/keys.d.ts.map +1 -0
  119. package/dist/session/keys.js +161 -0
  120. package/dist/session/keys.js.map +1 -0
  121. package/dist/session/session.d.ts +167 -26
  122. package/dist/session/session.d.ts.map +1 -1
  123. package/dist/session/session.js +1029 -171
  124. package/dist/session/session.js.map +1 -1
  125. package/dist/session/transaction.d.ts +81 -3
  126. package/dist/session/transaction.d.ts.map +1 -1
  127. package/dist/session/transaction.js +224 -37
  128. package/dist/session/transaction.js.map +1 -1
  129. package/dist/session/writer.d.ts +74 -0
  130. package/dist/session/writer.d.ts.map +1 -0
  131. package/dist/session/writer.js +294 -0
  132. package/dist/session/writer.js.map +1 -0
  133. package/dist/storage/indexeddb.d.ts +10 -2
  134. package/dist/storage/indexeddb.d.ts.map +1 -1
  135. package/dist/storage/indexeddb.js +91 -41
  136. package/dist/storage/indexeddb.js.map +1 -1
  137. package/dist/storage/jsonl/storage.d.ts +14 -2
  138. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  139. package/dist/storage/jsonl/storage.js +88 -24
  140. package/dist/storage/jsonl/storage.js.map +1 -1
  141. package/dist/storage/memory.d.ts +7 -2
  142. package/dist/storage/memory.d.ts.map +1 -1
  143. package/dist/storage/memory.js +28 -2
  144. package/dist/storage/memory.js.map +1 -1
  145. package/dist/storage/sqlite/storage.d.ts +14 -2
  146. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  147. package/dist/storage/sqlite/storage.js +110 -36
  148. package/dist/storage/sqlite/storage.js.map +1 -1
  149. package/dist/storage/store/commit.d.ts +33 -0
  150. package/dist/storage/store/commit.d.ts.map +1 -0
  151. package/dist/storage/store/commit.js +286 -0
  152. package/dist/storage/store/commit.js.map +1 -0
  153. package/dist/storage/store/documents.d.ts +43 -0
  154. package/dist/storage/store/documents.d.ts.map +1 -0
  155. package/dist/storage/store/documents.js +102 -0
  156. package/dist/storage/store/documents.js.map +1 -0
  157. package/dist/storage/store/index.d.ts +2 -0
  158. package/dist/storage/store/index.d.ts.map +1 -0
  159. package/dist/storage/store/index.js +2 -0
  160. package/dist/storage/store/index.js.map +1 -0
  161. package/dist/storage/store/queries.d.ts +49 -0
  162. package/dist/storage/store/queries.d.ts.map +1 -0
  163. package/dist/storage/store/queries.js +93 -0
  164. package/dist/storage/store/queries.js.map +1 -0
  165. package/dist/storage/store/storage.d.ts +59 -0
  166. package/dist/storage/store/storage.d.ts.map +1 -0
  167. package/dist/storage/store/storage.js +438 -0
  168. package/dist/storage/store/storage.js.map +1 -0
  169. package/dist/storage/store/tables.d.ts +54 -0
  170. package/dist/storage/store/tables.d.ts.map +1 -0
  171. package/dist/storage/store/tables.js +108 -0
  172. package/dist/storage/store/tables.js.map +1 -0
  173. package/dist/testing/commit-writes.d.ts +5 -0
  174. package/dist/testing/commit-writes.d.ts.map +1 -0
  175. package/dist/testing/commit-writes.js +12 -0
  176. package/dist/testing/commit-writes.js.map +1 -0
  177. package/dist/testing/env-conformance.js +7 -7
  178. package/dist/testing/env-conformance.js.map +1 -1
  179. package/dist/testing/index.d.ts +1 -0
  180. package/dist/testing/index.d.ts.map +1 -1
  181. package/dist/testing/index.js +1 -0
  182. package/dist/testing/index.js.map +1 -1
  183. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  184. package/dist/testing/storage-benchmark.js +18 -17
  185. package/dist/testing/storage-benchmark.js.map +1 -1
  186. package/dist/testing/storage-conformance.d.ts.map +1 -1
  187. package/dist/testing/storage-conformance.js +354 -69
  188. package/dist/testing/storage-conformance.js.map +1 -1
  189. package/dist/tool-search/tool-search.d.ts +6 -12
  190. package/dist/tool-search/tool-search.d.ts.map +1 -1
  191. package/dist/tool-search/tool-search.js +7 -7
  192. package/dist/tool-search/tool-search.js.map +1 -1
  193. package/dist/tools/bash.d.ts +12 -12
  194. package/dist/tools/bash.d.ts.map +1 -1
  195. package/dist/tools/bash.js +16 -15
  196. package/dist/tools/bash.js.map +1 -1
  197. package/dist/tools/edit.d.ts +7 -7
  198. package/dist/tools/edit.d.ts.map +1 -1
  199. package/dist/tools/edit.js +9 -9
  200. package/dist/tools/edit.js.map +1 -1
  201. package/dist/tools/index.d.ts +32 -32
  202. package/dist/tools/read.d.ts +6 -6
  203. package/dist/tools/read.d.ts.map +1 -1
  204. package/dist/tools/read.js +19 -8
  205. package/dist/tools/read.js.map +1 -1
  206. package/dist/tools/write.d.ts +4 -4
  207. package/dist/tools/write.d.ts.map +1 -1
  208. package/dist/tools/write.js +5 -5
  209. package/dist/tools/write.js.map +1 -1
  210. package/dist/types.d.ts +223 -77
  211. package/dist/types.d.ts.map +1 -1
  212. package/dist/types.js.map +1 -1
  213. package/package.json +19 -12
  214. package/dist/harness/events.d.ts +0 -186
  215. package/dist/harness/events.d.ts.map +0 -1
  216. package/dist/harness/events.js +0 -373
  217. package/dist/harness/events.js.map +0 -1
  218. package/dist/session/observation.d.ts +0 -42
  219. package/dist/session/observation.d.ts.map +0 -1
  220. package/dist/session/observation.js +0 -281
  221. 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 { assignJson } from "./json.js";
11
- import { endRun, LiveDoc } from "./live.js";
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: 1,
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, { type: "host", callback: "env" });
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
- type: "extension",
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
- let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;
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
- (await tx.doc(LiveDoc, runtime.conversationId)).generation = { attempt: attempt + 1 };
143
- const checkpoint = {
144
- phase: "prepare",
145
- attempt: attempt + 1,
146
- ...(compacted === undefined ? {} : { compacted }),
147
- };
148
- return { status: "running", checkpoint };
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
- if (checkpoint.phase === "poll") {
191
- const model = runtime.models.getModel(checkpoint.model.provider, checkpoint.model.modelId);
192
- if (model !== undefined) {
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
- const result = harnessError("aborted", `Tool ${call.name} was aborted`);
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, { status: "unanswered", reason: "no_model" });
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, settlement) {
467
+ async function endFailedRun(tx, runtime, live, failure, end) {
293
468
  const conversationId = runtime.conversationId;
294
- endRun(tx, live, runtime.taskId, settlement);
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 the throttle and awaits that
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
- await runtime.commit(async (tx) => {
335
- const live = await tx.doc(LiveDoc, runtime.conversationId);
336
- live.generation ??= { attempt };
337
- assignJson(live.generation, "message", message);
338
- return undefined;
339
- }, context);
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 boundary selected no user item and no reset.
456
- * Otherwise the run's inputs settle `done`, and selected user items start the next run.
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 { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());
473
- if (continuation !== undefined && users.length === 0 && !reset) {
474
- const user = { role: "user", content: continuation, timestamp: runtime.now() };
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
- * Append the tool-calling answer and start its tool round in one commit (spec §8.3). A call to a tool the request did
488
- * not offer gets its `tool_unavailable` result here; every other call gets a tool task owned by the generation, only the
489
- * first one now when the round is sequential. The generation then waits for them in its `tools` phase, keeping the run.
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
- if (!offered.has(call.name)) {
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 either end the run at the final boundary (`terminate`,
529
- * `handoff`, or a queued reset) or hand it to the next generation at the `postTools` boundary (spec §8.5).
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
- // Every call of the round, including those answered without a task, must ask to terminate.
544
- const terminate = slots.length > 0 &&
545
- slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);
546
- const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);
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 = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.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
- if (terminate || handoff !== undefined) {
556
- if (handoff !== undefined) {
557
- const message = { role: "user", content: handoff, timestamp: now };
558
- const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });
559
- boundary.head = entry.id;
560
- }
561
- const { users } = await applyBoundary(tx, boundary, "final", now);
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
- /** Start a run for `inputs`, placed input submissions: a new generation takes `pi.live.run`. */
594
- export async function startRun(tx, conversationId, live, inputs) {
595
- live.run = { taskId: await createGeneration(tx, conversationId, { submissionIds: inputs }), inputs };
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 = {}) {