@earendil-works/pi-durable 1.0.4 → 1.2.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 (196) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +149 -11
  3. package/dist/codemode/codemode.d.ts +173 -0
  4. package/dist/codemode/codemode.d.ts.map +1 -0
  5. package/dist/codemode/codemode.js +886 -0
  6. package/dist/codemode/codemode.js.map +1 -0
  7. package/dist/codemode/describe.d.ts +38 -0
  8. package/dist/codemode/describe.d.ts.map +1 -0
  9. package/dist/codemode/describe.js +119 -0
  10. package/dist/codemode/describe.js.map +1 -0
  11. package/dist/codemode/index.d.ts +3 -0
  12. package/dist/codemode/index.d.ts.map +1 -0
  13. package/dist/codemode/index.js +3 -0
  14. package/dist/codemode/index.js.map +1 -0
  15. package/dist/codemode/models.d.ts +28 -0
  16. package/dist/codemode/models.d.ts.map +1 -0
  17. package/dist/codemode/models.js +231 -0
  18. package/dist/codemode/models.js.map +1 -0
  19. package/dist/codemode/output.d.ts +41 -0
  20. package/dist/codemode/output.d.ts.map +1 -0
  21. package/dist/codemode/output.js +167 -0
  22. package/dist/codemode/output.js.map +1 -0
  23. package/dist/env/index.d.ts +42 -0
  24. package/dist/env/index.d.ts.map +1 -1
  25. package/dist/env/index.js.map +1 -1
  26. package/dist/errors.d.ts +20 -3
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +34 -5
  29. package/dist/errors.js.map +1 -1
  30. package/dist/harness/agent.d.ts +5 -2
  31. package/dist/harness/agent.d.ts.map +1 -1
  32. package/dist/harness/agent.js +72 -25
  33. package/dist/harness/agent.js.map +1 -1
  34. package/dist/harness/compaction.d.ts +6 -1
  35. package/dist/harness/compaction.d.ts.map +1 -1
  36. package/dist/harness/compaction.js +11 -8
  37. package/dist/harness/compaction.js.map +1 -1
  38. package/dist/harness/context.d.ts +29 -1
  39. package/dist/harness/context.d.ts.map +1 -1
  40. package/dist/harness/context.js +125 -24
  41. package/dist/harness/context.js.map +1 -1
  42. package/dist/harness/events.d.ts +35 -15
  43. package/dist/harness/events.d.ts.map +1 -1
  44. package/dist/harness/events.js +77 -23
  45. package/dist/harness/events.js.map +1 -1
  46. package/dist/harness/generation.d.ts +2 -0
  47. package/dist/harness/generation.d.ts.map +1 -1
  48. package/dist/harness/generation.js +34 -11
  49. package/dist/harness/generation.js.map +1 -1
  50. package/dist/harness/harness.d.ts +7 -0
  51. package/dist/harness/harness.d.ts.map +1 -1
  52. package/dist/harness/harness.js +56 -28
  53. package/dist/harness/harness.js.map +1 -1
  54. package/dist/harness/inbox.d.ts +16 -4
  55. package/dist/harness/inbox.d.ts.map +1 -1
  56. package/dist/harness/inbox.js +26 -3
  57. package/dist/harness/inbox.js.map +1 -1
  58. package/dist/harness/live.d.ts +74 -17
  59. package/dist/harness/live.d.ts.map +1 -1
  60. package/dist/harness/live.js +66 -14
  61. package/dist/harness/live.js.map +1 -1
  62. package/dist/harness/scheduler.d.ts +18 -5
  63. package/dist/harness/scheduler.d.ts.map +1 -1
  64. package/dist/harness/scheduler.js +794 -168
  65. package/dist/harness/scheduler.js.map +1 -1
  66. package/dist/harness/submissions.d.ts +7 -4
  67. package/dist/harness/submissions.d.ts.map +1 -1
  68. package/dist/harness/submissions.js +32 -13
  69. package/dist/harness/submissions.js.map +1 -1
  70. package/dist/harness/task-graph.d.ts +1 -1
  71. package/dist/harness/task-graph.d.ts.map +1 -1
  72. package/dist/harness/task-graph.js +15 -10
  73. package/dist/harness/task-graph.js.map +1 -1
  74. package/dist/harness/tool.d.ts +45 -5
  75. package/dist/harness/tool.d.ts.map +1 -1
  76. package/dist/harness/tool.js +388 -63
  77. package/dist/harness/tool.js.map +1 -1
  78. package/dist/harness/types.d.ts +171 -15
  79. package/dist/harness/types.d.ts.map +1 -1
  80. package/dist/harness/types.js.map +1 -1
  81. package/dist/harness/util.d.ts +6 -1
  82. package/dist/harness/util.d.ts.map +1 -1
  83. package/dist/harness/util.js +5 -2
  84. package/dist/harness/util.js.map +1 -1
  85. package/dist/harness/view.d.ts +6 -2
  86. package/dist/harness/view.d.ts.map +1 -1
  87. package/dist/harness/view.js +22 -12
  88. package/dist/harness/view.js.map +1 -1
  89. package/dist/images/cloudflare.d.ts +4 -0
  90. package/dist/images/cloudflare.d.ts.map +1 -0
  91. package/dist/images/cloudflare.js +9 -0
  92. package/dist/images/cloudflare.js.map +1 -0
  93. package/dist/images/exif.d.ts +3 -0
  94. package/dist/images/exif.d.ts.map +1 -0
  95. package/dist/images/exif.js +71 -0
  96. package/dist/images/exif.js.map +1 -0
  97. package/dist/images/index.d.ts +18 -0
  98. package/dist/images/index.d.ts.map +1 -0
  99. package/dist/images/index.js +125 -0
  100. package/dist/images/index.js.map +1 -0
  101. package/dist/images/node-worker.d.ts +17 -0
  102. package/dist/images/node-worker.d.ts.map +1 -0
  103. package/dist/images/node-worker.js +16 -0
  104. package/dist/images/node-worker.js.map +1 -0
  105. package/dist/images/node.d.ts +21 -0
  106. package/dist/images/node.d.ts.map +1 -0
  107. package/dist/images/node.js +121 -0
  108. package/dist/images/node.js.map +1 -0
  109. package/dist/index.d.ts +6 -6
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +3 -3
  112. package/dist/index.js.map +1 -1
  113. package/dist/search.d.ts +13 -0
  114. package/dist/search.d.ts.map +1 -0
  115. package/dist/search.js +85 -0
  116. package/dist/search.js.map +1 -0
  117. package/dist/session/observation.d.ts +10 -3
  118. package/dist/session/observation.d.ts.map +1 -1
  119. package/dist/session/observation.js +24 -34
  120. package/dist/session/observation.js.map +1 -1
  121. package/dist/session/session.d.ts +71 -6
  122. package/dist/session/session.d.ts.map +1 -1
  123. package/dist/session/session.js +463 -50
  124. package/dist/session/session.js.map +1 -1
  125. package/dist/session/transaction.d.ts +2 -0
  126. package/dist/session/transaction.d.ts.map +1 -1
  127. package/dist/session/transaction.js +19 -1
  128. package/dist/session/transaction.js.map +1 -1
  129. package/dist/storage/jsonl/storage.d.ts +8 -0
  130. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  131. package/dist/storage/jsonl/storage.js +2 -1
  132. package/dist/storage/jsonl/storage.js.map +1 -1
  133. package/dist/storage/memory.d.ts +2 -0
  134. package/dist/storage/memory.d.ts.map +1 -1
  135. package/dist/storage/memory.js +79 -27
  136. package/dist/storage/memory.js.map +1 -1
  137. package/dist/storage/scan.d.ts +18 -0
  138. package/dist/storage/scan.d.ts.map +1 -0
  139. package/dist/storage/scan.js +29 -0
  140. package/dist/storage/scan.js.map +1 -0
  141. package/dist/storage/sqlite/cloudflare.d.ts +46 -0
  142. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -0
  143. package/dist/storage/sqlite/cloudflare.js +106 -0
  144. package/dist/storage/sqlite/cloudflare.js.map +1 -0
  145. package/dist/storage/sqlite/storage.d.ts +2 -0
  146. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  147. package/dist/storage/sqlite/storage.js +77 -27
  148. package/dist/storage/sqlite/storage.js.map +1 -1
  149. package/dist/testing/env-conformance.d.ts.map +1 -1
  150. package/dist/testing/env-conformance.js +100 -0
  151. package/dist/testing/env-conformance.js.map +1 -1
  152. package/dist/testing/runner.d.ts +1 -1
  153. package/dist/testing/runner.d.ts.map +1 -1
  154. package/dist/testing/runner.js.map +1 -1
  155. package/dist/testing/storage-conformance.d.ts.map +1 -1
  156. package/dist/testing/storage-conformance.js +61 -2
  157. package/dist/testing/storage-conformance.js.map +1 -1
  158. package/dist/testing/types.d.ts +2 -0
  159. package/dist/testing/types.d.ts.map +1 -1
  160. package/dist/testing/types.js.map +1 -1
  161. package/dist/tool-search/index.d.ts +2 -0
  162. package/dist/tool-search/index.d.ts.map +1 -0
  163. package/dist/tool-search/index.js +2 -0
  164. package/dist/tool-search/index.js.map +1 -0
  165. package/dist/tool-search/tool-search.d.ts +23 -0
  166. package/dist/tool-search/tool-search.d.ts.map +1 -0
  167. package/dist/tool-search/tool-search.js +84 -0
  168. package/dist/tool-search/tool-search.js.map +1 -0
  169. package/dist/tools/bash.d.ts +10 -2
  170. package/dist/tools/bash.d.ts.map +1 -1
  171. package/dist/tools/bash.js +27 -9
  172. package/dist/tools/bash.js.map +1 -1
  173. package/dist/tools/edit.js +1 -1
  174. package/dist/tools/edit.js.map +1 -1
  175. package/dist/tools/image-processor.d.ts +47 -0
  176. package/dist/tools/image-processor.d.ts.map +1 -0
  177. package/dist/tools/image-processor.js +30 -0
  178. package/dist/tools/image-processor.js.map +1 -0
  179. package/dist/tools/image.d.ts +8 -0
  180. package/dist/tools/image.d.ts.map +1 -1
  181. package/dist/tools/image.js +47 -0
  182. package/dist/tools/image.js.map +1 -1
  183. package/dist/tools/index.d.ts +25 -2
  184. package/dist/tools/index.d.ts.map +1 -1
  185. package/dist/tools/index.js +13 -5
  186. package/dist/tools/index.js.map +1 -1
  187. package/dist/tools/read.d.ts +10 -2
  188. package/dist/tools/read.d.ts.map +1 -1
  189. package/dist/tools/read.js +87 -28
  190. package/dist/tools/read.js.map +1 -1
  191. package/dist/tools/write.js +1 -1
  192. package/dist/tools/write.js.map +1 -1
  193. package/dist/types.d.ts +89 -12
  194. package/dist/types.d.ts.map +1 -1
  195. package/dist/types.js.map +1 -1
  196. package/package.json +50 -5
@@ -2,34 +2,60 @@ import { copyJson } from "@earendil-works/chord";
2
2
  import { awaitWithContext } from "@earendil-works/chord/context";
3
3
  import { overlap } from "@earendil-works/chord/delta";
4
4
  import { validateToolArguments } from "@earendil-works/pi-ai/utils/validation";
5
+ import { Compile } from "typebox/compile";
6
+ import { defineDocFamily } from "../documents.js";
5
7
  import { AssistantEntry, ToolResultEntry } from "../entries.js";
8
+ import { errorMessage } from "../errors.js";
6
9
  import { defineTask } from "../tasks.js";
7
10
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, utf8ByteLength } from "../truncate.js";
8
11
  import { assignJson } from "./json.js";
9
- import { clearProgress, finishSlot, LiveDoc, toolSlot } from "./live.js";
12
+ import { clearProgress, finishSlot, LiveDoc, removeNestedSlots, toolSlot, } from "./live.js";
10
13
  import { boundOutput, OutputBuffer, PROGRESS_BYTES_PER_SECOND, Progress } from "./output.js";
11
14
  import { recordUsage } from "./usage.js";
15
+ /**
16
+ * Memo of a tool task that made a nested call, in any attempt, committed before the first admission of each attempt:
17
+ * only then can `pi.live` hold slots below it, which settling removes. A crash between the two only costs a scan.
18
+ */
19
+ const MADE_NESTED_CALLS = "pi.tool.madeNestedCalls";
20
+ /**
21
+ * The nested calls of a tool call, a task-scoped family of the calling task keyed by the nested call's key: so a rerun
22
+ * finds the calls it already made, and the caller reads each result. Created with the nested task; the nested call's
23
+ * terminal commit adds its result. One document per call, so no commit rewrites the others. Retires with the caller.
24
+ */
25
+ export const NestedCallDoc = defineDocFamily({
26
+ kind: "pi.tool.nested-call",
27
+ version: 1,
28
+ scope: "task",
29
+ family: true,
30
+ initial: () => ({}),
31
+ });
12
32
  /**
13
33
  * Built-in tool task: resolves the called tool among its phase agent's tools, validates, runs `beforeTool`, records intent,
14
- * executes, runs `afterTool`, and appends the result, all in one `call` handler so nothing separates resolution from
15
- * settlement. `execute` is reached only by recovery and applies the replay rule.
34
+ * executes, runs `afterTool`, and settles the result, all in one `call` handler so nothing separates resolution from
35
+ * settlement. `execute` is reached only by recovery and applies the replay rule. A model-issued call settles by
36
+ * appending its result entry; a nested call by writing its result to its caller's `NestedCallDoc`.
16
37
  */
17
38
  export const ToolTask = defineTask({
18
39
  name: "pi.tool",
19
- version: 1,
40
+ version: 2,
20
41
  initial: () => ({ phase: "call" }),
42
+ // Version 1 had only model-issued calls.
43
+ migrate: (input, checkpoint) => ({
44
+ input: { kind: "model", ...input },
45
+ checkpoint: checkpoint,
46
+ }),
21
47
  phases: {
22
48
  call: async (task, runtime, context) => {
23
49
  const call = await readCall(runtime, task.input, context);
24
- const tool = (await runtime.agent(context)).tools.find((each) => each.name === call.name);
50
+ const tool = await resolveTool(runtime, task.input, call.name, context);
25
51
  if (tool === undefined) {
26
52
  const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
27
- return settle(runtime, call, COMPLETED, () => error, context);
53
+ return settle(runtime, task.input, call, COMPLETED, () => error, context);
28
54
  }
29
55
  const prepared = prepare(tool, call.arguments);
30
56
  const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args);
31
57
  if ("error" in checked)
32
- return settle(runtime, call, COMPLETED, () => invalid(checked.error), context);
58
+ return settle(runtime, task.input, call, COMPLETED, () => invalid(checked.error), context);
33
59
  let args = checked.args;
34
60
  let block;
35
61
  await runtime.hooks.each("beforeTool", async (hook) => {
@@ -45,31 +71,37 @@ export const ToolTask = defineTask({
45
71
  catch (error) {
46
72
  if (runtime.signal.aborted)
47
73
  throw error;
48
- block = errorText(error);
74
+ block = errorMessage(error);
49
75
  }
50
76
  });
51
77
  if (block !== undefined) {
52
78
  const blocked = harnessError("blocked", `Tool call blocked: ${block}`);
53
- return settle(runtime, call, COMPLETED, () => blocked, context);
79
+ return settle(runtime, task.input, call, COMPLETED, () => blocked, context);
54
80
  }
55
81
  const validated = validate(tool, call, args);
56
82
  if ("error" in validated)
57
- return settle(runtime, call, COMPLETED, () => invalid(validated.error), context);
58
- const final = validated.args;
83
+ return settle(runtime, task.input, call, COMPLETED, () => invalid(validated.error), context);
84
+ // Strict JSON: repair may set optional properties to undefined, which the slot and checkpoint cannot hold.
85
+ const final = copyJson(validated.args, { omitUndefinedProperties: true });
59
86
  await runtime.commit(async (tx) => {
60
87
  const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
61
88
  if (slot !== undefined)
62
89
  slot.status = "running";
90
+ // 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)
94
+ slot.arguments = final;
63
95
  const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" };
64
96
  return { status: "running", checkpoint: intent };
65
97
  }, context);
66
- await run(runtime, call, tool, final, context);
98
+ await run(runtime, task.input, call, tool, final, context);
67
99
  },
68
100
  /** Recovery after intent: rerun only when the stored and the current policy both say `safe`. */
69
101
  execute: async (task, runtime, context) => {
70
102
  const { arguments: args, replay } = task.state.checkpoint;
71
103
  const call = await readCall(runtime, task.input, context);
72
- const tool = (await runtime.agent(context)).tools.find((each) => each.name === call.name);
104
+ const tool = await resolveTool(runtime, task.input, call.name, context);
73
105
  if (replay === "safe" && tool?.replay === "safe") {
74
106
  // The rerun reports from scratch; clear what the interrupted attempt published.
75
107
  await runtime.commit(async (tx) => {
@@ -78,22 +110,35 @@ export const ToolTask = defineTask({
78
110
  clearProgress(slot);
79
111
  return undefined;
80
112
  }, context);
81
- return run(runtime, call, tool, args, context);
113
+ return run(runtime, task.input, call, tool, args, context);
82
114
  }
83
115
  const message = `Tool ${call.name} was interrupted and may have partially run`;
84
116
  // `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted.
85
117
  const ending = { status: "failed", message };
86
- await settle(runtime, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);
118
+ await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);
87
119
  },
88
120
  },
89
121
  abort: async (task, runtime, context) => {
90
122
  const call = await readCall(runtime, task.input, context);
91
- const message = `Tool ${call.name} was aborted`;
92
- await settle(runtime, call, { status: "aborted" }, (slot) => fromSlot(slot, "aborted", message), 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);
93
131
  },
94
132
  });
95
- /** The tool call `callId` of the assistant entry. */
133
+ /** The called tool: among the tools the model is offered for a model-issued call, else among those tools may call. */
134
+ async function resolveTool(runtime, input, name, context) {
135
+ const agent = await runtime.agent(context);
136
+ return (input.kind === "model" ? agent.tools : agent.callable).find((tool) => tool.name === name);
137
+ }
138
+ /** The call: from the input for a nested call, which hooks see with its parent, else from the assistant entry. */
96
139
  async function readCall(runtime, input, context) {
140
+ if (input.kind === "nested")
141
+ return { ...input.call, parent: { taskId: input.parent, callId: input.parentCallId } };
97
142
  const entry = await runtime.entry(AssistantEntry, input.assistant, context);
98
143
  const message = entry?.model?.[0];
99
144
  const call = message?.role === "assistant"
@@ -111,7 +156,7 @@ function prepare(tool, args) {
111
156
  return { args: tool.prepareArguments(args) };
112
157
  }
113
158
  catch (error) {
114
- return { error: errorText(error) };
159
+ return { error: errorMessage(error) };
115
160
  }
116
161
  }
117
162
  /** Arguments validated and coerced against the implementation's schema. */
@@ -120,21 +165,31 @@ function validate(tool, call, args) {
120
165
  return { args: validateToolArguments(tool, { ...call, arguments: args }) };
121
166
  }
122
167
  catch (error) {
123
- return { error: errorText(error) };
168
+ return { error: errorMessage(error) };
124
169
  }
125
170
  }
126
171
  function invalid(message) {
127
172
  return harnessError("invalid_arguments", message);
128
173
  }
129
174
  /** Execute with the resolved implementation, then settle its result. */
130
- async function run(runtime, call, tool, args, context) {
175
+ async function run(runtime, input, call, tool, args, context) {
131
176
  const limits = {
132
177
  maxBytes: tool.outputLimits?.maxBytes ?? DEFAULT_MAX_BYTES,
133
178
  maxLines: tool.outputLimits?.maxLines ?? DEFAULT_MAX_LINES,
134
179
  retain: tool.outputLimits?.retain ?? "head",
135
180
  };
136
181
  const reported = { output: new OutputBuffer(limits), limits, diagnostics: [], details: undefined };
137
- const progress = publishProgress(runtime, reported, context);
182
+ const streams = input.kind === "model" || input.progress !== false;
183
+ const progress = publishProgress(runtime, reported, streams, context);
184
+ /** Nested call admissions this invocation started; cleanup waits for them, so it sees every nested call. */
185
+ const admissions = [];
186
+ // A caller that can rerun reattaches to its nested calls and child tasks after a restart; any other caller never
187
+ // resumes, so they are abandoned (`TaskOptions.abandonOnRestart`).
188
+ const resumes = tool.replay === "safe";
189
+ /** Default keys: the order of this invocation's calls, so a rerun that calls in the same order reattaches. */
190
+ let sequence = 0;
191
+ /** This attempt's commit of the `MADE_NESTED_CALLS` memo. */
192
+ let madeNestedMemo;
138
193
  let ended = false;
139
194
  const assertLive = () => {
140
195
  if (ended)
@@ -146,6 +201,7 @@ async function run(runtime, call, tool, args, context) {
146
201
  callId: call.id,
147
202
  registry: runtime.registry,
148
203
  agent: runtime.agent,
204
+ models: runtime.models,
149
205
  output: (chunk, skipped) => {
150
206
  assertLive();
151
207
  if (reported.output.push(chunk, skipped))
@@ -159,6 +215,11 @@ async function run(runtime, call, tool, args, context) {
159
215
  bytesPerSecond: PROGRESS_BYTES_PER_SECOND,
160
216
  }
161
217
  : undefined,
218
+ retainedOutput: () => {
219
+ assertLive();
220
+ const retained = reported.output.snapshot();
221
+ return { text: retained.text, truncated: retained.droppedBytes > 0 };
222
+ },
162
223
  diagnostic: (diagnostic) => {
163
224
  assertLive();
164
225
  reported.diagnostics.push(copyJson(diagnostic, { omitUndefinedProperties: true }));
@@ -183,9 +244,10 @@ async function run(runtime, call, tool, args, context) {
183
244
  },
184
245
  memo: runtime.memo,
185
246
  createTask: async (task, input, options, taskContext) => {
247
+ const child = options.ownership.kind === "task" && !resumes ? { abandonOnRestart: true, ...options } : options;
186
248
  let id;
187
249
  await runtime.commit(async (tx) => {
188
- id = await tx.createTask(task, input, options);
250
+ id = await tx.createTask(task, input, child);
189
251
  return undefined;
190
252
  }, taskContext);
191
253
  return id;
@@ -196,13 +258,42 @@ async function run(runtime, call, tool, args, context) {
196
258
  snapshot: runtime.snapshot,
197
259
  snapshotAsOf: runtime.snapshotAsOf,
198
260
  watchDoc: runtime.watchDoc,
261
+ executeTool: async (name, toolArgs, callContext, options = {}) => {
262
+ assertLive();
263
+ const key = options.key ?? String(++sequence);
264
+ if (options.key !== undefined)
265
+ checkKey(options.key);
266
+ // The memo first, once per attempt; a failed one is tried again by the next call.
267
+ madeNestedMemo ??= runtime.memo(MADE_NESTED_CALLS, true, callContext).catch((error) => {
268
+ madeNestedMemo = undefined;
269
+ throw error;
270
+ });
271
+ // Registered now, so cleanup waits for it.
272
+ const admission = madeNestedMemo.then(() => startNestedCall(runtime, call.id, name, toolArgs, { key, progress: options.progress, abandonOnRestart: !resumes }, callContext));
273
+ admissions.push(admission);
274
+ const id = await admission;
275
+ const settled = await runtime.waitForTask(id, callContext);
276
+ const stored = (await runtime.snapshot(NestedCallDoc, runtime.taskId, key, callContext))?.result;
277
+ // A copy the tool may change; a nested call the scheduler faulted or orphaned stored none.
278
+ return stored === undefined
279
+ ? fallbackResult(id, name, settled.state.outcome)
280
+ : copyJson(stored);
281
+ },
199
282
  };
200
283
  let result;
201
284
  let ending = COMPLETED;
285
+ // Execution time of this attempt; a rerun after recovery measures only itself.
286
+ let durationMs;
202
287
  try {
203
288
  // Built for this call, so a rerun after recovery gets the conversation's environment at that time.
204
289
  const env = await runtime.env(context);
205
- result = await tool.execute(args, { ...api, env }, context);
290
+ const startedAt = performance.now();
291
+ try {
292
+ result = await tool.execute(args, { ...api, env }, context);
293
+ }
294
+ finally {
295
+ durationMs = Math.round(performance.now() - startedAt);
296
+ }
206
297
  }
207
298
  catch (error) {
208
299
  if (runtime.signal.aborted) {
@@ -211,18 +302,20 @@ async function run(runtime, call, tool, args, context) {
211
302
  waiter.reject(error);
212
303
  throw error;
213
304
  }
214
- result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorText(error))] };
215
- // A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call owned; it no longer supervises it. The error text
216
- // is already in the result entry.
305
+ result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorMessage(error))] };
306
+ // A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call
307
+ // owned; it no longer supervises it. The error text is already in the result.
217
308
  ending = { status: "failed", message: `Tool ${call.name} threw` };
218
309
  }
219
310
  ended = true;
311
+ // No admission starts after `ended`; let those underway commit, so settlement aborts and lists their calls.
312
+ await Promise.allSettled(admissions);
220
313
  reported.output.end();
221
314
  // Details still waiting for a progress commit settle with the terminal commit, the final flush.
222
315
  const pending = await progress.stop();
223
316
  try {
224
- const settled = await finalResult(runtime, call, result, reported, context);
225
- await settle(runtime, call, ending, () => settled, context);
317
+ const settled = await finalResult(runtime, input, call, tool, result, reported, context);
318
+ await settle(runtime, input, call, ending, () => settled, context, durationMs);
226
319
  }
227
320
  catch (error) {
228
321
  for (const waiter of pending)
@@ -233,12 +326,14 @@ async function run(runtime, call, tool, args, context) {
233
326
  waiter.resolve();
234
327
  }
235
328
  /**
236
- * Throttled commits of what the tool reported into its `pi.live.tools` slot, each writing only what changed since the
237
- * last one.
329
+ * 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.
238
331
  */
239
- function publishProgress(runtime, reported, context) {
332
+ function publishProgress(runtime, reported, enabled, context) {
240
333
  let written = { text: "", details: undefined, diagnostics: 0 };
241
334
  return new Progress(async () => {
335
+ if (!enabled)
336
+ return 0;
242
337
  // Capture everything synchronously: the tool keeps reporting while the commit is in flight.
243
338
  const snapshot = reported.output.snapshot();
244
339
  const current = { text: snapshot.text, details: reported.details, diagnostics: reported.diagnostics.length };
@@ -291,56 +386,247 @@ function publishProgress(runtime, reported, context) {
291
386
  }
292
387
  /**
293
388
  * The settled result: the tool's result with the retained output and last details as fallbacks, its diagnostics after
294
- * those reported through the api, `afterTool` applied, and explicit text bounded, with the Harness's truncation
295
- * diagnostic last.
389
+ * those reported through the api, `afterTool` applied, explicit text bounded, and `structuredOutput` checked against the
390
+ * tool's schema, with the Harness's diagnostics last. A nested call whose `structuredOutput` breaks the contract gets an
391
+ * error result without it; a model-issued call only loses it, and the break is reported to the host.
296
392
  */
297
- async function finalResult(runtime, call, result, reported, context) {
393
+ async function finalResult(runtime, input, call, tool, result, reported, context) {
298
394
  const harness = [];
299
- const retained = result.content === undefined ? reported.output.snapshot() : undefined;
300
- const content = retained === undefined ? result.content : retained.text === "" ? [] : [{ type: "text", text: retained.text }];
395
+ const retained = result.output === undefined ? reported.output.snapshot() : undefined;
396
+ const output = retained === undefined ? result.output : retained.text === "" ? [] : [{ type: "text", text: retained.text }];
301
397
  let final = {
302
398
  ...result,
303
- content,
399
+ output,
304
400
  details: result.details === undefined ? reported.details : result.details,
305
401
  diagnostics: [...reported.diagnostics, ...(result.diagnostics ?? [])],
306
402
  };
307
403
  await runtime.hooks.each("afterTool", async (hook) => {
308
404
  final = (await hook(call, final, runtime, context)) ?? final;
309
405
  });
310
- // The retained output's truncation applies only while afterTool kept that content.
311
- if (retained !== undefined && retained.droppedBytes > 0 && final.content === content) {
406
+ // The retained output's truncation applies only while afterTool kept that output.
407
+ if (retained !== undefined && retained.droppedBytes > 0 && final.output === output) {
312
408
  harness.push(truncated(retained, reported.limits.retain));
313
409
  }
314
- const bounded = boundContent(final.content ?? [], reported.limits);
410
+ const bounded = boundContent(final.output ?? [], reported.limits);
315
411
  if (bounded.droppedBytes > 0)
316
412
  harness.push(truncated(bounded, reported.limits.retain));
317
- return { ...final, content: bounded.content, diagnostics: [...(final.diagnostics ?? []), ...harness] };
413
+ const broken = structuredOutputError(tool, final);
414
+ if (broken !== undefined) {
415
+ const { structuredOutput: _dropped, ...rest } = final;
416
+ if (input.kind === "nested") {
417
+ final = { ...rest, isError: true };
418
+ harness.push(toolDiagnostic("invalid_structured_output", broken));
419
+ }
420
+ else {
421
+ // The model never sees structured output, so its call stands; the host learns of the broken tool.
422
+ final = rest;
423
+ runtime.report(new Error(broken));
424
+ }
425
+ }
426
+ // Programs get the tool's own value: without a schema, its output. The model reads only `output`, so model-issued
427
+ // calls skip both.
428
+ if (input.kind === "nested" && tool.structuredOutputSchema === undefined) {
429
+ final = { ...final, structuredOutput: outputValue(bounded.content) };
430
+ }
431
+ // A schema tool's error result without structured output has its text only in the output, which programs never
432
+ // get: it becomes an error diagnostic, as a thrown error's message does, unless one says what went wrong already.
433
+ if (input.kind === "nested" &&
434
+ tool.structuredOutputSchema !== undefined &&
435
+ final.isError === true &&
436
+ final.structuredOutput === undefined &&
437
+ ![...(final.diagnostics ?? []), ...harness].some((diagnostic) => diagnostic.severity === "error")) {
438
+ const text = bounded.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n");
439
+ if (text !== "")
440
+ harness.push(toolDiagnostic("tool_error", text));
441
+ }
442
+ return { ...final, output: bounded.content, diagnostics: [...(final.diagnostics ?? []), ...harness] };
443
+ }
444
+ const structuredValidators = new WeakMap();
445
+ /** Why a result's `structuredOutput` breaks the tool's contract, or `undefined` when it keeps it. */
446
+ function structuredOutputError(tool, result) {
447
+ const schema = tool.structuredOutputSchema;
448
+ const value = result.structuredOutput;
449
+ if (schema === undefined) {
450
+ return value === undefined
451
+ ? undefined
452
+ : `Tool ${tool.name} returned structuredOutput but declares no structuredOutputSchema`;
453
+ }
454
+ if (value === undefined) {
455
+ return result.isError === true ? undefined : `Tool ${tool.name} returned no structuredOutput`;
456
+ }
457
+ let validator = structuredValidators.get(schema);
458
+ if (validator === undefined) {
459
+ validator = Compile(schema);
460
+ structuredValidators.set(schema, validator);
461
+ }
462
+ if (validator.Check(value))
463
+ return undefined;
464
+ const [first] = validator.Errors(value);
465
+ const path = first?.instancePath.replace(/^\//, "").replace(/\//g, ".") || "root";
466
+ return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`;
318
467
  }
319
468
  /**
320
- * Commit the tool's terminal state: append its result entry, mark its slot done, and complete or end aborted with the
321
- * entry ID. `build` receives the slot so interruption and abort can report the durable partial output.
469
+ * Commit the tool's terminal state and mark its slot done. A model-issued call appends its result entry and ends with
470
+ * the entry ID; a nested call writes its result to the caller's `NestedCallDoc`, records its usage, and ends with a
471
+ * 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.
322
474
  */
323
- async function settle(runtime, call, ending, build, context) {
475
+ async function settle(runtime, input, call, ending, build, context, durationMs) {
476
+ // A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.
477
+ // Every live nested call the task owns, also those an earlier attempt made and this one never called again.
478
+ const nestedIds = (await runtime.ownedTasks(context))
479
+ .filter((record) => record.kind === ToolTask.definition.name && record.input.kind === "nested")
480
+ .map((record) => record.id);
481
+ // Read before the settling commit: no admission starts after `ended`, and those underway were awaited.
482
+ const madeNested = (await runtime.memo(MADE_NESTED_CALLS, context)) === true;
483
+ // Mark them all before waiting for any: one may run until a sibling is cancelled.
484
+ await Promise.all(nestedIds.map((id) => runtime.abortOwned(id, context)));
324
485
  await runtime.commit(async (tx) => {
325
- const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
486
+ const conversationId = runtime.conversationId;
487
+ const live = await tx.doc(LiveDoc, conversationId);
488
+ const slot = toolSlot(live, runtime.taskId);
326
489
  const result = build(slot);
327
- const entry = await appendToolResult(tx, runtime.conversationId, call, result, runtime.now());
328
- if (slot !== undefined)
329
- finishSlot(slot, entry.id);
330
- const entryId = entry.id;
490
+ let settled;
491
+ if (input.kind === "nested") {
492
+ const nested = nestedResult(runtime.taskId, result, durationMs);
493
+ if (nested.usage !== undefined)
494
+ await recordUsage(tx, conversationId, "tools", call.name, nested.usage);
495
+ // The caller is never terminal before its owned nested calls, so its document is open; another task in it is a
496
+ // bug, not a race.
497
+ const state = await tx.doc(NestedCallDoc, input.parent, input.key, null);
498
+ if (state.taskId !== runtime.taskId)
499
+ throw new Error(`Nested call ${call.id} is not in its caller's index`);
500
+ state.result = nested;
501
+ if (slot !== undefined && "parentTaskId" in slot) {
502
+ finishSlot(slot);
503
+ slot.summary = summaryOf(nested, result.output ?? []);
504
+ }
505
+ settled = { kind: "nested" };
506
+ }
507
+ else {
508
+ const timing = { timestamp: runtime.now(), durationMs };
509
+ const entry = await appendToolResult(tx, conversationId, call, result, timing);
510
+ if (slot !== undefined && !("parentTaskId" in slot)) {
511
+ finishSlot(slot);
512
+ slot.entry = entry.id;
513
+ }
514
+ // Tools build control objects freely; drop keys set to undefined so the task result is strict JSON.
515
+ const control = result.control === undefined || ending.status !== "completed"
516
+ ? {}
517
+ : { control: copyJson(result.control, { omitUndefinedProperties: true }) };
518
+ settled = { kind: "model", entryId: entry.id, ...control };
519
+ }
520
+ // A call that made no nested calls, in any attempt, has no slots below it; skipping the scan keeps many leaves
521
+ // linear. Committed before the read: no nested call is admitted after `ended`.
522
+ if (madeNested)
523
+ removeNestedSlots(live, runtime.taskId);
331
524
  if (ending.status === "aborted")
332
- return { status: "terminal", outcome: { status: "aborted", result: { entryId } } };
525
+ return { status: "terminal", outcome: { status: "aborted", result: settled } };
333
526
  if (ending.status === "failed") {
334
527
  const error = { message: ending.message };
335
- return { status: "terminal", outcome: { status: "failed", error, result: { entryId } } };
528
+ return { status: "terminal", outcome: { status: "failed", error, result: settled } };
336
529
  }
337
- // Tools build control objects freely; drop keys set to undefined so the task result is strict JSON.
338
- const control = result.control === undefined
339
- ? {}
340
- : { control: copyJson(result.control, { omitUndefinedProperties: true }) };
341
- return { status: "terminal", outcome: { status: "completed", result: { entryId, ...control } } };
530
+ return { status: "terminal", outcome: { status: "completed", result: settled } };
342
531
  }, context);
343
532
  }
533
+ /**
534
+ * A nested call's result, as stored and returned: strict JSON, without `output`, which only the model reads, and
535
+ * without `control`, which only model-issued calls apply.
536
+ */
537
+ function nestedResult(taskId, result, durationMs) {
538
+ const nested = {
539
+ taskId,
540
+ structuredOutput: result.structuredOutput,
541
+ isError: result.isError ?? false,
542
+ details: result.details,
543
+ diagnostics: result.diagnostics ?? [],
544
+ usage: result.usage,
545
+ durationMs,
546
+ };
547
+ return copyJson(nested, {
548
+ omitUndefinedProperties: true,
549
+ });
550
+ }
551
+ /**
552
+ * Create nested call `key` of the call `parentCallId` in one commit: its tool task, owned by the calling task, its slot,
553
+ * and its `NestedCallDoc`. A key already made returns that call's task when it names the same tool and arguments.
554
+ */
555
+ async function startNestedCall(runtime, parentCallId, name, args, options, context) {
556
+ const key = options.key;
557
+ const call = {
558
+ type: "toolCall",
559
+ id: `${parentCallId}/${key}`,
560
+ name,
561
+ arguments: copyJson(args, { omitUndefinedProperties: true }),
562
+ };
563
+ let id;
564
+ await runtime.commit(async (tx) => {
565
+ // Opened, or created empty for a key not made yet.
566
+ const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null);
567
+ const existing = state.taskId;
568
+ if (existing !== undefined) {
569
+ const input = (await tx.task(existing))?.input;
570
+ const same = input?.kind === "nested" &&
571
+ input.call.name === name &&
572
+ jsonEqual(input.call.arguments, call.arguments);
573
+ if (!same)
574
+ throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`);
575
+ id = existing;
576
+ return undefined;
577
+ }
578
+ const input = {
579
+ kind: "nested",
580
+ parent: runtime.taskId,
581
+ parentCallId,
582
+ key,
583
+ call,
584
+ ...(options.progress === false ? { progress: false } : {}),
585
+ };
586
+ const created = await tx.createTask(ToolTask, input, {
587
+ ownership: { kind: "task", taskId: runtime.taskId },
588
+ abandonOnRestart: options.abandonOnRestart,
589
+ });
590
+ state.taskId = created;
591
+ const live = await tx.doc(LiveDoc, runtime.conversationId);
592
+ live.nestedTools ??= [];
593
+ const slot = {
594
+ callId: call.id,
595
+ parentCallId,
596
+ parentTaskId: runtime.taskId,
597
+ name,
598
+ taskId: created,
599
+ arguments: call.arguments,
600
+ status: "pending",
601
+ };
602
+ live.nestedTools.push(slot);
603
+ id = created;
604
+ return undefined;
605
+ }, context);
606
+ return id;
607
+ }
608
+ /**
609
+ * What programs get of a tool's output when it declares no schema: one text item as its string, one image as itself,
610
+ * nothing as `""`, and anything else as the content list.
611
+ */
612
+ function outputValue(output) {
613
+ if (output.length === 0)
614
+ return "";
615
+ if (output.length > 1)
616
+ return output;
617
+ const [only] = output;
618
+ return only.type === "text" ? only.text : only;
619
+ }
620
+ /** What the caller gets for a nested call without a stored result: one the scheduler faulted or orphaned. */
621
+ function fallbackResult(taskId, name, outcome) {
622
+ const message = outcome.status === "faulted"
623
+ ? `Tool ${name} failed: ${outcome.error.message}`
624
+ : outcome.status === "orphaned"
625
+ ? `Tool ${name} could not resume: ${outcome.reason}`
626
+ : `Tool ${name} ended without a result`;
627
+ const error = harnessError(outcome.status, message);
628
+ return { taskId, isError: true, diagnostics: error.diagnostics ?? [] };
629
+ }
344
630
  const COMPLETED = { status: "completed" };
345
631
  /** An error result from the slot's durable partial output, details, and diagnostics. */
346
632
  function fromSlot(slot, code, message) {
@@ -350,7 +636,7 @@ function fromSlot(slot, code, message) {
350
636
  diagnostics.push(truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }));
351
637
  diagnostics.push(toolDiagnostic(code, message));
352
638
  return {
353
- content: slot?.output === undefined || slot.output === "" ? [] : [{ type: "text", text: slot.output }],
639
+ output: slot?.output === undefined || slot.output === "" ? [] : [{ type: "text", text: slot.output }],
354
640
  isError: true,
355
641
  ...(slot?.details === undefined ? {} : { details: slot.details }),
356
642
  diagnostics,
@@ -358,7 +644,7 @@ function fromSlot(slot, code, message) {
358
644
  }
359
645
  /** An error result the Harness writes itself: no content and one `error` diagnostic with `code`. */
360
646
  export function harnessError(code, message) {
361
- return { content: [], isError: true, diagnostics: [toolDiagnostic(code, message)] };
647
+ return { output: [], isError: true, diagnostics: [toolDiagnostic(code, message)] };
362
648
  }
363
649
  function toolDiagnostic(code, message) {
364
650
  return { severity: "error", code, message };
@@ -372,13 +658,33 @@ function truncated(dropped, retain) {
372
658
  message: `Output truncated${kept}: ${dropped.droppedLines} lines, ${dropped.droppedBytes} bytes dropped`,
373
659
  };
374
660
  }
661
+ /** Error text a nested call's summary keeps. */
662
+ 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. */
664
+ function summaryOf(result, output) {
665
+ const error = result.isError ? errorTextOf(result, output).slice(0, MAX_SUMMARY_ERROR_CHARS) : "";
666
+ return {
667
+ isError: result.isError,
668
+ ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
669
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
670
+ ...(error === "" ? {} : { error }),
671
+ };
672
+ }
673
+ /** The error messages of a nested result, else the text items of its output, joined with newlines. */
674
+ function errorTextOf(result, output) {
675
+ const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
676
+ if (errors.length > 0)
677
+ return errors.map((diagnostic) => diagnostic.message).join("\n");
678
+ return output.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n");
679
+ }
375
680
  /**
376
681
  * Append a `pi.tool-result` entry. The content ends with the rendered diagnostics, so the stored message is exactly
377
682
  * what the model sees; `data` keeps the structured list. A result's usage is added to `pi.usage` in the same commit.
378
683
  */
379
- export async function appendToolResult(tx, conversationId, call, result, timestamp) {
684
+ export async function appendToolResult(tx, conversationId, call, result, meta) {
685
+ const { timestamp, durationMs } = meta;
380
686
  const diagnostics = [...(result.diagnostics ?? [])];
381
- const content = [...(result.content ?? [])];
687
+ const content = [...(result.output ?? [])];
382
688
  if (diagnostics.length > 0)
383
689
  content.push({ type: "text", text: renderDiagnostics(diagnostics) });
384
690
  const message = {
@@ -389,6 +695,7 @@ export async function appendToolResult(tx, conversationId, call, result, timesta
389
695
  ...(result.details === undefined ? {} : { details: result.details }),
390
696
  ...(result.usage === undefined ? {} : { usage: result.usage }),
391
697
  isError: result.isError ?? false,
698
+ ...(durationMs === undefined ? {} : { durationMs }),
392
699
  timestamp,
393
700
  };
394
701
  if (result.usage !== undefined)
@@ -417,7 +724,25 @@ function boundContent(content, limits) {
417
724
  }
418
725
  return { content: result, droppedBytes: bounded.droppedBytes, droppedLines: bounded.droppedLines };
419
726
  }
420
- function errorText(error) {
421
- return error instanceof Error ? error.message : String(error);
727
+ /**
728
+ * Explicit nested call keys are path segments of the call ID, so IDs of different nested calls never collide, and are
729
+ * never plain positive integers, which default keys use. `__proto__` stays reserved, as it was when keys indexed a JSON object.
730
+ */
731
+ function checkKey(key) {
732
+ if (key === "" || key.includes("/") || key === "__proto__" || /^[1-9][0-9]*$/.test(key)) {
733
+ throw new Error(`Nested call key ${JSON.stringify(key)} must be non-empty, without "/", not "__proto__", and not a positive integer`);
734
+ }
735
+ }
736
+ /** Structural JSON equality; object key order does not matter. */
737
+ function jsonEqual(a, b) {
738
+ if (a === b)
739
+ return true;
740
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
741
+ return false;
742
+ if (Array.isArray(a) || Array.isArray(b)) {
743
+ return (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i])));
744
+ }
745
+ const keys = Object.keys(a);
746
+ return (keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key])));
422
747
  }
423
748
  //# sourceMappingURL=tool.js.map