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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|