From b839ce4f233884f8c936ea7d8618b7ff24fde25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 14:25:44 -0400 Subject: [PATCH] Inject hire_agent and file_approval into CEO agent (C4 part 3/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../agent-runtime/internal-tools/router.ts | 64 ++++++++++++++ src/main/agent-runtime/runtime-bridge.ts | 84 ++++++++++++++++++- src/main/bootstrap/wire-api-server.ts | 26 +++++- .../orchestration/internal-tool-selector.ts | 42 ++++++++++ src/main/orchestration/turn-context.ts | 4 + src/main/orchestration/types.ts | 8 ++ src/main/worker/runtime/helpers.ts | 8 ++ src/main/worker/runtime/index.ts | 31 ++++++- src/main/worker/runtime/pi-agent-factory.ts | 4 + 9 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 src/main/agent-runtime/internal-tools/router.ts create mode 100644 src/main/orchestration/internal-tool-selector.ts diff --git a/src/main/agent-runtime/internal-tools/router.ts b/src/main/agent-runtime/internal-tools/router.ts new file mode 100644 index 00000000..3738fe5c --- /dev/null +++ b/src/main/agent-runtime/internal-tools/router.ts @@ -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 }, + }); + } +} diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index 1f922077..dd39113e 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -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(); private readonly sessionStores = new Map(); + 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, diff --git a/src/main/bootstrap/wire-api-server.ts b/src/main/bootstrap/wire-api-server.ts index f8eec3cb..15965f2e 100644 --- a/src/main/bootstrap/wire-api-server.ts +++ b/src/main/bootstrap/wire-api-server.ts @@ -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( diff --git a/src/main/orchestration/internal-tool-selector.ts b/src/main/orchestration/internal-tool-selector.ts new file mode 100644 index 00000000..59dac1df --- /dev/null +++ b/src/main/orchestration/internal-tool-selector.ts @@ -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; +} diff --git a/src/main/orchestration/turn-context.ts b/src/main/orchestration/turn-context.ts index b0fc73bd..a62c1f3a 100644 --- a/src/main/orchestration/turn-context.ts +++ b/src/main/orchestration/turn-context.ts @@ -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, }; } diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts index f999c2fd..5e0d0921 100644 --- a/src/main/orchestration/types.ts +++ b/src/main/orchestration/types.ts @@ -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 { diff --git a/src/main/worker/runtime/helpers.ts b/src/main/worker/runtime/helpers.ts index ae0fae43..73b02223 100644 --- a/src/main/worker/runtime/helpers.ts +++ b/src/main/worker/runtime/helpers.ts @@ -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">; } /** diff --git a/src/main/worker/runtime/index.ts b/src/main/worker/runtime/index.ts index 112e1d70..c6ce3a23 100644 --- a/src/main/worker/runtime/index.ts +++ b/src/main/worker/runtime/index.ts @@ -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` is generic over the concrete TypeBox + // schema; two tools with different schemas cannot share a single + // `AgentTool[]` 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, diff --git a/src/main/worker/runtime/pi-agent-factory.ts b/src/main/worker/runtime/pi-agent-factory.ts index 3b1ca7ce..2a11dbe1 100644 --- a/src/main/worker/runtime/pi-agent-factory.ts +++ b/src/main/worker/runtime/pi-agent-factory.ts @@ -30,6 +30,10 @@ export interface PiAgentFactoryInput { taskSessionId: string; systemPrompt: string; model: Model; + // `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;