@earendil-works/pi-durable 1.3.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 (257) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +287 -82
  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 +21 -16
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.d.ts.map +1 -1
  8. package/dist/codemode/models.js +6 -4
  9. package/dist/codemode/models.js.map +1 -1
  10. package/dist/documents.d.ts +1 -1
  11. package/dist/documents.d.ts.map +1 -1
  12. package/dist/documents.js.map +1 -1
  13. package/dist/env/index.d.ts +12 -10
  14. package/dist/env/index.d.ts.map +1 -1
  15. package/dist/env/index.js.map +1 -1
  16. package/dist/env/node-watch.d.ts +3 -3
  17. package/dist/env/node-watch.d.ts.map +1 -1
  18. package/dist/env/node-watch.js +18 -16
  19. package/dist/env/node-watch.js.map +1 -1
  20. package/dist/env/node.d.ts +2 -2
  21. package/dist/env/node.d.ts.map +1 -1
  22. package/dist/env/node.js +12 -4
  23. package/dist/env/node.js.map +1 -1
  24. package/dist/errors.d.ts +18 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +26 -3
  27. package/dist/errors.js.map +1 -1
  28. package/dist/harness/agent.d.ts +13 -2
  29. package/dist/harness/agent.d.ts.map +1 -1
  30. package/dist/harness/agent.js +48 -6
  31. package/dist/harness/agent.js.map +1 -1
  32. package/dist/harness/compaction.d.ts +3 -1
  33. package/dist/harness/compaction.d.ts.map +1 -1
  34. package/dist/harness/compaction.js +38 -6
  35. package/dist/harness/compaction.js.map +1 -1
  36. package/dist/harness/context.d.ts +23 -7
  37. package/dist/harness/context.d.ts.map +1 -1
  38. package/dist/harness/context.js +71 -25
  39. package/dist/harness/context.js.map +1 -1
  40. package/dist/harness/define.d.ts +7 -3
  41. package/dist/harness/define.d.ts.map +1 -1
  42. package/dist/harness/define.js +4 -0
  43. package/dist/harness/define.js.map +1 -1
  44. package/dist/harness/generation.d.ts +38 -5
  45. package/dist/harness/generation.d.ts.map +1 -1
  46. package/dist/harness/generation.js +497 -108
  47. package/dist/harness/generation.js.map +1 -1
  48. package/dist/harness/harness.d.ts +7 -2
  49. package/dist/harness/harness.d.ts.map +1 -1
  50. package/dist/harness/harness.js +147 -57
  51. package/dist/harness/harness.js.map +1 -1
  52. package/dist/harness/inbox.d.ts +7 -3
  53. package/dist/harness/inbox.d.ts.map +1 -1
  54. package/dist/harness/inbox.js +7 -4
  55. package/dist/harness/inbox.js.map +1 -1
  56. package/dist/harness/json.d.ts +20 -4
  57. package/dist/harness/json.d.ts.map +1 -1
  58. package/dist/harness/json.js +23 -5
  59. package/dist/harness/json.js.map +1 -1
  60. package/dist/harness/live.d.ts +122 -16
  61. package/dist/harness/live.d.ts.map +1 -1
  62. package/dist/harness/live.js +93 -12
  63. package/dist/harness/live.js.map +1 -1
  64. package/dist/harness/partial-changes.d.ts +54 -0
  65. package/dist/harness/partial-changes.d.ts.map +1 -0
  66. package/dist/harness/partial-changes.js +162 -0
  67. package/dist/harness/partial-changes.js.map +1 -0
  68. package/dist/harness/prompt.d.ts +1 -1
  69. package/dist/harness/prompt.d.ts.map +1 -1
  70. package/dist/harness/prompt.js +1 -1
  71. package/dist/harness/prompt.js.map +1 -1
  72. package/dist/harness/registry-reader.d.ts +11 -0
  73. package/dist/harness/registry-reader.d.ts.map +1 -0
  74. package/dist/harness/registry-reader.js +25 -0
  75. package/dist/harness/registry-reader.js.map +1 -0
  76. package/dist/harness/scheduler.d.ts +86 -14
  77. package/dist/harness/scheduler.d.ts.map +1 -1
  78. package/dist/harness/scheduler.js +1459 -369
  79. package/dist/harness/scheduler.js.map +1 -1
  80. package/dist/harness/submissions.d.ts +23 -11
  81. package/dist/harness/submissions.d.ts.map +1 -1
  82. package/dist/harness/submissions.js +104 -36
  83. package/dist/harness/submissions.js.map +1 -1
  84. package/dist/harness/task-graph.d.ts +28 -8
  85. package/dist/harness/task-graph.d.ts.map +1 -1
  86. package/dist/harness/task-graph.js +85 -36
  87. package/dist/harness/task-graph.js.map +1 -1
  88. package/dist/harness/telemetry.d.ts +307 -0
  89. package/dist/harness/telemetry.d.ts.map +1 -0
  90. package/dist/harness/telemetry.js +136 -0
  91. package/dist/harness/telemetry.js.map +1 -0
  92. package/dist/harness/tool.d.ts +2 -0
  93. package/dist/harness/tool.d.ts.map +1 -1
  94. package/dist/harness/tool.js +223 -61
  95. package/dist/harness/tool.js.map +1 -1
  96. package/dist/harness/transcript.d.ts +72 -0
  97. package/dist/harness/transcript.d.ts.map +1 -0
  98. package/dist/harness/transcript.js +264 -0
  99. package/dist/harness/transcript.js.map +1 -0
  100. package/dist/harness/types.d.ts +302 -48
  101. package/dist/harness/types.d.ts.map +1 -1
  102. package/dist/harness/types.js.map +1 -1
  103. package/dist/harness/util.d.ts +7 -1
  104. package/dist/harness/util.d.ts.map +1 -1
  105. package/dist/harness/util.js +13 -0
  106. package/dist/harness/util.js.map +1 -1
  107. package/dist/harness/view.d.ts +43 -22
  108. package/dist/harness/view.d.ts.map +1 -1
  109. package/dist/harness/view.js +130 -71
  110. package/dist/harness/view.js.map +1 -1
  111. package/dist/index.d.ts +13 -9
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +7 -5
  114. package/dist/index.js.map +1 -1
  115. package/dist/search.d.ts +19 -3
  116. package/dist/search.d.ts.map +1 -1
  117. package/dist/search.js +107 -44
  118. package/dist/search.js.map +1 -1
  119. package/dist/session/cached-storage.d.ts +71 -0
  120. package/dist/session/cached-storage.d.ts.map +1 -0
  121. package/dist/session/cached-storage.js +269 -0
  122. package/dist/session/cached-storage.js.map +1 -0
  123. package/dist/session/conversation-tails.d.ts +33 -0
  124. package/dist/session/conversation-tails.d.ts.map +1 -0
  125. package/dist/session/conversation-tails.js +101 -0
  126. package/dist/session/conversation-tails.js.map +1 -0
  127. package/dist/session/keys.d.ts +68 -0
  128. package/dist/session/keys.d.ts.map +1 -0
  129. package/dist/session/keys.js +161 -0
  130. package/dist/session/keys.js.map +1 -0
  131. package/dist/session/session.d.ts +181 -29
  132. package/dist/session/session.d.ts.map +1 -1
  133. package/dist/session/session.js +1073 -183
  134. package/dist/session/session.js.map +1 -1
  135. package/dist/session/transaction.d.ts +86 -4
  136. package/dist/session/transaction.d.ts.map +1 -1
  137. package/dist/session/transaction.js +250 -38
  138. package/dist/session/transaction.js.map +1 -1
  139. package/dist/session/writer.d.ts +74 -0
  140. package/dist/session/writer.d.ts.map +1 -0
  141. package/dist/session/writer.js +294 -0
  142. package/dist/session/writer.js.map +1 -0
  143. package/dist/storage/indexeddb.d.ts +68 -0
  144. package/dist/storage/indexeddb.d.ts.map +1 -0
  145. package/dist/storage/indexeddb.js +881 -0
  146. package/dist/storage/indexeddb.js.map +1 -0
  147. package/dist/storage/jsonl/storage.d.ts +30 -2
  148. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  149. package/dist/storage/jsonl/storage.js +88 -24
  150. package/dist/storage/jsonl/storage.js.map +1 -1
  151. package/dist/storage/memory.d.ts +7 -2
  152. package/dist/storage/memory.d.ts.map +1 -1
  153. package/dist/storage/memory.js +29 -3
  154. package/dist/storage/memory.js.map +1 -1
  155. package/dist/storage/sqlite/cloudflare.d.ts +2 -51
  156. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -1
  157. package/dist/storage/sqlite/cloudflare.js +2 -133
  158. package/dist/storage/sqlite/cloudflare.js.map +1 -1
  159. package/dist/storage/sqlite/database.d.ts +1 -36
  160. package/dist/storage/sqlite/database.d.ts.map +1 -1
  161. package/dist/storage/sqlite/database.js.map +1 -1
  162. package/dist/storage/sqlite/index.d.ts +1 -1
  163. package/dist/storage/sqlite/index.d.ts.map +1 -1
  164. package/dist/storage/sqlite/index.js.map +1 -1
  165. package/dist/storage/sqlite/migrations.d.ts +2 -5
  166. package/dist/storage/sqlite/migrations.d.ts.map +1 -1
  167. package/dist/storage/sqlite/migrations.js +28 -28
  168. package/dist/storage/sqlite/migrations.js.map +1 -1
  169. package/dist/storage/sqlite/node.d.ts +2 -37
  170. package/dist/storage/sqlite/node.d.ts.map +1 -1
  171. package/dist/storage/sqlite/node.js +3 -174
  172. package/dist/storage/sqlite/node.js.map +1 -1
  173. package/dist/storage/sqlite/schema.d.ts +1 -3
  174. package/dist/storage/sqlite/schema.d.ts.map +1 -1
  175. package/dist/storage/sqlite/schema.js +2 -9
  176. package/dist/storage/sqlite/schema.js.map +1 -1
  177. package/dist/storage/sqlite/storage.d.ts +15 -3
  178. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  179. package/dist/storage/sqlite/storage.js +110 -36
  180. package/dist/storage/sqlite/storage.js.map +1 -1
  181. package/dist/storage/store/commit.d.ts +33 -0
  182. package/dist/storage/store/commit.d.ts.map +1 -0
  183. package/dist/storage/store/commit.js +286 -0
  184. package/dist/storage/store/commit.js.map +1 -0
  185. package/dist/storage/store/documents.d.ts +43 -0
  186. package/dist/storage/store/documents.d.ts.map +1 -0
  187. package/dist/storage/store/documents.js +102 -0
  188. package/dist/storage/store/documents.js.map +1 -0
  189. package/dist/storage/store/index.d.ts +2 -0
  190. package/dist/storage/store/index.d.ts.map +1 -0
  191. package/dist/storage/store/index.js +2 -0
  192. package/dist/storage/store/index.js.map +1 -0
  193. package/dist/storage/store/queries.d.ts +49 -0
  194. package/dist/storage/store/queries.d.ts.map +1 -0
  195. package/dist/storage/store/queries.js +93 -0
  196. package/dist/storage/store/queries.js.map +1 -0
  197. package/dist/storage/store/storage.d.ts +59 -0
  198. package/dist/storage/store/storage.d.ts.map +1 -0
  199. package/dist/storage/store/storage.js +438 -0
  200. package/dist/storage/store/storage.js.map +1 -0
  201. package/dist/storage/store/tables.d.ts +54 -0
  202. package/dist/storage/store/tables.d.ts.map +1 -0
  203. package/dist/storage/store/tables.js +108 -0
  204. package/dist/storage/store/tables.js.map +1 -0
  205. package/dist/testing/commit-writes.d.ts +5 -0
  206. package/dist/testing/commit-writes.d.ts.map +1 -0
  207. package/dist/testing/commit-writes.js +12 -0
  208. package/dist/testing/commit-writes.js.map +1 -0
  209. package/dist/testing/env-conformance.js +7 -7
  210. package/dist/testing/env-conformance.js.map +1 -1
  211. package/dist/testing/index.d.ts +1 -0
  212. package/dist/testing/index.d.ts.map +1 -1
  213. package/dist/testing/index.js +1 -0
  214. package/dist/testing/index.js.map +1 -1
  215. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  216. package/dist/testing/storage-benchmark.js +18 -17
  217. package/dist/testing/storage-benchmark.js.map +1 -1
  218. package/dist/testing/storage-conformance.d.ts.map +1 -1
  219. package/dist/testing/storage-conformance.js +369 -67
  220. package/dist/testing/storage-conformance.js.map +1 -1
  221. package/dist/timers.d.ts +21 -0
  222. package/dist/timers.d.ts.map +1 -0
  223. package/dist/timers.js +72 -0
  224. package/dist/timers.js.map +1 -0
  225. package/dist/tool-search/tool-search.d.ts +6 -12
  226. package/dist/tool-search/tool-search.d.ts.map +1 -1
  227. package/dist/tool-search/tool-search.js +7 -7
  228. package/dist/tool-search/tool-search.js.map +1 -1
  229. package/dist/tools/bash.d.ts +12 -12
  230. package/dist/tools/bash.d.ts.map +1 -1
  231. package/dist/tools/bash.js +16 -15
  232. package/dist/tools/bash.js.map +1 -1
  233. package/dist/tools/edit.d.ts +7 -7
  234. package/dist/tools/edit.d.ts.map +1 -1
  235. package/dist/tools/edit.js +9 -9
  236. package/dist/tools/edit.js.map +1 -1
  237. package/dist/tools/index.d.ts +32 -32
  238. package/dist/tools/read.d.ts +6 -6
  239. package/dist/tools/read.d.ts.map +1 -1
  240. package/dist/tools/read.js +19 -8
  241. package/dist/tools/read.js.map +1 -1
  242. package/dist/tools/write.d.ts +4 -4
  243. package/dist/tools/write.d.ts.map +1 -1
  244. package/dist/tools/write.js +5 -5
  245. package/dist/tools/write.js.map +1 -1
  246. package/dist/types.d.ts +331 -64
  247. package/dist/types.d.ts.map +1 -1
  248. package/dist/types.js.map +1 -1
  249. package/package.json +27 -10
  250. package/dist/harness/events.d.ts +0 -186
  251. package/dist/harness/events.d.ts.map +0 -1
  252. package/dist/harness/events.js +0 -373
  253. package/dist/harness/events.js.map +0 -1
  254. package/dist/session/observation.d.ts +0 -42
  255. package/dist/session/observation.d.ts.map +0 -1
  256. package/dist/session/observation.js +0 -281
  257. package/dist/session/observation.js.map +0 -1
@@ -4,15 +4,17 @@ 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 } from "./agent.js";
8
- import { createCompaction, estimateContext, selectCut } from "./compaction.js";
9
- import { applyBoundary, prepareBoundary } from "./inbox.js";
10
- import { assignJson } from "./json.js";
11
- import { endRun, LiveDoc } from "./live.js";
7
+ import { addTools, agentHooks, sectionExtension } from "./agent.js";
8
+ import { compactionPolicy, createCompaction, estimateContext, selectCut } from "./compaction.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";
17
+ import { optionalExtension } from "./util.js";
16
18
  const DEFAULT_POLL_AFTER_MS = 5000;
17
19
  /**
18
20
  * Built-in generation task: prepares the positional system prompt and tool loadout, requests or polls the model,
@@ -20,8 +22,15 @@ const DEFAULT_POLL_AFTER_MS = 5000;
20
22
  */
21
23
  export const GenerationTask = defineTask({
22
24
  name: "pi.generation",
23
- version: 1,
25
+ version: 2,
24
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
+ }),
25
34
  phases: {
26
35
  /**
27
36
  * Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to
@@ -30,6 +39,8 @@ export const GenerationTask = defineTask({
30
39
  * may land meanwhile.
31
40
  */
32
41
  prepare: async (task, runtime, context) => {
42
+ if (cutOffTooOften(task, runtime))
43
+ return endCutOff(runtime, task, context);
33
44
  const { conversationId } = runtime;
34
45
  const agent = await runtime.agent(context);
35
46
  const settings = runtime.settings;
@@ -53,8 +64,13 @@ export const GenerationTask = defineTask({
53
64
  }
54
65
  }
55
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);
56
73
  const shown = replaySections(view.messages);
57
- const report = (error) => runtime.report(error);
58
74
  let env;
59
75
  try {
60
76
  env = await runtime.env(context);
@@ -62,13 +78,16 @@ export const GenerationTask = defineTask({
62
78
  catch (error) {
63
79
  if (context.abortSignal?.aborted)
64
80
  throw error;
65
- 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
- const desired = await renderSections(agent.sections, input, shown, report, context);
84
+ const desired = await renderSections(agent.sections, input, shown, (error, section) => runtime.report(error, {
85
+ kind: "extension",
86
+ ...optionalExtension(sectionExtension(agent, section.key)),
87
+ }), context);
69
88
  const entries = planSystemEntries(view, desired, agent.tools, runtime.now());
70
89
  const threshold = compacted === undefined
71
- ? thresholdCompaction(view, entries, resolved.contextWindow, settings.compaction)
90
+ ? thresholdCompaction(view, entries, resolved.contextWindow, compactionPolicy(settings, model))
72
91
  : undefined;
73
92
  if (threshold === "blocking") {
74
93
  // Compact first and prepare again; the transcript is unchanged until the compaction appends.
@@ -80,11 +99,10 @@ export const GenerationTask = defineTask({
80
99
  return;
81
100
  }
82
101
  await runtime.commit(async (tx) => {
83
- 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));
84
104
  for (const entry of entries)
85
105
  cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;
86
- if (cutoff === undefined)
87
- throw new Error(`Conversation ${conversationId} has no entries to send`);
88
106
  // Checked in this commit, so a compaction admitted during preparation counts.
89
107
  if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {
90
108
  await createCompaction(tx, conversationId, { reason: "threshold" });
@@ -101,14 +119,22 @@ export const GenerationTask = defineTask({
101
119
  }, context);
102
120
  },
103
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);
104
128
  const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;
105
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.
106
132
  await runtime.commit(async (tx) => {
107
133
  const live = await tx.doc(LiveDoc, conversationId);
108
134
  await convertPartial(tx, live, conversationId);
109
135
  live.generation = { attempt };
110
136
  return undefined;
111
- }, context);
137
+ }, context, PROGRESS_COMMIT);
112
138
  const model = runtime.models.getModel(ref.provider, ref.modelId);
113
139
  if (model === undefined)
114
140
  return failNoModel(runtime, ref, context);
@@ -122,37 +148,62 @@ export const GenerationTask = defineTask({
122
148
  const options = {
123
149
  ...streamOptions,
124
150
  signal: runtime.signal,
151
+ telemetry: context.telemetry,
125
152
  sessionId: await ensureProviderSessionId(runtime, context),
126
153
  ...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),
154
+ onPayload: (payload) => providerPayload(runtime, model, payload, context),
127
155
  };
128
156
  const message = await streamResponse(runtime, model, messages, options, attempt, context);
129
- const request = { attempt, compacted, model: ref, cutoff, messages: view.messages };
157
+ const request = { attempt, compacted, model: ref, thinkingLevel, cutoff, messages: view.messages };
130
158
  await classify(runtime, request, message, context);
131
159
  },
160
+ /** After the backoff's sleep; a run before `until` (an early wake, or a record of an earlier version) sleeps again. */
132
161
  retry: async (task, runtime, context) => {
133
- const { attempt, compacted, until } = task.state.checkpoint;
134
- await runtime.sleep(until, context);
162
+ const { attempt, compacted, until, poll } = task.state.checkpoint;
163
+ if (cutOffTooOften(task, runtime))
164
+ return endCutOff(runtime, task, context);
165
+ if (until > runtime.now())
166
+ return runtime.sleepUntil(until, task.state.checkpoint, context);
135
167
  await runtime.commit(async (tx) => {
136
- (await tx.doc(LiveDoc, runtime.conversationId)).generation = { attempt: attempt + 1 };
137
- const checkpoint = {
138
- phase: "prepare",
139
- attempt: attempt + 1,
140
- ...(compacted === undefined ? {} : { compacted }),
141
- };
142
- 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 } };
143
181
  }, context);
144
182
  },
183
+ /** After the sleep until `pollAt`; a run before it (an early wake, or a record of an earlier version) sleeps again. */
145
184
  poll: async (task, runtime, context) => {
146
- 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);
192
+ if (pollAt > runtime.now())
193
+ return runtime.sleepUntil(pollAt, task.state.checkpoint, context);
147
194
  const model = runtime.models.getModel(ref.provider, ref.modelId);
148
195
  if (model === undefined)
149
196
  return failNoModel(runtime, ref, context);
150
- await runtime.sleep(pollAt, context);
151
- const message = await runtime.models.fetchDeferred(model, handle, { signal: runtime.signal });
152
- const request = { attempt, compacted, model: ref, cutoff, pollAt };
197
+ const message = await runtime.models.fetchDeferred(model, handle, {
198
+ signal: runtime.signal,
199
+ telemetry: context.telemetry,
200
+ });
201
+ const request = { attempt, compacted, model: ref, thinkingLevel, cutoff, pollAt };
153
202
  await classify(runtime, request, message, context);
154
203
  },
155
204
  tools: async (task, runtime, context) => {
205
+ if (cutOffTooOften(task, runtime))
206
+ return endCutOff(runtime, task, context);
156
207
  const { assistant, tools, pending } = task.state.checkpoint;
157
208
  const [next, ...rest] = pending;
158
209
  if (next === undefined)
@@ -160,8 +211,8 @@ export const GenerationTask = defineTask({
160
211
  // Sequential round: start the next call and wait for it.
161
212
  await runtime.commit(async (tx) => {
162
213
  const live = await tx.doc(LiveDoc, runtime.conversationId);
163
- const taskId = await createToolTask(tx, runtime, assistant, next);
164
214
  const slot = live.tools?.find((slot) => slot.callId === next && slot.taskId === undefined);
215
+ const taskId = await createToolTask(tx, runtime, assistant, next, slot?.unoffered);
165
216
  if (slot !== undefined)
166
217
  slot.taskId = taskId;
167
218
  const checkpoint = {
@@ -173,22 +224,50 @@ export const GenerationTask = defineTask({
173
224
  return { status: "waiting", checkpoint, on: [taskId], policy: "allSettled" };
174
225
  }, context);
175
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
+ },
176
262
  },
177
263
  abort: async (task, runtime, context) => {
178
264
  const checkpoint = task.state.checkpoint;
179
- if (checkpoint.phase === "poll") {
180
- const model = runtime.models.getModel(checkpoint.model.provider, checkpoint.model.modelId);
181
- if (model !== undefined) {
182
- try {
183
- await runtime.models.cancelDeferred(model, checkpoint.handle, { signal: runtime.signal });
184
- }
185
- catch (error) {
186
- runtime.report(error);
187
- }
188
- }
189
- }
265
+ const polled = deferredOf(checkpoint);
266
+ if (polled !== undefined)
267
+ await cancelDeferred(runtime, polled, context);
190
268
  const conversationId = runtime.conversationId;
191
- // 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`.
192
271
  const unstarted = checkpoint.phase === "tools"
193
272
  ? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context)
194
273
  : [];
@@ -196,9 +275,14 @@ export const GenerationTask = defineTask({
196
275
  const live = await tx.doc(LiveDoc, conversationId);
197
276
  await convertPartial(tx, live, conversationId);
198
277
  for (const call of unstarted) {
199
- 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`);
200
283
  await appendToolResult(tx, conversationId, call, result, { timestamp: runtime.now() });
201
284
  }
285
+ // In `finish` and `drain` the run answered already: it ends without a failure, its inputs stay `done`.
202
286
  endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" });
203
287
  return { status: "terminal", outcome: { status: "aborted" } };
204
288
  }, context);
@@ -210,9 +294,9 @@ async function readCalls(runtime, assistant, callIds, context) {
210
294
  const calls = message?.role === "assistant" ? message.content.filter((content) => content.type === "toolCall") : [];
211
295
  return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1));
212
296
  }
213
- /** A tool task for call `callId`, owned by the generation. */
214
- function createToolTask(tx, runtime, assistant, callId) {
215
- 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 } });
216
300
  }
217
301
  /**
218
302
  * Which threshold compaction preparation starts before its request (spec §8.3): `blocking` above
@@ -245,23 +329,152 @@ function compactionFailure(outcome) {
245
329
  /** Settle the run's inputs `unanswered` with `model_error` and fail with `text`. */
246
330
  async function failModelError(runtime, text, context) {
247
331
  await runtime.commit(async (tx) => {
332
+ const failure = await prepareFailure(tx, runtime);
248
333
  const live = await tx.doc(LiveDoc, runtime.conversationId);
249
- endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
334
+ await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "model_error", detail: text });
250
335
  return {
251
336
  status: "terminal",
252
337
  outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
253
338
  };
254
339
  }, context);
255
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
+ }
256
430
  /** Settle the run's inputs `unanswered` with `no_model` and fail. */
257
431
  async function failNoModel(runtime, ref, context) {
258
432
  const message = ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
259
433
  await runtime.commit(async (tx) => {
434
+ const failure = await prepareFailure(tx, runtime);
260
435
  const live = await tx.doc(LiveDoc, runtime.conversationId);
261
- endRun(tx, live, runtime.taskId, { 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
+ });
262
442
  return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
263
443
  }, context);
264
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
+ }
456
+ async function prepareFailure(tx, runtime) {
457
+ const settings = runtime.settings;
458
+ const policy = settings.afterFailedRun;
459
+ if (policy !== "continue")
460
+ return { policy };
461
+ return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) };
462
+ }
463
+ /**
464
+ * End a failed run, settling its inputs with `settlement`, and do with the inputs queued behind it what
465
+ * `settings.afterFailedRun` says (spec §6): keep them, start the next run with them, or withdraw them.
466
+ */
467
+ async function endFailedRun(tx, runtime, live, failure, end) {
468
+ const conversationId = runtime.conversationId;
469
+ endRun(tx, live, runtime.taskId, end);
470
+ if (failure.policy === "withdraw")
471
+ await withdrawQueuedInputs(tx, conversationId, "run_failed");
472
+ if (failure.boundary === undefined)
473
+ return;
474
+ const { users, first } = await applyBoundary(tx, failure.boundary, "final", runtime.now());
475
+ if (users.length > 0)
476
+ await startRun(tx, conversationId, live, { inputs: users, first }, { keepFailure: true });
477
+ }
265
478
  /**
266
479
  * Append a committed partial left by an interrupted, aborted, faulted, or orphaned attempt as an aborted assistant
267
480
  * entry; the caller replaces or removes `generation`.
@@ -273,10 +486,22 @@ export async function convertPartial(tx, live, conversationId) {
273
486
  const message = copyJson(partial);
274
487
  await appendAssistant(tx, conversationId, { ...message, stopReason: "aborted" });
275
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
+ }
276
499
  /**
277
500
  * Stream one request and return the terminal message. Partials commit as trailing writes at most every
278
- * `progress.partialIntervalMs` (default 100 ms) with one commit in flight; `finally` stops the throttle and awaits that
279
- * 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.
280
505
  */
281
506
  async function streamResponse(runtime, model, messages, options, attempt, context) {
282
507
  const interval = runtime.settings.progress.partialIntervalMs;
@@ -284,6 +509,7 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
284
509
  let timer;
285
510
  let inFlight;
286
511
  let stopped = false;
512
+ const changes = new PartialChanges();
287
513
  const flush = () => {
288
514
  timer = undefined;
289
515
  const partial = pending;
@@ -291,14 +517,22 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
291
517
  if (partial === undefined || stopped)
292
518
  return;
293
519
  inFlight = (async () => {
294
- // 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.
295
522
  const message = copyJson(partial, { omitUndefinedProperties: true });
296
- await runtime.commit(async (tx) => {
297
- const live = await tx.doc(LiveDoc, runtime.conversationId);
298
- live.generation ??= { attempt };
299
- assignJson(live.generation, "message", message);
300
- return undefined;
301
- }, 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
+ }
302
536
  })()
303
537
  .catch((error) => {
304
538
  // Rejections after an abort mark or close are expected; the committed state stays consistent.
@@ -308,7 +542,7 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
308
542
  .finally(() => {
309
543
  inFlight = undefined;
310
544
  if (pending !== undefined && !stopped)
311
- timer = setTimeout(flush, interval);
545
+ timer = runtime.setTimer(flush, interval);
312
546
  });
313
547
  };
314
548
  try {
@@ -318,15 +552,16 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
318
552
  // never gets past it, so it never leaves a partial.
319
553
  if (event.type === "done" || event.type === "error" || event.partial.content.length === 0)
320
554
  continue;
555
+ changes.observe(event);
321
556
  pending = event.partial;
322
557
  if (timer === undefined && inFlight === undefined)
323
- timer = setTimeout(flush, interval);
558
+ timer = runtime.setTimer(flush, interval);
324
559
  }
325
560
  return await events.result();
326
561
  }
327
562
  finally {
328
563
  stopped = true;
329
- clearTimeout(timer);
564
+ timer?.();
330
565
  await inFlight;
331
566
  }
332
567
  }
@@ -335,7 +570,10 @@ async function classify(runtime, request, message, context) {
335
570
  // An abort mark or close: the abort invocation or the reopened run handles the committed state.
336
571
  runtime.signal.throwIfAborted();
337
572
  const conversationId = runtime.conversationId;
338
- 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;
339
577
  if (message.stopReason === "deferred" && message.deferred !== undefined) {
340
578
  const handle = message.deferred;
341
579
  const pollAt = Math.max(runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS), request.pollAt === undefined ? Number.NEGATIVE_INFINITY : request.pollAt + 1);
@@ -346,11 +584,12 @@ async function classify(runtime, request, message, context) {
346
584
  attempt,
347
585
  ...(compacted === undefined ? {} : { compacted }),
348
586
  model: ref,
587
+ ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
349
588
  cutoff,
350
589
  handle,
351
590
  pollAt,
352
591
  };
353
- return { status: "running", checkpoint };
592
+ return { status: "sleeping", checkpoint, until: pollAt };
354
593
  }, context);
355
594
  return;
356
595
  }
@@ -365,10 +604,10 @@ async function classify(runtime, request, message, context) {
365
604
  // The retry and compaction policies govern the next attempt, so they are read now rather than pinned at preparation.
366
605
  const settings = runtime.settings;
367
606
  const overflow = message.stopReason === "error" && isContextOverflow(message);
368
- if (overflow && compacted === undefined && settings.compaction.enabled) {
369
- const policy = settings.compaction;
607
+ const compaction = compactionPolicy(settings, ref);
608
+ if (overflow && compacted === undefined && compaction.enabled) {
370
609
  const view = await runtime.context(conversationId, context, { at: cutoff });
371
- if (selectCut(view, policy.keepRecentTokens) !== undefined) {
610
+ if (selectCut(view, compaction.keepRecentTokens) !== undefined) {
372
611
  const text = message.errorMessage ?? "Context overflow";
373
612
  await runtime.commit(async (tx) => {
374
613
  const live = await tx.doc(LiveDoc, conversationId);
@@ -390,9 +629,11 @@ async function classify(runtime, request, message, context) {
390
629
  attempt <= policy.maxRetries;
391
630
  const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;
392
631
  await runtime.commit(async (tx) => {
632
+ // Read before the first table write; only a failure uses it.
633
+ const failure = retry ? undefined : await prepareFailure(tx, runtime);
393
634
  const live = await tx.doc(LiveDoc, conversationId);
394
635
  await appendAssistant(tx, conversationId, message);
395
- if (retry) {
636
+ if (failure === undefined) {
396
637
  live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } };
397
638
  const checkpoint = {
398
639
  phase: "retry",
@@ -400,10 +641,10 @@ async function classify(runtime, request, message, context) {
400
641
  ...(compacted === undefined ? {} : { compacted }),
401
642
  until,
402
643
  };
403
- return { status: "running", checkpoint };
644
+ return { status: "sleeping", checkpoint, until };
404
645
  }
405
646
  const text = message.errorMessage ?? `Model response ended with stop reason ${message.stopReason}`;
406
- endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
647
+ await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "model_error", detail: text });
407
648
  return {
408
649
  status: "terminal",
409
650
  outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
@@ -412,8 +653,9 @@ async function classify(runtime, request, message, context) {
412
653
  }
413
654
  /**
414
655
  * A final answer; the final boundary places queued items (spec §6). The first `onYield` continuation appends a user
415
- * message and hands the run to a successor generation, but only when the boundary selected no user item and no reset.
416
- * 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.
417
659
  */
418
660
  async function answer(runtime, message, context) {
419
661
  let continuation;
@@ -422,6 +664,7 @@ async function answer(runtime, message, context) {
422
664
  return;
423
665
  continuation = (await hook(message, runtime, context))?.continue;
424
666
  });
667
+ const afterRun = await asksAfterRun(runtime, context);
425
668
  const conversationId = runtime.conversationId;
426
669
  await runtime.commit(async (tx) => {
427
670
  // Queue modes are read on the Session line, when the boundary is decided.
@@ -429,24 +672,135 @@ async function answer(runtime, message, context) {
429
672
  const live = await tx.doc(LiveDoc, conversationId);
430
673
  const entry = await appendAssistant(tx, conversationId, message);
431
674
  const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
432
- const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());
433
- if (continuation !== undefined && users.length === 0 && !reset) {
434
- 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() };
435
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;
436
688
  handOver(live, runtime.taskId, await createGeneration(tx, conversationId));
437
689
  delete live.generation;
438
690
  return result;
439
691
  }
440
692
  endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
441
693
  if (users.length > 0)
442
- await startRun(tx, conversationId, live, users);
694
+ await startRun(tx, conversationId, live, { inputs: users, first });
443
695
  return result;
444
696
  }, context);
445
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
+ }
788
+ /**
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
+ }
446
799
  /**
447
- * Append the tool-calling answer and start its tool round in one commit (spec §8.3). A call to a tool the request did
448
- * not offer gets its `tool_unavailable` result here; every other call gets a tool task owned by the generation, only the
449
- * first one now when the round is sequential. The generation then waits for them in its `tools` phase, keeping the run.
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.
450
804
  */
451
805
  async function startToolRound(runtime, request, message, calls, context) {
452
806
  const conversationId = runtime.conversationId;
@@ -463,30 +817,33 @@ async function startToolRound(runtime, request, message, calls, context) {
463
817
  const tools = [];
464
818
  const pending = [];
465
819
  for (const call of calls) {
466
- if (!offered.has(call.name)) {
467
- const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
468
- const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });
469
- slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });
470
- continue;
471
- }
820
+ const unoffered = offered.has(call.name) ? {} : { unoffered: true };
472
821
  if (sequential && tools.length > 0) {
473
822
  pending.push(call.id);
474
- slots.push({ callId: call.id, name: call.name, status: "pending" });
823
+ slots.push({ callId: call.id, name: call.name, status: "pending", ...unoffered });
475
824
  continue;
476
825
  }
477
- const taskId = await createToolTask(tx, runtime, entry.id, call.id);
826
+ const taskId = await createToolTask(tx, runtime, entry.id, call.id, unoffered.unoffered);
478
827
  tools.push(taskId);
479
- slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });
828
+ slots.push({ callId: call.id, name: call.name, taskId, status: "pending", ...unoffered });
480
829
  }
481
830
  delete live.generation;
482
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
+ }
483
838
  const checkpoint = { phase: "tools", assistant: entry.id, tools, pending };
484
839
  return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };
485
840
  }, context);
486
841
  }
487
842
  /**
488
- * The round's tools are terminal: apply their controls and either end the run at the final boundary (`terminate`,
489
- * `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).
490
847
  */
491
848
  async function finishToolRound(runtime, assistant, tools, context) {
492
849
  const conversationId = runtime.conversationId;
@@ -500,36 +857,60 @@ async function finishToolRound(runtime, assistant, tools, context) {
500
857
  const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
501
858
  const results = slots.flatMap((slot) => (slot.entry === undefined ? [] : [slot.entry]));
502
859
  await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
503
- // Every call of the round, including those answered without a task, must ask to terminate.
504
- const terminate = slots.length > 0 &&
505
- slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);
506
- 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 ?? []);
507
865
  // The last handoff in call order wins.
508
- 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));
509
870
  await runtime.commit(async (tx) => {
510
871
  const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
511
872
  if (added.length > 0)
512
873
  await addTools(tx, conversationId, added);
513
874
  const live = await tx.doc(LiveDoc, conversationId);
514
875
  const now = runtime.now();
515
- if (terminate || handoff !== undefined) {
516
- if (handoff !== undefined) {
517
- const message = { role: "user", content: handoff, timestamp: now };
518
- const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });
519
- boundary.head = entry.id;
520
- }
521
- 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);
522
891
  endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });
523
892
  if (users.length > 0)
524
- 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
+ });
525
905
  }
526
906
  else {
527
- const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);
907
+ const { users, first, reset } = await applyBoundary(tx, boundary, "postTools", now);
528
908
  if (reset) {
529
909
  // The queued reset cut the run's context before an answer.
530
910
  endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });
531
- if (users.length > 0)
532
- await startRun(tx, conversationId, live, users);
911
+ if (users.length > 0) {
912
+ await startRun(tx, conversationId, live, { inputs: users, first }, { keepFailure: true });
913
+ }
533
914
  }
534
915
  else {
535
916
  delete live.tools;
@@ -550,13 +931,21 @@ async function appendAssistant(tx, conversationId, message) {
550
931
  await recordUsage(tx, conversationId, "models", `${message.provider}/${message.model}`, message.usage);
551
932
  return tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
552
933
  }
553
- /** Start a run for `inputs`, placed input submissions: a new generation takes `pi.live.run`. */
554
- export async function startRun(tx, conversationId, live, inputs) {
555
- live.run = { taskId: await createGeneration(tx, conversationId), 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;
556
945
  }
557
946
  /** A generation owned by its conversation. */
558
- function createGeneration(tx, conversationId) {
559
- return tx.createTask(GenerationTask, {}, { ownership: { kind: "conversation" }, conversationId });
947
+ function createGeneration(tx, conversationId, telemetry = {}) {
948
+ return tx.createTask(GenerationTask, {}, { ownership: { kind: "conversation" }, conversationId, ...telemetry });
560
949
  }
561
950
  /** Hand run control from `from` to `to`; the run's inputs move with it. */
562
951
  export function handOver(live, from, to) {