feat(durable): calls to tools not offered run as tool tasks

This commit is contained in:
Mario Zechner 2026-10-28 11:45:19 +00:00
parent 87eff524f9
commit 1c6928b309
11 changed files with 370 additions and 82 deletions

View file

@ -93,6 +93,7 @@
- `SessionEnd` (`Session.closed`, `Harness.closed`) is tagged by `kind`: `{ kind: "closed" }` or `{ kind: "failed", error }` (was `reason`). Before: `harness.closed.then((end) => { if (end.reason === "failed") reopen(end.error); })`. After: `harness.closed.then((end) => { if (end.kind === "failed") reopen(end.error); })`. - `SessionEnd` (`Session.closed`, `Harness.closed`) is tagged by `kind`: `{ kind: "closed" }` or `{ kind: "failed", error }` (was `reason`). Before: `harness.closed.then((end) => { if (end.reason === "failed") reopen(end.error); })`. After: `harness.closed.then((end) => { if (end.kind === "failed") reopen(end.error); })`.
- `ReportFrom`, the second argument of a task's `runtime.report(error, from)`, is tagged by `kind`: `{ kind: "extension", extension? }` or `{ kind: "host", callback }` (was `type`). Before: `runtime.report(error, { type: "host", callback: "env" })`. After: `runtime.report(error, { kind: "host", callback: "env" })`. - `ReportFrom`, the second argument of a task's `runtime.report(error, from)`, is tagged by `kind`: `{ kind: "extension", extension? }` or `{ kind: "host", callback }` (was `type`). Before: `runtime.report(error, { type: "host", callback: "env" })`. After: `runtime.report(error, { kind: "host", callback: "env" })`.
- `afterTool` runs on every result a tool task writes, not only on an executed call's: also on the error results the Harness writes itself, for a tool that does not resolve (`tool_unavailable`), invalid arguments (`invalid_arguments`, with `call.arguments` as the model sent them), a block (`blocked`), an interruption (`interrupted`), the restart limit (`restart_limit`), and an abort (`aborted`, `abandoned`); the diagnostic code tells them apart. Such a result is bounded with the tool's output limits after the hooks, never keeps a `structuredOutput`, and applies `control` only when the call completes; a nested call a hook turns into a success gets its bounded output as value unless the tool declares a schema. Once a tool task was cut off more than `settings.restartLimit` times its hooks no longer run, nor in the last attempt the scheduler gives its abort handler, and an abort's hooks delay `Conversation.abort()`. A non-replay-safe tool interrupted that often now ends with `restart_limit` too. Hooks that assumed the tool ran (cast `call.arguments`, read `structuredOutput`) check `result.diagnostics` first ([#11043](https://github.com/earendil-works/pi/issues/11043)) - `afterTool` runs on every result a tool task writes, not only on an executed call's: also on the error results the Harness writes itself, for a tool that does not resolve (`tool_unavailable`), invalid arguments (`invalid_arguments`, with `call.arguments` as the model sent them), a block (`blocked`), an interruption (`interrupted`), the restart limit (`restart_limit`), and an abort (`aborted`, `abandoned`); the diagnostic code tells them apart. Such a result is bounded with the tool's output limits after the hooks, never keeps a `structuredOutput`, and applies `control` only when the call completes; a nested call a hook turns into a success gets its bounded output as value unless the tool declares a schema. Once a tool task was cut off more than `settings.restartLimit` times its hooks no longer run, nor in the last attempt the scheduler gives its abort handler, and an abort's hooks delay `Conversation.abort()`. A non-replay-safe tool interrupted that often now ends with `restart_limit` too. Hooks that assumed the tool ran (cast `call.arguments`, read `structuredOutput`) check `result.diagnostics` first ([#11043](https://github.com/earendil-works/pi/issues/11043))
- A call to a tool its request did not offer gets a `pi.tool` task like any call, flagged `unoffered: true` in its input and its `pi.live.tools` slot, instead of a result the generation wrote without one: the task resolves nothing (a deferred tool the agent has still never runs), settles `tool_unavailable`, and runs `afterTool`, whose `control` then counts toward the round (one such call no longer prevents `terminate`). Its slot starts `pending` with a `taskId` instead of `done`, and its result entry lands in a later commit than the tool-calling answer. Controls are applied in call order. `pi.tool` is version 3 and `pi.generation` version 2 (a sequential round keeps such calls pending until their turn): an older Harness leaves both blocked (`task_too_old`) instead of resolving the flagged name ([#11043](https://github.com/earendil-works/pi/issues/11043))
### Added ### Added

View file

@ -3140,7 +3140,7 @@ await conversation.configure({ extensions: { add: [Venv] } }, context);
|---|---|---| |---|---|---|
| generation `prepare` | agent (tools, sections, model, thinking level), stream options, compaction thresholds for that model | once per request; model, stream options, and offered tools are fixed in the request checkpoint (section 8.3) | | generation `prepare` | agent (tools, sections, model, thinking level), stream options, compaction thresholds for that model | once per request; model, stream options, and offered tools are fixed in the request checkpoint (section 8.3) |
| every task phase | extension selection for hooks; `runtime.agent()` | at most once per phase handler, at its first hook dispatch or `agent()` call, from that phase's snapshot and the committed `pi.agent`, off the Session line, with the invocation's context; fixed for the rest of the phase | | every task phase | extension selection for hooks; `runtime.agent()` | at most once per phase handler, at its first hook dispatch or `agent()` call, from that phase's snapshot and the committed `pi.agent`, off the Session line, with the invocation's context; fixed for the rest of the phase |
| tool task | the implementation of an accepted call | at `call` and `execute`: the name among the phase agent's `tools`, the current selection after the tools filter; no such tool, for example one removed after preparation: `tool_unavailable`. Replay policy on reopen uses the same lookup (section 7.3) | | tool task | the implementation of an accepted call | at `call` and `execute`: the name among the phase agent's `tools`, the current selection after the tools filter; no such tool, for example one removed after preparation, or a call flagged `unoffered` (section 8.3): `tool_unavailable`. Replay policy on reopen uses the same lookup (section 7.3) |
| generation, when a tool round starts | tool execution mode | once per round (section 8.3) | | generation, when a tool round starts | tool execution mode | once per round (section 8.3) |
| generation, compaction | retry policy | at each attempt's classification | | generation, compaction | retry policy | at each attempt's classification |
| generation, on a context overflow | compaction policy for the request's model | at classification | | generation, on a context overflow | compaction policy for the request's model | at classification |
@ -3703,7 +3703,10 @@ A tool call is accepted only if its request offered the tool. Generation checks
this against the tool set replayed from the committed model context through the this against the tool set replayed from the committed model context through the
request's `cutoff`; `beforeRequest` replacements do not change it. It answers a request's `cutoff`; `beforeRequest` replacements do not change it. It answers a
call to a tool it did not offer with the `tool_unavailable` result described in call to a tool it did not offer with the `tool_unavailable` result described in
section 2.2, without a tool task. An agent change after preparation does not section 2.2, through a tool task whose input is flagged `unoffered`: the task
resolves nothing, so even a tool the agent has (a deferred one not loaded yet)
never runs, and `afterTool` and `control` apply to the result as to any call's.
An agent change after preparation does not
affect this check (section 2.2). The tool task resolves the called name among affect this check (section 2.2). The tool task resolves the called name among
the `tools` of the phase's agent resolution, the tools filter applied. It uses the `tools` of the phase's agent resolution, the tools filter applied. It uses
that implementation that implementation
@ -4256,7 +4259,7 @@ is exported as an `Entry` token.
| `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions, `onYield` continuations | | `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions, `onYield` continuations |
| `pi.assistant` | `[AssistantMessage]` with any stop reason | generation | | `pi.assistant` | `[AssistantMessage]` with any stop reason | generation |
| `pi.system` | `[SystemMessage]` with `content: ""` (section 7.4) | generation preparation | | `pi.system` | `[SystemMessage]` with `content: ""` (section 7.4) | generation preparation |
| `pi.tool-result` | `[ToolResultMessage]` | tool tasks; generation for calls to tools its request did not offer | | `pi.tool-result` | `[ToolResultMessage]` | tool tasks; generation for calls an aborted round never started |
| `pi.reset` | absent, or `[UserMessage]` with the handoff text; always `head: "self"` | `reset()`, generation `tools` phase for `handoff` | | `pi.reset` | absent, or `[UserMessage]` with the handoff text; always `head: "self"` | `reset()`, generation `tools` phase for `handoff` |
| `pi.compaction` | `[UserMessage]` with the wrapped summary; `head` is the first kept entry | compaction tasks (section 8.7) | | `pi.compaction` | `[UserMessage]` with the wrapped summary; `head` is the first kept entry | compaction tasks (section 8.7) |
@ -4308,11 +4311,10 @@ type LiveState = {
tools?: { tools?: {
callId: string; callId: string;
name: string; name: string;
/** /** Absent for a call not started yet (sequential round, `pending`). */
* Absent for a call not started yet (sequential round, `pending`) and for a call its request did not offer,
* which starts `done` with the `entry` generation wrote.
*/
taskId?: TaskId; taskId?: TaskId;
/** A call to a tool its request did not offer: its task settles `tool_unavailable` without running anything. */
unoffered?: true;
status: "pending" | "running" | "done"; status: "pending" | "running" | "done";
/** Retained running output and what the bounds dropped. */ /** Retained running output and what the bounds dropped. */
output?: string; output?: string;
@ -4396,8 +4398,8 @@ its 64 KiB scan, the commit writes the retained window as one set. Either way
each commit writes at most one window, and the throttle bounds the rate. each commit writes at most one window, and the throttle bounds the rate.
Generation creates `tools` with one `pending` slot per call in the commit that Generation creates `tools` with one `pending` slot per call in the commit that
appends the tool-calling answer; a call its request did not offer starts `done` appends the tool-calling answer; a call its request did not offer is flagged
with its result entry. A tool task sets its slot `running` in its intent commit, `unoffered`. A tool task sets its slot `running` in its intent commit,
publishes throttled output and details into it with deferred commits (section publishes throttled output and details into it with deferred commits (section
4.1), and in its terminal commit sets 4.1), and in its terminal commit sets
`done` and `entry` and removes `output`, `droppedBytes`, `droppedLines`, `done` and `entry` and removes `output`, `droppedBytes`, `droppedLines`,
@ -4506,7 +4508,11 @@ type GenerationCheckpoint =
type GenerationResult = { entryId: EntryId }; type GenerationResult = { entryId: EntryId };
``` ```
`pi.generation` is version 1 and starts at `{ phase: "prepare", attempt: 1 }`. `pi.generation` is version 2 and starts at `{ phase: "prepare", attempt: 1 }`;
`migrate` keeps a version 1 record. Version 2 keeps calls to tools not offered
in a sequential round's `pending`, flagged `unoffered` in their slots (section
8.5), which a version 1 Harness would start as tool tasks without the flag, so
it leaves version 2 blocked (`task_too_old`).
The run's inputs live in `pi.live.run`, not in the task input. The run's inputs live in `pi.live.run`, not in the task input.
- `prepare` runs section 7.4 with the phase's agent resolution and resolves - `prepare` runs section 7.4 with the phase's agent resolution and resolves
@ -4630,11 +4636,12 @@ A tool round starts in the commit that appends the tool-calling answer:
1. The offered tools are replayed with pi-ai `getCurrentTools()` from the 1. The offered tools are replayed with pi-ai `getCurrentTools()` from the
committed model context through `cutoff`: `request` already holds it, and committed model context through `cutoff`: `request` already holds it, and
`poll` derives it again. A call to a tool not offered gets its `poll` derives it again.
`tool_unavailable` result entry here, without a task. 2. Every call gets a `pi.tool` task owned by the generation, in call order, with
2. Every other call gets a `pi.tool` task owned by the generation, with input input `{ assistant, callId }`, plus `unoffered: true` (also on its slot) for a
`{ assistant, callId }`. The round is sequential when the settings' call to a tool not offered, which settles `tool_unavailable` without resolving
`toolExecution`, read as the round starts, is `sequential` or any called the name (section 8.4). The round is sequential when the settings'
`toolExecution`, read as the round starts, is `sequential` or any offered called
tool, resolved from the phase's agent as the tool task resolves it (section tool, resolved from the phase's agent as the tool task resolves it (section
7.3), has `executionMode: "sequential"`: then only the first call gets its 7.3), has `executionMode: "sequential"`: then only the first call gets its
task now and the rest wait in `pending`. Otherwise every call gets its task at task now and the rest wait in `pending`. Otherwise every call gets its task at
@ -4664,9 +4671,11 @@ type ToolTaskCheckpoint =
type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" }; type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" };
``` ```
`pi.tool` is version 2 and starts at `{ phase: "call" }`; `migrate` gives a `pi.tool` is version 3 and starts at `{ phase: "call" }`; `migrate` gives a
version 1 input `kind: "model"`, and readers treat a terminal result without version 1 input `kind: "model"` and keeps a version 2 input (version 3 added
`kind` as a model-issued call's. A model-issued call's input stays small `unoffered`), and readers treat a terminal result without `kind` as a
model-issued call's. A call flagged `unoffered` resolves no tool: `call` settles
it `tool_unavailable` and the abort handler does too. A model-issued call's input stays small
because the terminal record keeps it; the call is read from the assistant because the terminal record keeps it; the call is read from the assistant
entry. A nested call (section 7.3) carries its call, since no entry holds it, entry. A nested call (section 7.3) carries its call, since no entry holds it,
and settles differently: its result commit records the result's `usage` and settles differently: its result commit records the result's `usage`
@ -4767,7 +4776,8 @@ and then commits once:
It completes with `{ entryId }` of the tool-calling answer. Its abort handler It completes with `{ entryId }` of the tool-calling answer. Its abort handler
runs only after its tool tasks are terminal (section 5.5). For every call still runs only after its tool tasks are terminal (section 5.5). For every call still
in `pending`, which never started, it appends an `aborted` error result; a in `pending`, which never started, it appends an `aborted` error result
(`tool_unavailable` for an `unoffered` one), without `afterTool`; a
started call whose task faulted or was orphaned keeps started call whose task faulted or was orphaned keeps
its `done` slot without an entry (section 8.2). It then settles the inputs its `done` slot without an entry (section 8.2). It then settles the inputs
`unanswered` with `aborted`, removes `run`, `generation`, and `tools`, and ends `unanswered` with `aborted`, removes `run`, `generation`, and `tools`, and ends

View file

@ -143,8 +143,15 @@ const DEFAULT_POLL_AFTER_MS = 5000;
*/ */
export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint, GenerationResult, GenerationHooks>({ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint, GenerationResult, GenerationHooks>({
name: "pi.generation", name: "pi.generation",
version: 1, version: 2,
initial: () => ({ phase: "prepare", attempt: 1 }), initial: () => ({ phase: "prepare", attempt: 1 }),
// Version 2 leaves calls to tools not offered in a sequential round's `pending`, flagged `unoffered` in their slots.
// A version 1 Harness would start their tool tasks unflagged, resolving the name, so it leaves version 2 blocked
// instead. A version 1 checkpoint has no such calls and is kept as it is.
migrate: (input, checkpoint) => ({
input: input as GenerationInput,
checkpoint: checkpoint as GenerationCheckpoint,
}),
phases: { phases: {
/** /**
* Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to * Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to
@ -307,8 +314,8 @@ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint,
// Sequential round: start the next call and wait for it. // Sequential round: start the next call and wait for it.
await runtime.commit(async (tx): Promise<Next> => { await runtime.commit(async (tx): Promise<Next> => {
const live = await tx.doc(LiveDoc, runtime.conversationId); const live = await tx.doc(LiveDoc, runtime.conversationId);
const taskId = await createToolTask(tx, runtime, assistant, next);
const slot = live.tools?.find((slot) => slot.callId === next && slot.taskId === undefined); const slot = live.tools?.find((slot) => slot.callId === next && slot.taskId === undefined);
const taskId = await createToolTask(tx, runtime, assistant, next, slot?.unoffered);
if (slot !== undefined) slot.taskId = taskId; if (slot !== undefined) slot.taskId = taskId;
const checkpoint: GenerationCheckpoint = { const checkpoint: GenerationCheckpoint = {
phase: "tools", phase: "tools",
@ -325,7 +332,8 @@ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint,
const polled = deferredOf(checkpoint); const polled = deferredOf(checkpoint);
if (polled !== undefined) await cancelDeferred(runtime, polled, context); if (polled !== undefined) await cancelDeferred(runtime, polled, context);
const conversationId = runtime.conversationId; const conversationId = runtime.conversationId;
// Runs after the round's tool tasks are terminal; calls never started get `aborted` results (spec §8.5). // Runs after the round's tool tasks are terminal; calls never started get `aborted` results (spec §8.5), unoffered
// ones `tool_unavailable`.
const unstarted = const unstarted =
checkpoint.phase === "tools" checkpoint.phase === "tools"
? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context) ? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context)
@ -334,7 +342,11 @@ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint,
const live = await tx.doc(LiveDoc, conversationId); const live = await tx.doc(LiveDoc, conversationId);
await convertPartial(tx, live, conversationId); await convertPartial(tx, live, conversationId);
for (const call of unstarted) { for (const call of unstarted) {
const result = harnessError("aborted", `Tool ${call.name} was aborted`); // A call its request did not offer could not have run either way.
const unoffered = live.tools?.find((slot) => slot.callId === call.id)?.unoffered === true;
const result = unoffered
? harnessError("tool_unavailable", `Tool ${call.name} is not available`)
: harnessError("aborted", `Tool ${call.name} was aborted`);
await appendToolResult(tx, conversationId, call, result, { timestamp: runtime.now() }); await appendToolResult(tx, conversationId, call, result, { timestamp: runtime.now() });
} }
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" }); endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" });
@ -355,11 +367,17 @@ async function readCalls(
return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1)); return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1));
} }
/** A tool task for call `callId`, owned by the generation. */ /** A tool task for call `callId`, owned by the generation; `unoffered` for a call to a tool its request did not offer. */
function createToolTask(tx: Tx, runtime: Runtime, assistant: EntryId, callId: string): Promise<TaskId<ToolTaskResult>> { function createToolTask(
tx: Tx,
runtime: Runtime,
assistant: EntryId,
callId: string,
unoffered: true | undefined,
): Promise<TaskId<ToolTaskResult>> {
return tx.createTask( return tx.createTask(
ToolTask, ToolTask,
{ kind: "model", assistant, callId }, { kind: "model", assistant, callId, ...(unoffered === undefined ? {} : { unoffered }) },
{ ownership: { kind: "task", taskId: runtime.taskId } }, { ownership: { kind: "task", taskId: runtime.taskId } },
); );
} }
@ -776,9 +794,10 @@ async function answer(runtime: Runtime, message: AssistantMessage, context: Cont
} }
/** /**
* Append the tool-calling answer and start its tool round in one commit (spec §8.3). A call to a tool the request did * Append the tool-calling answer and start its tool round in one commit (spec §8.3). Every call gets a tool task owned by
* not offer gets its `tool_unavailable` result here; every other call gets a tool task owned by the generation, only the * the generation, in call order, only the first one now when the round is sequential; a call to a tool the request did
* first one now when the round is sequential. The generation then waits for them in its `tools` phase, keeping the run. * not offer is flagged, and its task settles `tool_unavailable`. The generation then waits for them in its `tools`
* phase, keeping the run.
*/ */
async function startToolRound( async function startToolRound(
runtime: Runtime, runtime: Runtime,
@ -806,20 +825,15 @@ async function startToolRound(
const tools: TaskId<ToolTaskResult>[] = []; const tools: TaskId<ToolTaskResult>[] = [];
const pending: string[] = []; const pending: string[] = [];
for (const call of calls) { for (const call of calls) {
if (!offered.has(call.name)) { const unoffered = offered.has(call.name) ? {} : ({ unoffered: true } as const);
const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });
slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });
continue;
}
if (sequential && tools.length > 0) { if (sequential && tools.length > 0) {
pending.push(call.id); pending.push(call.id);
slots.push({ callId: call.id, name: call.name, status: "pending" }); slots.push({ callId: call.id, name: call.name, status: "pending", ...unoffered });
continue; continue;
} }
const taskId = await createToolTask(tx, runtime, entry.id, call.id); const taskId = await createToolTask(tx, runtime, entry.id, call.id, unoffered.unoffered);
tools.push(taskId); tools.push(taskId);
slots.push({ callId: call.id, name: call.name, taskId, status: "pending" }); slots.push({ callId: call.id, name: call.name, taskId, status: "pending", ...unoffered });
} }
delete live.generation; delete live.generation;
live.tools = slots; live.tools = slots;
@ -853,13 +867,13 @@ async function finishToolRound(
const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? []; const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
const results = slots.flatMap((slot) => (slot.entry === undefined ? [] : [slot.entry])); const results = slots.flatMap((slot) => (slot.entry === undefined ? [] : [slot.entry]));
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context)); await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
// Every call of the round, including those answered without a task, must ask to terminate. // In call order, the slots' order; a slot without a task (faulted or orphaned before it got one) has no control.
const terminate = const ordered = slots.map((slot) => (slot.taskId === undefined ? undefined : controls.get(slot.taskId)));
slots.length > 0 && // Every call of the round must ask to terminate.
slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true); const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true);
const added = [...controls.values()].flatMap((control) => control?.addTools ?? []); const added = ordered.flatMap((control) => control?.addTools ?? []);
// The last handoff in call order wins. // The last handoff in call order wins.
const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff; const handoff = ordered.findLast((control) => control?.handoff !== undefined)?.handoff;
await runtime.commit(async (tx): Promise<Next> => { await runtime.commit(async (tx): Promise<Next> => {
const boundary = await prepareBoundary(tx, conversationId, runtime.settings); const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
if (added.length > 0) await addTools(tx, conversationId, added); if (added.length > 0) await addTools(tx, conversationId, added);

View file

@ -33,11 +33,10 @@ type SlotProgress = {
/** Presentation of one tool call of the current round. */ /** Presentation of one tool call of the current round. */
export type ToolSlot = SlotProgress & { export type ToolSlot = SlotProgress & {
/** /** Absent for a call not started yet (sequential round). */
* Absent for a call not started yet (sequential round) and for a call its request did not offer, which starts `done`
* with the `entry` generation wrote.
*/
taskId?: TaskId; taskId?: TaskId;
/** A call to a tool its request did not offer: its task settles `tool_unavailable` without running anything. */
unoffered?: true;
/** Result entry once done; absent when the tool task faulted or was orphaned. */ /** Result entry once done; absent when the tool task faulted or was orphaned. */
entry?: EntryId; entry?: EntryId;
}; };

View file

@ -64,7 +64,13 @@ import { optionalExtension } from "./util.ts";
* whose call the input carries because no entry holds it. * whose call the input carries because no entry holds it.
*/ */
export type ToolTaskInput = export type ToolTaskInput =
| { kind: "model"; assistant: EntryId; callId: string } | {
kind: "model";
assistant: EntryId;
callId: string;
/** A call to a tool its request did not offer: settles `tool_unavailable` without resolving the name. */
unoffered?: true;
}
| { | {
kind: "nested"; kind: "nested";
parent: TaskId; parent: TaskId;
@ -128,18 +134,21 @@ type Content = (TextContent | ImageContent)[];
*/ */
export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>({ export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>({
name: "pi.tool", name: "pi.tool",
version: 2, version: 3,
initial: () => ({ phase: "call" }), initial: () => ({ phase: "call" }),
// Version 1 had only model-issued calls. // Version 1 had only model-issued calls, without `kind`; version 3 added `unoffered`, absent in version 2 inputs.
migrate: (input, checkpoint) => ({ migrate: (input, checkpoint, fromVersion) => ({
input: { kind: "model", ...(input as { assistant: EntryId; callId: string }) }, input:
fromVersion === 1
? { kind: "model", ...(input as { assistant: EntryId; callId: string }) }
: (input as ToolTaskInput),
checkpoint: checkpoint as ToolTaskCheckpoint, checkpoint: checkpoint as ToolTaskCheckpoint,
}), }),
phases: { phases: {
call: async (task, runtime, context) => { call: async (task, runtime, context) => {
const call = await readCall(runtime, task.input, context); const call = await readCall(runtime, task.input, context);
if (cutOffTooOften(task, runtime)) return endCutOff(runtime, task, call, context); if (cutOffTooOften(task, runtime)) return endCutOff(runtime, task, call, context);
const tool = await resolveTool(runtime, task.input, call.name, context); const tool = isUnoffered(task.input) ? undefined : await resolveTool(runtime, task.input, call.name, context);
if (tool === undefined) { if (tool === undefined) {
const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`); const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`);
return reject(runtime, task.input, call, undefined, error, context); return reject(runtime, task.input, call, undefined, error, context);
@ -205,9 +214,11 @@ export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskRe
}, },
abort: async (task, runtime, context) => { abort: async (task, runtime, context) => {
const call = await readCall(runtime, task.input, context); const call = await readCall(runtime, task.input, context);
// Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run. // Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run. An unoffered call
const [code, message] = // could not run in any case, and its result says so.
task.abortReason !== "restart" const [code, message] = isUnoffered(task.input)
? ["tool_unavailable", `Tool ${call.name} is not available`]
: task.abortReason !== "restart"
? ["aborted", `Tool ${call.name} was aborted`] ? ["aborted", `Tool ${call.name} was aborted`]
: task.state.checkpoint.phase === "execute" : task.state.checkpoint.phase === "execute"
? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`] ? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]
@ -288,16 +299,25 @@ async function settleFromSlot(
await settle(runtime, input, call, ending, settled, context); await settle(runtime, input, call, ending, settled, context);
} }
/** The tool whose output limits bound a hooks' result: the called tool, or none when it, or the agent, fails to resolve. */ /**
* The tool whose output limits bound a hooks' result: the called tool, or none when it, or the agent, fails to resolve,
* and for an unoffered call, which never resolves its name.
*/
async function toolForLimits( async function toolForLimits(
runtime: Runtime, runtime: Runtime,
input: ToolTaskInput, input: ToolTaskInput,
name: string, name: string,
context: Context, context: Context,
): Promise<ToolRegistration | undefined> { ): Promise<ToolRegistration | undefined> {
if (isUnoffered(input)) return undefined;
return resolveTool(runtime, input, name, context).catch(() => undefined); return resolveTool(runtime, input, name, context).catch(() => undefined);
} }
/** A model-issued call to a tool its request did not offer (spec §8.3). */
function isUnoffered(input: ToolTaskInput): boolean {
return input.kind === "model" && input.unoffered === true;
}
/** The called tool: among the tools the model is offered for a model-issued call, else among those tools may call. */ /** The called tool: among the tools the model is offered for a model-issued call, else among those tools may call. */
async function resolveTool( async function resolveTool(
runtime: Runtime, runtime: Runtime,

View file

@ -713,7 +713,7 @@ describe("generation", () => {
entry: entry.id, entry: entry.id,
}); });
const taskId = await tx.createTask( const taskId = await tx.createTask(
{ definition: { ...GenerationTask.definition, version: 2 } }, { definition: { ...GenerationTask.definition, version: GenerationTask.definition.version + 1 } },
{}, {},
{ ownership: { kind: "conversation" }, conversationId: root.id }, { ownership: { kind: "conversation" }, conversationId: root.id },
); );

View file

@ -347,7 +347,7 @@ describe("pi.live deltas", () => {
await harness.close(context); await harness.close(context);
}); });
it("starts calls the request did not offer as done and marks a faulted tool's slot done without an entry", async () => { it("starts calls the request did not offer as flagged tasks and marks a faulted tool's slot done without an entry", async () => {
const setup = chatSetup(); const setup = chatSetup();
addTool( addTool(
setup.registry, setup.registry,
@ -376,14 +376,13 @@ describe("pi.live deltas", () => {
end: () => {}, end: () => {},
}); });
await (await root.submit({ type: "input", content: "go" }, context)).wait(context); await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
const entries = await root.entries({}, 10, undefined, context);
const ghostResult = entries.items.find((entry) => entry.kind === "pi.tool-result")!.id;
const handover = commits.find((ops) => ops.some((op) => op[0] === "s" && op[1][0] === "tools"))!; const handover = commits.find((ops) => ops.some((op) => op[0] === "s" && op[1][0] === "tools"))!;
// #11043: an unoffered call gets a task too, flagged on its slot.
expect(handover).toContainEqual([ expect(handover).toContainEqual([
"s", "s",
["tools"], ["tools"],
[ [
{ callId: "g", name: "ghost", status: "done", entry: ghostResult }, { callId: "g", name: "ghost", taskId: expect.any(Number), status: "pending", unoffered: true },
{ callId: "b", name: "bad", taskId: expect.any(Number), status: "pending" }, { callId: "b", name: "bad", taskId: expect.any(Number), status: "pending" },
], ],
]); ]);

View file

@ -1233,14 +1233,34 @@ describe("nested tool calls", () => {
checkpoint: { phase: "call" }, checkpoint: { phase: "call" },
}); });
}); });
// #11043
it("keeps version 2 tool task input as it is", () => {
const migrate = ToolTask.definition.migrate!;
const model = { kind: "model", assistant: 3, callId: "c1" };
const nested = {
kind: "nested",
parent: 7,
parentCallId: "c1",
key: "k",
call: { type: "toolCall", id: "c1/k", name: "echo", arguments: {} },
};
expect(migrate(model, { phase: "call" }, 2).input).toEqual(model);
expect(migrate(nested, { phase: "call" }, 2).input).toEqual(nested);
});
}); });
describe("tool task version 1 records", () => { describe("tool task version 1 and 2 records", () => {
/** /**
* Store a live tool task for call `c1` of an assistant entry, close, and rewrite its record as version 1 stored it: * Store a live tool task for call `c1` of an assistant entry, close, and rewrite its record as `version` stored it,
* `{ assistant, callId }` input at version 1, with `checkpoint`. Returns the storage path and the task ID. * with `checkpoint`: version 1 had `{ assistant, callId }` input, version 2 added `kind`. Returns the storage path
* and the task ID.
*/ */
async function storeVersion1(setup: ChatSetup, checkpoint: JsonValue): Promise<{ path: string; id: TaskId }> { async function storeVersion(
setup: ChatSetup,
checkpoint: JsonValue,
version: 1 | 2 = 1,
): Promise<{ path: string; id: TaskId }> {
const directory = await mkdtemp(join(tmpdir(), "pi-durable-tool-v1-")); const directory = await mkdtemp(join(tmpdir(), "pi-durable-tool-v1-"));
directories.add(directory); directories.add(directory);
const path = join(directory, "session.sqlite"); const path = join(directory, "session.sqlite");
@ -1257,10 +1277,11 @@ describe("tool task version 1 records", () => {
await harness.close(context); await harness.close(context);
const storage = await openNodeSqliteStorage(path); const storage = await openNodeSqliteStorage(path);
const record = (await storage.task(id, context))!; const record = (await storage.task(id, context))!;
const { kind: _kind, ...input } = record.input as Extract<ToolTaskInput, { kind: "model" }>; const { kind: _kind, ...version1 } = record.input as Extract<ToolTaskInput, { kind: "model" }>;
const input = version === 1 ? version1 : record.input;
const state = { ...record.state, checkpoint } as typeof record.state; const state = { ...record.state, checkpoint } as typeof record.state;
await storage.commit( await storage.commit(
[{ type: "task", value: { ...record, version: 1, input, state } as typeof record }], [{ type: "task", value: { ...record, version, input, state } as typeof record }],
() => {}, () => {},
context, context,
); );
@ -1272,12 +1293,12 @@ describe("tool task version 1 records", () => {
const setup = chatSetup(); const setup = chatSetup();
const echo = echoTool(); const echo = echoTool();
addTool(setup.registry, echo.registration); addTool(setup.registry, echo.registration);
const { path, id } = await storeVersion1(setup, { phase: "call" }); const { path, id } = await storeVersion(setup, { phase: "call" });
const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup);
expect((await harness.getTask(id, context))?.version).toBe(1); expect((await harness.getTask(id, context))?.version).toBe(1);
harness.resume(); harness.resume();
const settled = await harness.waitForTask(id, context); const settled = await harness.waitForTask(id, context);
expect(settled.version).toBe(2); expect(settled.version).toBe(3);
expect(settled.input).toMatchObject({ kind: "model", callId: "c1" }); expect(settled.input).toMatchObject({ kind: "model", callId: "c1" });
expect(settled.state.outcome).toMatchObject({ status: "completed", result: { kind: "model" } }); expect(settled.state.outcome).toMatchObject({ status: "completed", result: { kind: "model" } });
expect(echo.state.runs).toBe(1); expect(echo.state.runs).toBe(1);
@ -1291,11 +1312,11 @@ describe("tool task version 1 records", () => {
const echo = echoTool(); const echo = echoTool();
addTool(setup.registry, echo.registration); addTool(setup.registry, echo.registration);
const intent = { phase: "execute", arguments: { text: "old" }, replay: "unsafe" }; const intent = { phase: "execute", arguments: { text: "old" }, replay: "unsafe" };
const { path, id } = await storeVersion1(setup, intent); const { path, id } = await storeVersion(setup, intent);
const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup);
harness.resume(); harness.resume();
const settled = await harness.waitForTask(id, context); const settled = await harness.waitForTask(id, context);
expect(settled.version).toBe(2); expect(settled.version).toBe(3);
expect(settled.state.outcome).toMatchObject({ status: "failed", result: { kind: "model" } }); expect(settled.state.outcome).toMatchObject({ status: "failed", result: { kind: "model" } });
expect(echo.state.runs).toBe(0); expect(echo.state.runs).toBe(0);
const [result] = results(await allEntries(root)); const [result] = results(await allEntries(root));
@ -1303,4 +1324,23 @@ describe("tool task version 1 records", () => {
expect(text(result)).toContain("Tool echo was interrupted"); expect(text(result)).toContain("Tool echo was interrupted");
await harness.close(context); await harness.close(context);
}); });
// #11043
it("reserves a stored version 2 task under version 3 and runs it as a model-issued call", async () => {
const setup = chatSetup();
const echo = echoTool();
addTool(setup.registry, echo.registration);
const { path, id } = await storeVersion(setup, { phase: "call" }, 2);
const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup);
expect((await harness.getTask(id, context))?.version).toBe(2);
harness.resume();
const settled = await harness.waitForTask(id, context);
expect(settled.version).toBe(3);
expect(settled.input).toEqual({ kind: "model", assistant: expect.any(Number), callId: "c1" });
expect(settled.state.outcome).toMatchObject({ status: "completed", result: { kind: "model" } });
expect(echo.state.runs).toBe(1);
const [result] = results(await allEntries(root));
expect([result!.toolCallId, text(result)]).toEqual(["c1", "echo old"]);
await harness.close(context);
});
}); });

View file

@ -19,6 +19,7 @@ import {
LiveDoc, LiveDoc,
MemoryStorage, MemoryStorage,
type NestedToolExecutionResult, type NestedToolExecutionResult,
ResetEntry,
ROOT_CONVERSATION_ID, ROOT_CONVERSATION_ID,
type Storage, type Storage,
type TaskId, type TaskId,
@ -624,3 +625,180 @@ describe("afterTool on results rebuilt from the slot", () => {
await opened.harness.close(context); await opened.harness.close(context);
}); });
}); });
describe("calls to tools the request did not offer", () => {
// #11043
it("run afterTool inside their own tool task", async () => {
const setup = chatSetup();
const seen: { readonly id: string; readonly taskId: TaskId }[] = [];
addHooks(setup.registry, ToolTask, {
afterTool: (call, result, api) => {
seen.push({ id: call.id, taskId: api.taskId });
return { ...result, output: [{ type: "text", text: "try another tool" }] };
},
});
setup.faux.setResponses([calls(["ghost", {}, "c1"]), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
expect(seen.map((each) => each.id)).toEqual(["c1"]);
expect(await harness.getTask(seen[0]!.taskId, context)).toMatchObject({
kind: "pi.tool",
input: { kind: "model", callId: "c1", unoffered: true },
});
const entries = await allEntries(root);
expect(text(results(entries).get("c1")).startsWith("try another tool|")).toBe(true);
expect(await diagnosticsOf(entries, "c1")).toEqual(["tool_unavailable"]);
await harness.close(context);
});
// #11043
it("count toward a round's terminate", async () => {
const setup = chatSetup();
addTool(
setup.registry,
tool("echo", async () => ({ output: [] })),
);
addHooks(setup.registry, ToolTask, {
afterTool: (_call, result) => ({ ...result, control: { terminate: true } }),
});
setup.faux.setResponses([calls(["echo", {}, "c1"], ["ghost", {}, "c2"]), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const settled = await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
expect(settled.status).toBe("done");
expect(setup.faux.state.callCount).toBe(1);
await harness.close(context);
});
// #11043
it("take their turn in a sequential round, and the last handoff in call order wins", async () => {
const setup = chatSetup();
setup.settings.toolExecution = "sequential";
const order: string[] = [];
for (const name of ["a", "b"]) {
addTool(
setup.registry,
tool(name, async () => {
order.push(name);
return { output: [] };
}),
);
}
addHooks(setup.registry, ToolTask, {
afterTool: (call, result) => {
order.push(`hook ${call.id}`);
return call.id === "c1" ? undefined : { ...result, control: { handoff: `from ${call.id}` } };
},
});
setup.faux.setResponses([calls(["a", {}, "c1"], ["ghost", {}, "c2"], ["b", {}, "c3"]), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
expect(order).toEqual(["a", "hook c1", "hook c2", "b", "hook c3"]);
const resets = (await allEntries(root)).filter((entry) => ResetEntry.is(entry));
expect(resets.map((entry) => (entry.model?.[0] as { content: string }).content)).toEqual(["from c3"]);
await harness.close(context);
});
// #11043
it("each get a tool task in a parallel round of only such calls", async () => {
const setup = chatSetup();
const seen: string[] = [];
addHooks(setup.registry, ToolTask, {
afterTool: (call) => {
seen.push(call.id);
return undefined;
},
});
setup.faux.setResponses([calls(["ghost", {}, "c1"], ["spirit", {}, "c2"], ["ghost", {}, "c3"]), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const settled = await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
expect(settled.status).toBe("done");
expect(seen.sort()).toEqual(["c1", "c2", "c3"]);
const entries = await allEntries(root);
for (const id of ["c1", "c2", "c3"]) expect(await diagnosticsOf(entries, id)).toEqual(["tool_unavailable"]);
// The round's results go to the next request.
expect(setup.faux.state.callCount).toBe(2);
await harness.close(context);
});
// #11043
it("keep their flag in a version 2 generation's sequential round, also one migrated from version 1", async () => {
for (const version of [2, 1]) {
const path = await sqlitePath();
const setup = chatSetup();
setup.settings.toolExecution = "sequential";
const started = deferred();
let runs = 0;
const a = tool(
"a",
async (_args, _api, callContext) => {
runs++;
if (runs > 1) return { output: [] };
started.resolve();
return aborted(callContext.abortSignal!);
},
{ replay: "safe" },
);
addTool(setup.registry, a);
addTool(
setup.registry,
tool("ghost", async () => ({ output: [{ type: "text", text: "ran" }] })),
);
setup.faux.setResponses([calls(["a", {}, "c1"], ["ghost", {}, "c2"]), DONE]);
let opened = await open(path, setup);
let root = opened.root;
// `ghost` is registered, but not offered to this request.
await root.configure({ tools: [a] }, context);
const id = (await root.submit({ type: "input", content: "go" }, context)).id;
await started.promise;
const live = (await opened.harness.snapshot(LiveDoc, ROOT_CONVERSATION_ID, context))!;
const generation = live.run!.taskId;
expect(live.tools?.map((slot) => [slot.callId, slot.unoffered])).toEqual([
["c1", undefined],
["c2", true],
]);
expect((await opened.harness.getTask(generation, context))?.version).toBe(2);
// The tools are offered when the round goes on: only the flag keeps `ghost` from running.
await root.configure({ tools: null }, context);
await opened.harness.close(context);
if (version === 1) {
const storage = await openNodeSqliteStorage(path);
const record = (await storage.task(generation, context))!;
await storage.commit([{ type: "task", value: { ...record, version: 1 } }], () => {}, context);
await storage.close(context);
}
opened = await open(path, setup);
root = opened.root;
expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");
expect((await opened.harness.getTask(generation, context))?.version).toBe(2);
const entries = await allEntries(root);
expect(await diagnosticsOf(entries, "c2")).toEqual(["tool_unavailable"]);
expect(text(results(entries).get("c2"))).not.toContain("ran");
await opened.harness.close(context);
}
});
// #11043
it("settle unavailable, not aborted, when their sequential round is aborted before they start", async () => {
const setup = chatSetup();
setup.settings.toolExecution = "sequential";
const started = deferred();
addTool(
setup.registry,
tool("wait", async (_args, _api, callContext) => {
started.resolve();
return aborted(callContext.abortSignal!);
}),
);
setup.faux.setResponses([calls(["wait", {}, "c1"], ["ghost", {}, "c2"]), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const submission = await root.submit({ type: "input", content: "go" }, context);
await started.promise;
await root.abort(context);
expect((await submission.wait(context)).status).toBe("unanswered");
const entries = await allEntries(root);
expect(await diagnosticsOf(entries, "c1")).toEqual(["aborted"]);
expect(await diagnosticsOf(entries, "c2")).toEqual(["tool_unavailable"]);
await harness.close(context);
});
});

View file

@ -138,7 +138,7 @@ describe("tool round", () => {
await harness.close(context); await harness.close(context);
}); });
it("answers calls to tools the request did not offer without a task", async () => { it("answers calls to tools the request did not offer through a flagged tool task", async () => {
const setup = chatSetup(); const setup = chatSetup();
addTool( addTool(
setup.registry, setup.registry,
@ -157,8 +157,34 @@ describe("tool round", () => {
expect(ghostEntry?.data).toEqual({ expect(ghostEntry?.data).toEqual({
diagnostics: [{ severity: "error", code: "tool_unavailable", message: "Tool ghost is not available" }], diagnostics: [{ severity: "error", code: "tool_unavailable", message: "Tool ghost is not available" }],
}); });
// #11043: the unoffered call has its own task, so `afterTool` and `control` apply to it as to any call.
const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 20), context); const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 20), context);
expect(tasks.items.filter((task) => task.kind === "pi.tool")).toHaveLength(1); const inputs = tasks.items.filter((task) => task.kind === "pi.tool").map((task) => task.input);
expect(inputs).toHaveLength(2);
expect(inputs).toContainEqual(expect.objectContaining({ callId: "c1", unoffered: true }));
expect(inputs).toContainEqual(expect.not.objectContaining({ unoffered: true }));
await harness.close(context);
});
// #11043
it("never runs a tool the request did not offer, even when the agent has it", async () => {
const setup = chatSetup();
let ran = 0;
addTool(
setup.registry,
tool(
"hidden",
async () => {
ran++;
return { output: [] };
},
{ deferred: true },
),
);
const { harness, entries, status } = await run(setup, [calls(["hidden", {}, "c1"]), DONE]);
expect(status).toBe("done");
expect(ran).toBe(0);
expect(resultText(results(entries)[0]!)).toBe("<harness>\n[error] Tool hidden is not available\n</harness>");
await harness.close(context); await harness.close(context);
}); });

View file

@ -327,7 +327,7 @@ describe("a run as the conversation view shows it", () => {
await harness.close(context); await harness.close(context);
}); });
it("ends a call that never runs with its result at once, and a tool aborted with its generation", async () => { it("ends a call that never runs through its own task, and a tool aborted with its generation", async () => {
const setup = chatSetup(); const setup = chatSetup();
addTool( addTool(
setup.registry, setup.registry,
@ -352,13 +352,14 @@ describe("a run as the conversation view shows it", () => {
await submission.wait(context); await submission.wait(context);
history.detach(); history.detach();
const states = history.live(); const states = history.live();
// The call not offered is listed done, with its result entry, in the commit of its calling answer. // #11043: the call not offered is listed pending with its task in the commit of its calling answer, and its task
// ends it done with its result entry.
const listed = slotsOf(states, "c1").findIndex((slot) => slot !== undefined); const listed = slotsOf(states, "c1").findIndex((slot) => slot !== undefined);
const ghost = slotsOf(states, "c1")[listed]!; expect(slotsOf(states, "c1")[listed]).toMatchObject({ status: "pending", unoffered: true });
expect(ghost.status).toBe("done"); expect(slotsOf(states, "c1")[listed]!.taskId).toBeDefined();
const listing = history.views[listed]!.entries; expect(history.views[listed]!.entries.at(-1)!.kind).toBe("pi.assistant");
expect(listing.map((entry) => entry.kind).slice(-2)).toEqual(["pi.assistant", "pi.tool-result"]); const ended = slotsOf(states, "c1").findIndex((slot) => slot?.status === "done");
expect(ghost.entry).toBe(listing.at(-1)!.id); expect(slotsOf(states, "c1")[ended]!.entry).toBe(history.views[ended]!.entries.at(-1)!.id);
// The aborted tool ends with its result entry, then the run ends without an answer. // The aborted tool ends with its result entry, then the run ends without an answer.
const done = slotsOf(states, "c2").findIndex((slot) => slot?.status === "done"); const done = slotsOf(states, "c2").findIndex((slot) => slot?.status === "done");
expect(slotsOf(states, "c2")[done]!.entry).toBe(history.views[done]!.entries.at(-1)!.id); expect(slotsOf(states, "c2")[done]!.entry).toBe(history.views[done]!.entries.at(-1)!.id);