Pin local hook signatures to pi-agent-core compile-time contracts (C5)

Adds src/main/worker/pi-hook-contract.ts, a compile-only contract that re-states every pi-agent-core hook callback shape (beforeToolCall / afterToolCall / transformContext) as a named alias and pins the local worker implementations against them via type-level ExpectHookType assertions. pi-agent-factory.ts imports the contract for side effects so tsc drags it into the build graph. A future pi upgrade that renames a field or changes an argument now fails typecheck here instead of at first destructive tool invocation.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 11:35:04 -04:00
1 parent f6a58f6a37
commit 5a41489bc4
2 files changed
+122

No files matched your search

+3
View File
@@ -30,6 +30,9 @@ import type {
TransformContextRpcResult,
} from "@main/agent-runtime/hook-rpc";
import type { WorkerRpcClient } from "./worker-rpc";
// Side-effect import: drags the hook signature contract into the typecheck
// graph so any drift in pi-agent-core hook shapes breaks the build here.
import "./pi-hook-contract";
export interface PiAgentFactoryInput {
runId: string;
+119
View File
@@ -0,0 +1,119 @@
/**
* Compile-time contract between local hook implementations and
* `pi-agent-core` callback signatures.
*
* B1 pinned `@mariozechner/pi-agent-core` to 0.67.68 (in-tree reference)
* while our worker wires local hooks — `beforeToolCall`, `afterToolCall`,
* `transformContext` — into the pi Agent constructor. Those hooks carry
* deep structural coupling: pi decides the context shape passed in and
* the result shape we return. A silent signature drift on upgrade would
* slip past runtime tests until a real tool call fires in production.
*
* This module re-states every hook callback type as a named alias sourced
* from pi, then pins the concrete inline callbacks from `pi-agent-factory`
* to those aliases via the `ExpectHookType` helper. If pi ever renames a
* type, drops a field, or changes an argument, `tsc` fails here — not at
* the first destructive-tool invocation on a user machine.
*
* The contract is compile-only: no runtime validator, no fallback path.
* Importing this file for side effects (see `pi-agent-factory`) is enough
* to drag it into the typecheck graph.
*/
import type {
AfterToolCallContext,
AfterToolCallResult,
AgentMessage,
AgentOptions,
BeforeToolCallContext,
BeforeToolCallResult,
} from "@mariozechner/pi-agent-core";
/**
* Canonical hook callback types sourced directly from `AgentOptions`.
*
* Using `NonNullable<AgentOptions[...]>` keeps a single upstream source
* of truth — if pi changes the option signature the alias fails, which
* cascades into every `satisfies` check below.
*/
export type PiBeforeToolCall = NonNullable<AgentOptions["beforeToolCall"]>;
export type PiAfterToolCall = NonNullable<AgentOptions["afterToolCall"]>;
export type PiTransformContext = NonNullable<AgentOptions["transformContext"]>;
/**
* Context/result types carried across the hook boundary. Pinned by name
* here so `hook-rpc.ts` payloads stay anchored to the pi-provided shapes
* even when the local RPC wrapper strips or re-packs fields.
*/
export type PiBeforeToolCallContext = BeforeToolCallContext;
export type PiBeforeToolCallResult = BeforeToolCallResult;
export type PiAfterToolCallContext = AfterToolCallContext;
export type PiAfterToolCallResult = AfterToolCallResult;
export type PiAgentMessage = AgentMessage;
/**
* Type-level assertion: `Fn` must be assignable to the pi hook slot `Slot`.
*
* The helper produces `never` when `Fn` drifts, so any `satisfies` use
* resolves to `never` and fails to satisfy the target. That failure is
* the compile-time breakage we want — a single red squiggle pointing at
* the offending hook.
*/
type ExpectHookType<Slot, Fn extends Slot> = Fn;
/**
* Callable shapes actually installed by `pi-agent-factory.createPiAgent`.
*
* These mirror the inline hook functions passed into `new Agent({...})`.
* Keeping them as standalone named types means a future refactor that
* pulls them out of the factory closure into named exports only has to
* swap the `typeof` lookup — the contract check below continues to hold.
*/
type LocalBeforeToolCallShape = (
ctx: BeforeToolCallContext,
signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;
type LocalAfterToolCallShape = (
ctx: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
type LocalTransformContextShape = (
messages: AgentMessage[],
signal?: AbortSignal,
) => Promise<AgentMessage[]>;
/**
* Contract assertions — these are pure type positions; no runtime code.
*
* Each alias forces `tsc` to verify the local shape is still a valid
* substitute for the corresponding pi slot. Removing or renaming a
* field on either side breaks the build.
*/
export type BeforeToolCallContract = ExpectHookType<PiBeforeToolCall, LocalBeforeToolCallShape>;
export type AfterToolCallContract = ExpectHookType<PiAfterToolCall, LocalAfterToolCallShape>;
export type TransformContextContract = ExpectHookType<PiTransformContext, LocalTransformContextShape>;
/**
* Hook handler return envelopes used by `agent-runtime/hook-handlers.ts`.
*
* The handlers run inside main and ship results back to the worker over
* the RPC channel, so they do not implement the raw pi signatures.
* Pinning the mapped shapes here guarantees that any change to the
* upstream result type surfaces in the RPC payload layer too.
*/
export type BeforeToolCallHandlerResult = {
readonly block: boolean;
readonly reason: string | null;
};
export type AfterToolCallHandlerResult = {
readonly contentOverride: AfterToolCallResult["content"] | null;
readonly isErrorOverride: AfterToolCallResult["isError"] | null;
};
export type TransformContextHandlerResult = {
readonly changed: boolean;
readonly messages: AgentMessage[] | null;
};