fix(durable): type ToolControl.terminate as boolean

The type change and changelog entry landed in 1de1b98e7; this adds the
regression test for terminate: false.

closes #10858
This commit is contained in:
Mario Zechner 2026-10-16 08:19:56 +00:00
parent 4954d76050
commit 6085986e4b
9 changed files with 132 additions and 14 deletions

View file

@ -2,6 +2,18 @@
## [Unreleased]
### Added
- `settings.compaction.modelOverrides` sets compaction thresholds per model, keyed by exact `"provider/modelId"`, so one harness can serve small- and large-window models; the threshold check, overflow recovery, and the compaction task use the entry of the model they act for ([#10942](https://github.com/earendil-works/pi/issues/10942)).
### Changed
- `ToolControl.terminate` is now typed `boolean` instead of `true`, matching `pi-agent-core` and `pi-coding-agent`; `false` does not end the run ([#10858](https://github.com/earendil-works/pi/issues/10858)).
### Fixed
- The README's Errors section now limits the `StorageRequestError` exemption to reads: a `StorageRequestError` thrown by `commit()` or `mintId()` fails the Harness like any other storage error ([#11014](https://github.com/earendil-works/pi/issues/11014)).
## [1.3.0] - 2026-10-15
### Added

View file

@ -475,10 +475,16 @@ settings: {
reserveTokens: 16384, // above contextWindow - reserveTokens, the next request waits for a compaction
keepRecentTokens: 20000, // roughly how much recent context stays verbatim
backgroundTokens: 32768, // this far below that, a compaction starts in the background; 0 disables it
modelOverrides: {
// exact "provider/modelId"; each field set here replaces the one above for that model
"local/small-32k": { reserveTokens: 4096, keepRecentTokens: 8000, backgroundTokens: 4096 },
},
},
}
```
The fixed token counts rarely suit a 32k and a 1M context window alike; `modelOverrides` lets one harness serve both. The conversation's current model picks the entry, at the threshold check, after a context overflow, and when a compaction selects what to summarize.
When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as `stale` when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in `pi.usage`. A `beforeCompact` hook on `CompactionTask` can decline or supply its own summary.
When a compaction that a generation waits for at the blocking threshold fails, the generation fails too, without sending a request: its inputs settle `unanswered` with `model_error` and detail `Compaction failed: <error>`. One that finds nothing to compact or is declined lets the request go ahead.
@ -713,7 +719,7 @@ The package root loads TypeBox, because the tool task validates arguments with p
Three rules decide what an error does:
1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `session_failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted.
2. **An invalid request fails only that call:** a storage throws `StorageRequestError` for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too.
2. **An invalid read fails only that call:** a storage read throws `StorageRequestError` for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too. The exemption covers reads only: any error from `commit()` or `mintId()`, `StorageRequestError` included, ends the Harness as in rule 1.
3. **Your code fails only its own unit:** a throwing task phase faults that task, a throwing tool gets an error result, a throwing hook is reported and skipped (`beforeTool` blocks the call), and a throwing commit callback rolls back that commit. Listeners that throw, or return a rejected promise, are reported and the others still run. The exception is a host `RegistryReader` whose `snapshot()` throws: the Harness cannot schedule without it, so it fails the Harness like a storage error.
`harness.closed` settles once the Harness has closed, by `close()` or after a storage error, so a host restarts on failure like this:

View file

@ -408,6 +408,14 @@ type CompactionPolicy = {
backgroundTokens: number;
};
/**
* `modelOverrides` is keyed by exact `"provider/modelId"`; each field it sets replaces the harness-wide one for that
* model. Readers resolve the policy for the model they act for (see the reader table below).
*/
type CompactionSettings = Partial<CompactionPolicy> & {
readonly modelOverrides?: Readonly<Record<string, Partial<CompactionPolicy>>>;
};
type ToolExecutionMode = "parallel" | "sequential";
/** How many queued items of one mode a boundary selects (section 6). */
@ -419,7 +427,7 @@ type HarnessSettings = {
readonly extensions?: readonly Extension[];
readonly stream?: ConversationStreamOptions;
readonly retry?: Partial<ConversationRetryPolicy>;
readonly compaction?: Partial<CompactionPolicy>;
readonly compaction?: CompactionSettings;
readonly toolExecution?: ToolExecutionMode;
readonly steeringMode?: QueueMode;
readonly followUpMode?: QueueMode;
@ -434,7 +442,7 @@ type Settings = {
readonly extensions?: readonly Extension[]; // default: every installed extension, in install order
readonly stream: ConversationStreamOptions; // default {}
readonly retry: ConversationRetryPolicy; // { enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }
readonly compaction: CompactionPolicy; // { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, backgroundTokens: 32768 }
readonly compaction: CompactionPolicy & Pick<CompactionSettings, "modelOverrides">; // { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, backgroundTokens: 32768 }
readonly toolExecution: ToolExecutionMode; // "parallel"
readonly steeringMode: QueueMode; // "one-at-a-time"
readonly followUpMode: QueueMode; // "one-at-a-time"
@ -2663,12 +2671,13 @@ await conversation.configure({ extensions: { add: [Venv] } }, context);
| reader | resolves | when |
|---|---|---|
| generation `prepare` | agent (tools, sections, model, thinking level), stream options, compaction thresholds | 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 |
| 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) |
| generation, when a tool round starts | tool execution mode | once per round (section 8.3) |
| generation, compaction | retry policy | at each attempt's classification |
| compaction `select` | model, thinking level, stream options, compaction thresholds | once; the summary request stays pinned across its retries (section 8.7) |
| generation, on a context overflow | compaction policy for the request's model | at classification |
| compaction `select` | model, thinking level, stream options, compaction thresholds for that model | once; the summary request stays pinned across its retries (section 8.7) |
| boundaries and idle admission, on the Session line | queue modes | per boundary (section 6) |
| tool task, `prepare`, `runtime.env()` | environment | at each call (section 2.2) |

View file

@ -27,11 +27,13 @@ import { ensureProviderSessionId } from "./provider.ts";
import { admitSubmission } from "./submissions.ts";
import type {
CompactionHooks,
CompactionPolicy,
CompactionReason,
CompactionResult,
ContextView,
ConversationStreamOptions,
ModelRef,
Settings,
} from "./types.ts";
import { recordUsage } from "./usage.ts";
@ -119,7 +121,7 @@ export const CompactionTask = defineTask<CompactionInput, CompactionCheckpoint,
const ref = agent.model;
const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId);
if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context);
const policy = settings.compaction;
const policy = compactionPolicy(settings, ref);
const view = await runtime.context(conversationId, context);
const cut = selectCut(view, policy.keepRecentTokens);
if (cut === undefined) return complete(runtime, context);
@ -253,6 +255,18 @@ export async function createCompaction(
return taskId;
}
/** The compaction policy for `model`: its `modelOverrides` entry over the harness-wide policy, field by field. */
export function compactionPolicy(settings: Settings, model: ModelRef): CompactionPolicy {
const { modelOverrides, ...policy } = settings.compaction;
const override = modelOverrides?.[`${model.provider}/${model.modelId}`];
return {
enabled: override?.enabled ?? policy.enabled,
reserveTokens: override?.reserveTokens ?? policy.reserveTokens,
keepRecentTokens: override?.keepRecentTokens ?? policy.keepRecentTokens,
backgroundTokens: override?.backgroundTokens ?? policy.backgroundTokens,
};
}
/**
* Index in `view.entries` of the first entry a summary keeps, or `undefined` when there is nothing to compact
* (spec §8.7). Walks back from the tail until `keepRecentTokens` are kept, then cuts at the first candidate at or after

View file

@ -27,7 +27,7 @@ import type {
TypedEntry,
} from "../types.ts";
import { addTools } from "./agent.ts";
import { createCompaction, estimateContext, selectCut } from "./compaction.ts";
import { compactionPolicy, createCompaction, estimateContext, selectCut } from "./compaction.ts";
import { applyBoundary, prepareBoundary } from "./inbox.ts";
import { assignJson } from "./json.ts";
import { endRun, LiveDoc, type LiveState, type ToolSlot } from "./live.ts";
@ -163,7 +163,7 @@ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint,
const entries = planSystemEntries(view, desired, agent.tools, runtime.now());
const threshold =
compacted === undefined
? thresholdCompaction(view, entries, resolved.contextWindow, settings.compaction)
? thresholdCompaction(view, entries, resolved.contextWindow, compactionPolicy(settings, model))
: undefined;
if (threshold === "blocking") {
// Compact first and prepare again; the transcript is unchanged until the compaction appends.
@ -487,10 +487,10 @@ async function classify(
// The retry and compaction policies govern the next attempt, so they are read now rather than pinned at preparation.
const settings = runtime.settings;
const overflow = message.stopReason === "error" && isContextOverflow(message);
if (overflow && compacted === undefined && settings.compaction.enabled) {
const policy = settings.compaction;
const compaction = compactionPolicy(settings, ref);
if (overflow && compacted === undefined && compaction.enabled) {
const view = await runtime.context(conversationId, context, { at: cutoff });
if (selectCut(view, policy.keepRecentTokens) !== undefined) {
if (selectCut(view, compaction.keepRecentTokens) !== undefined) {
const text = message.errorMessage ?? "Context overflow";
await runtime.commit(async (tx): Promise<Next> => {
const live = await tx.doc(LiveDoc, conversationId);

View file

@ -136,7 +136,8 @@ export interface ConversationHandle {
/** Post-tools controls requested by a tool result. */
export type ToolControl = {
readonly addTools?: readonly string[];
readonly terminate?: true;
/** End the run without another model request when every result of the round sets it to `true`; `false` is a no-op. */
readonly terminate?: boolean;
readonly handoff?: string;
};
@ -506,6 +507,14 @@ export type CompactionPolicy = {
backgroundTokens: number;
};
/**
* Harness-wide compaction settings. `modelOverrides` is keyed by exact `"provider/modelId"`; each field it sets
* replaces the harness-wide one for that model, so small- and large-window models can share a harness.
*/
export type CompactionSettings = Partial<CompactionPolicy> & {
readonly modelOverrides?: Readonly<Record<string, Partial<CompactionPolicy>>>;
};
/**
* How often running progress is committed. Each progress commit is a storage write; a host whose storage is remote can
* commit less often, so live answers and tool output appear in larger steps.
@ -532,7 +541,7 @@ export type HarnessSettings = {
readonly extensions?: readonly Extension[];
readonly stream?: ConversationStreamOptions;
readonly retry?: Partial<ConversationRetryPolicy>;
readonly compaction?: Partial<CompactionPolicy>;
readonly compaction?: CompactionSettings;
readonly progress?: Partial<ProgressPolicy>;
readonly toolExecution?: ToolExecutionMode;
readonly steeringMode?: QueueMode;
@ -556,7 +565,8 @@ export type Settings = {
readonly extensions?: readonly Extension[];
readonly stream: ConversationStreamOptions;
readonly retry: ConversationRetryPolicy;
readonly compaction: CompactionPolicy;
/** Harness-wide policy over the defaults; per-model overrides apply where the model is known. */
readonly compaction: CompactionPolicy & Pick<CompactionSettings, "modelOverrides">;
readonly progress: ProgressPolicy;
readonly toolExecution: ToolExecutionMode;
readonly steeringMode: QueueMode;

View file

@ -68,6 +68,7 @@ export type {
CompactionPolicy,
CompactionReason,
CompactionResult,
CompactionSettings,
ContextView,
Conversation,
ConversationAbortOptions,

View file

@ -2390,3 +2390,52 @@ describe("context contributions", () => {
await chat.harness.close(context);
});
});
// #10942: per-model compaction settings.
describe("per-model compaction overrides", () => {
it("applies the current model's override to the blocking threshold", async () => {
for (const key of ["faux/faux-1", "faux/other"]) {
const chat = await open({ contextWindow: 1000 });
await history(chat);
// Harness-wide: blocking only above the full window; the override for faux-1 blocks above 700.
chat.setup.settings.compaction = {
...BLOCKING,
reserveTokens: 0,
modelOverrides: { [key]: { reserveTokens: 300 } },
};
chat.faux.summaries.push(summary());
await turn(chat, text("u4", 200), "a4");
expect(chat.faux.summaryRequests).toHaveLength(key === "faux/faux-1" ? 1 : 0);
await chat.harness.close(context);
}
});
it("applies the request model's override on a context overflow", async () => {
const chat = await open();
await history(chat);
chat.setup.settings.compaction = { ...MANUAL, modelOverrides: { "faux/faux-1": { enabled: true } } };
chat.faux.summaries.push(summary());
chat.faux.agent.push(failure(OVERFLOW), answer("fits"));
const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context);
expect((await input.wait(context)).status).toBe("done");
expect(chat.faux.summaryRequests).toHaveLength(1);
await chat.harness.close(context);
});
it("applies the model's override when a compaction selects and sizes its summary", async () => {
const chat = await open();
await history(chat);
chat.setup.settings.compaction = {
...MANUAL,
keepRecentTokens: 100_000,
modelOverrides: { "faux/faux-1": { keepRecentTokens: 150, reserveTokens: 500 } },
};
chat.faux.summaries.push(summary());
expect((await result(chat, await chat.root.compact(undefined, context))).status).toBe("completed");
// 0.8 * 500 from the override, below the model's 900.
expect(chat.faux.summaryRequests[0]!.options?.maxTokens).toBe(400);
const messages = (await chat.root.context(context)).messages;
expect(messages.slice(1).map(userText)).toEqual([text("u3", 100), text("a3", 100)]);
await chat.harness.close(context);
});
});

View file

@ -673,6 +673,23 @@ describe("tool results", () => {
]);
await first.harness.close(context);
});
// #10858
it("does not terminate on terminate: false", async () => {
const setup = chatSetup();
const keep = tool("keep", async () => ({ output: [], control: { terminate: false } }));
addTool(setup.registry, keep);
const { harness, entries, status } = await run(
setup,
[calls(["keep", {}, "c1"]), DONE],
async (_harness, root) => {
await root.configure({ tools: [keep] }, context);
},
);
expect(status).toBe("done");
expect(entries.at(-1)!.kind).toBe("pi.assistant");
await harness.close(context);
});
});
describe("generation hooks", () => {