feat(runtime): wire pi-agent-core hooks end-to-end

The worker previously constructed `new Agent({systemPrompt, model, tools,
messages, streamFn})` and stopped there — transformContext,
beforeToolCall, afterToolCall, sessionId, thinkingLevel, and steer/
followUp were all dark. Approval enforcement, artifact projection,
auto-compaction, and mid-run steering all had to reach the agent
through other, non-canonical channels (event observers, prompt-block
injection at prepareRun, API-layer gates). That left the whole pi
contract at one-tenth utilisation and forced business logic into the
wrong seams.

This change rebuilds the contract assembly layer:

  - `pi-agent-factory` builds the Agent with the full option surface:
    sessionId (taskSessionId), thinkingLevel (mapped from
    agent.metadataJson), and three RPC-proxied hooks.
  - Each hook shuttles a thin payload over the existing worker RPC
    channel into `RuntimeBridge`, which routes to `hook-handlers`:
      * beforeToolCall → approval-gate policy (blocks destructive
        tools while a pending approval is outstanding).
      * afterToolCall  → filesystem artifact projection, previously
        nested in the tool_execution_end event observer.
      * transformContext → pass-through today; compaction wires in
        next commit.
  - ParentMessage gains `steer-run` / `followup-run`; `WorkerRunner`
    keeps a runId→Agent registry so main can inject mid-run messages
    into the active pi loop.

`worker-types` extends the rpc-request method enum; `runtime-bridge`
loses the per-event artifact projector and gains a `findActiveRunForAgent`
lookup used by the upcoming inbox → steer path. No public main API
changes yet — the wiring is the foundation for the next five commits.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 00:48:13 -04:00
1 parent f041336721
commit e1693899a2
8 files changed
+561 -153

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
/**
* Factory that assembles a `pi-agent-core` Agent from a prepared worker
* job. Encapsulates the full Pi contract (initial state, sessionId,
* thinkingLevel, beforeToolCall / afterToolCall / transformContext
* hooks, streamFn) so `worker-runner` stays focused on run lifecycle.
*
* The three tool-execution hooks proxy into main via the worker RPC
* client; main answers from `RuntimeBridge`. Pi's loop is tolerant of
* thrown hooks — a thrown `beforeToolCall` produces an error tool
* result, a thrown `afterToolCall` likewise — so RPC failures degrade
* to a deterministic tool-error instead of crashing the run. The
* `transformContext` contract explicitly forbids throwing, so on RPC
* failure we return the original messages unchanged.
*/
import {
Agent,
type AfterToolCallResult,
type AgentMessage,
type BeforeToolCallResult,
type ThinkingLevel,
} from "@mariozechner/pi-agent-core";
import { streamSimple, type Api, type Model } from "@mariozechner/pi-ai";
import type {
AfterToolCallRpcPayload,
AfterToolCallRpcResult,
BeforeToolCallRpcPayload,
BeforeToolCallRpcResult,
TransformContextRpcPayload,
TransformContextRpcResult,
} from "@main/agent-runtime/hook-rpc";
import type { WorkerRpcClient } from "./worker-rpc";
export interface PiAgentFactoryInput {
runId: string;
taskSessionId: string;
systemPrompt: string;
model: Model<Api>;
tools: never;
priorMessages: AgentMessage[];
thinkingLevel: ThinkingLevel;
apiKey: string;
extraHeaders: Record<string, string> | undefined;
rpc: WorkerRpcClient;
}
/** Map the UI-facing `thinkingEffort` string onto pi's `ThinkingLevel`. */
export function resolveThinkingLevel(effort: string | null | undefined): ThinkingLevel {
switch (effort) {
case "minimal":
return "minimal";
case "low":
return "low";
case "medium":
return "medium";
case "high":
return "high";
case "xhigh":
return "xhigh";
default:
return "off";
}
}
export function createPiAgent(input: PiAgentFactoryInput): Agent {
const { runId, rpc } = input;
return new Agent({
initialState: {
systemPrompt: input.systemPrompt,
model: input.model,
tools: input.tools,
messages: input.priorMessages,
thinkingLevel: input.thinkingLevel,
},
sessionId: input.taskSessionId,
streamFn: (model, messages, options) =>
streamSimple(model, messages, {
...options,
apiKey: input.apiKey,
headers: { ...(options?.headers ?? {}), ...(input.extraHeaders ?? {}) },
}),
beforeToolCall: async (ctx): Promise<BeforeToolCallResult | undefined> => {
const payload: BeforeToolCallRpcPayload = {
toolName: ctx.toolCall.name,
toolCallId: ctx.toolCall.id,
args: ctx.args,
};
const response = await rpc.invoke(runId, "beforeToolCall", payload);
if (!response.ok) {
// RPC failure must fail closed for destructive tools: main is
// the source of truth for approval state, and silently running
// without it would bypass the gate. The loop converts the thrown
// error into a tool-error result and surfaces it to the agent.
throw new Error(`beforeToolCall RPC failed: ${response.error}`);
}
const result = response.result as BeforeToolCallRpcResult;
if (!result.block) return undefined;
return { block: true, reason: result.reason ?? undefined };
},
afterToolCall: async (ctx): Promise<AfterToolCallResult | undefined> => {
const payload: AfterToolCallRpcPayload = {
toolName: ctx.toolCall.name,
toolCallId: ctx.toolCall.id,
args: ctx.args,
resultContent: ctx.result.content,
resultDetails: ctx.result.details,
isError: ctx.isError,
};
const response = await rpc.invoke(runId, "afterToolCall", payload);
if (!response.ok) {
// After-hook failure is informational: the tool already ran,
// projection is a best-effort write. Drop the hook rather than
// corrupting the tool result.
return undefined;
}
const result = response.result as AfterToolCallRpcResult;
const override: AfterToolCallResult = {};
if (result.contentOverride) override.content = result.contentOverride;
if (result.isErrorOverride !== null) override.isError = result.isErrorOverride;
return Object.keys(override).length === 0 ? undefined : override;
},
transformContext: async (messages): Promise<AgentMessage[]> => {
const payload: TransformContextRpcPayload = { messages };
const response = await rpc.invoke(runId, "transformContext", payload);
if (!response.ok) return messages;
const result = response.result as TransformContextRpcResult;
if (result.changed && result.messages) return result.messages;
return messages;
},
});
}
+8
View File
@@ -128,6 +128,14 @@ parentPort.on("message", (event) => {
cancelRun(message.runId);
return;
}
if (message.type === "steer-run") {
runner.steer(message.runId, message.message);
return;
}
if (message.type === "followup-run") {
runner.followUp(message.runId, message.message);
return;
}
if (message.type === "rpc-response") {
rpc.resolve(message.rpcId, message.response);
return;
+9
View File
@@ -1,3 +1,4 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { RunStatus } from "@shared/types";
export interface RunJob {
@@ -22,10 +23,18 @@ export interface RunJob {
executionMode: "ai_ceo" | "ai_cofounder";
}
/**
* Parent → worker control-plane messages. `steer-run` and `followup-run`
* are the inward half of pi's mid-run injection contract: the worker owns
* the Agent instance, main owns the inbox DB, so main posts the enqueued
* user message here and the worker calls `agent.steer()` / `agent.followUp()`.
*/
export type ParentMessage =
| { type: "enqueue-run"; payload: RunJob }
| { type: "cancel-run"; runId: string }
| { type: "shutdown" }
| { type: "steer-run"; runId: string; message: AgentMessage }
| { type: "followup-run"; runId: string; message: AgentMessage }
| { type: "rpc-response"; rpcId: string; response: { ok: true; result: unknown } | { ok: false; error: string } };
export type ChildMessage =
+44 -17
View File
@@ -1,11 +1,10 @@
import { Agent } from "@mariozechner/pi-agent-core";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import { createCodingTools } from "@mariozechner/pi-coding-agent";
import type { Agent, AgentMessage } from "@mariozechner/pi-agent-core";
import { resolveModel, resolveExtraStreamHeaders } from "../model-resolver";
import type { RunStatus } from "@shared/types";
import type { ChildMessage, RunJob } from "./worker-helpers";
import { applyEnvOverrides, buildRedactor } from "./worker-helpers";
import { createCodingTools } from "@mariozechner/pi-coding-agent";
import { createPiAgent, resolveThinkingLevel } from "./pi-agent-factory";
import type { WorkerRpcClient } from "./worker-rpc";
interface PrepareRunResult {
@@ -21,9 +20,21 @@ interface RunnerDeps {
isCancelled: (runId: string) => boolean;
}
/**
* Lifecycle owner for one-and-only-one active pi `Agent` per run.
*
* Responsibilities:
* - Calls `prepareRun` on main to materialise session + credentials.
* - Builds an `Agent` via `createPiAgent` with the full Pi contract.
* - Streams every pi `AgentEvent` into the worker → main pipe.
* - Persists each `message_end` message through `persistMessage` RPC.
* - Exposes `steer` / `followUp` to the worker entry so mid-run
* injections from main land on the in-process Agent instance.
*/
export class WorkerRunner {
private readonly abortControllers = new Map<string, AbortController>();
private readonly runPromises = new Map<string, Promise<void>>();
private readonly activeAgents = new Map<string, Agent>();
constructor(private readonly deps: RunnerDeps) {}
@@ -32,6 +43,22 @@ export class WorkerRunner {
if (controller) controller.abort();
}
/** Enqueue a steering message onto the active Agent for this run. */
steer(runId: string, message: AgentMessage): boolean {
const agent = this.activeAgents.get(runId);
if (!agent) return false;
agent.steer(message);
return true;
}
/** Enqueue a follow-up message onto the active Agent for this run. */
followUp(runId: string, message: AgentMessage): boolean {
const agent = this.activeAgents.get(runId);
if (!agent) return false;
agent.followUp(message);
return true;
}
/**
* Aborts every in-flight run and returns a promise that resolves once
* each `run()` has settled (success, failure, or abort). Callers (the
@@ -110,20 +137,19 @@ export class WorkerRunner {
const cwd = job.workspacePath ?? process.cwd();
const tools = createCodingTools(cwd) as never;
const agent = new Agent({
initialState: {
systemPrompt: job.systemPrompt ?? "",
model,
tools,
messages: prepared.priorMessages,
},
streamFn: (m, messages, options) =>
streamSimple(m, messages, {
...options,
apiKey: prepared.apiKey,
headers: { ...(options?.headers ?? {}), ...(extraHeaders ?? {}) },
}),
const agent = createPiAgent({
runId: job.runId,
taskSessionId: job.taskSessionId,
systemPrompt: job.systemPrompt ?? "",
model,
tools,
priorMessages: prepared.priorMessages,
thinkingLevel: resolveThinkingLevel(job.thinkingEffort),
apiKey: prepared.apiKey,
extraHeaders,
rpc,
});
this.activeAgents.set(job.runId, agent);
const unsubscribe = agent.subscribe(async (event) => {
const timestamp = new Date().toISOString();
@@ -167,6 +193,7 @@ export class WorkerRunner {
await runPromise;
} finally {
unsubscribe();
this.activeAgents.delete(job.runId);
}
} finally {
restoreProviderEnv();
+7 -1
View File
@@ -47,7 +47,13 @@ export const workerMessageSchema = z.union([
type: z.literal("rpc-request"),
rpcId: z.string(),
runId: z.string().uuid(),
method: z.enum(["persistMessage", "prepareRun"]),
method: z.enum([
"persistMessage",
"prepareRun",
"beforeToolCall",
"afterToolCall",
"transformContext",
]),
payload: z.unknown(),
}),
]);