Inject hire_agent and file_approval into CEO agent (C4 part 3/5)

Wire the two internal tools from parts 1-2 end-to-end:

- `invokeInternalTool` RPC method added to RuntimeBridge; worker tool
  `execute` calls route here, handler dispatches by tool name via the
  new `agent-runtime/internal-tools/router.ts`.
- `InternalToolCallbacks` (`onApprovalCreated` / `onApprovalResolved`
  / `onDomainChanged`) are late-bound on the bridge via
  `setInternalToolCallbacks`; `wire-api-server` now cross-wires the
  same callbacks used by the HTTP approval route so thread integration
  / automation fire identically across both entry points.
- `RunJob.internalTools` (new field) carries the per-run tool list
  main decides at dispatch time via `orchestration/internal-tool-
  selector.ts`. Selection rule: `hire_agent` → any agent with
  `canRequestHire` authority; `file_approval` → CEOs only (scoped to
  the `approve_ceo_strategy` use case).
- Worker `createPiAgent` call site now assembles `codingTools +
  internalTools` before handing to pi. Exhaustiveness guard on the
  switch catches new tool names at compile time.

No prompt-level wiring yet — the `initial-user-turn` and
`team-building.en.md` updates ship in parts 4 and 5 so this commit
only adds the tool surface without changing the CEO's observable
behaviour (the existing HTTP path is still the one the prompt tells
the CEO to call).

tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 14:25:44 -04:00
1 parent d65f156732
commit b839ce4f23
9 files changed
+265 -6

No files matched your search

@@ -0,0 +1,64 @@
/**
* Dispatch router for main-side internal-tool handlers (C4 part 3/5).
*
* The worker RPC method `invokeInternalTool` lands here; this module
* picks the right handler by `toolName` and delegates. Adding a new
* internal tool means: (a) new worker-side tool definition under
* `src/main/worker/runtime/internal-tools/`, (b) new handler under
* `src/main/agent-runtime/internal-tools/`, (c) one new `case` in the
* switch below. No third file to update, no registry lookup — the
* compile-time `never`-exhaustiveness on the default case catches a
* missing branch at build time.
*
* The router never catches: every handler throws a classified
* `AppError` on failure and the caller (`RuntimeBridge`) translates the
* throw into the `{ ok: false, error }` RPC envelope. Silent swallow
* would collapse tool-error plus tool-success onto the same success
* wire shape, which is the exact failure mode the tool-error
* convention exists to prevent.
*/
import { HIRE_AGENT_TOOL_NAME } from "@main/worker/runtime/internal-tools/hire-agent-tool";
import { FILE_APPROVAL_TOOL_NAME } from "@main/worker/runtime/internal-tools/file-approval-tool";
import type {
InvokeInternalToolRpcPayload,
InvokeInternalToolRpcResult,
} from "@main/worker/runtime/internal-tools/internal-tool-rpc";
import {
handleHireAgentToolCall,
type HireAgentHandlerDeps,
} from "./hire-agent-handler";
import {
handleFileApprovalToolCall,
type FileApprovalHandlerDeps,
} from "./file-approval-handler";
import { UserInputError } from "@main/errors";
export interface InternalToolRouterDeps {
hireAgent: HireAgentHandlerDeps;
fileApproval: FileApprovalHandlerDeps;
}
export interface InternalToolRouterContext {
companyId: string;
agentId: string;
}
export function routeInternalToolCall(
deps: InternalToolRouterDeps,
ctx: InternalToolRouterContext,
payload: InvokeInternalToolRpcPayload,
): InvokeInternalToolRpcResult {
switch (payload.toolName) {
case HIRE_AGENT_TOOL_NAME:
return handleHireAgentToolCall(deps.hireAgent, ctx, payload.args);
case FILE_APPROVAL_TOOL_NAME:
return handleFileApprovalToolCall(deps.fileApproval, ctx, payload.args);
default:
throw new UserInputError({
code: "internal_tool.unknown",
message: `Unknown internal tool: ${payload.toolName}`,
context: { toolName: payload.toolName },
});
}
}
+83 -1
View File
@@ -29,6 +29,33 @@ import type {
BeforeToolCallRpcPayload,
TransformContextRpcPayload,
} from "./hook-rpc";
import { routeInternalToolCall } from "./internal-tools/router";
import type { InvokeInternalToolRpcPayload } from "@main/worker/runtime/internal-tools/internal-tool-rpc";
/**
* Side-effect callbacks the internal-tool handlers invoke after a
* successful DB write. These come from the same wire-api-server fabric
* the HTTP routes funnel through, so thread integration / automation /
* renderer domain-change notifications stay identical across both
* entry points. Supplied lazily via `setInternalToolCallbacks` because
* the graph builds `RuntimeBridge` before the orchestration/thread-
* integration layer exists.
*/
export interface InternalToolCallbacks {
onApprovalCreated: (
approvalId: string,
companyId: string,
taskId: string | null,
approvalType: string,
summary: string,
) => void;
onApprovalResolved: (
approvalId: string,
companyId: string,
decision: string,
) => void;
onDomainChanged: () => void;
}
export interface RuntimeBridgeDeps {
db: AppDatabase;
@@ -46,7 +73,8 @@ type RpcMethod =
| "prepareRun"
| "beforeToolCall"
| "afterToolCall"
| "transformContext";
| "transformContext"
| "invokeInternalTool";
/**
* Single owner of pi runtime side-effects in main.
@@ -99,6 +127,7 @@ export class RuntimeBridge {
private readonly costWriter: CostWriter;
private readonly activeRuns = new Map<string, ActiveRunContext>();
private readonly sessionStores = new Map<string, SqliteSessionStore>();
private internalToolCallbacks: InternalToolCallbacks | null = null;
/**
* Per-run inbound-message registry. Primary identity is
* `record.id` (the `agent_messages.id`). A monotonically allocated
@@ -115,6 +144,20 @@ export class RuntimeBridge {
this.costWriter = new CostWriter({ db: deps.db });
}
/**
* Late-bind the side-effect callbacks used by the internal-tool
* router. The graph must call this exactly once before any tool
* invocation fires; missing callbacks at RPC time throw rather than
* silently skipping the notification, so a wiring regression surfaces
* on the first tool call instead of as a stale-UI ghost.
*/
setInternalToolCallbacks(callbacks: InternalToolCallbacks): void {
if (this.internalToolCallbacks) {
throw new Error("RuntimeBridge.setInternalToolCallbacks called twice.");
}
this.internalToolCallbacks = callbacks;
}
private registerActiveRun(context: ActiveRunContext): void {
this.activeRuns.set(context.runId, context);
this.getOrCreateStore(context.taskSessionId);
@@ -519,12 +562,51 @@ export class RuntimeBridge {
return this.handleAfterToolCallRpc(request.runId, request.payload as AfterToolCallRpcPayload);
case "transformContext":
return this.handleTransformContextRpc(request.runId, request.payload as TransformContextRpcPayload);
case "invokeInternalTool":
return this.handleInvokeInternalToolRpc(request.runId, request.payload as InvokeInternalToolRpcPayload);
}
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
private handleInvokeInternalToolRpc(
runId: string,
payload: InvokeInternalToolRpcPayload,
): { ok: true; result: unknown } | { ok: false; error: string } {
const ctx = this.activeRuns.get(runId);
if (!ctx) {
return { ok: false, error: `invokeInternalTool: no active run ${runId}` };
}
const callbacks = this.internalToolCallbacks;
if (!callbacks) {
return {
ok: false,
error: "invokeInternalTool: internal-tool callbacks not wired — service graph init regression.",
};
}
const result = routeInternalToolCall(
{
hireAgent: {
db: this.deps.db,
onApprovalResolved: callbacks.onApprovalResolved,
onDomainChanged: callbacks.onDomainChanged,
logger: this.deps.logger,
},
fileApproval: {
db: this.deps.db,
onApprovalCreated: callbacks.onApprovalCreated,
onApprovalResolved: callbacks.onApprovalResolved,
onDomainChanged: callbacks.onDomainChanged,
logger: this.deps.logger,
},
},
{ companyId: ctx.companyId, agentId: ctx.agentId },
payload,
);
return { ok: true, result };
}
private async handlePrepareRunRpc(
runId: string,
payloadRaw: unknown,
+23 -3
View File
@@ -90,6 +90,28 @@ export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
automation.handleApprovalResolved(approvalId, companyId, decision);
deps.threadIntegration.onApprovalResolved(approvalId, companyId, decision);
};
const onApprovalCreated = (
approvalId: string,
companyId: string,
taskId: string | null,
approvalType: string,
summary: string,
) => {
deps.threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
};
// Cross-wire the same callbacks into the pi runtime bridge so the
// internal-tool path (C4) triggers identical thread/automation
// notifications as the HTTP route.
const piRuntime = deps.serviceGraph.getPiRuntime();
if (!piRuntime) {
throw new Error("Pi runtime is not initialized; cannot wire internal-tool callbacks.");
}
piRuntime.runtimeBridge.setInternalToolCallbacks({
onApprovalCreated,
onApprovalResolved,
onDomainChanged: deps.publishDomainChanged,
});
const allowedOrigins = [deps.isDev && deps.devServerUrl ? new URL(deps.devServerUrl).origin : null, "app://-"]
.filter((origin): origin is string => Boolean(origin));
@@ -102,9 +124,7 @@ export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
onCommentPosted: (taskId, companyId, authorAgentId) => {
dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined });
},
onApprovalCreated: (approvalId, companyId, taskId, approvalType, summary) => {
deps.threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
},
onApprovalCreated,
onApprovalResolved,
createDocument: (input) =>
createDocumentWithLifecycle(
@@ -0,0 +1,42 @@
/**
* Decide which internal tools (`hire_agent`, `file_approval`) to attach
* to a given run's agent (C4 part 3/5).
*
* The selector runs on main at dispatch time — the worker never
* re-derives this. Rule set, in order:
*
* - An agent that `canRequestHire` (CEO, HR, dept lead with reports,
* anyone with org-management authority) receives `hire_agent`.
* - A CEO (reportsTo null) additionally receives `file_approval` so
* the `approve_ceo_strategy` flow works without the HTTP shim. Peer
* agents use the HTTP path until the file_approval surface is
* generalised.
*
* No hedging, no sentinel values: if the DB lookup throws the caller
* is already in a broken state and should not silently fall through
* to an empty tool set.
*/
import type { AppDatabase } from "@main/database";
import type { AgentRecord } from "@shared/types";
import { canRequestHire } from "@main/agent-permissions";
export type InternalToolName = "hire_agent" | "file_approval";
export function resolveInternalToolsForAgent(
db: AppDatabase,
agent: AgentRecord,
): InternalToolName[] {
const tools: InternalToolName[] = [];
if (canRequestHire(db, agent.companyId, agent.id)) {
tools.push("hire_agent");
}
// `file_approval` is currently scoped to the CEO (no manager) because
// the canonical use case (`approve_ceo_strategy`) is CEO-only and
// the broader approval surface ships via the HTTP route for now.
// Widen the predicate when non-CEO agents need tool-call approvals.
if (agent.reportsTo === null) {
tools.push("file_approval");
}
return tools;
}
+4
View File
@@ -14,6 +14,7 @@ import type { WorkspaceLockMode, EnqueueRunPayload } from "./types";
import { normalizeCommentsForPrompt } from "../messaging/normalize";
import { getConnectorModelPolicy, validateConnectorModelSelection } from "../connector-model-policy";
import { resolveInitialUserTurn } from "./initial-user-turn";
import { resolveInternalToolsForAgent } from "./internal-tool-selector";
import { logAppError } from "../errors/index.js";
export interface RunDispatchParams {
@@ -364,6 +365,8 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
{ db },
);
const internalTools = resolveInternalToolsForAgent(db, agent);
return {
runId,
companyId,
@@ -382,5 +385,6 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
taskSessionId: taskSessionId ?? runId,
executionMode: db.company.getCompanyExecutionMode(companyId) as "ai_ceo" | "ai_cofounder",
initialUserTurn,
internalTools,
};
}
+8
View File
@@ -46,6 +46,14 @@ export interface EnqueueRunPayload {
* on a concrete action cue instead of a context-free `Continue.`.
*/
initialUserTurn: string;
/**
* Internal tools main decides to inject for this specific run (C4).
* Currently only CEOs with hire authority receive `hire_agent` and
* `file_approval`; any other agent's `internalTools` is empty. The
* decision is made at dispatch time from the resolved CEO mode so the
* worker never re-derives it.
*/
internalTools: ReadonlyArray<"hire_agent" | "file_approval">;
}
export interface OrchestrationDependencies {
+8
View File
@@ -21,6 +21,14 @@ export interface RunJob {
executionMode: "ai_ceo" | "ai_cofounder";
/** Seed user turn that kicks off the pi agent loop. Never empty. */
initialUserTurn: string;
/**
* Internal tools (`hire_agent`, `file_approval`) to register on this
* run's agent in addition to the default coding tools. Determined on
* main side before enqueue (see `pi-agent-factory.ts:inject hire_agent
* and file_approval into CEO agent`) so the worker never needs to
* inspect company/agent state. Empty array = no extra tools.
*/
internalTools: ReadonlyArray<"hire_agent" | "file_approval">;
}
/**
+29 -2
View File
@@ -1,4 +1,4 @@
import type { Agent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { Agent, AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import { resolveModel, resolveExtraStreamHeaders } from "../../model-resolver";
import type { RunStatus } from "@shared/types";
import type { ChildMessage, RunJob } from "./helpers";
@@ -6,6 +6,8 @@ import { applyEnvOverrides, buildRedactor } from "./helpers";
import { createCodingTools } from "@mariozechner/pi-coding-agent";
import { createPiAgent, resolveThinkingLevel } from "./pi-agent-factory";
import type { WorkerRpcClient } from "../entry/rpc";
import { createHireAgentTool, HIRE_AGENT_TOOL_NAME } from "./internal-tools/hire-agent-tool";
import { createFileApprovalTool, FILE_APPROVAL_TOOL_NAME } from "./internal-tools/file-approval-tool";
interface PrepareRunResult {
provider: string;
@@ -138,7 +140,32 @@ export class WorkerRunner {
const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey);
const cwd = job.workspacePath ?? process.cwd();
const tools = createCodingTools(cwd) as never;
const codingTools = createCodingTools(cwd);
// pi's `AgentTool<TSchema>` is generic over the concrete TypeBox
// schema; two tools with different schemas cannot share a single
// `AgentTool<TSchema>[]` without widening to the base shape. The
// internal-tool factories return properly-typed instances; we
// widen via unknown only at the array boundary so each execute
// signature stays type-checked at definition time.
const internalTools: AgentTool[] = [];
for (const toolName of job.internalTools ?? []) {
switch (toolName) {
case HIRE_AGENT_TOOL_NAME:
internalTools.push(createHireAgentTool(rpc, job.runId) as unknown as AgentTool);
break;
case FILE_APPROVAL_TOOL_NAME:
internalTools.push(createFileApprovalTool(rpc, job.runId) as unknown as AgentTool);
break;
default: {
// Exhaustiveness guard: a new entry in the `internalTools`
// array without a matching case should be a compile-time
// regression.
const _exhaustive: never = toolName;
throw new Error(`Unknown internal tool requested: ${String(_exhaustive)}`);
}
}
}
const tools = [...codingTools, ...internalTools] as never;
const agent = createPiAgent({
runId: job.runId,
@@ -30,6 +30,10 @@ export interface PiAgentFactoryInput {
taskSessionId: string;
systemPrompt: string;
model: Model<Api>;
// `never` preserves the pre-C4 contract where the caller owns tool
// typing (pi-coding-agent's union is too wide to mirror here and C4
// adds local internal tools with their own TSchema). The caller casts
// a concrete tools array at the `createPiAgent` site.
tools: never;
priorMessages: AgentMessage[];
thinkingLevel: ThinkingLevel;