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

+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,