@earendil-works/pi-durable 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/README.md +181 -84
  3. package/dist/codemode/codemode.d.ts +4 -4
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +9 -12
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.js +4 -4
  8. package/dist/codemode/models.js.map +1 -1
  9. package/dist/env/index.d.ts +12 -10
  10. package/dist/env/index.d.ts.map +1 -1
  11. package/dist/env/index.js.map +1 -1
  12. package/dist/env/node-watch.d.ts +3 -3
  13. package/dist/env/node-watch.d.ts.map +1 -1
  14. package/dist/env/node-watch.js +18 -16
  15. package/dist/env/node-watch.js.map +1 -1
  16. package/dist/env/node.d.ts +2 -2
  17. package/dist/env/node.d.ts.map +1 -1
  18. package/dist/env/node.js +1 -1
  19. package/dist/env/node.js.map +1 -1
  20. package/dist/errors.d.ts +18 -2
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +26 -3
  23. package/dist/errors.js.map +1 -1
  24. package/dist/harness/agent.d.ts.map +1 -1
  25. package/dist/harness/agent.js +10 -2
  26. package/dist/harness/agent.js.map +1 -1
  27. package/dist/harness/compaction.d.ts.map +1 -1
  28. package/dist/harness/compaction.js +19 -2
  29. package/dist/harness/compaction.js.map +1 -1
  30. package/dist/harness/context.d.ts +23 -7
  31. package/dist/harness/context.d.ts.map +1 -1
  32. package/dist/harness/context.js +71 -25
  33. package/dist/harness/context.js.map +1 -1
  34. package/dist/harness/define.d.ts +7 -3
  35. package/dist/harness/define.d.ts.map +1 -1
  36. package/dist/harness/define.js +4 -0
  37. package/dist/harness/define.js.map +1 -1
  38. package/dist/harness/generation.d.ts +38 -5
  39. package/dist/harness/generation.d.ts.map +1 -1
  40. package/dist/harness/generation.js +445 -96
  41. package/dist/harness/generation.js.map +1 -1
  42. package/dist/harness/harness.d.ts +2 -0
  43. package/dist/harness/harness.d.ts.map +1 -1
  44. package/dist/harness/harness.js +87 -62
  45. package/dist/harness/harness.js.map +1 -1
  46. package/dist/harness/inbox.d.ts +5 -1
  47. package/dist/harness/inbox.d.ts.map +1 -1
  48. package/dist/harness/inbox.js +4 -1
  49. package/dist/harness/inbox.js.map +1 -1
  50. package/dist/harness/json.d.ts +20 -4
  51. package/dist/harness/json.d.ts.map +1 -1
  52. package/dist/harness/json.js +23 -5
  53. package/dist/harness/json.js.map +1 -1
  54. package/dist/harness/live.d.ts +122 -16
  55. package/dist/harness/live.d.ts.map +1 -1
  56. package/dist/harness/live.js +93 -12
  57. package/dist/harness/live.js.map +1 -1
  58. package/dist/harness/partial-changes.d.ts +54 -0
  59. package/dist/harness/partial-changes.d.ts.map +1 -0
  60. package/dist/harness/partial-changes.js +162 -0
  61. package/dist/harness/partial-changes.js.map +1 -0
  62. package/dist/harness/registry-reader.d.ts +11 -0
  63. package/dist/harness/registry-reader.d.ts.map +1 -0
  64. package/dist/harness/registry-reader.js +25 -0
  65. package/dist/harness/registry-reader.js.map +1 -0
  66. package/dist/harness/scheduler.d.ts +46 -19
  67. package/dist/harness/scheduler.d.ts.map +1 -1
  68. package/dist/harness/scheduler.js +973 -376
  69. package/dist/harness/scheduler.js.map +1 -1
  70. package/dist/harness/submissions.d.ts +17 -10
  71. package/dist/harness/submissions.d.ts.map +1 -1
  72. package/dist/harness/submissions.js +62 -25
  73. package/dist/harness/submissions.js.map +1 -1
  74. package/dist/harness/task-graph.d.ts +12 -9
  75. package/dist/harness/task-graph.d.ts.map +1 -1
  76. package/dist/harness/task-graph.js +38 -28
  77. package/dist/harness/task-graph.js.map +1 -1
  78. package/dist/harness/telemetry.d.ts +11 -11
  79. package/dist/harness/telemetry.d.ts.map +1 -1
  80. package/dist/harness/telemetry.js +7 -7
  81. package/dist/harness/telemetry.js.map +1 -1
  82. package/dist/harness/tool.d.ts +2 -0
  83. package/dist/harness/tool.d.ts.map +1 -1
  84. package/dist/harness/tool.js +171 -55
  85. package/dist/harness/tool.js.map +1 -1
  86. package/dist/harness/transcript.d.ts +72 -0
  87. package/dist/harness/transcript.d.ts.map +1 -0
  88. package/dist/harness/transcript.js +264 -0
  89. package/dist/harness/transcript.js.map +1 -0
  90. package/dist/harness/types.d.ts +218 -59
  91. package/dist/harness/types.d.ts.map +1 -1
  92. package/dist/harness/types.js.map +1 -1
  93. package/dist/harness/util.d.ts +3 -1
  94. package/dist/harness/util.d.ts.map +1 -1
  95. package/dist/harness/util.js +9 -0
  96. package/dist/harness/util.js.map +1 -1
  97. package/dist/harness/view.d.ts +43 -22
  98. package/dist/harness/view.d.ts.map +1 -1
  99. package/dist/harness/view.js +130 -71
  100. package/dist/harness/view.js.map +1 -1
  101. package/dist/index.d.ts +9 -7
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +4 -3
  104. package/dist/index.js.map +1 -1
  105. package/dist/search.d.ts +19 -3
  106. package/dist/search.d.ts.map +1 -1
  107. package/dist/search.js +107 -44
  108. package/dist/search.js.map +1 -1
  109. package/dist/session/cached-storage.d.ts +71 -0
  110. package/dist/session/cached-storage.d.ts.map +1 -0
  111. package/dist/session/cached-storage.js +269 -0
  112. package/dist/session/cached-storage.js.map +1 -0
  113. package/dist/session/conversation-tails.d.ts +33 -0
  114. package/dist/session/conversation-tails.d.ts.map +1 -0
  115. package/dist/session/conversation-tails.js +101 -0
  116. package/dist/session/conversation-tails.js.map +1 -0
  117. package/dist/session/keys.d.ts +68 -0
  118. package/dist/session/keys.d.ts.map +1 -0
  119. package/dist/session/keys.js +161 -0
  120. package/dist/session/keys.js.map +1 -0
  121. package/dist/session/session.d.ts +167 -26
  122. package/dist/session/session.d.ts.map +1 -1
  123. package/dist/session/session.js +1029 -171
  124. package/dist/session/session.js.map +1 -1
  125. package/dist/session/transaction.d.ts +81 -3
  126. package/dist/session/transaction.d.ts.map +1 -1
  127. package/dist/session/transaction.js +224 -37
  128. package/dist/session/transaction.js.map +1 -1
  129. package/dist/session/writer.d.ts +74 -0
  130. package/dist/session/writer.d.ts.map +1 -0
  131. package/dist/session/writer.js +294 -0
  132. package/dist/session/writer.js.map +1 -0
  133. package/dist/storage/indexeddb.d.ts +10 -2
  134. package/dist/storage/indexeddb.d.ts.map +1 -1
  135. package/dist/storage/indexeddb.js +91 -41
  136. package/dist/storage/indexeddb.js.map +1 -1
  137. package/dist/storage/jsonl/storage.d.ts +14 -2
  138. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  139. package/dist/storage/jsonl/storage.js +88 -24
  140. package/dist/storage/jsonl/storage.js.map +1 -1
  141. package/dist/storage/memory.d.ts +7 -2
  142. package/dist/storage/memory.d.ts.map +1 -1
  143. package/dist/storage/memory.js +28 -2
  144. package/dist/storage/memory.js.map +1 -1
  145. package/dist/storage/sqlite/storage.d.ts +14 -2
  146. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  147. package/dist/storage/sqlite/storage.js +110 -36
  148. package/dist/storage/sqlite/storage.js.map +1 -1
  149. package/dist/storage/store/commit.d.ts +33 -0
  150. package/dist/storage/store/commit.d.ts.map +1 -0
  151. package/dist/storage/store/commit.js +286 -0
  152. package/dist/storage/store/commit.js.map +1 -0
  153. package/dist/storage/store/documents.d.ts +43 -0
  154. package/dist/storage/store/documents.d.ts.map +1 -0
  155. package/dist/storage/store/documents.js +102 -0
  156. package/dist/storage/store/documents.js.map +1 -0
  157. package/dist/storage/store/index.d.ts +2 -0
  158. package/dist/storage/store/index.d.ts.map +1 -0
  159. package/dist/storage/store/index.js +2 -0
  160. package/dist/storage/store/index.js.map +1 -0
  161. package/dist/storage/store/queries.d.ts +49 -0
  162. package/dist/storage/store/queries.d.ts.map +1 -0
  163. package/dist/storage/store/queries.js +93 -0
  164. package/dist/storage/store/queries.js.map +1 -0
  165. package/dist/storage/store/storage.d.ts +59 -0
  166. package/dist/storage/store/storage.d.ts.map +1 -0
  167. package/dist/storage/store/storage.js +438 -0
  168. package/dist/storage/store/storage.js.map +1 -0
  169. package/dist/storage/store/tables.d.ts +54 -0
  170. package/dist/storage/store/tables.d.ts.map +1 -0
  171. package/dist/storage/store/tables.js +108 -0
  172. package/dist/storage/store/tables.js.map +1 -0
  173. package/dist/testing/commit-writes.d.ts +5 -0
  174. package/dist/testing/commit-writes.d.ts.map +1 -0
  175. package/dist/testing/commit-writes.js +12 -0
  176. package/dist/testing/commit-writes.js.map +1 -0
  177. package/dist/testing/env-conformance.js +7 -7
  178. package/dist/testing/env-conformance.js.map +1 -1
  179. package/dist/testing/index.d.ts +1 -0
  180. package/dist/testing/index.d.ts.map +1 -1
  181. package/dist/testing/index.js +1 -0
  182. package/dist/testing/index.js.map +1 -1
  183. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  184. package/dist/testing/storage-benchmark.js +18 -17
  185. package/dist/testing/storage-benchmark.js.map +1 -1
  186. package/dist/testing/storage-conformance.d.ts.map +1 -1
  187. package/dist/testing/storage-conformance.js +354 -69
  188. package/dist/testing/storage-conformance.js.map +1 -1
  189. package/dist/tool-search/tool-search.d.ts +6 -12
  190. package/dist/tool-search/tool-search.d.ts.map +1 -1
  191. package/dist/tool-search/tool-search.js +7 -7
  192. package/dist/tool-search/tool-search.js.map +1 -1
  193. package/dist/tools/bash.d.ts +12 -12
  194. package/dist/tools/bash.d.ts.map +1 -1
  195. package/dist/tools/bash.js +16 -15
  196. package/dist/tools/bash.js.map +1 -1
  197. package/dist/tools/edit.d.ts +7 -7
  198. package/dist/tools/edit.d.ts.map +1 -1
  199. package/dist/tools/edit.js +9 -9
  200. package/dist/tools/edit.js.map +1 -1
  201. package/dist/tools/index.d.ts +32 -32
  202. package/dist/tools/read.d.ts +6 -6
  203. package/dist/tools/read.d.ts.map +1 -1
  204. package/dist/tools/read.js +19 -8
  205. package/dist/tools/read.js.map +1 -1
  206. package/dist/tools/write.d.ts +4 -4
  207. package/dist/tools/write.d.ts.map +1 -1
  208. package/dist/tools/write.js +5 -5
  209. package/dist/tools/write.js.map +1 -1
  210. package/dist/types.d.ts +223 -77
  211. package/dist/types.d.ts.map +1 -1
  212. package/dist/types.js.map +1 -1
  213. package/package.json +19 -12
  214. package/dist/harness/events.d.ts +0 -186
  215. package/dist/harness/events.d.ts.map +0 -1
  216. package/dist/harness/events.js +0 -373
  217. package/dist/harness/events.js.map +0 -1
  218. package/dist/session/observation.d.ts +0 -42
  219. package/dist/session/observation.d.ts.map +0 -1
  220. package/dist/session/observation.js +0 -281
  221. package/dist/session/observation.js.map +0 -1
@@ -1,9 +1,10 @@
1
1
  import { copyJson } from "@earendil-works/chord";
2
2
  import { awaitWithContext } from "@earendil-works/chord/context";
3
- import { overlap } from "@earendil-works/chord/delta";
3
+ import { overlap, ownedSlice } from "@earendil-works/chord/delta";
4
4
  import { boundOutput, OutputBuffer, PROGRESS_BYTES_PER_SECOND, Progress, } from "@earendil-works/chord/output";
5
5
  import { validateToolArguments } from "@earendil-works/pi-ai/utils/validation";
6
- import { Compile } from "typebox/compile";
6
+ import { compile } from "@earendil-works/pi-schema";
7
+ import { formats } from "@earendil-works/pi-schema/formats";
7
8
  import { defineDocFamily } from "../documents.js";
8
9
  import { AssistantEntry, ToolResultEntry } from "../entries.js";
9
10
  import { errorMessage } from "../errors.js";
@@ -11,7 +12,7 @@ import { defineTask } from "../tasks.js";
11
12
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, utf8ByteLength } from "../truncate.js";
12
13
  import { toolExtension } from "./agent.js";
13
14
  import { assignJson } from "./json.js";
14
- import { clearProgress, finishSlot, LiveDoc, removeNestedSlots, toolSlot, } from "./live.js";
15
+ import { clearProgress, cutOffCount, cutOffTooOften, finishSlot, LiveDoc, PROGRESS_COMMIT, removeNestedSlots, salvageable, toolSlot, } from "./live.js";
15
16
  import { recordUsage } from "./usage.js";
16
17
  import { optionalExtension } from "./util.js";
17
18
  /**
@@ -41,25 +42,29 @@ export const NestedCallDoc = defineDocFamily({
41
42
  */
42
43
  export const ToolTask = defineTask({
43
44
  name: "pi.tool",
44
- version: 2,
45
+ version: 3,
45
46
  initial: () => ({ phase: "call" }),
46
- // Version 1 had only model-issued calls.
47
- migrate: (input, checkpoint) => ({
48
- input: { kind: "model", ...input },
47
+ // Version 1 had only model-issued calls, without `kind`; version 3 added `unoffered`, absent in version 2 inputs.
48
+ migrate: (input, checkpoint, fromVersion) => ({
49
+ input: fromVersion === 1
50
+ ? { kind: "model", ...input }
51
+ : input,
49
52
  checkpoint: checkpoint,
50
53
  }),
51
54
  phases: {
52
55
  call: async (task, runtime, context) => {
53
56
  const call = await readCall(runtime, task.input, context);
54
- const tool = await resolveTool(runtime, task.input, call.name, context);
57
+ if (cutOffTooOften(task, runtime))
58
+ return endCutOff(runtime, task, call, context);
59
+ const tool = isUnoffered(task.input) ? undefined : await resolveTool(runtime, task.input, call.name, context);
55
60
  if (tool === undefined) {
56
61
  const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
57
- return settle(runtime, task.input, call, COMPLETED, () => error, context);
62
+ return reject(runtime, task.input, call, undefined, error, context);
58
63
  }
59
64
  const prepared = prepare(tool, call.arguments);
60
65
  const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args);
61
66
  if ("error" in checked)
62
- return settle(runtime, task.input, call, COMPLETED, () => invalid(checked.error), context);
67
+ return reject(runtime, task.input, call, tool, invalid(checked.error), context);
63
68
  let args = checked.args;
64
69
  let block;
65
70
  await runtime.hooks.each("beforeTool", async (hook) => {
@@ -80,22 +85,26 @@ export const ToolTask = defineTask({
80
85
  });
81
86
  if (block !== undefined) {
82
87
  const blocked = harnessError("blocked", `Tool call blocked: ${block}`);
83
- return settle(runtime, task.input, call, COMPLETED, () => blocked, context);
88
+ return reject(runtime, task.input, call, tool, blocked, context);
84
89
  }
85
90
  const validated = validate(tool, call, args);
86
91
  if ("error" in validated)
87
- return settle(runtime, task.input, call, COMPLETED, () => invalid(validated.error), context);
92
+ return reject(runtime, task.input, call, tool, invalid(validated.error), context);
88
93
  // Strict JSON: repair may set optional properties to undefined, which the slot and checkpoint cannot hold.
89
94
  const final = copyJson(validated.args, { omitUndefinedProperties: true });
95
+ // The slot's status and arguments are presentation, stored like progress (`PROGRESS_COMMIT`); the intent is
96
+ // durable before the tool runs. Parallel calls of one round then share no immediate write to `pi.live`.
90
97
  await runtime.commit(async (tx) => {
91
98
  const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
92
99
  if (slot !== undefined)
93
100
  slot.status = "running";
94
101
  // A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed.
95
- // Compared with the input, which the slot got at admission: reading the draft would track every leaf.
96
- const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments, final);
97
- if (changed && slot !== undefined && "parentTaskId" in slot)
102
+ // Assigned whole: an equal value records no change, and a rerun corrects what an earlier attempt showed.
103
+ if (slot !== undefined && "parentTaskId" in slot)
98
104
  slot.arguments = final;
105
+ return undefined;
106
+ }, context, PROGRESS_COMMIT);
107
+ await runtime.commit(() => {
99
108
  const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" };
100
109
  return { status: "running", checkpoint: intent };
101
110
  }, context);
@@ -105,13 +114,21 @@ export const ToolTask = defineTask({
105
114
  execute: async (task, runtime, context) => {
106
115
  const { arguments: args, replay } = task.state.checkpoint;
107
116
  const call = await readCall(runtime, task.input, context);
117
+ // Also before an interruption result: its `afterTool` hooks may be what cut the task off.
118
+ if (cutOffTooOften(task, runtime))
119
+ return endCutOff(runtime, task, call, context);
108
120
  const tool = await resolveTool(runtime, task.input, call.name, context);
109
121
  if (replay === "safe" && tool?.replay === "safe") {
110
- // The rerun reports from scratch; clear what the interrupted attempt published.
122
+ // The rerun reports from scratch: clear what the interrupted attempt published, and show the slot running with
123
+ // the arguments it runs with again, as the deferred commit that showed them may have been lost with the crash.
111
124
  await runtime.commit(async (tx) => {
112
125
  const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
113
- if (slot !== undefined)
126
+ if (slot !== undefined) {
114
127
  clearProgress(slot);
128
+ slot.status = "running";
129
+ if ("parentTaskId" in slot)
130
+ slot.arguments = args;
131
+ }
115
132
  return undefined;
116
133
  }, context);
117
134
  return run(runtime, task.input, call, tool, args, context);
@@ -119,26 +136,92 @@ export const ToolTask = defineTask({
119
136
  const message = `Tool ${call.name} was interrupted and may have partially run`;
120
137
  // `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted.
121
138
  const ending = { status: "failed", message };
122
- await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);
139
+ // Not cut off too often (checked above), so the hooks run.
140
+ await settleFromSlot(runtime, task.input, call, { tool }, ending, "interrupted", message, context);
123
141
  },
124
142
  },
125
143
  abort: async (task, runtime, context) => {
126
144
  const call = await readCall(runtime, task.input, context);
127
- // Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run.
128
- const [code, message] = task.abortReason !== "restart"
129
- ? ["aborted", `Tool ${call.name} was aborted`]
130
- : task.state.checkpoint.phase === "execute"
131
- ? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]
132
- : ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`];
133
- const ending = { status: "aborted" };
134
- await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, code, message), context);
145
+ // Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run. An unoffered call
146
+ // could not run in any case, and its result says so.
147
+ const [code, message] = isUnoffered(task.input)
148
+ ? ["tool_unavailable", `Tool ${call.name} is not available`]
149
+ : task.abortReason !== "restart"
150
+ ? ["aborted", `Tool ${call.name} was aborted`]
151
+ : task.state.checkpoint.phase === "execute"
152
+ ? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]
153
+ : ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`];
154
+ // The scheduler runs an abort handler only while its restarts are below the limit, and faults the task without a
155
+ // result when the last attempt is cut off. That attempt skips the hooks, which may be what cuts it off.
156
+ const hooks = (task.restarts ?? 0) < runtime.settings.restartLimit - 1
157
+ ? { tool: await toolForLimits(runtime, task.input, call.name, context) }
158
+ : undefined;
159
+ await settleFromSlot(runtime, task.input, call, hooks, { status: "aborted" }, code, message, context);
135
160
  },
136
161
  });
162
+ /**
163
+ * End a call cut off `settings.restartLimit` times in a row (spec §5.4) with an error result, built from the slot like
164
+ * an interrupted call's, instead of running it again. `failed`, like an interruption, so its owned work is aborted.
165
+ */
166
+ async function endCutOff(runtime, task, call, context) {
167
+ const message = `Tool ${call.name} was interrupted ${cutOffCount(task)}; it is not run again`;
168
+ const ending = { status: "failed", message, reason: "restart_limit" };
169
+ // Past the limit the hooks may be what keeps cutting the task off (`salvageable`).
170
+ const hooks = salvageable(task, runtime)
171
+ ? { tool: await toolForLimits(runtime, task.input, call.name, context) }
172
+ : undefined;
173
+ await settleFromSlot(runtime, task.input, call, hooks, ending, "restart_limit", message, context);
174
+ }
175
+ /**
176
+ * Settle a result the Harness decided in the `call` phase, without running the tool: the tool did not resolve, the
177
+ * arguments are invalid, or `beforeTool` blocked the call. `afterTool` applies as to an executed call's result.
178
+ */
179
+ async function reject(runtime, input, call, tool, result, context) {
180
+ const settled = await afterHarnessResult(runtime, input, call, tool, result, undefined, context);
181
+ await settle(runtime, input, call, COMPLETED, settled, context);
182
+ }
183
+ /**
184
+ * Settle an error result built from the call's slot (`fromSlot`): an interruption, the restart limit, or an abort.
185
+ * The tool is not running, so the slot read before the commit is still current when it commits. With `hooks`, the
186
+ * result passes `afterTool` and is bounded with the limits of `hooks.tool` (the defaults when it is `undefined`);
187
+ * without, it is settled as built, when the hooks may be what cuts the task off.
188
+ */
189
+ async function settleFromSlot(runtime, input, call, hooks, ending, code, message, context) {
190
+ const live = await runtime.snapshot(LiveDoc, runtime.conversationId, context);
191
+ const { result, truncation } = fromSlot(live === undefined ? undefined : toolSlot(live, runtime.taskId), code, message);
192
+ const settled = hooks !== undefined
193
+ ? await afterHarnessResult(runtime, input, call, hooks.tool, result, truncation, context)
194
+ : truncation === undefined
195
+ ? result
196
+ : { ...result, diagnostics: [...(result.diagnostics ?? []), truncation] };
197
+ await settle(runtime, input, call, ending, settled, context);
198
+ }
199
+ /**
200
+ * The tool whose output limits bound a hooks' result: the called tool, or none when it, or the agent, fails to resolve,
201
+ * and for an unoffered call, which never resolves its name.
202
+ */
203
+ async function toolForLimits(runtime, input, name, context) {
204
+ if (isUnoffered(input))
205
+ return undefined;
206
+ return resolveTool(runtime, input, name, context).catch(() => undefined);
207
+ }
208
+ /** A model-issued call to a tool its request did not offer (spec §8.3). */
209
+ function isUnoffered(input) {
210
+ return input.kind === "model" && input.unoffered === true;
211
+ }
137
212
  /** The called tool: among the tools the model is offered for a model-issued call, else among those tools may call. */
138
213
  async function resolveTool(runtime, input, name, context) {
139
214
  const agent = await runtime.agent(context);
140
215
  return (input.kind === "model" ? agent.tools : agent.callable).find((tool) => tool.name === name);
141
216
  }
217
+ /** The tool's output limits, the defaults filled in; all defaults when the tool did not resolve. */
218
+ function outputLimits(tool) {
219
+ return {
220
+ maxBytes: tool?.outputLimits?.maxBytes ?? DEFAULT_MAX_BYTES,
221
+ maxLines: tool?.outputLimits?.maxLines ?? DEFAULT_MAX_LINES,
222
+ retain: tool?.outputLimits?.retain ?? "head",
223
+ };
224
+ }
142
225
  /** The call: from the input for a nested call, which hooks see with its parent, else from the assistant entry. */
143
226
  async function readCall(runtime, input, context) {
144
227
  if (input.kind === "nested") {
@@ -186,11 +269,7 @@ function invalid(message) {
186
269
  }
187
270
  /** Execute with the resolved implementation, then settle its result. */
188
271
  async function run(runtime, input, call, tool, args, context) {
189
- const limits = {
190
- maxBytes: tool.outputLimits?.maxBytes ?? DEFAULT_MAX_BYTES,
191
- maxLines: tool.outputLimits?.maxLines ?? DEFAULT_MAX_LINES,
192
- retain: tool.outputLimits?.retain ?? "head",
193
- };
272
+ const limits = outputLimits(tool);
194
273
  const reported = { output: new OutputBuffer(limits), limits, diagnostics: [], details: undefined };
195
274
  const streams = input.kind === "model" || input.progress !== false;
196
275
  const progress = publishProgress(runtime, reported, streams, context);
@@ -255,12 +334,12 @@ async function run(runtime, input, call, tool, args, context) {
255
334
  committed.catch(() => { });
256
335
  return awaitWithContext(committed, detailsContext);
257
336
  },
258
- commit: async (change, commitContext) => {
337
+ commit: async (change, commitContext, options) => {
259
338
  let result;
260
339
  await runtime.commit(async (tx) => {
261
340
  result = await change(tx);
262
341
  return undefined;
263
- }, commitContext);
342
+ }, commitContext, options);
264
343
  return result;
265
344
  },
266
345
  memo: runtime.memo,
@@ -335,7 +414,7 @@ async function run(runtime, input, call, tool, args, context) {
335
414
  const pending = await progress.stop();
336
415
  try {
337
416
  const settled = await finalResult(runtime, input, call, tool, result, reported, context);
338
- await settle(runtime, input, call, ending, () => settled, context, durationMs);
417
+ await settle(runtime, input, call, ending, settled, context, durationMs);
339
418
  }
340
419
  catch (error) {
341
420
  for (const waiter of pending)
@@ -366,7 +445,7 @@ function progressClock(runtime) {
366
445
  }
367
446
  /**
368
447
  * Throttled commits of what the tool reported into its slot, in `pi.live.tools` or `pi.live.nestedTools`, each writing
369
- * only what changed since the last one; none when `enabled` is false.
448
+ * only what changed since the last one, deferred (`PROGRESS_COMMIT`); none when `enabled` is false.
370
449
  */
371
450
  function publishProgress(runtime, reported, enabled, context) {
372
451
  let written = { text: "", details: undefined, diagnostics: 0 };
@@ -414,7 +493,7 @@ function publishProgress(runtime, reported, enabled, context) {
414
493
  slot.diagnostics.push(diagnostic);
415
494
  }
416
495
  return undefined;
417
- }, context);
496
+ }, context, PROGRESS_COMMIT);
418
497
  written = current;
419
498
  return bytes;
420
499
  }, (error) => {
@@ -461,7 +540,7 @@ async function finalResult(runtime, input, call, tool, result, reported, context
461
540
  final = rest;
462
541
  // Only names the extension: a failed resolution leaves the report without the name.
463
542
  const extension = await runtime.agent(context).then((agent) => toolExtension(agent, tool.name), () => undefined);
464
- runtime.report(new Error(broken), { type: "extension", ...optionalExtension(extension) });
543
+ runtime.report(new Error(broken), { kind: "extension", ...optionalExtension(extension) });
465
544
  }
466
545
  }
467
546
  // Programs get the tool's own value: without a schema, its output. The model reads only `output`, so model-issued
@@ -482,6 +561,33 @@ async function finalResult(runtime, input, call, tool, result, reported, context
482
561
  }
483
562
  return { ...final, output: bounded.content, diagnostics: [...(final.diagnostics ?? []), ...harness] };
484
563
  }
564
+ /**
565
+ * A result the Harness wrote itself, without executing the tool, after the `afterTool` chain, normalized like an
566
+ * executed call's: the output bounded with the tool's limits (the defaults when it did not resolve), a truncation
567
+ * diagnostic when that dropped something, and the hooks' `structuredOutput` dropped, since the tool never produced
568
+ * one. A nested call that the hooks turned into a success gets the bounded output as its value, as a schema-less
569
+ * tool's success does, unless the tool declares a schema. `usage` is kept and recorded; `control` applies only when
570
+ * the task completes. `truncation` describes what the result's own output lost before (a slot's retained output); it
571
+ * is kept only while the hooks keep that output.
572
+ */
573
+ async function afterHarnessResult(runtime, input, call, tool, result, truncation, context) {
574
+ let final = result;
575
+ await runtime.hooks.each("afterTool", async (hook) => {
576
+ final = (await hook(call, final, runtime, context)) ?? final;
577
+ });
578
+ const harness = [];
579
+ if (truncation !== undefined && final.output === result.output)
580
+ harness.push(truncation);
581
+ const limits = outputLimits(tool);
582
+ const bounded = boundContent(final.output ?? [], limits);
583
+ if (bounded.droppedBytes > 0)
584
+ harness.push(truncated(bounded, limits.retain));
585
+ const { structuredOutput: _dropped, ...rest } = final;
586
+ const value = input.kind === "nested" && final.isError !== true && tool?.structuredOutputSchema === undefined
587
+ ? { structuredOutput: outputValue(bounded.content) }
588
+ : {};
589
+ return { ...rest, ...value, output: bounded.content, diagnostics: [...(rest.diagnostics ?? []), ...harness] };
590
+ }
485
591
  const structuredValidators = new WeakMap();
486
592
  /** Why a result's `structuredOutput` breaks the tool's contract, or `undefined` when it keeps it. */
487
593
  function structuredOutputError(tool, result) {
@@ -497,23 +603,26 @@ function structuredOutputError(tool, result) {
497
603
  }
498
604
  let validator = structuredValidators.get(schema);
499
605
  if (validator === undefined) {
500
- validator = Compile(schema);
606
+ validator = compile(schema, { formats, unresolvedRefs: "reject" });
501
607
  structuredValidators.set(schema, validator);
502
608
  }
503
- if (validator.Check(value))
609
+ if (validator.check(value))
504
610
  return undefined;
505
- const [first] = validator.Errors(value);
506
- const path = first?.instancePath.replace(/^\//, "").replace(/\//g, ".") || "root";
611
+ const [first] = validator.errors(value);
612
+ const path = first?.path
613
+ .slice(1)
614
+ .split("/")
615
+ .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"))
616
+ .join(".") || "root";
507
617
  return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`;
508
618
  }
509
619
  /**
510
620
  * Commit the tool's terminal state and mark its slot done. A model-issued call appends its result entry and ends with
511
621
  * the entry ID; a nested call writes its result to the caller's `NestedCallDoc`, records its usage, and ends with a
512
622
  * small receipt. Nested calls this call left running are aborted first, so they report into their slots before the
513
- * slots below the call leave `pi.live`. `build` receives the slot so interruption and abort can report the durable
514
- * partial output.
623
+ * slots below the call leave `pi.live`. `control` applies only when the task completes.
515
624
  */
516
- async function settle(runtime, input, call, ending, build, context, durationMs) {
625
+ async function settle(runtime, input, call, ending, result, context, durationMs) {
517
626
  // A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.
518
627
  // Every live nested call the task owns, also those an earlier attempt made and this one never called again.
519
628
  const nestedIds = (await runtime.ownedTasks(context))
@@ -527,7 +636,6 @@ async function settle(runtime, input, call, ending, build, context, durationMs)
527
636
  const conversationId = runtime.conversationId;
528
637
  const live = await tx.doc(LiveDoc, conversationId);
529
638
  const slot = toolSlot(live, runtime.taskId);
530
- const result = build(slot);
531
639
  let settled;
532
640
  if (input.kind === "nested") {
533
641
  const nested = nestedResult(runtime.taskId, result, durationMs);
@@ -565,7 +673,10 @@ async function settle(runtime, input, call, ending, build, context, durationMs)
565
673
  if (ending.status === "aborted")
566
674
  return { status: "terminal", outcome: { status: "aborted", result: settled } };
567
675
  if (ending.status === "failed") {
568
- const error = { message: ending.message };
676
+ const error = {
677
+ message: ending.message,
678
+ ...(ending.reason === undefined ? {} : { detail: { reason: ending.reason } }),
679
+ };
569
680
  return { status: "terminal", outcome: { status: "failed", error, result: settled } };
570
681
  }
571
682
  return { status: "terminal", outcome: { status: "completed", result: settled } };
@@ -674,19 +785,21 @@ function fallbackResult(taskId, name, outcome) {
674
785
  return { taskId, isError: true, diagnostics: error.diagnostics ?? [] };
675
786
  }
676
787
  const COMPLETED = { status: "completed" };
677
- /** An error result from the slot's durable partial output, details, and diagnostics. */
788
+ /**
789
+ * An error result from the slot's durable partial output, details, and diagnostics, ending with `code`; and the
790
+ * truncation diagnostic of that output, when its bounds dropped some, which the caller adds after `afterTool`.
791
+ */
678
792
  function fromSlot(slot, code, message) {
679
- const diagnostics = [...(slot?.diagnostics ?? [])];
680
793
  const droppedBytes = slot?.droppedBytes ?? 0;
681
- if (droppedBytes > 0)
682
- diagnostics.push(truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }));
683
- diagnostics.push(toolDiagnostic(code, message));
684
- return {
794
+ const result = {
685
795
  output: slot?.output === undefined || slot.output === "" ? [] : [{ type: "text", text: slot.output }],
686
796
  isError: true,
687
797
  ...(slot?.details === undefined ? {} : { details: slot.details }),
688
- diagnostics,
798
+ diagnostics: [...(slot?.diagnostics ?? []), toolDiagnostic(code, message)],
689
799
  };
800
+ if (droppedBytes === 0)
801
+ return { result };
802
+ return { result, truncation: truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }) };
690
803
  }
691
804
  /** An error result the Harness writes itself: no content and one `error` diagnostic with `code`. */
692
805
  export function harnessError(code, message) {
@@ -706,9 +819,12 @@ function truncated(dropped, retain) {
706
819
  }
707
820
  /** Error text a nested call's summary keeps. */
708
821
  const MAX_SUMMARY_ERROR_CHARS = 500;
709
- /** How a nested call ended, for its slot: error text, from its diagnostics or else its `output`, bounded. */
822
+ /**
823
+ * How a nested call ended, for its slot: error text, from its diagnostics or else its `output`, bounded. The bound is a
824
+ * copy, so the slot never keeps a large output alive through a slice of it.
825
+ */
710
826
  function summaryOf(result, output) {
711
- const error = result.isError ? errorTextOf(result, output).slice(0, MAX_SUMMARY_ERROR_CHARS) : "";
827
+ const error = result.isError ? ownedSlice(errorTextOf(result, output), 0, MAX_SUMMARY_ERROR_CHARS) : "";
712
828
  return {
713
829
  isError: result.isError,
714
830
  ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),