@earendil-works/pi-durable 0.99.1 → 1.0.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 (203) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +557 -46
  3. package/dist/entries.d.ts +20 -2
  4. package/dist/entries.d.ts.map +1 -1
  5. package/dist/entries.js +14 -1
  6. package/dist/entries.js.map +1 -1
  7. package/dist/env/index.d.ts +18 -38
  8. package/dist/env/index.d.ts.map +1 -1
  9. package/dist/env/index.js +2 -0
  10. package/dist/env/index.js.map +1 -1
  11. package/dist/env/node.d.ts +2 -0
  12. package/dist/env/node.d.ts.map +1 -1
  13. package/dist/env/node.js +53 -58
  14. package/dist/env/node.js.map +1 -1
  15. package/dist/harness/agent.d.ts +31 -0
  16. package/dist/harness/agent.d.ts.map +1 -0
  17. package/dist/harness/agent.js +209 -0
  18. package/dist/harness/agent.js.map +1 -0
  19. package/dist/harness/compaction.d.ts +56 -0
  20. package/dist/harness/compaction.d.ts.map +1 -0
  21. package/dist/harness/compaction.js +382 -0
  22. package/dist/harness/compaction.js.map +1 -0
  23. package/dist/harness/context.d.ts +8 -0
  24. package/dist/harness/context.d.ts.map +1 -1
  25. package/dist/harness/context.js +32 -21
  26. package/dist/harness/context.js.map +1 -1
  27. package/dist/harness/define.d.ts +18 -0
  28. package/dist/harness/define.d.ts.map +1 -0
  29. package/dist/harness/define.js +25 -0
  30. package/dist/harness/define.js.map +1 -0
  31. package/dist/harness/events.d.ts +166 -0
  32. package/dist/harness/events.d.ts.map +1 -0
  33. package/dist/harness/events.js +319 -0
  34. package/dist/harness/events.js.map +1 -0
  35. package/dist/harness/generation.d.ts +36 -7
  36. package/dist/harness/generation.d.ts.map +1 -1
  37. package/dist/harness/generation.js +356 -82
  38. package/dist/harness/generation.js.map +1 -1
  39. package/dist/harness/harness.d.ts.map +1 -1
  40. package/dist/harness/harness.js +141 -106
  41. package/dist/harness/harness.js.map +1 -1
  42. package/dist/harness/inbox.d.ts +59 -0
  43. package/dist/harness/inbox.d.ts.map +1 -0
  44. package/dist/harness/inbox.js +97 -0
  45. package/dist/harness/inbox.js.map +1 -0
  46. package/dist/harness/json.d.ts +10 -0
  47. package/dist/harness/json.d.ts.map +1 -0
  48. package/dist/harness/json.js +33 -0
  49. package/dist/harness/json.js.map +1 -0
  50. package/dist/harness/live.d.ts +66 -7
  51. package/dist/harness/live.d.ts.map +1 -1
  52. package/dist/harness/live.js +71 -7
  53. package/dist/harness/live.js.map +1 -1
  54. package/dist/harness/output.d.ts +62 -0
  55. package/dist/harness/output.d.ts.map +1 -0
  56. package/dist/harness/output.js +261 -0
  57. package/dist/harness/output.js.map +1 -0
  58. package/dist/harness/prompt.d.ts +9 -6
  59. package/dist/harness/prompt.d.ts.map +1 -1
  60. package/dist/harness/prompt.js +57 -14
  61. package/dist/harness/prompt.js.map +1 -1
  62. package/dist/harness/registry.d.ts +2 -3
  63. package/dist/harness/registry.d.ts.map +1 -1
  64. package/dist/harness/registry.js +69 -277
  65. package/dist/harness/registry.js.map +1 -1
  66. package/dist/harness/scheduler.d.ts +42 -12
  67. package/dist/harness/scheduler.d.ts.map +1 -1
  68. package/dist/harness/scheduler.js +649 -75
  69. package/dist/harness/scheduler.js.map +1 -1
  70. package/dist/harness/submissions.d.ts +14 -9
  71. package/dist/harness/submissions.d.ts.map +1 -1
  72. package/dist/harness/submissions.js +76 -34
  73. package/dist/harness/submissions.js.map +1 -1
  74. package/dist/harness/task-graph.d.ts +49 -0
  75. package/dist/harness/task-graph.d.ts.map +1 -0
  76. package/dist/harness/task-graph.js +163 -0
  77. package/dist/harness/task-graph.js.map +1 -0
  78. package/dist/harness/tool.d.ts +36 -0
  79. package/dist/harness/tool.d.ts.map +1 -0
  80. package/dist/harness/tool.js +415 -0
  81. package/dist/harness/tool.js.map +1 -0
  82. package/dist/harness/types.d.ts +330 -125
  83. package/dist/harness/types.d.ts.map +1 -1
  84. package/dist/harness/types.js.map +1 -1
  85. package/dist/harness/usage.d.ts +18 -0
  86. package/dist/harness/usage.d.ts.map +1 -0
  87. package/dist/harness/usage.js +57 -0
  88. package/dist/harness/usage.js.map +1 -0
  89. package/dist/harness/view.d.ts +43 -0
  90. package/dist/harness/view.d.ts.map +1 -0
  91. package/dist/harness/view.js +189 -0
  92. package/dist/harness/view.js.map +1 -0
  93. package/dist/index.d.ts +13 -5
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +8 -2
  96. package/dist/index.js.map +1 -1
  97. package/dist/session/observation.d.ts +18 -11
  98. package/dist/session/observation.d.ts.map +1 -1
  99. package/dist/session/observation.js +24 -13
  100. package/dist/session/observation.js.map +1 -1
  101. package/dist/session/session.d.ts +10 -1
  102. package/dist/session/session.d.ts.map +1 -1
  103. package/dist/session/session.js +23 -5
  104. package/dist/session/session.js.map +1 -1
  105. package/dist/session/transaction.d.ts +12 -3
  106. package/dist/session/transaction.d.ts.map +1 -1
  107. package/dist/session/transaction.js +90 -31
  108. package/dist/session/transaction.js.map +1 -1
  109. package/dist/storage/jsonl/storage.d.ts +22 -2
  110. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  111. package/dist/storage/jsonl/storage.js +1 -1
  112. package/dist/storage/jsonl/storage.js.map +1 -1
  113. package/dist/storage/memory.js +1 -1
  114. package/dist/storage/memory.js.map +1 -1
  115. package/dist/storage/sqlite/database.d.ts +26 -19
  116. package/dist/storage/sqlite/database.d.ts.map +1 -1
  117. package/dist/storage/sqlite/database.js.map +1 -1
  118. package/dist/storage/sqlite/index.d.ts +1 -1
  119. package/dist/storage/sqlite/index.d.ts.map +1 -1
  120. package/dist/storage/sqlite/index.js.map +1 -1
  121. package/dist/storage/sqlite/migrations.js +7 -7
  122. package/dist/storage/sqlite/migrations.js.map +1 -1
  123. package/dist/storage/sqlite/node.d.ts +23 -7
  124. package/dist/storage/sqlite/node.d.ts.map +1 -1
  125. package/dist/storage/sqlite/node.js +129 -47
  126. package/dist/storage/sqlite/node.js.map +1 -1
  127. package/dist/storage/sqlite/storage.d.ts +12 -0
  128. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  129. package/dist/storage/sqlite/storage.js +121 -129
  130. package/dist/storage/sqlite/storage.js.map +1 -1
  131. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  132. package/dist/testing/storage-benchmark.js +0 -1
  133. package/dist/testing/storage-benchmark.js.map +1 -1
  134. package/dist/testing/storage-conformance.d.ts.map +1 -1
  135. package/dist/testing/storage-conformance.js +34 -2
  136. package/dist/testing/storage-conformance.js.map +1 -1
  137. package/dist/tools/bash.d.ts +28 -0
  138. package/dist/tools/bash.d.ts.map +1 -0
  139. package/dist/tools/bash.js +70 -0
  140. package/dist/tools/bash.js.map +1 -0
  141. package/dist/tools/edit-diff.d.ts +89 -0
  142. package/dist/tools/edit-diff.d.ts.map +1 -0
  143. package/dist/tools/edit-diff.js +386 -0
  144. package/dist/tools/edit-diff.js.map +1 -0
  145. package/dist/tools/edit.d.ts +18 -0
  146. package/dist/tools/edit.d.ts.map +1 -0
  147. package/dist/tools/edit.js +117 -0
  148. package/dist/tools/edit.js.map +1 -0
  149. package/dist/tools/env.d.ts +5 -0
  150. package/dist/tools/env.d.ts.map +1 -0
  151. package/dist/tools/env.js +7 -0
  152. package/dist/tools/env.js.map +1 -0
  153. package/dist/tools/file-mutation-queue.d.ts +9 -0
  154. package/dist/tools/file-mutation-queue.d.ts.map +1 -0
  155. package/dist/tools/file-mutation-queue.js +52 -0
  156. package/dist/tools/file-mutation-queue.js.map +1 -0
  157. package/dist/tools/image.d.ts +2 -0
  158. package/dist/tools/image.d.ts.map +1 -0
  159. package/dist/tools/image.js +92 -0
  160. package/dist/tools/image.js.map +1 -0
  161. package/dist/tools/index.d.ts +23 -0
  162. package/dist/tools/index.d.ts.map +1 -0
  163. package/dist/tools/index.js +15 -0
  164. package/dist/tools/index.js.map +1 -0
  165. package/dist/tools/path-utils.d.ts +5 -0
  166. package/dist/tools/path-utils.d.ts.map +1 -0
  167. package/dist/tools/path-utils.js +26 -0
  168. package/dist/tools/path-utils.js.map +1 -0
  169. package/dist/tools/read.d.ts +17 -0
  170. package/dist/tools/read.d.ts.map +1 -0
  171. package/dist/tools/read.js +101 -0
  172. package/dist/tools/read.js.map +1 -0
  173. package/dist/tools/write.d.ts +10 -0
  174. package/dist/tools/write.d.ts.map +1 -0
  175. package/dist/tools/write.js +31 -0
  176. package/dist/tools/write.js.map +1 -0
  177. package/dist/{env/utils/truncate.d.ts → truncate.d.ts} +1 -17
  178. package/dist/truncate.d.ts.map +1 -0
  179. package/dist/truncate.js +146 -0
  180. package/dist/truncate.js.map +1 -0
  181. package/dist/types.d.ts +97 -19
  182. package/dist/types.d.ts.map +1 -1
  183. package/dist/types.js.map +1 -1
  184. package/package.json +32 -14
  185. package/dist/env/utils/adaptive-publisher.d.ts +0 -24
  186. package/dist/env/utils/adaptive-publisher.d.ts.map +0 -1
  187. package/dist/env/utils/adaptive-publisher.js +0 -79
  188. package/dist/env/utils/adaptive-publisher.js.map +0 -1
  189. package/dist/env/utils/output-capture.d.ts +0 -31
  190. package/dist/env/utils/output-capture.d.ts.map +0 -1
  191. package/dist/env/utils/output-capture.js +0 -238
  192. package/dist/env/utils/output-capture.js.map +0 -1
  193. package/dist/env/utils/truncate.d.ts.map +0 -1
  194. package/dist/env/utils/truncate.js +0 -294
  195. package/dist/env/utils/truncate.js.map +0 -1
  196. package/dist/harness/config.d.ts +0 -20
  197. package/dist/harness/config.d.ts.map +0 -1
  198. package/dist/harness/config.js +0 -18
  199. package/dist/harness/config.js.map +0 -1
  200. package/docs/chord-delta-findings.md +0 -358
  201. package/docs/pico-v5-chord-usage.md +0 -373
  202. package/docs/pico-v5-handoff.md +0 -581
  203. package/docs/pico-v5.md +0 -3084
@@ -1,15 +1,22 @@
1
1
  import { copyJson } from "@earendil-works/chord";
2
- import { isRetryableAssistantError, retryDelayMs, } from "@earendil-works/pi-ai";
3
- import { AssistantEntry, SystemEntry } from "../entries.js";
2
+ import { isContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
3
+ import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry";
4
+ import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
5
+ import { AssistantEntry, ResetEntry, SystemEntry, UserEntry } from "../entries.js";
4
6
  import { defineTask } from "../tasks.js";
5
- import { ConversationConfig, DEFAULT_RETRY_POLICY } from "./config.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";
6
11
  import { endRun, LiveDoc } from "./live.js";
7
12
  import { planSystemEntries, renderSections, replaySections } from "./prompt.js";
13
+ import { appendToolResult, harnessError, ToolTask } from "./tool.js";
14
+ import { recordUsage } from "./usage.js";
8
15
  const PARTIAL_THROTTLE_MS = 100;
9
16
  const DEFAULT_POLL_AFTER_MS = 5000;
10
17
  /**
11
- * Built-in generation task: prepares the positional system prompt, requests or polls the model, retries, and classifies
12
- * the response. The run's inputs live in `pi.live.run`.
18
+ * Built-in generation task: prepares the positional system prompt and tool loadout, requests or polls the model,
19
+ * retries, and classifies the response. The run's inputs live in `pi.live.run`.
13
20
  */
14
21
  export const GenerationTask = defineTask({
15
22
  name: "pi.generation",
@@ -17,45 +24,75 @@ export const GenerationTask = defineTask({
17
24
  initial: () => ({ phase: "prepare", attempt: 1 }),
18
25
  phases: {
19
26
  /**
20
- * Render the system prompt and append the positional `pi.system` entries it needs, then move to `request`. Only
21
- * the Harness writes to a busy conversation, so the transcript read here is still the tail at the commit.
27
+ * Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to
28
+ * `request`. The agent and settings resolved here are fixed for this request. Only the Harness writes to a busy
29
+ * conversation, so the transcript read here is still the tail at the commit.
22
30
  */
23
31
  prepare: async (task, runtime, context) => {
24
- const { conversationId, registry } = runtime;
25
- for (const failure of registry.failures())
26
- if (failure.kind === "section")
27
- runtime.report(failure.error);
28
- const { model, thinkingLevel, streamOptions } = (await runtime.snapshot(ConversationConfig, conversationId, context)) ??
29
- ConversationConfig.definition.initial();
30
- if (model === undefined || runtime.models.getModel(model.provider, model.modelId) === undefined) {
32
+ const { conversationId } = runtime;
33
+ const agent = await runtime.agent(context);
34
+ const settings = runtime.settings;
35
+ const { model, thinkingLevel } = agent;
36
+ const resolved = model === undefined ? undefined : runtime.models.getModel(model.provider, model.modelId);
37
+ if (model === undefined || resolved === undefined)
31
38
  return failNoModel(runtime, model, context);
39
+ const { attempt, compacted, overflow } = task.state.checkpoint;
40
+ if (compacted !== undefined && overflow !== undefined) {
41
+ const [outcome] = await runtime.outcomes([compacted], context);
42
+ if (outcome?.status !== "completed" || outcome.result.entryId === undefined) {
43
+ return failModelError(runtime, overflow, context);
44
+ }
32
45
  }
33
46
  const view = await runtime.context(conversationId, context);
34
47
  const shown = replaySections(view.messages);
35
- const input = {
36
- conversationId,
37
- tools: [],
38
- shown: Object.fromEntries(shown),
39
- model,
40
- thinkingLevel,
41
- read: runtime,
42
- };
43
48
  const report = (error) => runtime.report(error);
44
- const desired = await renderSections(registry.sections(), input, shown, report, context);
45
- const entries = planSystemEntries(view, desired, runtime.now());
49
+ let env;
50
+ try {
51
+ env = await runtime.env(context);
52
+ }
53
+ catch (error) {
54
+ if (context.abortSignal?.aborted)
55
+ throw error;
56
+ report(error);
57
+ }
58
+ const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };
59
+ const desired = await renderSections(agent.sections, input, shown, report, context);
60
+ const entries = planSystemEntries(view, desired, agent.tools, runtime.now());
61
+ const threshold = compacted === undefined
62
+ ? thresholdCompaction(view, entries, resolved.contextWindow, settings.compaction)
63
+ : undefined;
64
+ if (threshold === "blocking") {
65
+ // Compact first and prepare again; the transcript is unchanged until the compaction appends.
66
+ await runtime.commit(async (tx) => {
67
+ const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId);
68
+ const checkpoint = { phase: "prepare", attempt, compacted: child };
69
+ return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };
70
+ }, context);
71
+ return;
72
+ }
46
73
  await runtime.commit(async (tx) => {
47
74
  let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;
48
75
  for (const entry of entries)
49
76
  cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;
50
77
  if (cutoff === undefined)
51
78
  throw new Error(`Conversation ${conversationId} has no entries to send`);
52
- const { attempt } = task.state.checkpoint;
53
- const request = { attempt, model, thinkingLevel, streamOptions: streamOptions ?? {}, cutoff };
79
+ // Checked in this commit, so a compaction admitted during preparation counts.
80
+ if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {
81
+ await createCompaction(tx, conversationId, { reason: "threshold" });
82
+ }
83
+ const request = {
84
+ attempt,
85
+ ...(compacted === undefined ? {} : { compacted }),
86
+ model,
87
+ thinkingLevel,
88
+ streamOptions: settings.stream,
89
+ cutoff,
90
+ };
54
91
  return { status: "running", checkpoint: { phase: "request", ...request } };
55
92
  }, context);
56
93
  },
57
94
  request: async (task, runtime, context) => {
58
- const { attempt, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;
95
+ const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;
59
96
  const conversationId = runtime.conversationId;
60
97
  await runtime.commit(async (tx) => {
61
98
  const live = await tx.doc(LiveDoc, conversationId);
@@ -67,30 +104,64 @@ export const GenerationTask = defineTask({
67
104
  if (model === undefined)
68
105
  return failNoModel(runtime, ref, context);
69
106
  const view = await runtime.context(conversationId, context, cutoff);
107
+ let messages = view.messages;
108
+ await runtime.hooks.each("beforeRequest", async (hook) => {
109
+ const replaced = await hook({ messages }, runtime, context);
110
+ if (replaced !== undefined)
111
+ messages = replaced.messages;
112
+ });
70
113
  const options = {
71
114
  ...streamOptions,
72
115
  signal: runtime.signal,
73
116
  ...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),
74
117
  };
75
- const message = await streamResponse(runtime, model, view.messages, options, attempt, context);
76
- await classify(runtime, attempt, ref, undefined, message, context);
118
+ const message = await streamResponse(runtime, model, messages, options, attempt, context);
119
+ const request = { attempt, compacted, model: ref, cutoff, messages: view.messages };
120
+ await classify(runtime, request, message, context);
77
121
  },
78
122
  retry: async (task, runtime, context) => {
79
- const { attempt, until } = task.state.checkpoint;
123
+ const { attempt, compacted, until } = task.state.checkpoint;
80
124
  await runtime.sleep(until, context);
81
125
  await runtime.commit(async (tx) => {
82
126
  (await tx.doc(LiveDoc, runtime.conversationId)).generation = { attempt: attempt + 1 };
83
- return { status: "running", checkpoint: { phase: "prepare", attempt: attempt + 1 } };
127
+ const checkpoint = {
128
+ phase: "prepare",
129
+ attempt: attempt + 1,
130
+ ...(compacted === undefined ? {} : { compacted }),
131
+ };
132
+ return { status: "running", checkpoint };
84
133
  }, context);
85
134
  },
86
135
  poll: async (task, runtime, context) => {
87
- const { attempt, model: ref, handle, pollAt } = task.state.checkpoint;
136
+ const { attempt, compacted, model: ref, cutoff, handle, pollAt } = task.state.checkpoint;
88
137
  const model = runtime.models.getModel(ref.provider, ref.modelId);
89
138
  if (model === undefined)
90
139
  return failNoModel(runtime, ref, context);
91
140
  await runtime.sleep(pollAt, context);
92
141
  const message = await runtime.models.fetchDeferred(model, handle, { signal: runtime.signal });
93
- await classify(runtime, attempt, ref, pollAt, message, context);
142
+ const request = { attempt, compacted, model: ref, cutoff, pollAt };
143
+ await classify(runtime, request, message, context);
144
+ },
145
+ tools: async (task, runtime, context) => {
146
+ const { assistant, tools, pending } = task.state.checkpoint;
147
+ const [next, ...rest] = pending;
148
+ if (next === undefined)
149
+ return finishToolRound(runtime, assistant, tools, context);
150
+ // Sequential round: start the next call and wait for it.
151
+ await runtime.commit(async (tx) => {
152
+ const live = await tx.doc(LiveDoc, runtime.conversationId);
153
+ const taskId = await createToolTask(tx, runtime, assistant, next);
154
+ const slot = live.tools?.find((slot) => slot.callId === next && slot.taskId === undefined);
155
+ if (slot !== undefined)
156
+ slot.taskId = taskId;
157
+ const checkpoint = {
158
+ phase: "tools",
159
+ assistant,
160
+ tools: [...tools, taskId],
161
+ pending: rest,
162
+ };
163
+ return { status: "waiting", checkpoint, on: [taskId], policy: "allSettled" };
164
+ }, context);
94
165
  },
95
166
  },
96
167
  abort: async (task, runtime, context) => {
@@ -107,14 +178,59 @@ export const GenerationTask = defineTask({
107
178
  }
108
179
  }
109
180
  const conversationId = runtime.conversationId;
181
+ // Runs after the round's tool tasks are terminal; calls never started get `aborted` results (spec §8.5).
182
+ const unstarted = checkpoint.phase === "tools"
183
+ ? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context)
184
+ : [];
110
185
  await runtime.commit(async (tx) => {
111
186
  const live = await tx.doc(LiveDoc, conversationId);
112
187
  await convertPartial(tx, live, conversationId);
188
+ for (const call of unstarted) {
189
+ const result = harnessError("aborted", `Tool ${call.name} was aborted`);
190
+ await appendToolResult(tx, conversationId, call, result, runtime.now());
191
+ }
113
192
  endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" });
114
193
  return { status: "terminal", outcome: { status: "aborted" } };
115
194
  }, context);
116
195
  },
117
196
  });
197
+ /** The calls `callIds` of the assistant entry, in the given order. */
198
+ async function readCalls(runtime, assistant, callIds, context) {
199
+ const message = (await runtime.entry(AssistantEntry, assistant, context))?.model?.[0];
200
+ const calls = message?.role === "assistant" ? message.content.filter((content) => content.type === "toolCall") : [];
201
+ return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1));
202
+ }
203
+ /** A tool task for call `callId`, owned by the generation. */
204
+ function createToolTask(tx, runtime, assistant, callId) {
205
+ return tx.createTask(ToolTask, { assistant, callId }, { ownership: { kind: "task", taskId: runtime.taskId } });
206
+ }
207
+ /**
208
+ * Which threshold compaction preparation starts before its request (spec §8.3): `blocking` above
209
+ * `contextWindow - reserveTokens`, `background` above the background threshold, and only when range selection finds a
210
+ * cut. The caller starts a background one only while no compaction is listed.
211
+ */
212
+ function thresholdCompaction(view, planned, contextWindow, policy) {
213
+ if (!policy.enabled || contextWindow <= 0)
214
+ return undefined;
215
+ const tokens = estimateContext(view, planned.flatMap((entry) => entry.model ?? []));
216
+ const blocking = contextWindow - policy.reserveTokens;
217
+ const background = blocking - policy.backgroundTokens;
218
+ const over = tokens > blocking ? "blocking" : policy.backgroundTokens > 0 && tokens > background ? "background" : undefined;
219
+ if (over === undefined || selectCut(view, policy.keepRecentTokens) === undefined)
220
+ return undefined;
221
+ return over;
222
+ }
223
+ /** Settle the run's inputs `unanswered` with `model_error` and fail with `text`. */
224
+ async function failModelError(runtime, text, context) {
225
+ await runtime.commit(async (tx) => {
226
+ const live = await tx.doc(LiveDoc, runtime.conversationId);
227
+ endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
228
+ return {
229
+ status: "terminal",
230
+ outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
231
+ };
232
+ }, context);
233
+ }
118
234
  /** Settle the run's inputs `unanswered` with `no_model` and fail. */
119
235
  async function failNoModel(runtime, ref, context) {
120
236
  const message = ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
@@ -124,13 +240,16 @@ async function failNoModel(runtime, ref, context) {
124
240
  return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
125
241
  }, context);
126
242
  }
127
- /** Append a committed partial left by an interrupted attempt as an aborted assistant entry; the caller replaces `generation`. */
128
- async function convertPartial(tx, live, conversationId) {
243
+ /**
244
+ * Append a committed partial left by an interrupted, aborted, faulted, or orphaned attempt as an aborted assistant
245
+ * entry; the caller replaces or removes `generation`.
246
+ */
247
+ export async function convertPartial(tx, live, conversationId) {
129
248
  const partial = live.generation?.message;
130
249
  if (partial === undefined)
131
250
  return;
132
251
  const message = copyJson(partial);
133
- await tx.appendEntry(AssistantEntry, conversationId, { model: [{ ...message, stopReason: "aborted" }] });
252
+ await appendAssistant(tx, conversationId, { ...message, stopReason: "aborted" });
134
253
  }
135
254
  /**
136
255
  * Stream one request and return the terminal message. Partials commit as trailing writes at most every 100 ms with one
@@ -171,7 +290,9 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
171
290
  try {
172
291
  const events = runtime.models.streamSimple(model, { messages: [...messages] }, options);
173
292
  for await (const event of events) {
174
- if (event.type === "done" || event.type === "error")
293
+ // A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response
294
+ // never gets past it, so it never leaves a partial.
295
+ if (event.type === "done" || event.type === "error" || event.partial.content.length === 0)
175
296
  continue;
176
297
  pending = event.partial;
177
298
  if (timer === undefined && inFlight === undefined)
@@ -185,43 +306,77 @@ async function streamResponse(runtime, model, messages, options, attempt, contex
185
306
  await inFlight;
186
307
  }
187
308
  }
188
- /**
189
- * Classify a terminal provider message in one commit that also clears the partial. `previousPollAt` is set when the
190
- * message came from polling, so a still deferred result polls strictly later.
191
- */
192
- async function classify(runtime, attempt, ref, previousPollAt, message, context) {
309
+ /** Classify a terminal provider message in one commit that also clears the partial. */
310
+ async function classify(runtime, request, message, context) {
193
311
  // An abort mark or close: the abort invocation or the reopened run handles the committed state.
194
312
  runtime.signal.throwIfAborted();
195
313
  const conversationId = runtime.conversationId;
314
+ const { attempt, compacted, model: ref, cutoff } = request;
196
315
  if (message.stopReason === "deferred" && message.deferred !== undefined) {
197
316
  const handle = message.deferred;
198
- const pollAt = Math.max(runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS), previousPollAt === undefined ? Number.NEGATIVE_INFINITY : previousPollAt + 1);
317
+ const pollAt = Math.max(runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS), request.pollAt === undefined ? Number.NEGATIVE_INFINITY : request.pollAt + 1);
199
318
  await runtime.commit(async (tx) => {
200
319
  (await tx.doc(LiveDoc, conversationId)).generation = { attempt, deferred: { pollAt } };
201
- return { status: "running", checkpoint: { phase: "poll", attempt, model: ref, handle, pollAt } };
320
+ const checkpoint = {
321
+ phase: "poll",
322
+ attempt,
323
+ ...(compacted === undefined ? {} : { compacted }),
324
+ model: ref,
325
+ cutoff,
326
+ handle,
327
+ pollAt,
328
+ };
329
+ return { status: "running", checkpoint };
202
330
  }, context);
203
331
  return;
204
332
  }
205
- const policy = (await runtime.snapshot(ConversationConfig, conversationId, context))?.retry ?? DEFAULT_RETRY_POLICY;
333
+ await runtime.hooks.each("afterResponse", (hook) => hook(message, runtime, context));
334
+ const calls = message.content.filter((content) => content.type === "toolCall");
335
+ if (message.stopReason === "toolUse" && calls.length > 0) {
336
+ return startToolRound(runtime, request, message, calls, context);
337
+ }
338
+ if (message.stopReason === "stop" || message.stopReason === "length" || message.stopReason === "toolUse") {
339
+ return answer(runtime, message, context);
340
+ }
341
+ // The retry and compaction policies govern the next attempt, so they are read now rather than pinned at preparation.
342
+ const settings = runtime.settings;
343
+ const overflow = message.stopReason === "error" && isContextOverflow(message);
344
+ if (overflow && compacted === undefined && settings.compaction.enabled) {
345
+ const policy = settings.compaction;
346
+ const view = await runtime.context(conversationId, context, cutoff);
347
+ if (selectCut(view, policy.keepRecentTokens) !== undefined) {
348
+ const text = message.errorMessage ?? "Context overflow";
349
+ await runtime.commit(async (tx) => {
350
+ const live = await tx.doc(LiveDoc, conversationId);
351
+ await appendAssistant(tx, conversationId, message);
352
+ delete live.generation;
353
+ const child = await createCompaction(tx, conversationId, { reason: "overflow" }, runtime.taskId);
354
+ const checkpoint = { phase: "prepare", attempt, compacted: child, overflow: text };
355
+ return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };
356
+ }, context);
357
+ return;
358
+ }
359
+ }
360
+ const policy = settings.retry;
361
+ // An overflow is never retried: only a compaction can make the next request fit.
206
362
  const retry = message.stopReason === "error" &&
363
+ !overflow &&
207
364
  isRetryableAssistantError(message) &&
208
365
  policy.enabled &&
209
366
  attempt <= policy.maxRetries;
210
367
  const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;
211
368
  await runtime.commit(async (tx) => {
212
369
  const live = await tx.doc(LiveDoc, conversationId);
213
- const entry = await tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
214
- switch (message.stopReason) {
215
- case "stop":
216
- case "length":
217
- // Until the tool chain exists, a tool call settles as the answer.
218
- case "toolUse":
219
- endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
220
- return { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
221
- }
370
+ await appendAssistant(tx, conversationId, message);
222
371
  if (retry) {
223
372
  live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } };
224
- return { status: "running", checkpoint: { phase: "retry", attempt, until } };
373
+ const checkpoint = {
374
+ phase: "retry",
375
+ attempt,
376
+ ...(compacted === undefined ? {} : { compacted }),
377
+ until,
378
+ };
379
+ return { status: "running", checkpoint };
225
380
  }
226
381
  const text = message.errorMessage ?? `Model response ended with stop reason ${message.stopReason}`;
227
382
  endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
@@ -232,35 +387,154 @@ async function classify(runtime, attempt, ref, previousPollAt, message, context)
232
387
  }, context);
233
388
  }
234
389
  /**
235
- * Assign `value` at `target[key]` leaf by leaf. Chord records a container assignment as one full set and only emits an
236
- * append when a string leaf is reassigned with a longer string, so writing the partial whole would store and publish the
237
- * complete message on every flush.
390
+ * A final answer; the final boundary places queued items (spec §6). The first `onYield` continuation appends a user
391
+ * message and hands the run to a successor generation, but only when the boundary selected no user item and no reset.
392
+ * Otherwise the run's inputs settle `done`, and selected user items start the next run.
238
393
  */
239
- function assignJson(target, key, value) {
240
- const slots = target;
241
- const current = slots[key];
242
- if (isRecord(current) && isRecord(value)) {
243
- for (const name of Object.keys(current))
244
- if (!Object.hasOwn(value, name))
245
- delete current[name];
246
- for (const [name, child] of Object.entries(value))
247
- assignJson(current, name, child);
248
- return;
249
- }
250
- if (Array.isArray(current) && Array.isArray(value) && current.length <= value.length) {
251
- const items = current;
252
- for (let index = 0; index < value.length; index++) {
253
- if (index < items.length)
254
- assignJson(items, index, value[index]);
255
- else
256
- items.push(value[index]);
394
+ async function answer(runtime, message, context) {
395
+ let continuation;
396
+ await runtime.hooks.each("onYield", async (hook) => {
397
+ if (continuation !== undefined)
398
+ return;
399
+ continuation = (await hook(message, runtime, context))?.continue;
400
+ });
401
+ const conversationId = runtime.conversationId;
402
+ await runtime.commit(async (tx) => {
403
+ // Queue modes are read on the Session line, when the boundary is decided.
404
+ const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
405
+ const live = await tx.doc(LiveDoc, conversationId);
406
+ const entry = await appendAssistant(tx, conversationId, message);
407
+ const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
408
+ const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());
409
+ if (continuation !== undefined && users.length === 0 && !reset) {
410
+ const user = { role: "user", content: continuation, timestamp: runtime.now() };
411
+ await tx.appendEntry(UserEntry, conversationId, { model: [user] });
412
+ handOver(live, runtime.taskId, await createGeneration(tx, conversationId));
413
+ delete live.generation;
414
+ return result;
257
415
  }
258
- return;
259
- }
260
- if (current !== value)
261
- slots[key] = value;
416
+ endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
417
+ if (users.length > 0)
418
+ await startRun(tx, conversationId, live, users);
419
+ return result;
420
+ }, context);
421
+ }
422
+ /**
423
+ * Append the tool-calling answer and start its tool round in one commit (spec §8.3). A call to a tool the request did
424
+ * not offer gets its `tool_unavailable` result here; every other call gets a tool task owned by the generation, only the
425
+ * first one now when the round is sequential. The generation then waits for them in its `tools` phase, keeping the run.
426
+ */
427
+ async function startToolRound(runtime, request, message, calls, context) {
428
+ const conversationId = runtime.conversationId;
429
+ const messages = request.messages ?? (await runtime.context(conversationId, context, request.cutoff)).messages;
430
+ const offered = new Set(getCurrentTools(messages).map((tool) => tool.name));
431
+ // Read as the round starts; a tool is resolved as its tool task resolves it.
432
+ const tools = (await runtime.agent(context)).tools;
433
+ const sequential = runtime.settings.toolExecution === "sequential" ||
434
+ calls.some((call) => offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential");
435
+ await runtime.commit(async (tx) => {
436
+ const live = await tx.doc(LiveDoc, conversationId);
437
+ const entry = await appendAssistant(tx, conversationId, message);
438
+ const slots = [];
439
+ const tools = [];
440
+ const pending = [];
441
+ for (const call of calls) {
442
+ if (!offered.has(call.name)) {
443
+ const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
444
+ const result = await appendToolResult(tx, conversationId, call, unavailable, runtime.now());
445
+ slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });
446
+ continue;
447
+ }
448
+ if (sequential && tools.length > 0) {
449
+ pending.push(call.id);
450
+ slots.push({ callId: call.id, name: call.name, status: "pending" });
451
+ continue;
452
+ }
453
+ const taskId = await createToolTask(tx, runtime, entry.id, call.id);
454
+ tools.push(taskId);
455
+ slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });
456
+ }
457
+ delete live.generation;
458
+ live.tools = slots;
459
+ const checkpoint = { phase: "tools", assistant: entry.id, tools, pending };
460
+ return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };
461
+ }, context);
462
+ }
463
+ /**
464
+ * The round's tools are terminal: apply their controls and either end the run at the final boundary (`terminate`,
465
+ * `handoff`, or a queued reset) or hand it to the next generation at the `postTools` boundary (spec §8.5).
466
+ */
467
+ async function finishToolRound(runtime, assistant, tools, context) {
468
+ const conversationId = runtime.conversationId;
469
+ const controls = new Map();
470
+ const outcomes = await runtime.outcomes(tools, context);
471
+ tools.forEach((id, index) => {
472
+ const outcome = outcomes[index];
473
+ controls.set(id, outcome.status === "completed" ? outcome.result.control : undefined);
474
+ });
475
+ const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
476
+ const results = slots.flatMap((slot) => (slot.entry === undefined ? [] : [slot.entry]));
477
+ await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
478
+ // Every call of the round, including those answered without a task, must ask to terminate.
479
+ const terminate = slots.length > 0 &&
480
+ slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);
481
+ const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);
482
+ // The last handoff in call order wins.
483
+ const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff;
484
+ await runtime.commit(async (tx) => {
485
+ const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
486
+ if (added.length > 0)
487
+ await addTools(tx, conversationId, added);
488
+ const live = await tx.doc(LiveDoc, conversationId);
489
+ const now = runtime.now();
490
+ if (terminate || handoff !== undefined) {
491
+ if (handoff !== undefined) {
492
+ const message = { role: "user", content: handoff, timestamp: now };
493
+ const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });
494
+ boundary.head = entry.id;
495
+ }
496
+ const { users } = await applyBoundary(tx, boundary, "final", now);
497
+ endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });
498
+ if (users.length > 0)
499
+ await startRun(tx, conversationId, live, users);
500
+ }
501
+ else {
502
+ const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);
503
+ if (reset) {
504
+ // The queued reset cut the run's context before an answer.
505
+ endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });
506
+ if (users.length > 0)
507
+ await startRun(tx, conversationId, live, users);
508
+ }
509
+ else {
510
+ delete live.tools;
511
+ if (live.run?.taskId === runtime.taskId)
512
+ live.run.inputs.push(...users);
513
+ handOver(live, runtime.taskId, await createGeneration(tx, conversationId));
514
+ }
515
+ }
516
+ return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };
517
+ }, context);
518
+ }
519
+ /**
520
+ * Append a provider result and add its usage to `pi.usage` in the same commit.
521
+ * REMINDER: every built-in writer of assistant entries goes through here, so the usage ledger stays complete.
522
+ */
523
+ async function appendAssistant(tx, conversationId, message) {
524
+ await recordUsage(tx, conversationId, "models", `${message.provider}/${message.model}`, message.usage);
525
+ return tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
526
+ }
527
+ /** Start a run for `inputs`, placed input submissions: a new generation takes `pi.live.run`. */
528
+ export async function startRun(tx, conversationId, live, inputs) {
529
+ live.run = { taskId: await createGeneration(tx, conversationId), inputs };
530
+ }
531
+ /** A generation owned by its conversation. */
532
+ function createGeneration(tx, conversationId) {
533
+ return tx.createTask(GenerationTask, {}, { ownership: { kind: "conversation" }, conversationId });
262
534
  }
263
- function isRecord(value) {
264
- return typeof value === "object" && value !== null && !Array.isArray(value);
535
+ /** Hand run control from `from` to `to`; the run's inputs move with it. */
536
+ export function handOver(live, from, to) {
537
+ if (live.run?.taskId === from)
538
+ live.run.taskId = to;
265
539
  }
266
540
  //# sourceMappingURL=generation.js.map