pi-durable-python 1.3.0__py3-none-any.whl

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 (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1365 @@
1
+ """Built-in ``pi.tool`` task: resolve, validate, hook, execute, and persist a tool call.
2
+
3
+ Port of v1.3.0 ``src/harness/tool.ts``. The phase handler resolves the called tool among its phase
4
+ agent's tools, repairs and validates the arguments, runs ``beforeTool``, records intent, executes
5
+ the tool, runs ``afterTool``, and settles the result — all in one handler so no phase boundary
6
+ separates resolution from settlement. ``execute`` is reached only by recovery and applies the replay
7
+ rule. A model-issued call settles by appending its ``pi.tool-result`` entry; a nested call, which a
8
+ running tool made through ``executeTool()``, writes its result into its caller's task-scoped
9
+ ``NestedCallDoc`` and ends with a small receipt.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import re
17
+ import time
18
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
19
+ from dataclasses import dataclass
20
+ from typing import Any, Literal, NotRequired, TypeAlias, TypedDict, cast
21
+
22
+ from ..context import Context, await_with_context
23
+ from ..delta import OVERLAP_SCAN_LIMIT
24
+ from ..documents import define_doc_family
25
+ from ..entries import AssistantEntry, ToolResultEntry
26
+ from ..errors import error_message, is_failure
27
+ from ..ids import ConversationId, EntryId, TaskId
28
+ from ..json import JsonValue, copy_json
29
+ from ..session.transaction import Transaction
30
+ from ..tasks import define_task
31
+ from ..types import EntryDraft, MessageRecord, TaskRecord
32
+ from .agent import DEFAULT_PROGRESS_POLICY
33
+ from .live import (
34
+ TOOL_TASK_KIND,
35
+ LiveDoc,
36
+ clear_progress,
37
+ finish_slot,
38
+ remove_nested_slots,
39
+ tool_slot,
40
+ )
41
+ from .output import (
42
+ DEFAULT_MAX_BYTES,
43
+ DEFAULT_MAX_LINES,
44
+ PROGRESS_BYTES_PER_SECOND,
45
+ OutputBuffer,
46
+ OutputLimits,
47
+ Progress,
48
+ bound_output,
49
+ overlap,
50
+ utf8_byte_length,
51
+ )
52
+ from .scheduler import json_equal
53
+ from .types import (
54
+ NestedToolExecutionResult,
55
+ ToolControl,
56
+ ToolDiagnostic,
57
+ ToolExecutionResult,
58
+ ToolHookCall,
59
+ ToolRegistration,
60
+ )
61
+ from .usage import record_usage
62
+ from .util import assign_json, call_hook
63
+ from .validation import (
64
+ ToolArgumentError,
65
+ structured_output_error,
66
+ validate_tool_arguments,
67
+ )
68
+
69
+
70
+ class ModelToolTaskInput(TypedDict):
71
+ """A model-issued call, read from its assistant entry."""
72
+
73
+ kind: Literal["model"]
74
+ assistant: EntryId
75
+ callId: str
76
+
77
+
78
+ class NestedToolTaskInput(TypedDict):
79
+ """A nested call, whose call the input carries because no entry holds it."""
80
+
81
+ kind: Literal["nested"]
82
+ parent: TaskId
83
+ parentCallId: str
84
+ key: str
85
+ call: dict[str, Any]
86
+ progress: NotRequired[Literal[False]]
87
+
88
+
89
+ ToolTaskInput: TypeAlias = ModelToolTaskInput | NestedToolTaskInput
90
+ """A model-issued call or a nested one a running tool made through ``executeTool()``."""
91
+
92
+
93
+ class NestedCallState(TypedDict, total=False):
94
+ """One nested call of a running tool: its task, and once it ended, its result."""
95
+
96
+ taskId: TaskId
97
+ """Absent only inside the commit that creates the call; a creation that fails leaves no member."""
98
+ result: dict[str, JsonValue]
99
+ """Exactly what ``executeTool()`` returns; absent while running, and for a faulted or orphaned call."""
100
+
101
+
102
+ NestedCallDoc = define_doc_family(
103
+ {
104
+ "kind": "pi.tool.nested-call",
105
+ "version": 1,
106
+ "scope": "task",
107
+ "family": True,
108
+ "initial": lambda _seed: {},
109
+ }
110
+ )
111
+ """The nested calls of a tool call, a task-scoped family of the calling task keyed by the call's key.
112
+
113
+ One document per call, so admitting a call rewrites no other call's, and a rerun finds the calls it
114
+ already made. Created with the nested task; retires with the caller.
115
+ """
116
+
117
+ MADE_NESTED_CALLS = "pi.tool.madeNestedCalls"
118
+ """Memo of a tool task that made a nested call, committed before its first admission of each attempt."""
119
+
120
+ MAX_SUMMARY_ERROR_CHARS = 500
121
+ """How much error text a nested call's slot summary keeps."""
122
+
123
+
124
+ class ExecuteCheckpoint(TypedDict):
125
+ phase: Literal["execute"]
126
+ arguments: dict[str, JsonValue]
127
+ replay: Literal["safe", "unsafe"]
128
+
129
+
130
+ class CallCheckpoint(TypedDict):
131
+ phase: Literal["call"]
132
+
133
+
134
+ ToolTaskCheckpoint = CallCheckpoint | ExecuteCheckpoint
135
+ """Checkpoint shapes the tool phases read and write."""
136
+
137
+
138
+ class ModelToolTaskResult(TypedDict):
139
+ """A model-issued call's result is its transcript entry; ``control`` only applies while completed."""
140
+
141
+ kind: Literal["model"]
142
+ entryId: EntryId
143
+ control: NotRequired[ToolControl]
144
+
145
+
146
+ class NestedToolTaskResult(TypedDict):
147
+ """A nested call's result lives in its caller's ``NestedCallDoc``, so its receipt stays small."""
148
+
149
+ kind: Literal["nested"]
150
+
151
+
152
+ ToolTaskResult: TypeAlias = ModelToolTaskResult | NestedToolTaskResult
153
+
154
+
155
+ class Ending(TypedDict, total=False):
156
+ """How a tool task ends; the result entry is appended either way."""
157
+
158
+ status: Literal["completed", "aborted", "failed"]
159
+ message: str
160
+
161
+
162
+ COMPLETED: Ending = {"status": "completed"}
163
+
164
+ Content = list[JsonValue]
165
+
166
+
167
+ async def _call_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
168
+ input = cast(ToolTaskInput, task["input"])
169
+ call = await read_call(runtime, input, context)
170
+ tool = await resolve_tool(runtime, input, call["name"], context)
171
+ if tool is None:
172
+ message = f"Tool {call['name']} is not available"
173
+ await settle(
174
+ runtime, input, call, COMPLETED, lambda _slot: harness_error("tool_unavailable", message), context
175
+ )
176
+ return
177
+ prepared = prepare(tool, call.get("arguments") or {})
178
+ if isinstance(prepared, str):
179
+ error = invalid(prepared)
180
+ await settle(runtime, input, call, COMPLETED, lambda _slot: error, context)
181
+ return
182
+ args = prepared
183
+ checked = validate(tool, call, args)
184
+ if isinstance(checked, str):
185
+ error = invalid(checked)
186
+ await settle(runtime, input, call, COMPLETED, lambda _slot: error, context)
187
+ return
188
+ args = checked
189
+ block: str | None = None
190
+
191
+ async def before_tool(handler: Callable[..., Any]) -> None:
192
+ nonlocal args, block
193
+ if block is not None:
194
+ return
195
+ try:
196
+ decision = await call_hook(handler, {**call, "arguments": args}, runtime, context)
197
+ if isinstance(decision, Mapping):
198
+ if decision.get("block") is not None:
199
+ block = str(decision["block"])
200
+ elif decision.get("arguments") is not None:
201
+ args = cast(dict[str, JsonValue], decision["arguments"])
202
+ except BaseException as error:
203
+ if runtime.signal is not None and runtime.signal.aborted:
204
+ raise
205
+ block = error_message(error)
206
+
207
+ await runtime.hooks.each("beforeTool", before_tool)
208
+ if block is not None:
209
+ blocked = f"Tool call blocked: {block}"
210
+ await settle(
211
+ runtime, input, call, COMPLETED, lambda _slot: harness_error("blocked", blocked), context
212
+ )
213
+ return
214
+ validated = validate(tool, call, args)
215
+ if isinstance(validated, str):
216
+ error = invalid(validated)
217
+ await settle(runtime, input, call, COMPLETED, lambda _slot: error, context)
218
+ return
219
+ # Strict JSON: repair may set optional properties to None, which the slot and checkpoint cannot hold.
220
+ final = cast(dict[str, JsonValue], copy_json(validated))
221
+
222
+ async def intent(tx: Transaction) -> dict[str, Any]:
223
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
224
+ slot = tool_slot(live, runtime.task_id)
225
+ if slot is not None:
226
+ slot["status"] = "running"
227
+ # A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may
228
+ # have changed. Compared with the input, which the slot got at admission: reading the draft
229
+ # would track every leaf.
230
+ changed = input["kind"] == "nested" and not json_equal(
231
+ cast(Any, input["call"]).get("arguments") or {}, final
232
+ )
233
+ if changed and slot is not None and "parentTaskId" in slot:
234
+ slot["arguments"] = final
235
+ return {
236
+ "status": "running",
237
+ "checkpoint": {
238
+ "phase": "execute",
239
+ "arguments": final,
240
+ "replay": tool.replay or "unsafe",
241
+ },
242
+ }
243
+
244
+ await runtime.commit(intent, context)
245
+ await run(runtime, input, call, tool, final, context)
246
+
247
+
248
+ async def _execute_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
249
+ input = cast(ToolTaskInput, task["input"])
250
+ checkpoint = cast(ExecuteCheckpoint, task["state"]["checkpoint"])
251
+ args = checkpoint["arguments"]
252
+ replay = checkpoint["replay"]
253
+ call = await read_call(runtime, input, context)
254
+ tool = await resolve_tool(runtime, input, call["name"], context)
255
+ if replay == "safe" and tool is not None and tool.replay == "safe":
256
+ # The rerun reports from scratch; clear what the interrupted attempt published.
257
+ async def clear(tx: Transaction) -> None:
258
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
259
+ slot = tool_slot(live, runtime.task_id)
260
+ if slot is not None:
261
+ clear_progress(slot)
262
+
263
+ await runtime.commit(clear, context)
264
+ await run(runtime, input, call, tool, args, context)
265
+ return
266
+ message = f"Tool {call['name']} was interrupted and may have partially run"
267
+ # ``failed`` records cancellation intent, so the call's owned conversations, left unsupervised,
268
+ # are aborted.
269
+ ending: Ending = {"status": "failed", "message": message}
270
+ await settle(
271
+ runtime,
272
+ input,
273
+ call,
274
+ ending,
275
+ lambda slot: from_slot(slot, "interrupted", message),
276
+ context,
277
+ )
278
+
279
+
280
+ async def _abort(task: TaskRecord, runtime: Any, context: Context) -> None:
281
+ input = cast(ToolTaskInput, task["input"])
282
+ call = await read_call(runtime, input, context)
283
+ # Abandoned after a restart (``TaskOptions.abandonOnRestart``): say whether the call may have run.
284
+ checkpoint = task["state"].get("checkpoint") or {}
285
+ if task.get("abortReason") != "restart":
286
+ code, message = "aborted", f"Tool {call['name']} was aborted"
287
+ elif checkpoint.get("phase") == "execute":
288
+ code = "interrupted"
289
+ message = f"Tool {call['name']} was interrupted by a restart and may have partially run"
290
+ else:
291
+ code = "abandoned"
292
+ message = f"Tool {call['name']} was not started: its caller ended with a restart"
293
+ ending: Ending = {"status": "aborted"}
294
+ await settle(
295
+ runtime,
296
+ input,
297
+ call,
298
+ ending,
299
+ lambda slot: from_slot(slot, code, message),
300
+ context,
301
+ )
302
+
303
+
304
+ def _migrate(input: Any, checkpoint: Any, _version: int) -> dict[str, Any]:
305
+ """Version 1 had only model-issued calls."""
306
+
307
+ return {
308
+ "input": {"kind": "model", **cast(Mapping[str, Any], input)},
309
+ "checkpoint": checkpoint,
310
+ }
311
+
312
+
313
+ ToolTask = define_task(
314
+ {
315
+ "name": TOOL_TASK_KIND,
316
+ "version": 2,
317
+ "initial": lambda _input: {"phase": "call"},
318
+ "migrate": _migrate,
319
+ "phases": {"call": _call_phase, "execute": _execute_phase},
320
+ "abort": _abort,
321
+ }
322
+ )
323
+
324
+
325
+ async def resolve_tool(
326
+ runtime: Any,
327
+ input: ToolTaskInput,
328
+ name: str,
329
+ context: Context,
330
+ ) -> ToolRegistration | None:
331
+ """The called tool: among those the model is offered, else among those tools may call."""
332
+
333
+ agent = await runtime.agent(context)
334
+ candidates = agent.tools if input["kind"] == "model" else agent.callable
335
+ return next((tool for tool in candidates if tool.name == name), None)
336
+
337
+
338
+ def entry_tool_calls(entry: Mapping[str, Any] | None) -> list[ToolHookCall]:
339
+ """The tool calls of an entry's assistant message, in call order."""
340
+
341
+ message = None if entry is None else (entry.get("model") or [None])[0]
342
+ if not isinstance(message, Mapping) or message.get("role") != "assistant":
343
+ return []
344
+ return [
345
+ cast(ToolHookCall, dict(content))
346
+ for content in cast(Sequence[Mapping[str, Any]], message.get("content") or ())
347
+ if content.get("type") == "toolCall"
348
+ ]
349
+
350
+
351
+ async def read_call(runtime: Any, input: ToolTaskInput, context: Context) -> ToolHookCall:
352
+ """The call: from the input for a nested call, which hooks see with its parent, else from its entry."""
353
+
354
+ if input["kind"] == "nested":
355
+ return cast(
356
+ ToolHookCall,
357
+ {
358
+ **input["call"],
359
+ "parent": {"taskId": input["parent"], "callId": input["parentCallId"]},
360
+ },
361
+ )
362
+ entry = await runtime.entry(AssistantEntry, input["assistant"], context)
363
+ call = next(
364
+ (candidate for candidate in entry_tool_calls(entry) if candidate.get("id") == input["callId"]),
365
+ None,
366
+ )
367
+ if call is None:
368
+ raise RuntimeError(f"Entry {input['assistant']} has no tool call {input['callId']}")
369
+ return call
370
+
371
+
372
+ def prepare(tool: ToolRegistration, args: Any) -> dict[str, JsonValue] | str:
373
+ """The call's arguments as repaired by the tool, or why the repair failed."""
374
+
375
+ if tool.prepare_arguments is None:
376
+ return cast(dict[str, JsonValue], args)
377
+ try:
378
+ repaired = tool.prepare_arguments(args)
379
+ if hasattr(repaired, "__await__"):
380
+ raise TypeError("prepare_arguments must be synchronous")
381
+ return cast(dict[str, JsonValue], repaired)
382
+ except Exception as error:
383
+ return error_message(error)
384
+
385
+
386
+ def validate(tool: ToolRegistration, call: Mapping[str, Any], args: Any) -> dict[str, JsonValue] | str:
387
+ """Arguments validated and coerced against the implementation's schema."""
388
+
389
+ try:
390
+ return cast(dict[str, JsonValue], validate_tool_arguments(tool, {**call, "arguments": args}))
391
+ except ToolArgumentError as error:
392
+ return error_message(error)
393
+ except Exception as error:
394
+ return error_message(error)
395
+
396
+
397
+ def invalid(message: str) -> ToolExecutionResult:
398
+ return harness_error("invalid_arguments", message)
399
+
400
+
401
+ class Reported:
402
+ """What a running tool reported through its api: output, the last details, and diagnostics."""
403
+
404
+ __slots__ = ("output", "limits", "diagnostics", "details")
405
+
406
+ def __init__(self, limits: OutputLimits) -> None:
407
+ self.output = OutputBuffer(limits)
408
+ self.limits = limits
409
+ self.diagnostics: list[ToolDiagnostic] = []
410
+ self.details: JsonValue = None
411
+
412
+
413
+ async def run(
414
+ runtime: Any,
415
+ input: ToolTaskInput,
416
+ call: ToolHookCall,
417
+ tool: ToolRegistration,
418
+ args: dict[str, JsonValue],
419
+ context: Context,
420
+ ) -> None:
421
+ """Execute with the resolved implementation, then settle its result."""
422
+
423
+ reported = Reported(output_limits(tool))
424
+ streams = input["kind"] == "model" or input.get("progress") is not False
425
+ progress = publish_progress(runtime, reported, streams, context)
426
+ ended = False
427
+
428
+ def assert_live() -> None:
429
+ if ended:
430
+ raise RuntimeError(f"Tool call {call['id']} has settled")
431
+
432
+ api = ToolExecutionApiImpl(
433
+ runtime=runtime,
434
+ reported=reported,
435
+ progress=progress,
436
+ call=call,
437
+ assert_live=assert_live,
438
+ resumes=tool.replay == "safe",
439
+ )
440
+ result: ToolExecutionResult = {}
441
+ ending: Ending = COMPLETED
442
+ duration_ms: int | None = None
443
+ try:
444
+ # Built for this call, so a rerun after recovery gets the conversation's environment at that time.
445
+ env = await runtime.env(context)
446
+ api.env = env
447
+ started_at = time.monotonic()
448
+ try:
449
+ raw = tool.execute(args, api, context) if tool.execute is not None else {}
450
+ if hasattr(raw, "__await__"):
451
+ raw = await cast(Awaitable[Any], raw)
452
+ if raw is None:
453
+ raw = {}
454
+ if not isinstance(raw, Mapping):
455
+ raise TypeError(f"Tool {call['name']} returned no result object")
456
+ result = cast(ToolExecutionResult, dict(raw))
457
+ finally:
458
+ duration_ms = round((time.monotonic() - started_at) * 1000)
459
+ except BaseException as error:
460
+ if runtime.signal is not None and runtime.signal.aborted:
461
+ ended = True
462
+ for waiter in await progress.stop():
463
+ _reject(waiter, error)
464
+ raise
465
+ if not is_failure(error):
466
+ # A teardown signal without an abort mark keeps unwinding.
467
+ raise
468
+ result = {
469
+ "isError": True,
470
+ "diagnostics": [tool_diagnostic("tool_error", error_message(error))],
471
+ }
472
+ # A throw, from ``execute()`` or from building the environment, ends the task ``failed``, which
473
+ # cancels what the call owned; it no longer supervises it.
474
+ ending = {"status": "failed", "message": f"Tool {call['name']} threw"}
475
+ ended = True
476
+ # No admission starts after ``ended``; let those underway commit, so settlement aborts and lists
477
+ # their calls.
478
+ await asyncio.gather(*api.admissions, return_exceptions=True)
479
+ reported.output.end()
480
+ # Details still waiting for a progress commit settle with the terminal commit, the final flush.
481
+ pending = await progress.stop()
482
+ try:
483
+ settled = await final_result(runtime, input, call, tool, result, reported, context)
484
+ await settle(runtime, input, call, ending, lambda _slot: settled, context, duration_ms)
485
+ except BaseException as error:
486
+ for waiter in pending:
487
+ _reject(waiter, error)
488
+ raise
489
+ for waiter in pending:
490
+ if not waiter.done():
491
+ waiter.set_result(None)
492
+
493
+
494
+ def output_limits(tool: ToolRegistration) -> OutputLimits:
495
+ """A tool's output limits, over the defaults 50 KiB, 2,000 lines, and head retention."""
496
+
497
+ raw = dict(tool.output_limits or {})
498
+ max_bytes = raw.get("max_bytes", raw.get("maxBytes"))
499
+ max_lines = raw.get("max_lines", raw.get("maxLines"))
500
+ retain = raw.get("retain")
501
+ retain = "head" if retain is None else retain
502
+ if retain not in ("head", "tail"):
503
+ raise TypeError(f"Tool {tool.name} output_limits.retain must be 'head' or 'tail'")
504
+ return OutputLimits(
505
+ max_bytes=DEFAULT_MAX_BYTES if max_bytes is None else int(max_bytes),
506
+ max_lines=DEFAULT_MAX_LINES if max_lines is None else int(max_lines),
507
+ retain=cast(str, retain),
508
+ )
509
+
510
+
511
+ class ToolExecutionApiImpl:
512
+ """The api a running tool receives: bounded output, durable commits, and Harness reads."""
513
+
514
+ def __init__(
515
+ self,
516
+ *,
517
+ runtime: Any,
518
+ reported: Reported,
519
+ progress: Progress,
520
+ call: ToolHookCall,
521
+ assert_live: Callable[[], None],
522
+ resumes: bool,
523
+ ) -> None:
524
+ self._runtime = runtime
525
+ self._reported = reported
526
+ self._progress = progress
527
+ self._call = call
528
+ self._assert_live = assert_live
529
+ self._resumes = resumes
530
+ self.admissions: list[asyncio.Future[Any]] = []
531
+ """Nested call admissions this invocation started; cleanup waits for them."""
532
+ self._made_nested: asyncio.Future[Any] | None = None
533
+ """This attempt's commit of the ``MADE_NESTED_CALLS`` memo."""
534
+ self._sequence = 0
535
+ """Default keys: the order of this invocation's calls, so a rerun calling in order reattaches."""
536
+ self.task_id = runtime.task_id
537
+ self.conversation_id = runtime.conversation_id
538
+ self.call_id = call.get("id", "")
539
+ self.registry = runtime.registry
540
+ self.models = runtime.models
541
+ self.env: Any = None
542
+ limits = reported.limits
543
+ self.output_window: Mapping[str, Any] | None = (
544
+ {
545
+ "max_bytes": limits.max_bytes,
546
+ "max_lines": limits.max_lines,
547
+ "min_interval_ms": int(
548
+ runtime.settings.progress.get(
549
+ "output_interval_ms", DEFAULT_PROGRESS_POLICY["output_interval_ms"]
550
+ )
551
+ ),
552
+ "bytes_per_second": PROGRESS_BYTES_PER_SECOND,
553
+ }
554
+ if limits.retain == "tail"
555
+ else None
556
+ )
557
+
558
+ async def agent(self, context: Context) -> Any:
559
+ return await self._runtime.agent(context)
560
+
561
+ def output(self, chunk: str | bytes, skipped: Mapping[str, Any] | None = None) -> None:
562
+ self._assert_live()
563
+ if self._reported.output.push(chunk, skipped):
564
+ self._progress.mark()
565
+
566
+ def retained_output(self) -> Mapping[str, Any]:
567
+ self._assert_live()
568
+ retained = self._reported.output.snapshot()
569
+ return {"text": retained.text, "truncated": retained.dropped_bytes > 0}
570
+
571
+ def diagnostic(self, diagnostic: Mapping[str, Any]) -> None:
572
+ self._assert_live()
573
+ self._reported.diagnostics.append(cast(ToolDiagnostic, copy_json(dict(diagnostic))))
574
+ self._progress.mark()
575
+
576
+ def details(self, value: JsonValue, context: Context) -> Awaitable[None]:
577
+ # The update and its waiter are registered before the caller awaits, as an async function's
578
+ # synchronous prefix does upstream.
579
+ self._assert_live()
580
+ signal = context.abort_signal
581
+ if signal is not None:
582
+ signal.raise_if_aborted()
583
+ self._reported.details = copy_json(value)
584
+ committed = self._progress.mark_and_wait()
585
+ committed.add_done_callback(lambda future: future.exception() if not future.cancelled() else None)
586
+
587
+ async def wait() -> None:
588
+ # Cancelling the wait leaves the update in place; the commit's own outcome stays observed.
589
+ await await_with_context(committed, context)
590
+
591
+ return wait()
592
+
593
+ async def commit(self, change: Callable[..., Any], context: Context) -> Any:
594
+ box: dict[str, Any] = {}
595
+
596
+ async def apply(tx: Transaction) -> None:
597
+ result = change(tx)
598
+ if hasattr(result, "__await__"):
599
+ result = await cast(Awaitable[Any], result)
600
+ box["value"] = result
601
+
602
+ await self._runtime.commit(apply, context)
603
+ return box.get("value")
604
+
605
+ async def memo(self, name: str, *args: Any) -> JsonValue | None:
606
+ return await self._runtime.memo(name, *args)
607
+
608
+ async def create_task(
609
+ self,
610
+ task: Any,
611
+ input: JsonValue,
612
+ options: Mapping[str, Any],
613
+ context: Context,
614
+ ) -> TaskId:
615
+ child = dict(options)
616
+ ownership = cast(Mapping[str, Any], child["ownership"])
617
+ if ownership.get("kind") == "task" and not self._resumes and "abandonOnRestart" not in child:
618
+ child["abandonOnRestart"] = True
619
+ box: dict[str, TaskId] = {}
620
+
621
+ async def apply(tx: Transaction) -> None:
622
+ box["id"] = await tx.create_task(task, input, cast(Any, child))
623
+
624
+ await self._runtime.commit(apply, context)
625
+ return box["id"]
626
+
627
+ async def get_task(self, id: TaskId, context: Context) -> TaskRecord | None:
628
+ return await self._runtime.get_task(id, context)
629
+
630
+ async def wait_for_task(self, id: TaskId, context: Context) -> Any:
631
+ return await self._runtime.wait_for_task(id, context)
632
+
633
+ async def conversation(self, id: ConversationId, context: Context) -> Any:
634
+ return await self._runtime.conversation(id, context)
635
+
636
+ async def snapshot(self, token: Any, *args: Any) -> Any:
637
+ return await self._runtime.snapshot(token, *args)
638
+
639
+ async def snapshot_as_of(self, token: Any, *args: Any) -> Any:
640
+ return await self._runtime.snapshot_as_of(token, *args)
641
+
642
+ async def watch_doc(self, token: Any, *args: Any) -> Any:
643
+ return await self._runtime.watch_doc(token, *args)
644
+
645
+ def execute_tool(
646
+ self,
647
+ name: str,
648
+ args: Mapping[str, Any],
649
+ context: Context,
650
+ options: Mapping[str, Any] | None = None,
651
+ ) -> Awaitable[NestedToolExecutionResult]:
652
+ """Run another tool as a nested call of this one and return its result.
653
+
654
+ The admission is registered synchronously, as upstream's async function's synchronous prefix
655
+ is, so a caller that starts a call without awaiting it still lets it commit before settling.
656
+ Cancelling the returned future's wait stops only the wait: the call runs on, and its key
657
+ returns its result.
658
+ """
659
+
660
+ self._assert_live()
661
+ values = options or {}
662
+ if values.get("key") is not None:
663
+ check_key(str(values["key"]))
664
+ key = str(values["key"])
665
+ else:
666
+ self._sequence += 1
667
+ key = str(self._sequence)
668
+ admission = asyncio.ensure_future(
669
+ self._admit(name, args, key, values.get("progress"), context)
670
+ )
671
+ admission.add_done_callback(_consume)
672
+ self.admissions.append(admission)
673
+ waiter = asyncio.ensure_future(self._await_nested(name, key, admission, context))
674
+ waiter.add_done_callback(_consume)
675
+ return waiter
676
+
677
+ async def _await_nested(
678
+ self,
679
+ name: str,
680
+ key: str,
681
+ admission: asyncio.Future[Any],
682
+ context: Context,
683
+ ) -> NestedToolExecutionResult:
684
+ task_id = await await_with_context(admission, context)
685
+ settled = await self._runtime.wait_for_task(task_id, context)
686
+ stored = await self._runtime.snapshot(NestedCallDoc, self.task_id, key, context)
687
+ value = None if stored is None else stored.get("result")
688
+ if value is None:
689
+ outcome = cast(Mapping[str, Any], settled["state"])["outcome"]
690
+ return fallback_result(task_id, name, cast(Mapping[str, Any], outcome))
691
+ # A copy the tool may change; a nested call the scheduler faulted or orphaned stored none.
692
+ return cast(NestedToolExecutionResult, copy_json(value))
693
+
694
+ async def _admit(
695
+ self,
696
+ name: str,
697
+ args: Mapping[str, Any],
698
+ key: str,
699
+ progress: Any,
700
+ context: Context,
701
+ ) -> TaskId:
702
+ # The memo first, once per attempt; a failed one is tried again by the next call.
703
+ if self._made_nested is None:
704
+ self._made_nested = asyncio.ensure_future(self._mark_made_nested(context))
705
+ await self._made_nested
706
+ return await start_nested_call(
707
+ self._runtime,
708
+ self.call_id,
709
+ name,
710
+ args,
711
+ {
712
+ "key": key,
713
+ "progress": progress,
714
+ "abandonOnRestart": not self._resumes,
715
+ },
716
+ context,
717
+ )
718
+
719
+ async def _mark_made_nested(self, context: Context) -> Any:
720
+ try:
721
+ return await self._runtime.memo(MADE_NESTED_CALLS, True, context)
722
+ except BaseException:
723
+ self._made_nested = None
724
+ raise
725
+
726
+
727
+ _UNSET = object()
728
+
729
+
730
+ def publish_progress(
731
+ runtime: Any, reported: Reported, enabled: bool, context: Context
732
+ ) -> Progress:
733
+ """Throttled commits of what the tool reported into its slot, each writing only what changed."""
734
+
735
+ written: dict[str, Any] = {"text": "", "details": _UNSET, "diagnostics": 0}
736
+
737
+ def write() -> Any:
738
+ if not enabled:
739
+ return 0
740
+ # Capture everything synchronously: the tool keeps reporting while the commit is in flight.
741
+ snapshot = reported.output.snapshot()
742
+ details = reported.details
743
+ diagnostics = len(reported.diagnostics)
744
+ added = reported.diagnostics[written["diagnostics"] : diagnostics]
745
+ details_changed = written["details"] is not details
746
+ # What the commit writes, as Chord diffs the string: an append, a trim plus an append of what
747
+ # follows the shared part, or the whole window when its bounded overlap search finds nothing.
748
+ bytes_ = 0
749
+ previous = cast(str, written["text"])
750
+ if snapshot.text != previous:
751
+ shared = (
752
+ len(previous)
753
+ if snapshot.text.startswith(previous)
754
+ else overlap(previous, snapshot.text, OVERLAP_SCAN_LIMIT)
755
+ )
756
+ bytes_ += utf8_byte_length(snapshot.text[shared:])
757
+ if details_changed:
758
+ bytes_ += utf8_byte_length(json.dumps(details))
759
+ if added:
760
+ bytes_ += utf8_byte_length(json.dumps(added))
761
+
762
+ async def commit() -> int:
763
+ async def flush(tx: Transaction) -> None:
764
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
765
+ slot = tool_slot(live, runtime.task_id)
766
+ if slot is None:
767
+ return
768
+ # REMINDER: assign output as one string field, so the delta records an append or a trim.
769
+ if (slot.get("output") or "") != snapshot.text:
770
+ slot["output"] = snapshot.text
771
+ if snapshot.dropped_bytes > 0:
772
+ slot["droppedBytes"] = snapshot.dropped_bytes
773
+ if snapshot.dropped_lines > 0:
774
+ slot["droppedLines"] = snapshot.dropped_lines
775
+ if details_changed and details is not None:
776
+ assign_json(slot, "details", details)
777
+ if added:
778
+ if "diagnostics" not in slot:
779
+ slot["diagnostics"] = list(added)
780
+ else:
781
+ for diagnostic in added:
782
+ slot["diagnostics"].append(diagnostic)
783
+
784
+ await runtime.commit(flush, context)
785
+ written["text"] = snapshot.text
786
+ written["details"] = details
787
+ written["diagnostics"] = diagnostics
788
+ return bytes_
789
+
790
+ return commit()
791
+
792
+ def on_error(error: BaseException) -> None:
793
+ # Rejections after an abort mark or close are expected; the committed state stays consistent.
794
+ if runtime.signal is None or not runtime.signal.aborted:
795
+ runtime.report(error)
796
+
797
+ interval = runtime.settings.progress.get(
798
+ "output_interval_ms", DEFAULT_PROGRESS_POLICY["output_interval_ms"]
799
+ )
800
+ return Progress(write, on_error, int(interval))
801
+
802
+
803
+ async def final_result(
804
+ runtime: Any,
805
+ input: ToolTaskInput,
806
+ call: ToolHookCall,
807
+ tool: ToolRegistration,
808
+ result: ToolExecutionResult,
809
+ reported: Reported,
810
+ context: Context,
811
+ ) -> ToolExecutionResult:
812
+ """The settled result: fallbacks, ``afterTool``, bounds, and ``structuredOutput`` checked.
813
+
814
+ A nested call whose ``structuredOutput`` breaks the contract gets an error result without it; a
815
+ model-issued call only loses it, and the break is reported to the host.
816
+ """
817
+
818
+ harness: list[ToolDiagnostic] = []
819
+ retained = reported.output.snapshot() if "output" not in result else None
820
+ if retained is None:
821
+ output: Content = list(result.get("output") or [])
822
+ else:
823
+ output = [] if retained.text == "" else [{"type": "text", "text": retained.text}]
824
+ final: ToolExecutionResult = {
825
+ **result,
826
+ "output": output,
827
+ "details": result["details"] if "details" in result else reported.details,
828
+ "diagnostics": [*reported.diagnostics, *(result.get("diagnostics") or [])],
829
+ }
830
+
831
+ async def after_tool(handler: Callable[..., Any]) -> None:
832
+ nonlocal final
833
+ replaced = await call_hook(handler, call, final, runtime, context)
834
+ if isinstance(replaced, Mapping):
835
+ final = cast(ToolExecutionResult, dict(replaced))
836
+
837
+ await runtime.hooks.each("afterTool", after_tool)
838
+ # The retained output's truncation applies only while afterTool kept that output.
839
+ if retained is not None and retained.dropped_bytes > 0 and final.get("output") is output:
840
+ harness.append(truncated(retained, reported.limits.retain))
841
+ bounded = bound_content(list(final.get("output") or []), reported.limits)
842
+ if bounded.dropped_bytes > 0:
843
+ harness.append(truncated(bounded, reported.limits.retain))
844
+ broken = structured_output_error(tool, final)
845
+ if broken is not None:
846
+ final = cast(
847
+ ToolExecutionResult,
848
+ {key: value for key, value in final.items() if key != "structuredOutput"},
849
+ )
850
+ if input["kind"] == "nested":
851
+ final = {**final, "isError": True}
852
+ harness.append(tool_diagnostic("invalid_structured_output", broken))
853
+ else:
854
+ # The model never sees structured output, so its call stands; the host learns of the break.
855
+ runtime.report(ValueError(broken))
856
+ # Programs get the tool's own value: without a schema, its output. The model reads only ``output``,
857
+ # so model-issued calls skip both.
858
+ if input["kind"] == "nested" and tool.structured_output_schema is None:
859
+ final = {**final, "structuredOutput": output_value(bounded.content)}
860
+ # A schema tool's error result without structured output has its text only in the output, which
861
+ # programs never get: it becomes an error diagnostic, unless one says what went wrong already.
862
+ if (
863
+ input["kind"] == "nested"
864
+ and tool.structured_output_schema is not None
865
+ and final.get("isError") is True
866
+ and "structuredOutput" not in final
867
+ ):
868
+ diagnostics = [*(final.get("diagnostics") or []), *harness]
869
+ if not any(item.get("severity") == "error" for item in diagnostics):
870
+ text = "\n".join(text_items(bounded.content))
871
+ if text != "":
872
+ harness.append(tool_diagnostic("tool_error", text))
873
+ return {
874
+ **final,
875
+ "output": bounded.content,
876
+ "diagnostics": [*(final.get("diagnostics") or []), *harness],
877
+ }
878
+
879
+
880
+ async def settle(
881
+ runtime: Any,
882
+ input: ToolTaskInput,
883
+ call: ToolHookCall,
884
+ ending: Ending,
885
+ build: Callable[[Any], ToolExecutionResult],
886
+ context: Context,
887
+ duration_ms: int | None = None,
888
+ ) -> None:
889
+ """Commit the tool's terminal state and mark its slot done.
890
+
891
+ A model-issued call appends its result entry; a nested call writes its result to its caller's
892
+ ``NestedCallDoc``, records its usage, and ends with a small receipt. Nested calls this task left
893
+ running are aborted first, so they report into their slots before the slots below it leave
894
+ ``pi.live``. ``build`` receives the slot so interruption and abort report the durable partial.
895
+ """
896
+
897
+ # Every live nested call the task owns, also those an earlier attempt made and this one never
898
+ # called again. Mark them all before waiting for any: one may run until a sibling is cancelled.
899
+ owned = await runtime.owned_tasks(context)
900
+ nested_ids = [
901
+ record["id"]
902
+ for record in owned
903
+ if record["kind"] == TOOL_TASK_KIND
904
+ and cast(Any, record["input"]).get("kind") == "nested"
905
+ ]
906
+ # Read before the settling commit: no admission starts after the call ended, and those underway
907
+ # were awaited.
908
+ made_nested = await runtime.memo(MADE_NESTED_CALLS, context) is True
909
+ if nested_ids:
910
+ await asyncio.gather(*(runtime.abort_owned(id, context) for id in nested_ids))
911
+
912
+ async def finish(tx: Transaction) -> dict[str, Any]:
913
+ conversation_id = runtime.conversation_id
914
+ live = await tx.doc(LiveDoc, conversation_id)
915
+ slot = tool_slot(live, runtime.task_id)
916
+ result = build(slot)
917
+ settled: ToolTaskResult
918
+ if input["kind"] == "nested":
919
+ nested = nested_result(runtime.task_id, result, duration_ms)
920
+ usage = nested.get("usage")
921
+ if usage is not None:
922
+ await record_usage(
923
+ tx, conversation_id, "tools", str(call["name"]), cast(Mapping[str, Any], usage)
924
+ )
925
+ # The caller is never terminal before its owned nested calls, so its document is open;
926
+ # another task in it is a bug, not a race.
927
+ state = await tx.doc(NestedCallDoc, input["parent"], input["key"], None)
928
+ if state.get("taskId") != runtime.task_id:
929
+ raise RuntimeError(f"Nested call {call['id']} is not in its caller's index")
930
+ state["result"] = cast(Any, nested)
931
+ if slot is not None and "parentTaskId" in slot:
932
+ finish_slot(slot)
933
+ slot["summary"] = summary_of(nested, cast(Content, result.get("output") or []))
934
+ settled = {"kind": "nested"}
935
+ else:
936
+ entry = await append_tool_result(
937
+ tx,
938
+ conversation_id,
939
+ call,
940
+ result,
941
+ {"timestamp": runtime.now(), "durationMs": duration_ms},
942
+ )
943
+ if slot is not None and "parentTaskId" not in slot:
944
+ finish_slot(slot)
945
+ slot["entry"] = entry["id"]
946
+ control = result.get("control")
947
+ if not isinstance(control, Mapping) or ending.get("status") != "completed":
948
+ settled = {"kind": "model", "entryId": entry["id"]}
949
+ else:
950
+ settled = {
951
+ "kind": "model",
952
+ "entryId": entry["id"],
953
+ "control": cast(ToolControl, copy_json(dict(control))),
954
+ }
955
+ # A call that made no nested calls, in any attempt, has no slots below it; skipping the scan
956
+ # keeps many leaves linear. Committed before the read: no admission starts after the call ended.
957
+ if made_nested:
958
+ remove_nested_slots(live, runtime.task_id)
959
+ if ending.get("status") == "aborted":
960
+ return {"status": "terminal", "outcome": {"status": "aborted", "result": settled}}
961
+ if ending.get("status") == "failed":
962
+ error = {"message": ending.get("message", "")}
963
+ return {
964
+ "status": "terminal",
965
+ "outcome": {"status": "failed", "error": error, "result": settled},
966
+ }
967
+ return {
968
+ "status": "terminal",
969
+ "outcome": {"status": "completed", "result": settled},
970
+ }
971
+
972
+ await runtime.commit(finish, context)
973
+
974
+
975
+ async def start_nested_call(
976
+ runtime: Any,
977
+ parent_call_id: str,
978
+ name: str,
979
+ args: Mapping[str, Any],
980
+ options: Mapping[str, Any],
981
+ context: Context,
982
+ ) -> TaskId:
983
+ """Create nested call ``key`` of ``parent_call_id`` in one commit.
984
+
985
+ The commit holds the nested task, its slot, and its ``NestedCallDoc`` member. A key already made
986
+ returns that call's task when it names the same tool and arguments.
987
+ """
988
+
989
+ key = str(options["key"])
990
+ call: dict[str, Any] = {
991
+ "type": "toolCall",
992
+ "id": f"{parent_call_id}/{key}",
993
+ "name": name,
994
+ "arguments": copy_json(dict(args)),
995
+ }
996
+ box: dict[str, TaskId] = {}
997
+
998
+ async def admit(tx: Transaction) -> None:
999
+ # Opened, or created empty for a key not made yet.
1000
+ state = await tx.doc(NestedCallDoc, runtime.task_id, key, None)
1001
+ existing = state.get("taskId")
1002
+ if existing is not None:
1003
+ record = await tx.task(existing)
1004
+ input = None if record is None else record["input"]
1005
+ same = (
1006
+ isinstance(input, Mapping)
1007
+ and input.get("kind") == "nested"
1008
+ and cast(Any, input.get("call")) is not None
1009
+ and cast(Mapping[str, Any], input["call"])["name"] == name
1010
+ and json_equal(
1011
+ cast(Any, cast(Mapping[str, Any], input["call"])).get("arguments"),
1012
+ call["arguments"],
1013
+ )
1014
+ )
1015
+ if not same:
1016
+ raise RuntimeError(
1017
+ f"Nested call {call['id']} was already made with another tool or other arguments"
1018
+ )
1019
+ box["id"] = existing
1020
+ return
1021
+ nested_input: dict[str, Any] = {
1022
+ "kind": "nested",
1023
+ "parent": runtime.task_id,
1024
+ "parentCallId": parent_call_id,
1025
+ "key": key,
1026
+ "call": call,
1027
+ }
1028
+ if options.get("progress") is False:
1029
+ nested_input["progress"] = False
1030
+ created = await tx.create_task(
1031
+ ToolTask,
1032
+ cast(Any, nested_input),
1033
+ {
1034
+ "ownership": {"kind": "task", "taskId": runtime.task_id},
1035
+ "abandonOnRestart": bool(options.get("abandonOnRestart")),
1036
+ },
1037
+ )
1038
+ state["taskId"] = created
1039
+ live = await tx.doc(LiveDoc, runtime.conversation_id)
1040
+ slot = {
1041
+ "callId": call["id"],
1042
+ "parentCallId": parent_call_id,
1043
+ "parentTaskId": runtime.task_id,
1044
+ "name": name,
1045
+ "taskId": created,
1046
+ "arguments": call["arguments"],
1047
+ "status": "pending",
1048
+ }
1049
+ existing_slots = live.get("nestedTools")
1050
+ if existing_slots is None:
1051
+ live["nestedTools"] = [slot]
1052
+ else:
1053
+ existing_slots.append(slot)
1054
+ box["id"] = created
1055
+
1056
+ await runtime.commit(admit, context)
1057
+ return box["id"]
1058
+
1059
+
1060
+ def nested_result(
1061
+ task_id: TaskId, result: ToolExecutionResult, duration_ms: int | None
1062
+ ) -> dict[str, Any]:
1063
+ """A nested call's result, as stored and returned: strict JSON, without ``output`` or ``control``."""
1064
+
1065
+ nested: dict[str, Any] = {"taskId": task_id}
1066
+ if "structuredOutput" in result:
1067
+ nested["structuredOutput"] = result["structuredOutput"]
1068
+ nested["isError"] = bool(result.get("isError", False))
1069
+ if "details" in result:
1070
+ nested["details"] = result["details"]
1071
+ nested["diagnostics"] = list(result.get("diagnostics") or [])
1072
+ if "usage" in result:
1073
+ nested["usage"] = result["usage"]
1074
+ if duration_ms is not None:
1075
+ nested["durationMs"] = duration_ms
1076
+ return cast(dict[str, Any], copy_json(nested))
1077
+
1078
+
1079
+ def text_items(content: Sequence[Any]) -> list[str]:
1080
+ """The text of every text item in result content, in order."""
1081
+
1082
+ return [
1083
+ cast(str, item.get("text", ""))
1084
+ for item in content
1085
+ if isinstance(item, Mapping) and item.get("type") == "text"
1086
+ ]
1087
+
1088
+
1089
+ def output_value(output: Content) -> JsonValue:
1090
+ """What programs get of a schema-less tool's output: a text, an image, ``""``, or the list."""
1091
+
1092
+ if len(output) == 0:
1093
+ return ""
1094
+ if len(output) > 1:
1095
+ return output
1096
+ only = output[0]
1097
+ return only.get("text", "") if isinstance(only, Mapping) and only.get("type") == "text" else only
1098
+
1099
+
1100
+ def fallback_result(
1101
+ task_id: TaskId, name: str, outcome: Mapping[str, Any]
1102
+ ) -> NestedToolExecutionResult:
1103
+ """What the caller gets for a nested call without a stored result: a faulted or orphaned one."""
1104
+
1105
+ status = str(outcome.get("status"))
1106
+ if status == "faulted":
1107
+ error = cast(Mapping[str, Any], outcome.get("error") or {})
1108
+ message = f"Tool {name} failed: {error_message(error)}"
1109
+ elif status == "orphaned":
1110
+ message = f"Tool {name} could not resume: {outcome.get('reason')}"
1111
+ else:
1112
+ message = f"Tool {name} ended without a result"
1113
+ error_result = harness_error(status, message)
1114
+ return cast(
1115
+ NestedToolExecutionResult,
1116
+ {
1117
+ "taskId": task_id,
1118
+ "isError": True,
1119
+ "diagnostics": list(error_result.get("diagnostics") or []),
1120
+ },
1121
+ )
1122
+
1123
+
1124
+ def summary_of(result: Mapping[str, Any], output: Content) -> dict[str, Any]:
1125
+ """How a nested call ended, for its slot: error text, from its diagnostics or else its output."""
1126
+
1127
+ error = error_text_of(result, output)[:MAX_SUMMARY_ERROR_CHARS] if result.get("isError") else ""
1128
+ summary: dict[str, Any] = {"isError": bool(result.get("isError"))}
1129
+ if result.get("durationMs") is not None:
1130
+ summary["durationMs"] = result["durationMs"]
1131
+ if result.get("usage") is not None:
1132
+ summary["usage"] = result["usage"]
1133
+ if error != "":
1134
+ summary["error"] = error
1135
+ return summary
1136
+
1137
+
1138
+ def error_text_of(result: Mapping[str, Any], output: Content) -> str:
1139
+ """The error messages of a nested result, else the text items of its output, joined with newlines."""
1140
+
1141
+ errors = [
1142
+ cast(Mapping[str, Any], item)
1143
+ for item in cast(Sequence[Any], result.get("diagnostics") or ())
1144
+ if isinstance(item, Mapping) and item.get("severity") == "error"
1145
+ ]
1146
+ if errors:
1147
+ return "\n".join(str(item.get("message")) for item in errors)
1148
+ return "\n".join(text_items(output))
1149
+
1150
+
1151
+ def check_key(key: str) -> None:
1152
+ """Explicit keys are path segments of the call ID, so IDs never collide."""
1153
+
1154
+ if key == "" or "/" in key or key == "__proto__" or re.fullmatch(r"[1-9][0-9]*", key):
1155
+ raise ValueError(
1156
+ f'Nested call key {json.dumps(key)} must be non-empty, without "/", not "__proto__", '
1157
+ "and not a positive integer"
1158
+ )
1159
+
1160
+
1161
+ async def append_tool_result(
1162
+ tx: Transaction,
1163
+ conversation_id: ConversationId,
1164
+ call: Mapping[str, Any],
1165
+ result: ToolExecutionResult,
1166
+ meta: Mapping[str, Any],
1167
+ ) -> Any:
1168
+ """Append a ``pi.tool-result`` entry whose content ends with the rendered diagnostics."""
1169
+
1170
+ timestamp = meta.get("timestamp")
1171
+ duration_ms = meta.get("durationMs")
1172
+ diagnostics = list(result.get("diagnostics") or [])
1173
+ content: Content = list(result.get("output") or [])
1174
+ if diagnostics:
1175
+ content.append({"type": "text", "text": render_diagnostics(diagnostics)})
1176
+ message: dict[str, Any] = {
1177
+ "role": "toolResult",
1178
+ "toolCallId": call["id"],
1179
+ "toolName": call["name"],
1180
+ "content": content,
1181
+ "isError": bool(result.get("isError", False)),
1182
+ "timestamp": timestamp,
1183
+ }
1184
+ if "details" in result:
1185
+ message["details"] = result["details"]
1186
+ if "usage" in result:
1187
+ message["usage"] = result["usage"]
1188
+ if duration_ms is not None:
1189
+ message["durationMs"] = duration_ms
1190
+ if "usage" in result:
1191
+ usage = cast(Mapping[str, Any], result["usage"])
1192
+ await record_usage(tx, conversation_id, "tools", str(call["name"]), usage)
1193
+ return await tx.append_entry(
1194
+ ToolResultEntry,
1195
+ conversation_id,
1196
+ cast(
1197
+ EntryDraft,
1198
+ {
1199
+ "kind": ToolResultEntry.kind,
1200
+ "model": [cast(MessageRecord, copy_json(message))],
1201
+ "data": {"diagnostics": copy_json(diagnostics)},
1202
+ },
1203
+ ),
1204
+ )
1205
+
1206
+
1207
+ def render_diagnostics(diagnostics: Sequence[ToolDiagnostic]) -> str:
1208
+ lines = "\n".join(f"[{item.get('severity')}] {item.get('message')}" for item in diagnostics)
1209
+ return f"<harness>\n{lines}\n</harness>"
1210
+
1211
+
1212
+ def from_slot(slot: Any, code: str, message: str) -> ToolExecutionResult:
1213
+ """An error result from the slot's durable partial output, details, and diagnostics."""
1214
+
1215
+ slot_diagnostics = (slot.get("diagnostics") if slot is not None else None) or ()
1216
+ diagnostics = [
1217
+ cast(ToolDiagnostic, copy_json(_plain(item))) for item in slot_diagnostics
1218
+ ]
1219
+ dropped_bytes = int((slot.get("droppedBytes") if slot is not None else 0) or 0)
1220
+ if dropped_bytes > 0:
1221
+ diagnostics.append(
1222
+ truncated(
1223
+ {
1224
+ "dropped_bytes": dropped_bytes,
1225
+ "dropped_lines": int((slot.get("droppedLines") if slot is not None else 0) or 0),
1226
+ }
1227
+ )
1228
+ )
1229
+ diagnostics.append(tool_diagnostic(code, message))
1230
+ output = slot.get("output") if slot is not None else None
1231
+ result: ToolExecutionResult = {
1232
+ "output": [] if not output else [{"type": "text", "text": output}],
1233
+ "isError": True,
1234
+ "diagnostics": diagnostics,
1235
+ }
1236
+ if slot is not None and "details" in slot:
1237
+ result["details"] = copy_json(_plain(slot.get("details")))
1238
+ return result
1239
+
1240
+
1241
+ def _plain(value: Any) -> Any:
1242
+ """A detached plain JSON value from a document draft member."""
1243
+
1244
+ from ..session.drafts import DocumentDraft, DraftList, DraftObject
1245
+
1246
+ if isinstance(value, (DocumentDraft, DraftObject, DraftList)):
1247
+ return value.snapshot()
1248
+ if isinstance(value, dict):
1249
+ return {key: _plain(item) for key, item in value.items()}
1250
+ if isinstance(value, list):
1251
+ return [_plain(item) for item in value]
1252
+ return value
1253
+
1254
+
1255
+ def harness_error(code: str, message: str) -> ToolExecutionResult:
1256
+ """An error result the Harness writes itself: no content and one ``error`` diagnostic with ``code``."""
1257
+
1258
+ return {"output": [], "isError": True, "diagnostics": [tool_diagnostic(code, message)]}
1259
+
1260
+
1261
+ def tool_diagnostic(code: str, message: str) -> ToolDiagnostic:
1262
+ return {"severity": "error", "code": code, "message": message}
1263
+
1264
+
1265
+ def truncated(dropped: Any, retain: str | None = None) -> ToolDiagnostic:
1266
+ """The Harness's truncation diagnostic; ``retain`` is unknown when rebuilt from a slot after recovery."""
1267
+
1268
+ if retain is None:
1269
+ kept = ""
1270
+ else:
1271
+ kept = " to its beginning" if retain == "head" else " to its end"
1272
+ if isinstance(dropped, Mapping):
1273
+ lines = int(dropped.get("dropped_lines", dropped.get("droppedLines", 0)) or 0)
1274
+ bytes_ = int(dropped.get("dropped_bytes", dropped.get("droppedBytes", 0)) or 0)
1275
+ else:
1276
+ lines = int(getattr(dropped, "dropped_lines", 0) or 0)
1277
+ bytes_ = int(getattr(dropped, "dropped_bytes", 0) or 0)
1278
+ return {
1279
+ "severity": "warn",
1280
+ "code": "truncated",
1281
+ "message": f"Output truncated{kept}: {lines} lines, {bytes_} bytes dropped",
1282
+ }
1283
+
1284
+
1285
+ @dataclass(frozen=True, slots=True)
1286
+ class _BoundedContent:
1287
+ content: Content
1288
+ dropped_bytes: int
1289
+ dropped_lines: int
1290
+
1291
+
1292
+ def bound_content(content: Content, limits: OutputLimits) -> _BoundedContent:
1293
+ """Bound the text of result content, keeping other content.
1294
+
1295
+ When the joined text exceeds the limits, the text items are replaced by one bounded item at the
1296
+ position of the first (head) or last (tail) text item.
1297
+ """
1298
+
1299
+ texts = [item for item in content if isinstance(item, Mapping) and item.get("type") == "text"]
1300
+ joined = "".join(text_items(content))
1301
+ bounded = bound_output(joined, limits)
1302
+ if bounded.dropped_bytes == 0:
1303
+ return _BoundedContent(content=content, dropped_bytes=0, dropped_lines=0)
1304
+ keep = texts[0] if limits.retain == "head" else texts[-1]
1305
+ result: Content = []
1306
+ for item in content:
1307
+ if not isinstance(item, Mapping) or item.get("type") != "text":
1308
+ result.append(item)
1309
+ elif item is keep:
1310
+ result.append({**item, "text": bounded.text})
1311
+ return _BoundedContent(
1312
+ content=result, dropped_bytes=bounded.dropped_bytes, dropped_lines=bounded.dropped_lines
1313
+ )
1314
+
1315
+
1316
+ def _reject(waiter: Any, error: BaseException) -> None:
1317
+ if not waiter.done():
1318
+ waiter.set_exception(error)
1319
+ waiter.exception()
1320
+
1321
+
1322
+ def _consume(future: asyncio.Future[Any]) -> None:
1323
+ """Observe a rejection a caller may ignore, so it raises only when awaited."""
1324
+
1325
+ if not future.cancelled():
1326
+ future.exception()
1327
+
1328
+
1329
+ __all__ = [
1330
+ "MADE_NESTED_CALLS",
1331
+ "ModelToolTaskInput",
1332
+ "ModelToolTaskResult",
1333
+ "NestedCallDoc",
1334
+ "NestedCallState",
1335
+ "NestedToolTaskInput",
1336
+ "NestedToolTaskResult",
1337
+ "ToolTask",
1338
+ "ToolTaskCheckpoint",
1339
+ "ToolTaskInput",
1340
+ "ToolTaskResult",
1341
+ "append_tool_result",
1342
+ "bound_content",
1343
+ "check_key",
1344
+ "entry_tool_calls",
1345
+ "fallback_result",
1346
+ "final_result",
1347
+ "from_slot",
1348
+ "harness_error",
1349
+ "nested_result",
1350
+ "output_limits",
1351
+ "output_value",
1352
+ "prepare",
1353
+ "publish_progress",
1354
+ "read_call",
1355
+ "render_diagnostics",
1356
+ "resolve_tool",
1357
+ "run",
1358
+ "settle",
1359
+ "start_nested_call",
1360
+ "summary_of",
1361
+ "text_items",
1362
+ "tool_diagnostic",
1363
+ "truncated",
1364
+ "validate",
1365
+ ]