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:
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user