@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
@@ -1,17 +1,20 @@
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";
10
11
  import { defineTask } from "../tasks.js";
11
12
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, utf8ByteLength } from "../truncate.js";
13
+ import { toolExtension } from "./agent.js";
12
14
  import { assignJson } from "./json.js";
13
- import { clearProgress, finishSlot, LiveDoc, removeNestedSlots, toolSlot, } from "./live.js";
15
+ import { clearProgress, cutOffCount, cutOffTooOften, finishSlot, LiveDoc, PROGRESS_COMMIT, removeNestedSlots, salvageable, toolSlot, } from "./live.js";
14
16
  import { recordUsage } from "./usage.js";
17
+ import { optionalExtension } from "./util.js";
15
18
  /**
16
19
  * Memo of a tool task that made a nested call, in any attempt, committed before the first admission of each attempt:
17
20
  * only then can `pi.live` hold slots below it, which settling removes. A crash between the two only costs a scan.
@@ -28,6 +31,8 @@ export const NestedCallDoc = defineDocFamily({
28
31
  scope: "task",
29
32
  family: true,
30
33
  initial: () => ({}),
34
+ // The document retires with its task.
35
+ checkpointWhen: () => false,
31
36
  });
32
37
  /**
33
38
  * Built-in tool task: resolves the called tool among its phase agent's tools, validates, runs `beforeTool`, records intent,
@@ -37,25 +42,29 @@ export const NestedCallDoc = defineDocFamily({
37
42
  */
38
43
  export const ToolTask = defineTask({
39
44
  name: "pi.tool",
40
- version: 2,
45
+ version: 3,
41
46
  initial: () => ({ phase: "call" }),
42
- // Version 1 had only model-issued calls.
43
- migrate: (input, checkpoint) => ({
44
- 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,
45
52
  checkpoint: checkpoint,
46
53
  }),
47
54
  phases: {
48
55
  call: async (task, runtime, context) => {
49
56
  const call = await readCall(runtime, task.input, context);
50
- 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);
51
60
  if (tool === undefined) {
52
61
  const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
53
- return settle(runtime, task.input, call, COMPLETED, () => error, context);
62
+ return reject(runtime, task.input, call, undefined, error, context);
54
63
  }
55
64
  const prepared = prepare(tool, call.arguments);
56
65
  const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args);
57
66
  if ("error" in checked)
58
- return settle(runtime, task.input, call, COMPLETED, () => invalid(checked.error), context);
67
+ return reject(runtime, task.input, call, tool, invalid(checked.error), context);
59
68
  let args = checked.args;
60
69
  let block;
61
70
  await runtime.hooks.each("beforeTool", async (hook) => {
@@ -76,22 +85,26 @@ export const ToolTask = defineTask({
76
85
  });
77
86
  if (block !== undefined) {
78
87
  const blocked = harnessError("blocked", `Tool call blocked: ${block}`);
79
- return settle(runtime, task.input, call, COMPLETED, () => blocked, context);
88
+ return reject(runtime, task.input, call, tool, blocked, context);
80
89
  }
81
90
  const validated = validate(tool, call, args);
82
91
  if ("error" in validated)
83
- return settle(runtime, task.input, call, COMPLETED, () => invalid(validated.error), context);
92
+ return reject(runtime, task.input, call, tool, invalid(validated.error), context);
84
93
  // Strict JSON: repair may set optional properties to undefined, which the slot and checkpoint cannot hold.
85
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`.
86
97
  await runtime.commit(async (tx) => {
87
98
  const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
88
99
  if (slot !== undefined)
89
100
  slot.status = "running";
90
101
  // A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed.
91
- // Compared with the input, which the slot got at admission: reading the draft would track every leaf.
92
- const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments, final);
93
- 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)
94
104
  slot.arguments = final;
105
+ return undefined;
106
+ }, context, PROGRESS_COMMIT);
107
+ await runtime.commit(() => {
95
108
  const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" };
96
109
  return { status: "running", checkpoint: intent };
97
110
  }, context);
@@ -101,13 +114,21 @@ export const ToolTask = defineTask({
101
114
  execute: async (task, runtime, context) => {
102
115
  const { arguments: args, replay } = task.state.checkpoint;
103
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);
104
120
  const tool = await resolveTool(runtime, task.input, call.name, context);
105
121
  if (replay === "safe" && tool?.replay === "safe") {
106
- // 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.
107
124
  await runtime.commit(async (tx) => {
108
125
  const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
109
- if (slot !== undefined)
126
+ if (slot !== undefined) {
110
127
  clearProgress(slot);
128
+ slot.status = "running";
129
+ if ("parentTaskId" in slot)
130
+ slot.arguments = args;
131
+ }
111
132
  return undefined;
112
133
  }, context);
113
134
  return run(runtime, task.input, call, tool, args, context);
@@ -115,30 +136,101 @@ export const ToolTask = defineTask({
115
136
  const message = `Tool ${call.name} was interrupted and may have partially run`;
116
137
  // `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted.
117
138
  const ending = { status: "failed", message };
118
- 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);
119
141
  },
120
142
  },
121
143
  abort: async (task, runtime, context) => {
122
144
  const call = await readCall(runtime, task.input, context);
123
- // Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run.
124
- const [code, message] = task.abortReason !== "restart"
125
- ? ["aborted", `Tool ${call.name} was aborted`]
126
- : task.state.checkpoint.phase === "execute"
127
- ? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]
128
- : ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`];
129
- const ending = { status: "aborted" };
130
- 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);
131
160
  },
132
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
+ }
133
212
  /** The called tool: among the tools the model is offered for a model-issued call, else among those tools may call. */
134
213
  async function resolveTool(runtime, input, name, context) {
135
214
  const agent = await runtime.agent(context);
136
215
  return (input.kind === "model" ? agent.tools : agent.callable).find((tool) => tool.name === name);
137
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
+ }
138
225
  /** The call: from the input for a nested call, which hooks see with its parent, else from the assistant entry. */
139
226
  async function readCall(runtime, input, context) {
140
- if (input.kind === "nested")
227
+ if (input.kind === "nested") {
228
+ context.telemetry.setAttributes({
229
+ "tool.name": input.call.name,
230
+ "tool.call_id": input.call.id,
231
+ });
141
232
  return { ...input.call, parent: { taskId: input.parent, callId: input.parentCallId } };
233
+ }
142
234
  const entry = await runtime.entry(AssistantEntry, input.assistant, context);
143
235
  const message = entry?.model?.[0];
144
236
  const call = message?.role === "assistant"
@@ -146,6 +238,10 @@ async function readCall(runtime, input, context) {
146
238
  : undefined;
147
239
  if (call === undefined)
148
240
  throw new Error(`Entry ${input.assistant} has no tool call ${input.callId}`);
241
+ context.telemetry.setAttributes({
242
+ "tool.name": call.name,
243
+ "tool.call_id": call.id,
244
+ });
149
245
  return call;
150
246
  }
151
247
  /** The call's arguments as repaired by the tool; a throwing repair makes them invalid. */
@@ -173,11 +269,7 @@ function invalid(message) {
173
269
  }
174
270
  /** Execute with the resolved implementation, then settle its result. */
175
271
  async function run(runtime, input, call, tool, args, context) {
176
- const limits = {
177
- maxBytes: tool.outputLimits?.maxBytes ?? DEFAULT_MAX_BYTES,
178
- maxLines: tool.outputLimits?.maxLines ?? DEFAULT_MAX_LINES,
179
- retain: tool.outputLimits?.retain ?? "head",
180
- };
272
+ const limits = outputLimits(tool);
181
273
  const reported = { output: new OutputBuffer(limits), limits, diagnostics: [], details: undefined };
182
274
  const streams = input.kind === "model" || input.progress !== false;
183
275
  const progress = publishProgress(runtime, reported, streams, context);
@@ -195,13 +287,21 @@ async function run(runtime, input, call, tool, args, context) {
195
287
  if (ended)
196
288
  throw new Error(`Tool call ${call.id} has settled`);
197
289
  };
290
+ /** Built for this call, below, so a rerun after recovery gets the conversation's environment at that time. */
291
+ let env;
198
292
  const api = {
199
293
  taskId: runtime.taskId,
200
294
  conversationId: runtime.conversationId,
201
295
  callId: call.id,
202
296
  registry: runtime.registry,
203
297
  agent: runtime.agent,
204
- models: runtime.models,
298
+ get models() {
299
+ return runtime.models;
300
+ },
301
+ get env() {
302
+ return env;
303
+ },
304
+ now: () => runtime.now(),
205
305
  output: (chunk, skipped) => {
206
306
  assertLive();
207
307
  if (reported.output.push(chunk, skipped))
@@ -234,12 +334,12 @@ async function run(runtime, input, call, tool, args, context) {
234
334
  committed.catch(() => { });
235
335
  return awaitWithContext(committed, detailsContext);
236
336
  },
237
- commit: async (change, commitContext) => {
337
+ commit: async (change, commitContext, options) => {
238
338
  let result;
239
339
  await runtime.commit(async (tx) => {
240
340
  result = await change(tx);
241
341
  return undefined;
242
- }, commitContext);
342
+ }, commitContext, options);
243
343
  return result;
244
344
  },
245
345
  memo: runtime.memo,
@@ -285,11 +385,10 @@ async function run(runtime, input, call, tool, args, context) {
285
385
  // Execution time of this attempt; a rerun after recovery measures only itself.
286
386
  let durationMs;
287
387
  try {
288
- // Built for this call, so a rerun after recovery gets the conversation's environment at that time.
289
- const env = await runtime.env(context);
388
+ env = await runtime.env(context);
290
389
  const startedAt = performance.now();
291
390
  try {
292
- result = await tool.execute(args, { ...api, env }, context);
391
+ result = await tool.execute(args, api, context);
293
392
  }
294
393
  finally {
295
394
  durationMs = Math.round(performance.now() - startedAt);
@@ -315,7 +414,7 @@ async function run(runtime, input, call, tool, args, context) {
315
414
  const pending = await progress.stop();
316
415
  try {
317
416
  const settled = await finalResult(runtime, input, call, tool, result, reported, context);
318
- await settle(runtime, input, call, ending, () => settled, context, durationMs);
417
+ await settle(runtime, input, call, ending, settled, context, durationMs);
319
418
  }
320
419
  catch (error) {
321
420
  for (const waiter of pending)
@@ -325,9 +424,28 @@ async function run(runtime, input, call, tool, args, context) {
325
424
  for (const waiter of pending)
326
425
  waiter.resolve();
327
426
  }
427
+ /**
428
+ * The Harness clock and timers for progress pacing. After the invocation ends nothing more commits, and the clock
429
+ * answers with the last time it read.
430
+ */
431
+ function progressClock(runtime) {
432
+ let last = 0;
433
+ return {
434
+ now: () => {
435
+ try {
436
+ last = runtime.now();
437
+ }
438
+ catch {
439
+ // The invocation ended.
440
+ }
441
+ return last;
442
+ },
443
+ setTimer: (callback, delayMs) => runtime.setTimer(callback, delayMs),
444
+ };
445
+ }
328
446
  /**
329
447
  * Throttled commits of what the tool reported into its slot, in `pi.live.tools` or `pi.live.nestedTools`, each writing
330
- * 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.
331
449
  */
332
450
  function publishProgress(runtime, reported, enabled, context) {
333
451
  let written = { text: "", details: undefined, diagnostics: 0 };
@@ -375,14 +493,14 @@ function publishProgress(runtime, reported, enabled, context) {
375
493
  slot.diagnostics.push(diagnostic);
376
494
  }
377
495
  return undefined;
378
- }, context);
496
+ }, context, PROGRESS_COMMIT);
379
497
  written = current;
380
498
  return bytes;
381
499
  }, (error) => {
382
500
  // Rejections after an abort mark or close are expected; the committed state stays consistent.
383
501
  if (!runtime.signal.aborted)
384
502
  runtime.report(error);
385
- }, runtime.settings.progress.outputIntervalMs);
503
+ }, runtime.settings.progress.outputIntervalMs, progressClock(runtime));
386
504
  }
387
505
  /**
388
506
  * The settled result: the tool's result with the retained output and last details as fallbacks, its diagnostics after
@@ -420,7 +538,9 @@ async function finalResult(runtime, input, call, tool, result, reported, context
420
538
  else {
421
539
  // The model never sees structured output, so its call stands; the host learns of the broken tool.
422
540
  final = rest;
423
- runtime.report(new Error(broken));
541
+ // Only names the extension: a failed resolution leaves the report without the name.
542
+ const extension = await runtime.agent(context).then((agent) => toolExtension(agent, tool.name), () => undefined);
543
+ runtime.report(new Error(broken), { kind: "extension", ...optionalExtension(extension) });
424
544
  }
425
545
  }
426
546
  // Programs get the tool's own value: without a schema, its output. The model reads only `output`, so model-issued
@@ -441,6 +561,33 @@ async function finalResult(runtime, input, call, tool, result, reported, context
441
561
  }
442
562
  return { ...final, output: bounded.content, diagnostics: [...(final.diagnostics ?? []), ...harness] };
443
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
+ }
444
591
  const structuredValidators = new WeakMap();
445
592
  /** Why a result's `structuredOutput` breaks the tool's contract, or `undefined` when it keeps it. */
446
593
  function structuredOutputError(tool, result) {
@@ -456,23 +603,26 @@ function structuredOutputError(tool, result) {
456
603
  }
457
604
  let validator = structuredValidators.get(schema);
458
605
  if (validator === undefined) {
459
- validator = Compile(schema);
606
+ validator = compile(schema, { formats, unresolvedRefs: "reject" });
460
607
  structuredValidators.set(schema, validator);
461
608
  }
462
- if (validator.Check(value))
609
+ if (validator.check(value))
463
610
  return undefined;
464
- const [first] = validator.Errors(value);
465
- 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";
466
617
  return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`;
467
618
  }
468
619
  /**
469
620
  * Commit the tool's terminal state and mark its slot done. A model-issued call appends its result entry and ends with
470
621
  * the entry ID; a nested call writes its result to the caller's `NestedCallDoc`, records its usage, and ends with a
471
622
  * small receipt. Nested calls this call left running are aborted first, so they report into their slots before the
472
- * slots below the call leave `pi.live`. `build` receives the slot so interruption and abort can report the durable
473
- * partial output.
623
+ * slots below the call leave `pi.live`. `control` applies only when the task completes.
474
624
  */
475
- async function settle(runtime, input, call, ending, build, context, durationMs) {
625
+ async function settle(runtime, input, call, ending, result, context, durationMs) {
476
626
  // A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.
477
627
  // Every live nested call the task owns, also those an earlier attempt made and this one never called again.
478
628
  const nestedIds = (await runtime.ownedTasks(context))
@@ -486,7 +636,6 @@ async function settle(runtime, input, call, ending, build, context, durationMs)
486
636
  const conversationId = runtime.conversationId;
487
637
  const live = await tx.doc(LiveDoc, conversationId);
488
638
  const slot = toolSlot(live, runtime.taskId);
489
- const result = build(slot);
490
639
  let settled;
491
640
  if (input.kind === "nested") {
492
641
  const nested = nestedResult(runtime.taskId, result, durationMs);
@@ -524,7 +673,10 @@ async function settle(runtime, input, call, ending, build, context, durationMs)
524
673
  if (ending.status === "aborted")
525
674
  return { status: "terminal", outcome: { status: "aborted", result: settled } };
526
675
  if (ending.status === "failed") {
527
- const error = { message: ending.message };
676
+ const error = {
677
+ message: ending.message,
678
+ ...(ending.reason === undefined ? {} : { detail: { reason: ending.reason } }),
679
+ };
528
680
  return { status: "terminal", outcome: { status: "failed", error, result: settled } };
529
681
  }
530
682
  return { status: "terminal", outcome: { status: "completed", result: settled } };
@@ -561,6 +713,7 @@ async function startNestedCall(runtime, parentCallId, name, args, options, conte
561
713
  arguments: copyJson(args, { omitUndefinedProperties: true }),
562
714
  };
563
715
  let id;
716
+ let replayed = false;
564
717
  await runtime.commit(async (tx) => {
565
718
  // Opened, or created empty for a key not made yet.
566
719
  const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null);
@@ -573,6 +726,7 @@ async function startNestedCall(runtime, parentCallId, name, args, options, conte
573
726
  if (!same)
574
727
  throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`);
575
728
  id = existing;
729
+ replayed = state.result !== undefined;
576
730
  return undefined;
577
731
  }
578
732
  const input = {
@@ -603,6 +757,9 @@ async function startNestedCall(runtime, parentCallId, name, args, options, conte
603
757
  id = created;
604
758
  return undefined;
605
759
  }, context);
760
+ if (replayed) {
761
+ context.telemetry.addEvent("pi.durable.replay", { "tool.name": name });
762
+ }
606
763
  return id;
607
764
  }
608
765
  /**
@@ -628,19 +785,21 @@ function fallbackResult(taskId, name, outcome) {
628
785
  return { taskId, isError: true, diagnostics: error.diagnostics ?? [] };
629
786
  }
630
787
  const COMPLETED = { status: "completed" };
631
- /** 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
+ */
632
792
  function fromSlot(slot, code, message) {
633
- const diagnostics = [...(slot?.diagnostics ?? [])];
634
793
  const droppedBytes = slot?.droppedBytes ?? 0;
635
- if (droppedBytes > 0)
636
- diagnostics.push(truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }));
637
- diagnostics.push(toolDiagnostic(code, message));
638
- return {
794
+ const result = {
639
795
  output: slot?.output === undefined || slot.output === "" ? [] : [{ type: "text", text: slot.output }],
640
796
  isError: true,
641
797
  ...(slot?.details === undefined ? {} : { details: slot.details }),
642
- diagnostics,
798
+ diagnostics: [...(slot?.diagnostics ?? []), toolDiagnostic(code, message)],
643
799
  };
800
+ if (droppedBytes === 0)
801
+ return { result };
802
+ return { result, truncation: truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }) };
644
803
  }
645
804
  /** An error result the Harness writes itself: no content and one `error` diagnostic with `code`. */
646
805
  export function harnessError(code, message) {
@@ -660,9 +819,12 @@ function truncated(dropped, retain) {
660
819
  }
661
820
  /** Error text a nested call's summary keeps. */
662
821
  const MAX_SUMMARY_ERROR_CHARS = 500;
663
- /** 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
+ */
664
826
  function summaryOf(result, output) {
665
- 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) : "";
666
828
  return {
667
829
  isError: result.isError,
668
830
  ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),