feat(durable): expose models on ToolExecutionApi and HookApi

closes #10395
This commit is contained in:
Mario Zechner 2026-10-06 17:14:43 +02:00
parent 68ccef1760
commit b0114ef5fa
5 changed files with 37 additions and 0 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `ToolExecutionApi.models` and `HookApi.models`: the Harness's `models`, so tools and hooks can resolve models and make requests with the same catalog, credentials, and request transforms as generation ([#10395](https://github.com/earendil-works/pi/issues/10395)).
### Changed
- Within one task invocation, `runtime.context()` reuses the range its previous read scanned and reads only entries committed since then, unless the head marker changed. A generation's request no longer rereads the whole transcript after its prepare, which roughly halves context reads per turn. The range is dropped when the invocation ends or sleeps.

View file

@ -2561,6 +2561,8 @@ type HookResult<T> = T | undefined | Promise<T | undefined>;
interface HookApi extends DocumentReader {
readonly taskId: TaskId;
readonly conversationId: ConversationId;
/** `HarnessOptions.models`. */
readonly models: Models;
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
}
@ -2693,6 +2695,8 @@ interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extends Docum
readonly registry: RegistrySnapshot;
/** The calling conversation's agent, as the tool task's phase resolved it. */
agent(context: Context): Promise<Agent>;
/** `HarnessOptions.models`: the catalog, credentials, and request transforms generation uses. */
readonly models: Models;
/** Built by `HarnessOptions.env` for this call. */
readonly env: ExecutionEnv | undefined;
output(chunk: string | Uint8Array): void;

View file

@ -190,6 +190,7 @@ async function run(
callId: call.id,
registry: runtime.registry,
agent: runtime.agent,
models: runtime.models,
output: (chunk, skipped) => {
assertLive();
if (reported.output.push(chunk, skipped)) progress.mark();

View file

@ -171,6 +171,8 @@ export interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extend
readonly registry: RegistrySnapshot;
/** The calling conversation's agent, as the tool task's phase resolved it. */
agent(context: Context): Promise<Agent>;
/** `HarnessOptions.models`: the catalog, credentials, and request transforms generation uses. */
readonly models: Models;
/** Built by `HarnessOptions.env` for this call; `undefined` without an environment. */
readonly env: ExecutionEnv | undefined;
/**
@ -605,6 +607,8 @@ export interface Harness extends Session {
export interface HookApi extends DocumentReader {
readonly taskId: TaskId;
readonly conversationId: ConversationId;
/** `HarnessOptions.models`. */
readonly models: Models;
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
}

View file

@ -111,6 +111,30 @@ describe("tool round", () => {
await harness.close(context);
});
// #10395
it("gives tools and hooks the Harness's models", async () => {
const setup = chatSetup();
const seen: unknown[] = [];
addTool(
setup.registry,
tool("echo", async (_args, api) => {
seen.push(api.models);
return { content: [] };
}),
);
addHooks(setup.registry, ToolTask, {
beforeTool: (_call, api) => {
seen.push(api.models);
return undefined;
},
});
const { harness, status } = await run(setup, [calls(["echo", {}, "c1"]), DONE]);
expect(status).toBe("done");
expect(seen).toEqual([setup.models, setup.models]);
expect(seen.every((models) => models === setup.models)).toBe(true);
await harness.close(context);
});
it("answers calls to tools the request did not offer without a task", async () => {
const setup = chatSetup();
addTool(