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,1079 @@
|
|
|
1
|
+
"""Built-in ``pi.generation`` task: prepare, request, retry, poll, and classify a model response.
|
|
2
|
+
|
|
3
|
+
Port of v1.3.0 ``src/harness/generation.ts``. The run's inputs live in ``pi.live.run``. Text answers
|
|
4
|
+
stream through the model protocol; partials are throttled durable commits into ``pi.live`` and are
|
|
5
|
+
converted to aborted assistant entries when an attempt is interrupted. A tool-calling answer starts
|
|
6
|
+
a tool round: one ``pi.tool`` task per call, owned by the generation, which the ``tools`` phase
|
|
7
|
+
waits on and then ends with the round's controls. Preparation starts a threshold compaction when the
|
|
8
|
+
context estimate crosses a policy threshold, and classification starts an overflow compaction, both
|
|
9
|
+
owned by the generation so the run waits for the summary.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
16
|
+
from typing import Any, NotRequired, TypedDict, cast
|
|
17
|
+
|
|
18
|
+
from ..context import Context
|
|
19
|
+
from ..entries import AssistantEntry, ResetEntry, SystemEntry, UserEntry
|
|
20
|
+
from ..ids import ConversationId, EntryId, TaskId
|
|
21
|
+
from ..json import JsonObject, JsonValue, copy_json
|
|
22
|
+
from ..providers import (
|
|
23
|
+
is_context_overflow,
|
|
24
|
+
is_retryable_assistant_error,
|
|
25
|
+
retry_delay_ms,
|
|
26
|
+
)
|
|
27
|
+
from ..session.drafts import DraftList, DraftObject
|
|
28
|
+
from ..session.transaction import Transaction
|
|
29
|
+
from ..tasks import define_task
|
|
30
|
+
from ..types import EntryDraft, MessageRecord, TaskRecord
|
|
31
|
+
from .agent import add_tools
|
|
32
|
+
from .compaction import create_compaction, estimate_context, select_cut
|
|
33
|
+
from .inbox import QueueModes, apply_boundary, prepare_boundary
|
|
34
|
+
from .live import LiveDoc, end_run, start_run
|
|
35
|
+
from .prompt import current_tools, plan_system_entries, render_sections, replay_sections
|
|
36
|
+
from .provider import ensure_provider_session_id
|
|
37
|
+
from .tool import ToolTask, append_tool_result, entry_tool_calls, harness_error
|
|
38
|
+
from .types import ContextView, ModelRef, PromptInput
|
|
39
|
+
from .usage import record_usage
|
|
40
|
+
from .util import assign_json, call_hook, request_stream_options, task_checkpoint
|
|
41
|
+
|
|
42
|
+
DEFAULT_POLL_AFTER_MS = 5_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GenerationInput(TypedDict):
|
|
46
|
+
"""The run's inputs live in ``pi.live.run``, so the task input is empty."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PrepareCheckpoint(TypedDict):
|
|
50
|
+
phase: str
|
|
51
|
+
attempt: int
|
|
52
|
+
compacted: NotRequired[TaskId]
|
|
53
|
+
overflow: NotRequired[str]
|
|
54
|
+
threshold: NotRequired[bool]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RequestCheckpoint(TypedDict):
|
|
58
|
+
phase: str
|
|
59
|
+
attempt: int
|
|
60
|
+
compacted: NotRequired[TaskId]
|
|
61
|
+
model: ModelRef
|
|
62
|
+
thinkingLevel: str
|
|
63
|
+
streamOptions: dict[str, Any]
|
|
64
|
+
cutoff: EntryId
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RetryCheckpoint(TypedDict):
|
|
68
|
+
phase: str
|
|
69
|
+
attempt: int
|
|
70
|
+
compacted: NotRequired[TaskId]
|
|
71
|
+
until: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PollCheckpoint(TypedDict):
|
|
75
|
+
phase: str
|
|
76
|
+
attempt: int
|
|
77
|
+
compacted: NotRequired[TaskId]
|
|
78
|
+
model: ModelRef
|
|
79
|
+
cutoff: EntryId
|
|
80
|
+
handle: JsonObject
|
|
81
|
+
pollAt: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ToolsCheckpoint(TypedDict):
|
|
85
|
+
phase: str
|
|
86
|
+
assistant: EntryId
|
|
87
|
+
tools: list[TaskId]
|
|
88
|
+
pending: list[str]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
GenerationCheckpoint = (
|
|
92
|
+
PrepareCheckpoint | RequestCheckpoint | RetryCheckpoint | PollCheckpoint | ToolsCheckpoint
|
|
93
|
+
)
|
|
94
|
+
"""Checkpoint shapes the generation phases read and write."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GenerationResult(TypedDict):
|
|
98
|
+
entryId: EntryId
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _prepare(attempt: int, **extra: Any) -> dict[str, Any]:
|
|
102
|
+
return {"phase": "prepare", "attempt": attempt, **extra}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _compact_fields(compacted: Any) -> dict[str, Any]:
|
|
106
|
+
return {} if compacted is None else {"compacted": compacted}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _prepare_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
|
|
110
|
+
conversation_id = runtime.conversation_id
|
|
111
|
+
agent = await runtime.agent(context)
|
|
112
|
+
settings = runtime.settings
|
|
113
|
+
ref = agent.model
|
|
114
|
+
model = None if ref is None else runtime.models.get_model(ref["provider"], ref["modelId"])
|
|
115
|
+
if ref is None or model is None:
|
|
116
|
+
await fail_no_model(runtime, ref, context)
|
|
117
|
+
return
|
|
118
|
+
checkpoint = task_checkpoint(task)
|
|
119
|
+
attempt = int(checkpoint["attempt"])
|
|
120
|
+
compacted = checkpoint.get("compacted")
|
|
121
|
+
overflow = checkpoint.get("overflow")
|
|
122
|
+
threshold_blocked = checkpoint.get("threshold") is True
|
|
123
|
+
if compacted is not None and overflow is not None:
|
|
124
|
+
outcome = (await runtime.outcomes([compacted], context))[0]
|
|
125
|
+
if outcome.get("status") != "completed" or (
|
|
126
|
+
cast(Mapping[str, Any], outcome.get("result") or {}).get("entryId") is None
|
|
127
|
+
):
|
|
128
|
+
await fail_model_error(runtime, str(overflow), context)
|
|
129
|
+
return
|
|
130
|
+
if compacted is not None and threshold_blocked:
|
|
131
|
+
outcome = (await runtime.outcomes([compacted], context))[0]
|
|
132
|
+
if outcome.get("status") != "completed":
|
|
133
|
+
await fail_model_error(runtime, compaction_failure(outcome), context)
|
|
134
|
+
return
|
|
135
|
+
view = await runtime.context(conversation_id, context)
|
|
136
|
+
shown = replay_sections(view["messages"])
|
|
137
|
+
report = runtime.report
|
|
138
|
+
env: Any = None
|
|
139
|
+
try:
|
|
140
|
+
env = await runtime.env(context)
|
|
141
|
+
except BaseException as error:
|
|
142
|
+
if context.abort_signal is not None and context.abort_signal.aborted:
|
|
143
|
+
raise
|
|
144
|
+
report(error)
|
|
145
|
+
prompt_input = PromptInput(
|
|
146
|
+
conversation_id=conversation_id,
|
|
147
|
+
agent=agent,
|
|
148
|
+
env=env,
|
|
149
|
+
shown=shown,
|
|
150
|
+
read=runtime,
|
|
151
|
+
)
|
|
152
|
+
desired = await render_sections(agent.sections, prompt_input, shown, report, context)
|
|
153
|
+
entries = plan_system_entries(view, desired, agent.tools, runtime.now())
|
|
154
|
+
threshold = (
|
|
155
|
+
None
|
|
156
|
+
if compacted is not None
|
|
157
|
+
else threshold_compaction(view, entries, model.context_window, settings.compaction)
|
|
158
|
+
)
|
|
159
|
+
if threshold == "blocking":
|
|
160
|
+
# Compact first and prepare again; the transcript is unchanged until the compaction appends.
|
|
161
|
+
|
|
162
|
+
async def compact(tx: Transaction) -> dict[str, Any]:
|
|
163
|
+
child = await create_compaction(tx, conversation_id, {"reason": "threshold"}, runtime.task_id)
|
|
164
|
+
checkpoint_value: dict[str, Any] = {
|
|
165
|
+
"phase": "prepare",
|
|
166
|
+
"attempt": attempt,
|
|
167
|
+
**_compact_fields(compacted),
|
|
168
|
+
"compacted": child,
|
|
169
|
+
"threshold": True,
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
"status": "waiting",
|
|
173
|
+
"checkpoint": checkpoint_value,
|
|
174
|
+
"on": [child],
|
|
175
|
+
"policy": "allSettled",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await runtime.commit(compact, context)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
async def request_commit(tx: Transaction) -> dict[str, Any]:
|
|
182
|
+
page = await tx.scan_entries({"conversationId": conversation_id}, 1)
|
|
183
|
+
cutoff: EntryId | None = page["items"][0]["id"] if page["items"] else None
|
|
184
|
+
for entry in entries:
|
|
185
|
+
cutoff = (
|
|
186
|
+
await tx.append_entry(
|
|
187
|
+
SystemEntry, conversation_id, cast(EntryDraft, entry)
|
|
188
|
+
)
|
|
189
|
+
)["id"]
|
|
190
|
+
if cutoff is None:
|
|
191
|
+
raise RuntimeError(f"Conversation {conversation_id} has no entries to send")
|
|
192
|
+
# Checked in this commit, so a compaction admitted during preparation counts.
|
|
193
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
194
|
+
if threshold == "background" and live.get("compactions") is None:
|
|
195
|
+
await create_compaction(tx, conversation_id, {"reason": "threshold"})
|
|
196
|
+
checkpoint_value: dict[str, Any] = {
|
|
197
|
+
"phase": "request",
|
|
198
|
+
"attempt": attempt,
|
|
199
|
+
**_compact_fields(compacted),
|
|
200
|
+
"model": ref,
|
|
201
|
+
"thinkingLevel": agent.thinking_level,
|
|
202
|
+
"streamOptions": request_stream_options(settings.stream),
|
|
203
|
+
"cutoff": cutoff,
|
|
204
|
+
}
|
|
205
|
+
return {"status": "running", "checkpoint": checkpoint_value}
|
|
206
|
+
|
|
207
|
+
await runtime.commit(request_commit, context)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _request_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
|
|
211
|
+
checkpoint = task_checkpoint(task)
|
|
212
|
+
attempt = int(checkpoint["attempt"])
|
|
213
|
+
compacted = checkpoint.get("compacted")
|
|
214
|
+
ref = cast(ModelRef, checkpoint["model"])
|
|
215
|
+
thinking_level = cast(str, checkpoint["thinkingLevel"])
|
|
216
|
+
stream_options = cast(dict[str, Any], checkpoint["streamOptions"])
|
|
217
|
+
cutoff = cast(EntryId, checkpoint["cutoff"])
|
|
218
|
+
conversation_id = runtime.conversation_id
|
|
219
|
+
|
|
220
|
+
async def start(tx: Transaction) -> None:
|
|
221
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
222
|
+
await convert_partial(tx, live, conversation_id)
|
|
223
|
+
live["generation"] = {"attempt": attempt}
|
|
224
|
+
|
|
225
|
+
await runtime.commit(start, context)
|
|
226
|
+
model = runtime.models.get_model(ref["provider"], ref["modelId"])
|
|
227
|
+
if model is None:
|
|
228
|
+
await fail_no_model(runtime, ref, context)
|
|
229
|
+
return
|
|
230
|
+
view = await runtime.context(conversation_id, context, {"at": cutoff})
|
|
231
|
+
messages = list(view["messages"])
|
|
232
|
+
|
|
233
|
+
async def before_request(handler: Callable[..., Any]) -> None:
|
|
234
|
+
nonlocal messages
|
|
235
|
+
replaced = await call_hook(handler, {"messages": messages}, runtime, context)
|
|
236
|
+
if replaced is not None:
|
|
237
|
+
value = replaced.get("messages") if isinstance(replaced, Mapping) else None
|
|
238
|
+
if value is not None:
|
|
239
|
+
messages = list(value)
|
|
240
|
+
|
|
241
|
+
await runtime.hooks.each("beforeRequest", before_request)
|
|
242
|
+
options: dict[str, Any] = {
|
|
243
|
+
**stream_options,
|
|
244
|
+
"signal": runtime.signal,
|
|
245
|
+
"sessionId": await ensure_provider_session_id(runtime, context),
|
|
246
|
+
}
|
|
247
|
+
if thinking_level != "off":
|
|
248
|
+
options["reasoning"] = thinking_level
|
|
249
|
+
message = await stream_response(runtime, model, messages, options, attempt, context)
|
|
250
|
+
request = {
|
|
251
|
+
"attempt": attempt,
|
|
252
|
+
"compacted": compacted,
|
|
253
|
+
"model": ref,
|
|
254
|
+
"cutoff": cutoff,
|
|
255
|
+
"messages": list(view["messages"]),
|
|
256
|
+
}
|
|
257
|
+
await classify(runtime, request, message, context)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _retry_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
|
|
261
|
+
checkpoint = task_checkpoint(task)
|
|
262
|
+
attempt = int(checkpoint["attempt"])
|
|
263
|
+
compacted = checkpoint.get("compacted")
|
|
264
|
+
await runtime.sleep(int(checkpoint["until"]), context)
|
|
265
|
+
conversation_id = runtime.conversation_id
|
|
266
|
+
|
|
267
|
+
async def retry(tx: Transaction) -> dict[str, Any]:
|
|
268
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
269
|
+
live["generation"] = {"attempt": attempt + 1}
|
|
270
|
+
return {
|
|
271
|
+
"status": "running",
|
|
272
|
+
"checkpoint": _prepare(attempt + 1, **_compact_fields(compacted)),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await runtime.commit(retry, context)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _poll_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
|
|
279
|
+
checkpoint = task_checkpoint(task)
|
|
280
|
+
ref = cast(ModelRef, checkpoint["model"])
|
|
281
|
+
model = runtime.models.get_model(ref["provider"], ref["modelId"])
|
|
282
|
+
if model is None:
|
|
283
|
+
await fail_no_model(runtime, ref, context)
|
|
284
|
+
return
|
|
285
|
+
await runtime.sleep(int(checkpoint["pollAt"]), context)
|
|
286
|
+
message = await runtime.models.fetch_deferred(
|
|
287
|
+
model, cast(JsonObject, checkpoint["handle"]), {"signal": runtime.signal}
|
|
288
|
+
)
|
|
289
|
+
await classify(
|
|
290
|
+
runtime,
|
|
291
|
+
{
|
|
292
|
+
"attempt": int(checkpoint["attempt"]),
|
|
293
|
+
"compacted": checkpoint.get("compacted"),
|
|
294
|
+
"model": ref,
|
|
295
|
+
"cutoff": checkpoint["cutoff"],
|
|
296
|
+
"pollAt": int(checkpoint["pollAt"]),
|
|
297
|
+
},
|
|
298
|
+
message,
|
|
299
|
+
context,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _tools_phase(task: TaskRecord, runtime: Any, context: Context) -> None:
|
|
304
|
+
checkpoint = cast(ToolsCheckpoint, task_checkpoint(task))
|
|
305
|
+
assistant = checkpoint["assistant"]
|
|
306
|
+
started = list(checkpoint["tools"])
|
|
307
|
+
pending = list(checkpoint["pending"])
|
|
308
|
+
if not pending:
|
|
309
|
+
await finish_tool_round(runtime, assistant, started, context)
|
|
310
|
+
return
|
|
311
|
+
# Sequential round: start the next call and wait for it.
|
|
312
|
+
call_id, rest = pending[0], pending[1:]
|
|
313
|
+
|
|
314
|
+
async def start(tx: Transaction) -> dict[str, Any]:
|
|
315
|
+
live = await tx.doc(LiveDoc, runtime.conversation_id)
|
|
316
|
+
task_id = await create_tool_task(tx, runtime, assistant, call_id)
|
|
317
|
+
for slot in live.get("tools") or ():
|
|
318
|
+
if slot.get("callId") == call_id and slot.get("taskId") is None:
|
|
319
|
+
slot["taskId"] = task_id
|
|
320
|
+
break
|
|
321
|
+
checkpoint_value: dict[str, Any] = {
|
|
322
|
+
"phase": "tools",
|
|
323
|
+
"assistant": assistant,
|
|
324
|
+
"tools": [*started, task_id],
|
|
325
|
+
"pending": rest,
|
|
326
|
+
}
|
|
327
|
+
return {"status": "waiting", "checkpoint": checkpoint_value, "on": [task_id], "policy": "allSettled"}
|
|
328
|
+
|
|
329
|
+
await runtime.commit(start, context)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def _abort(task: TaskRecord, runtime: Any, context: Context) -> None:
|
|
333
|
+
checkpoint = task_checkpoint(task)
|
|
334
|
+
if checkpoint["phase"] == "poll":
|
|
335
|
+
ref = cast(ModelRef, checkpoint["model"])
|
|
336
|
+
model = runtime.models.get_model(ref["provider"], ref["modelId"])
|
|
337
|
+
if model is not None:
|
|
338
|
+
try:
|
|
339
|
+
await runtime.models.cancel_deferred(
|
|
340
|
+
model, cast(JsonObject, checkpoint["handle"]), {"signal": runtime.signal}
|
|
341
|
+
)
|
|
342
|
+
except BaseException as error:
|
|
343
|
+
runtime.report(error)
|
|
344
|
+
conversation_id = runtime.conversation_id
|
|
345
|
+
# Runs after the round's tool tasks are terminal; calls never started get ``aborted`` results.
|
|
346
|
+
unstarted = (
|
|
347
|
+
await read_calls(runtime, checkpoint["assistant"], checkpoint["pending"], context)
|
|
348
|
+
if checkpoint["phase"] == "tools"
|
|
349
|
+
else []
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def stop(tx: Transaction) -> dict[str, Any]:
|
|
353
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
354
|
+
await convert_partial(tx, live, conversation_id)
|
|
355
|
+
for call in unstarted:
|
|
356
|
+
message = f"Tool {call['name']} was aborted"
|
|
357
|
+
await append_tool_result(
|
|
358
|
+
tx,
|
|
359
|
+
conversation_id,
|
|
360
|
+
call,
|
|
361
|
+
harness_error("aborted", message),
|
|
362
|
+
{"timestamp": runtime.now()},
|
|
363
|
+
)
|
|
364
|
+
end_run(tx, live, runtime.task_id, {"status": "unanswered", "reason": "aborted"})
|
|
365
|
+
return {"status": "terminal", "outcome": {"status": "aborted"}}
|
|
366
|
+
|
|
367
|
+
await runtime.commit(stop, context)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
GenerationTask = define_task(
|
|
371
|
+
{
|
|
372
|
+
"name": "pi.generation",
|
|
373
|
+
"version": 1,
|
|
374
|
+
"initial": lambda _input: {"phase": "prepare", "attempt": 1},
|
|
375
|
+
"phases": {
|
|
376
|
+
"prepare": _prepare_phase,
|
|
377
|
+
"request": _request_phase,
|
|
378
|
+
"retry": _retry_phase,
|
|
379
|
+
"poll": _poll_phase,
|
|
380
|
+
"tools": _tools_phase,
|
|
381
|
+
},
|
|
382
|
+
"abort": _abort,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _delay(seconds: float) -> Awaitable[None]:
|
|
388
|
+
return asyncio.sleep(seconds)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _Flusher:
|
|
392
|
+
"""One throttled partial publisher: at most one commit in flight and one commit per interval."""
|
|
393
|
+
|
|
394
|
+
def __init__(self, runtime: Any, interval_ms: int, attempt: int, context: Context) -> None:
|
|
395
|
+
self._runtime = runtime
|
|
396
|
+
self._interval = max(0, interval_ms) / 1000.0
|
|
397
|
+
self._attempt = attempt
|
|
398
|
+
self._context = context
|
|
399
|
+
self._pending: dict[str, Any] | None = None
|
|
400
|
+
self._stopped = False
|
|
401
|
+
self._wake = asyncio.Event()
|
|
402
|
+
self._stop = asyncio.Event()
|
|
403
|
+
self._task: asyncio.Task[None] | None = None
|
|
404
|
+
|
|
405
|
+
def start(self) -> None:
|
|
406
|
+
self._task = asyncio.ensure_future(self._run())
|
|
407
|
+
self._task.add_done_callback(
|
|
408
|
+
lambda done: done.exception() if not done.cancelled() else None
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def push(self, partial: dict[str, Any]) -> None:
|
|
412
|
+
self._pending = partial
|
|
413
|
+
self._wake.set()
|
|
414
|
+
|
|
415
|
+
async def stop(self) -> None:
|
|
416
|
+
self._stopped = True
|
|
417
|
+
self._stop.set()
|
|
418
|
+
self._wake.set()
|
|
419
|
+
if self._task is not None:
|
|
420
|
+
await self._task
|
|
421
|
+
|
|
422
|
+
async def _run(self) -> None:
|
|
423
|
+
while True:
|
|
424
|
+
await self._wake.wait()
|
|
425
|
+
self._wake.clear()
|
|
426
|
+
if self._stopped:
|
|
427
|
+
return
|
|
428
|
+
if self._interval > 0:
|
|
429
|
+
# A stop interrupts the throttle instead of waiting it out before classification.
|
|
430
|
+
try:
|
|
431
|
+
await asyncio.wait_for(self._stop.wait(), self._interval)
|
|
432
|
+
except TimeoutError:
|
|
433
|
+
pass
|
|
434
|
+
if self._stopped:
|
|
435
|
+
return
|
|
436
|
+
partial = self._pending
|
|
437
|
+
self._pending = None
|
|
438
|
+
if partial is None:
|
|
439
|
+
continue
|
|
440
|
+
try:
|
|
441
|
+
message = copy_json(partial)
|
|
442
|
+
await self._commit(message)
|
|
443
|
+
except BaseException as error:
|
|
444
|
+
if not self._runtime.signal.aborted:
|
|
445
|
+
self._runtime.report(error)
|
|
446
|
+
|
|
447
|
+
async def _commit(self, message: JsonValue) -> None:
|
|
448
|
+
conversation_id = self._runtime.conversation_id
|
|
449
|
+
attempt = self._attempt
|
|
450
|
+
|
|
451
|
+
async def flush(tx: Transaction) -> None:
|
|
452
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
453
|
+
if "generation" not in live:
|
|
454
|
+
live["generation"] = {"attempt": attempt}
|
|
455
|
+
generation = live["generation"]
|
|
456
|
+
assign_json(generation, "message", message)
|
|
457
|
+
|
|
458
|
+
await self._runtime.commit(flush, self._context)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def stream_response(
|
|
462
|
+
runtime: Any,
|
|
463
|
+
model: Any,
|
|
464
|
+
messages: Sequence[Mapping[str, Any]],
|
|
465
|
+
options: dict[str, Any],
|
|
466
|
+
attempt: int,
|
|
467
|
+
context: Context,
|
|
468
|
+
) -> dict[str, Any]:
|
|
469
|
+
"""Stream one request and return the terminal message, publishing throttled partials."""
|
|
470
|
+
|
|
471
|
+
interval = int(runtime.settings.progress.get("partial_interval_ms", 100))
|
|
472
|
+
flusher = _Flusher(runtime, interval, attempt, context)
|
|
473
|
+
flusher.start()
|
|
474
|
+
try:
|
|
475
|
+
stream = runtime.models.stream_simple(model, {"messages": list(messages)}, options)
|
|
476
|
+
async for event in stream:
|
|
477
|
+
kind = event.get("type")
|
|
478
|
+
if kind in ("done", "error"):
|
|
479
|
+
continue
|
|
480
|
+
partial = event.get("partial")
|
|
481
|
+
if not isinstance(partial, Mapping):
|
|
482
|
+
continue
|
|
483
|
+
content = partial.get("content")
|
|
484
|
+
if not content:
|
|
485
|
+
continue
|
|
486
|
+
flusher.push(cast(dict[str, Any], dict(partial)))
|
|
487
|
+
return await stream.result()
|
|
488
|
+
finally:
|
|
489
|
+
await flusher.stop()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def classify(
|
|
493
|
+
runtime: Any,
|
|
494
|
+
request: Mapping[str, Any],
|
|
495
|
+
message: Mapping[str, Any],
|
|
496
|
+
context: Context,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Classify a terminal provider message in one commit that also clears the partial."""
|
|
499
|
+
|
|
500
|
+
runtime.signal.raise_if_aborted()
|
|
501
|
+
conversation_id = runtime.conversation_id
|
|
502
|
+
attempt = int(request["attempt"])
|
|
503
|
+
compacted = request.get("compacted")
|
|
504
|
+
ref = cast(ModelRef, request["model"])
|
|
505
|
+
cutoff = cast(EntryId, request["cutoff"])
|
|
506
|
+
if message.get("stopReason") == "deferred" and message.get("deferred") is not None:
|
|
507
|
+
handle = cast(dict[str, Any], copy_json(message["deferred"]))
|
|
508
|
+
previous = request.get("pollAt")
|
|
509
|
+
poll_at = runtime.now() + int(handle.get("pollAfterMs", DEFAULT_POLL_AFTER_MS))
|
|
510
|
+
if previous is not None:
|
|
511
|
+
poll_at = max(poll_at, int(previous) + 1)
|
|
512
|
+
|
|
513
|
+
async def poll(tx: Transaction) -> dict[str, Any]:
|
|
514
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
515
|
+
live["generation"] = {"attempt": attempt, "deferred": {"pollAt": poll_at}}
|
|
516
|
+
return {
|
|
517
|
+
"status": "running",
|
|
518
|
+
"checkpoint": {
|
|
519
|
+
"phase": "poll",
|
|
520
|
+
"attempt": attempt,
|
|
521
|
+
**_compact_fields(compacted),
|
|
522
|
+
"model": ref,
|
|
523
|
+
"cutoff": cutoff,
|
|
524
|
+
"handle": handle,
|
|
525
|
+
"pollAt": poll_at,
|
|
526
|
+
},
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await runtime.commit(poll, context)
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
async def after_response(handler: Callable[..., Any]) -> None:
|
|
533
|
+
await call_hook(handler, message, runtime, context)
|
|
534
|
+
|
|
535
|
+
await runtime.hooks.each("afterResponse", after_response)
|
|
536
|
+
calls = [
|
|
537
|
+
content
|
|
538
|
+
for content in cast(Sequence[Mapping[str, Any]], message.get("content") or ())
|
|
539
|
+
if content.get("type") == "toolCall"
|
|
540
|
+
]
|
|
541
|
+
if message.get("stopReason") == "toolUse" and calls:
|
|
542
|
+
await start_tool_round(runtime, request, message, calls, context)
|
|
543
|
+
return
|
|
544
|
+
if message.get("stopReason") in ("stop", "length", "toolUse"):
|
|
545
|
+
await answer(runtime, message, context)
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
settings = runtime.settings
|
|
549
|
+
overflow = message.get("stopReason") == "error" and is_context_overflow(message)
|
|
550
|
+
if overflow and compacted is None and settings.compaction.get("enabled"):
|
|
551
|
+
policy = settings.compaction
|
|
552
|
+
view = await runtime.context(conversation_id, context, {"at": cutoff})
|
|
553
|
+
if select_cut(view, int(policy.get("keep_recent_tokens", 0))) is not None:
|
|
554
|
+
text = str(message.get("errorMessage") or "Context overflow")
|
|
555
|
+
|
|
556
|
+
async def compact(tx: Transaction) -> dict[str, Any]:
|
|
557
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
558
|
+
await append_assistant(tx, conversation_id, message)
|
|
559
|
+
del live["generation"]
|
|
560
|
+
child = await create_compaction(
|
|
561
|
+
tx, conversation_id, {"reason": "overflow"}, runtime.task_id
|
|
562
|
+
)
|
|
563
|
+
checkpoint_value: dict[str, Any] = {
|
|
564
|
+
"phase": "prepare",
|
|
565
|
+
"attempt": attempt,
|
|
566
|
+
**_compact_fields(compacted),
|
|
567
|
+
"compacted": child,
|
|
568
|
+
"overflow": text,
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
"status": "waiting",
|
|
572
|
+
"checkpoint": checkpoint_value,
|
|
573
|
+
"on": [child],
|
|
574
|
+
"policy": "allSettled",
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
await runtime.commit(compact, context)
|
|
578
|
+
return
|
|
579
|
+
policy = settings.retry
|
|
580
|
+
retry = (
|
|
581
|
+
message.get("stopReason") == "error"
|
|
582
|
+
and not overflow
|
|
583
|
+
and is_retryable_assistant_error(message)
|
|
584
|
+
and bool(policy.get("enabled", True))
|
|
585
|
+
and attempt <= int(policy.get("max_retries", 3))
|
|
586
|
+
)
|
|
587
|
+
until = runtime.now() + retry_delay_ms(policy, attempt) if retry else 0
|
|
588
|
+
|
|
589
|
+
async def settle(tx: Transaction) -> dict[str, Any]:
|
|
590
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
591
|
+
await append_assistant(tx, conversation_id, message)
|
|
592
|
+
if retry:
|
|
593
|
+
error_text = str(message.get("errorMessage") or "")
|
|
594
|
+
live["generation"] = {"attempt": attempt, "retry": {"at": until, "error": error_text}}
|
|
595
|
+
return {
|
|
596
|
+
"status": "running",
|
|
597
|
+
"checkpoint": {
|
|
598
|
+
"phase": "retry",
|
|
599
|
+
"attempt": attempt,
|
|
600
|
+
**_compact_fields(compacted),
|
|
601
|
+
"until": until,
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
text = str(
|
|
605
|
+
message.get("errorMessage")
|
|
606
|
+
or f"Model response ended with stop reason {message.get('stopReason')}"
|
|
607
|
+
)
|
|
608
|
+
end_run(
|
|
609
|
+
tx,
|
|
610
|
+
live,
|
|
611
|
+
runtime.task_id,
|
|
612
|
+
{"status": "unanswered", "reason": "model_error", "detail": text},
|
|
613
|
+
)
|
|
614
|
+
return {
|
|
615
|
+
"status": "terminal",
|
|
616
|
+
"outcome": {
|
|
617
|
+
"status": "failed",
|
|
618
|
+
"error": {"message": text, "detail": {"reason": "model_error"}},
|
|
619
|
+
},
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
await runtime.commit(settle, context)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
async def answer(runtime: Any, message: Mapping[str, Any], context: Context) -> None:
|
|
626
|
+
"""Commit a final answer, apply the final boundary, and hand the run to its successor."""
|
|
627
|
+
|
|
628
|
+
continuation: Any = None
|
|
629
|
+
|
|
630
|
+
async def on_yield(handler: Callable[..., Any]) -> None:
|
|
631
|
+
nonlocal continuation
|
|
632
|
+
if continuation is not None:
|
|
633
|
+
return
|
|
634
|
+
result = await call_hook(handler, message, runtime, context)
|
|
635
|
+
if isinstance(result, Mapping):
|
|
636
|
+
continuation = result.get("continue")
|
|
637
|
+
|
|
638
|
+
await runtime.hooks.each("onYield", on_yield)
|
|
639
|
+
conversation_id = runtime.conversation_id
|
|
640
|
+
|
|
641
|
+
async def finish(tx: Transaction) -> dict[str, Any]:
|
|
642
|
+
settings = runtime.settings
|
|
643
|
+
boundary = await prepare_boundary(
|
|
644
|
+
tx,
|
|
645
|
+
conversation_id,
|
|
646
|
+
QueueModes(settings.steering_mode, settings.follow_up_mode),
|
|
647
|
+
)
|
|
648
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
649
|
+
entry = await append_assistant(tx, conversation_id, message)
|
|
650
|
+
result = {
|
|
651
|
+
"status": "terminal",
|
|
652
|
+
"outcome": {"status": "completed", "result": {"entryId": entry["id"]}},
|
|
653
|
+
}
|
|
654
|
+
placed = await apply_boundary(tx, boundary, "final", runtime.now())
|
|
655
|
+
if continuation is not None and not placed.users and not placed.reset:
|
|
656
|
+
user = {"role": "user", "content": continuation, "timestamp": runtime.now()}
|
|
657
|
+
await tx.append_entry(
|
|
658
|
+
UserEntry, conversation_id, cast(EntryDraft, {"model": [user]})
|
|
659
|
+
)
|
|
660
|
+
del live["generation"]
|
|
661
|
+
hand_over(
|
|
662
|
+
live,
|
|
663
|
+
runtime.task_id,
|
|
664
|
+
await create_generation(tx, conversation_id, task_for(runtime)),
|
|
665
|
+
)
|
|
666
|
+
return result
|
|
667
|
+
end_run(
|
|
668
|
+
tx,
|
|
669
|
+
live,
|
|
670
|
+
runtime.task_id,
|
|
671
|
+
{"status": "done", "answer": entry["id"]},
|
|
672
|
+
)
|
|
673
|
+
if placed.users:
|
|
674
|
+
await start_run(tx, conversation_id, live, placed.users, task_for(runtime))
|
|
675
|
+
return result
|
|
676
|
+
|
|
677
|
+
await runtime.commit(finish, context)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
async def read_calls(
|
|
681
|
+
runtime: Any,
|
|
682
|
+
assistant: EntryId,
|
|
683
|
+
call_ids: Sequence[str],
|
|
684
|
+
context: Context,
|
|
685
|
+
) -> list[dict[str, Any]]:
|
|
686
|
+
"""The calls ``call_ids`` of the assistant entry, in the given order."""
|
|
687
|
+
|
|
688
|
+
entry = await runtime.entry(AssistantEntry, assistant, context)
|
|
689
|
+
calls = entry_tool_calls(entry)
|
|
690
|
+
return [
|
|
691
|
+
dict(call)
|
|
692
|
+
for call_id in call_ids
|
|
693
|
+
for call in calls
|
|
694
|
+
if call.get("id") == call_id
|
|
695
|
+
]
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _is_sequential(tools: Sequence[Any], name: str) -> bool:
|
|
699
|
+
tool = next((candidate for candidate in tools if candidate.name == name), None)
|
|
700
|
+
return tool is not None and tool.execution_mode == "sequential"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
async def create_tool_task(
|
|
704
|
+
tx: Transaction, runtime: Any, assistant: EntryId, call_id: str
|
|
705
|
+
) -> TaskId:
|
|
706
|
+
"""A tool task for ``call_id``, owned by the generation."""
|
|
707
|
+
|
|
708
|
+
return await tx.create_task(
|
|
709
|
+
ToolTask,
|
|
710
|
+
{"kind": "model", "assistant": assistant, "callId": call_id},
|
|
711
|
+
{"ownership": {"kind": "task", "taskId": runtime.task_id}},
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
async def start_tool_round(
|
|
716
|
+
runtime: Any,
|
|
717
|
+
request: Mapping[str, Any],
|
|
718
|
+
message: Mapping[str, Any],
|
|
719
|
+
calls: Sequence[Mapping[str, Any]],
|
|
720
|
+
context: Context,
|
|
721
|
+
) -> None:
|
|
722
|
+
"""Append the tool-calling answer and start its tool round in one commit.
|
|
723
|
+
|
|
724
|
+
A call to a tool the request did not offer gets its ``tool_unavailable`` result here; every other
|
|
725
|
+
call gets a tool task owned by the generation, only the first one now when the round is
|
|
726
|
+
sequential. The generation then waits for them in its ``tools`` phase, keeping the run.
|
|
727
|
+
"""
|
|
728
|
+
|
|
729
|
+
conversation_id = runtime.conversation_id
|
|
730
|
+
messages = request.get("messages")
|
|
731
|
+
if messages is None:
|
|
732
|
+
view = await runtime.context(conversation_id, context, {"at": request["cutoff"]})
|
|
733
|
+
messages = view["messages"]
|
|
734
|
+
offered = {tool["name"] for tool in current_tools(cast(Sequence[Mapping[str, Any]], messages))}
|
|
735
|
+
# Read as the round starts; a tool is resolved as its tool task resolves it.
|
|
736
|
+
agent = await runtime.agent(context)
|
|
737
|
+
sequential = runtime.settings.tool_execution == "sequential" or any(
|
|
738
|
+
call["name"] in offered and _is_sequential(agent.tools, call["name"]) for call in calls
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
async def start(tx: Transaction) -> dict[str, Any]:
|
|
742
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
743
|
+
entry = await append_assistant(tx, conversation_id, message)
|
|
744
|
+
slots: list[dict[str, Any]] = []
|
|
745
|
+
tool_tasks: list[TaskId] = []
|
|
746
|
+
pending: list[str] = []
|
|
747
|
+
for call in calls:
|
|
748
|
+
if call["name"] not in offered:
|
|
749
|
+
unavailable = harness_error("tool_unavailable", f"Tool {call['name']} is not available")
|
|
750
|
+
result = await append_tool_result(
|
|
751
|
+
tx,
|
|
752
|
+
conversation_id,
|
|
753
|
+
call,
|
|
754
|
+
unavailable,
|
|
755
|
+
{"timestamp": runtime.now()},
|
|
756
|
+
)
|
|
757
|
+
slots.append(
|
|
758
|
+
{
|
|
759
|
+
"callId": call["id"],
|
|
760
|
+
"name": call["name"],
|
|
761
|
+
"status": "done",
|
|
762
|
+
"entry": result["id"],
|
|
763
|
+
}
|
|
764
|
+
)
|
|
765
|
+
continue
|
|
766
|
+
if sequential and tool_tasks:
|
|
767
|
+
pending.append(call["id"])
|
|
768
|
+
slots.append({"callId": call["id"], "name": call["name"], "status": "pending"})
|
|
769
|
+
continue
|
|
770
|
+
task_id = await create_tool_task(tx, runtime, entry["id"], call["id"])
|
|
771
|
+
tool_tasks.append(task_id)
|
|
772
|
+
slots.append(
|
|
773
|
+
{
|
|
774
|
+
"callId": call["id"],
|
|
775
|
+
"name": call["name"],
|
|
776
|
+
"taskId": task_id,
|
|
777
|
+
"status": "pending",
|
|
778
|
+
}
|
|
779
|
+
)
|
|
780
|
+
if "generation" in live:
|
|
781
|
+
del live["generation"]
|
|
782
|
+
live["tools"] = slots
|
|
783
|
+
checkpoint_value: dict[str, Any] = {
|
|
784
|
+
"phase": "tools",
|
|
785
|
+
"assistant": entry["id"],
|
|
786
|
+
"tools": tool_tasks,
|
|
787
|
+
"pending": pending,
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
"status": "waiting",
|
|
791
|
+
"checkpoint": checkpoint_value,
|
|
792
|
+
"on": tool_tasks,
|
|
793
|
+
"policy": "allSettled",
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
await runtime.commit(start, context)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
async def finish_tool_round(
|
|
800
|
+
runtime: Any,
|
|
801
|
+
assistant: EntryId,
|
|
802
|
+
tools: Sequence[TaskId],
|
|
803
|
+
context: Context,
|
|
804
|
+
) -> None:
|
|
805
|
+
"""The round's tools are terminal: apply their controls, then end the run or hand it on."""
|
|
806
|
+
|
|
807
|
+
conversation_id = runtime.conversation_id
|
|
808
|
+
controls: dict[TaskId, dict[str, Any] | None] = {}
|
|
809
|
+
outcomes = await runtime.outcomes(tools, context)
|
|
810
|
+
for task_id, outcome in zip(tools, outcomes, strict=False):
|
|
811
|
+
control = None
|
|
812
|
+
if outcome.get("status") == "completed":
|
|
813
|
+
result = outcome.get("result")
|
|
814
|
+
if isinstance(result, Mapping) and result.get("kind") != "nested":
|
|
815
|
+
raw = result.get("control")
|
|
816
|
+
control = cast(dict[str, Any], raw) if isinstance(raw, Mapping) else None
|
|
817
|
+
controls[task_id] = control
|
|
818
|
+
state = await runtime.snapshot(LiveDoc, conversation_id, context)
|
|
819
|
+
slots = [dict(slot) for slot in (state or {}).get("tools") or ()]
|
|
820
|
+
results = [slot["entry"] for slot in slots if slot.get("entry") is not None]
|
|
821
|
+
|
|
822
|
+
async def after_tools(handler: Callable[..., Any]) -> None:
|
|
823
|
+
await call_hook(handler, assistant, results, runtime, context)
|
|
824
|
+
|
|
825
|
+
await runtime.hooks.each("afterTools", after_tools)
|
|
826
|
+
# Every call of the round, including those answered without a task, must ask to terminate.
|
|
827
|
+
terminate = bool(slots) and all(
|
|
828
|
+
slot.get("taskId") is not None and (controls.get(slot["taskId"]) or {}).get("terminate") is True
|
|
829
|
+
for slot in slots
|
|
830
|
+
)
|
|
831
|
+
added = [
|
|
832
|
+
name
|
|
833
|
+
for control in controls.values()
|
|
834
|
+
for name in ((control or {}).get("addTools") or ())
|
|
835
|
+
]
|
|
836
|
+
# The last handoff in call order wins.
|
|
837
|
+
handoff: str | None = None
|
|
838
|
+
for control in controls.values():
|
|
839
|
+
if control is not None and control.get("handoff") is not None:
|
|
840
|
+
handoff = str(control["handoff"])
|
|
841
|
+
|
|
842
|
+
async def finish(tx: Transaction) -> dict[str, Any]:
|
|
843
|
+
boundary = await prepare_boundary(
|
|
844
|
+
tx,
|
|
845
|
+
conversation_id,
|
|
846
|
+
QueueModes(runtime.settings.steering_mode, runtime.settings.follow_up_mode),
|
|
847
|
+
)
|
|
848
|
+
if added:
|
|
849
|
+
await add_tools(tx, conversation_id, added)
|
|
850
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
851
|
+
now = runtime.now()
|
|
852
|
+
if terminate or handoff is not None:
|
|
853
|
+
if handoff is not None:
|
|
854
|
+
message = {"role": "user", "content": handoff, "timestamp": now}
|
|
855
|
+
entry = await tx.append_entry(
|
|
856
|
+
ResetEntry,
|
|
857
|
+
conversation_id,
|
|
858
|
+
cast(EntryDraft, {"head": "self", "model": [message]}),
|
|
859
|
+
)
|
|
860
|
+
boundary.head = entry["id"]
|
|
861
|
+
placed = await apply_boundary(tx, boundary, "final", now)
|
|
862
|
+
end_run(tx, live, runtime.task_id, {"status": "done", "answer": assistant})
|
|
863
|
+
if placed.users:
|
|
864
|
+
await start_run(tx, conversation_id, live, placed.users, task_for(runtime))
|
|
865
|
+
else:
|
|
866
|
+
placed = await apply_boundary(tx, boundary, "postTools", now)
|
|
867
|
+
if placed.reset:
|
|
868
|
+
# The queued reset cut the run's context before an answer.
|
|
869
|
+
end_run(tx, live, runtime.task_id, {"status": "unanswered", "reason": "reset"})
|
|
870
|
+
if placed.users:
|
|
871
|
+
await start_run(tx, conversation_id, live, placed.users, task_for(runtime))
|
|
872
|
+
else:
|
|
873
|
+
for key in ("tools", "nestedTools"):
|
|
874
|
+
if key in live:
|
|
875
|
+
del live[key]
|
|
876
|
+
run = live.get("run")
|
|
877
|
+
if run is not None and run["taskId"] == runtime.task_id:
|
|
878
|
+
run["inputs"].extend(placed.users)
|
|
879
|
+
hand_over(
|
|
880
|
+
live,
|
|
881
|
+
runtime.task_id,
|
|
882
|
+
await create_generation(tx, conversation_id, task_for(runtime)),
|
|
883
|
+
)
|
|
884
|
+
return {
|
|
885
|
+
"status": "terminal",
|
|
886
|
+
"outcome": {"status": "completed", "result": {"entryId": assistant}},
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
await runtime.commit(finish, context)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def task_for(runtime: Any) -> Any:
|
|
893
|
+
"""The registry's generation task, the definition a successor run uses."""
|
|
894
|
+
|
|
895
|
+
task = runtime.registry.task("pi.generation")
|
|
896
|
+
if task is None:
|
|
897
|
+
raise RuntimeError("Registry lacks the pi.generation task")
|
|
898
|
+
return task
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
async def fail_model_error(runtime: Any, text: str, context: Context) -> None:
|
|
902
|
+
conversation_id = runtime.conversation_id
|
|
903
|
+
|
|
904
|
+
async def fail(tx: Transaction) -> dict[str, Any]:
|
|
905
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
906
|
+
end_run(
|
|
907
|
+
tx,
|
|
908
|
+
live,
|
|
909
|
+
runtime.task_id,
|
|
910
|
+
{"status": "unanswered", "reason": "model_error", "detail": text},
|
|
911
|
+
)
|
|
912
|
+
return {
|
|
913
|
+
"status": "terminal",
|
|
914
|
+
"outcome": {
|
|
915
|
+
"status": "failed",
|
|
916
|
+
"error": {"message": text, "detail": {"reason": "model_error"}},
|
|
917
|
+
},
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
await runtime.commit(fail, context)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
async def fail_no_model(runtime: Any, ref: ModelRef | None, context: Context) -> None:
|
|
924
|
+
message = (
|
|
925
|
+
"No model is configured"
|
|
926
|
+
if ref is None
|
|
927
|
+
else f"Model {ref['provider']}/{ref['modelId']} is not available"
|
|
928
|
+
)
|
|
929
|
+
conversation_id = runtime.conversation_id
|
|
930
|
+
|
|
931
|
+
async def fail(tx: Transaction) -> dict[str, Any]:
|
|
932
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
933
|
+
end_run(tx, live, runtime.task_id, {"status": "unanswered", "reason": "no_model"})
|
|
934
|
+
return {
|
|
935
|
+
"status": "terminal",
|
|
936
|
+
"outcome": {
|
|
937
|
+
"status": "failed",
|
|
938
|
+
"error": {"message": message, "detail": {"reason": "no_model"}},
|
|
939
|
+
},
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
await runtime.commit(fail, context)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def compaction_failure(outcome: Mapping[str, Any]) -> str:
|
|
946
|
+
status = outcome.get("status")
|
|
947
|
+
if status in ("failed", "faulted"):
|
|
948
|
+
return f"Compaction failed: {cast(Mapping[str, Any], outcome.get('error') or {}).get('message', '')}"
|
|
949
|
+
if status == "orphaned":
|
|
950
|
+
return f"Compaction failed: {outcome.get('reason', '')}"
|
|
951
|
+
return "Compaction was aborted"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def threshold_compaction(
|
|
955
|
+
view: ContextView,
|
|
956
|
+
planned: Sequence[Mapping[str, Any]],
|
|
957
|
+
context_window: int,
|
|
958
|
+
policy: Mapping[str, Any],
|
|
959
|
+
) -> str | None:
|
|
960
|
+
"""Which threshold compaction preparation starts before its request (spec section 8.3).
|
|
961
|
+
|
|
962
|
+
``blocking`` above ``contextWindow - reserveTokens``, ``background`` above the background
|
|
963
|
+
threshold, and only when range selection finds a cut. The caller starts a background one only
|
|
964
|
+
while no compaction is listed.
|
|
965
|
+
"""
|
|
966
|
+
|
|
967
|
+
if not policy.get("enabled") or context_window <= 0:
|
|
968
|
+
return None
|
|
969
|
+
planned_messages = [
|
|
970
|
+
message
|
|
971
|
+
for entry in planned
|
|
972
|
+
for message in cast(Sequence[Mapping[str, Any]], entry.get("model") or ())
|
|
973
|
+
]
|
|
974
|
+
tokens = estimate_context(view, planned_messages)
|
|
975
|
+
blocking = context_window - int(policy.get("reserve_tokens", 0))
|
|
976
|
+
background = blocking - int(policy.get("background_tokens", 0))
|
|
977
|
+
over = (
|
|
978
|
+
"blocking"
|
|
979
|
+
if tokens > blocking
|
|
980
|
+
else "background"
|
|
981
|
+
if int(policy.get("background_tokens", 0)) > 0 and tokens > background
|
|
982
|
+
else None
|
|
983
|
+
)
|
|
984
|
+
if over is None or select_cut(view, int(policy.get("keep_recent_tokens", 0))) is None:
|
|
985
|
+
return None
|
|
986
|
+
return over
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
async def convert_partial(
|
|
990
|
+
tx: Transaction, live: Any, conversation_id: ConversationId
|
|
991
|
+
) -> None:
|
|
992
|
+
"""Append a committed partial as an aborted assistant entry; the caller replaces or removes it."""
|
|
993
|
+
|
|
994
|
+
generation = live.get("generation")
|
|
995
|
+
if generation is None:
|
|
996
|
+
return
|
|
997
|
+
partial = generation.get("message")
|
|
998
|
+
if partial is None:
|
|
999
|
+
return
|
|
1000
|
+
message = cast(dict[str, Any], copy_json(_plain(partial)))
|
|
1001
|
+
message["stopReason"] = "aborted"
|
|
1002
|
+
await append_assistant(tx, conversation_id, message)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
async def append_assistant(
|
|
1006
|
+
tx: Transaction, conversation_id: ConversationId, message: Mapping[str, Any]
|
|
1007
|
+
) -> Any:
|
|
1008
|
+
"""Append a provider result and add its usage to ``pi.usage`` in the same commit."""
|
|
1009
|
+
|
|
1010
|
+
provider = str(message.get("provider") or "")
|
|
1011
|
+
model = str(message.get("model") or "")
|
|
1012
|
+
await record_usage(
|
|
1013
|
+
tx,
|
|
1014
|
+
conversation_id,
|
|
1015
|
+
"models",
|
|
1016
|
+
f"{provider}/{model}",
|
|
1017
|
+
cast(Mapping[str, Any], message.get("usage") or {}),
|
|
1018
|
+
)
|
|
1019
|
+
return await tx.append_entry(
|
|
1020
|
+
AssistantEntry,
|
|
1021
|
+
conversation_id,
|
|
1022
|
+
cast(EntryDraft, {"model": [cast(MessageRecord, copy_json(message))]}),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
async def create_generation(
|
|
1027
|
+
tx: Transaction, conversation_id: ConversationId, task: Any = GenerationTask
|
|
1028
|
+
) -> TaskId:
|
|
1029
|
+
"""A generation owned by its conversation."""
|
|
1030
|
+
|
|
1031
|
+
return await tx.create_task(
|
|
1032
|
+
task, {}, {"ownership": {"kind": "conversation"}, "conversationId": conversation_id}
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def hand_over(live: Any, from_id: TaskId, to_id: TaskId) -> None:
|
|
1037
|
+
"""Hand run control from ``from_id`` to ``to_id``; the run's inputs move with it."""
|
|
1038
|
+
|
|
1039
|
+
run = live.get("run")
|
|
1040
|
+
if run is not None and run["taskId"] == from_id:
|
|
1041
|
+
run["taskId"] = to_id
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
# ── Partial writes ──────────────────────────────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _plain(value: Any) -> Any:
|
|
1048
|
+
"""A detached JSON value for a draft member."""
|
|
1049
|
+
|
|
1050
|
+
if isinstance(value, (DraftObject, DraftList)):
|
|
1051
|
+
return value.snapshot()
|
|
1052
|
+
return value
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _is_record(value: Any) -> bool:
|
|
1056
|
+
return isinstance(value, (dict, DraftObject))
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _is_array(value: Any) -> bool:
|
|
1060
|
+
return isinstance(value, (list, DraftList))
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
__all__ = [
|
|
1064
|
+
"DEFAULT_POLL_AFTER_MS",
|
|
1065
|
+
"GenerationCheckpoint",
|
|
1066
|
+
"GenerationInput",
|
|
1067
|
+
"GenerationResult",
|
|
1068
|
+
"GenerationTask",
|
|
1069
|
+
"append_assistant",
|
|
1070
|
+
"classify",
|
|
1071
|
+
"compaction_failure",
|
|
1072
|
+
"convert_partial",
|
|
1073
|
+
"create_generation",
|
|
1074
|
+
"fail_model_error",
|
|
1075
|
+
"fail_no_model",
|
|
1076
|
+
"hand_over",
|
|
1077
|
+
"stream_response",
|
|
1078
|
+
"threshold_compaction",
|
|
1079
|
+
]
|