From e2c839be4951263a27224b8b05081ccf9203a80f 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:17:03 -0400 Subject: [PATCH] Add internal hire_agent tool (C4 part 1/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a first-class `hire_agent` pi tool definition + main-side handler so the CEO no longer has to route hires through `bash + node -e + fetch` against the internal HTTP API. The tool's `execute` method proxies to main via a new `invokeInternalTool` RPC method (wiring lands in part 3/5); the handler reuses the existing `createHireRequest` DB primitive and auto-approval policy so the tool path and the legacy HTTP path produce identical approval rows. No runtime wiring yet — the tool is not injected into any agent's tool set in this commit. That keeps the behavioural surface unchanged for this step while giving part 3/5 a concrete handler to bind. Error taxonomy: - hire_agent.permission.denied / input.invalid / name.invalid / routing.* / connector.* / duplicate.* → UserInputError - hire_agent.create.failed / activity.failed → UpstreamError tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green --- .../internal-tools/hire-agent-handler.ts | 305 ++++++++++++++++++ .../runtime/internal-tools/hire-agent-tool.ts | 147 +++++++++ .../internal-tools/internal-tool-rpc.ts | 45 +++ 3 files changed, 497 insertions(+) create mode 100644 src/main/agent-runtime/internal-tools/hire-agent-handler.ts create mode 100644 src/main/worker/runtime/internal-tools/hire-agent-tool.ts create mode 100644 src/main/worker/runtime/internal-tools/internal-tool-rpc.ts diff --git a/src/main/agent-runtime/internal-tools/hire-agent-handler.ts b/src/main/agent-runtime/internal-tools/hire-agent-handler.ts new file mode 100644 index 00000000..fcc452e7 --- /dev/null +++ b/src/main/agent-runtime/internal-tools/hire-agent-handler.ts @@ -0,0 +1,305 @@ +/** + * Main-side handler for the `hire_agent` internal tool (C4 part 1/5). + * + * Contract: this handler accepts the exact same field shape the HTTP + * `POST /api/companies/:id/agents` route accepts (see + * `api-server/routes-agents.ts`) and funnels through the same + * `db.createHireRequest` primitive + auto-approval policy so both paths + * produce identical approval rows. The HTTP path is kept live for + * legacy clients (see C4 part 5/5 deprecation marker); this is the + * tool-first path the CEO prompt now advertises. + * + * Error policy: + * - Input validation fails → throw `UserInputError`. The worker's tool + * wrapper converts `{ ok: false, error }` into a pi tool-error + * result, so the CEO sees the validation message on its next turn + * and can correct the call. + * - DB write / approval layer fails → throw `UpstreamError`. Same + * surface behaviour, different classification for telemetry. + * + * No fallbacks, no silent catches: a malformed hire is a bug worth + * surfacing to the model, not worth papering over. + */ + +import type { AppDatabase } from "@main/database"; +import { + canManageOrganization, + canRequestHire, + getHireNameValidationError, + getHireRoutingValidationError, + resolveHireReportsTo, +} from "@main/agent-permissions"; +import { resolveApprovalDecision } from "@main/approval-policy"; +import { shouldAutoApprove } from "@main/execution-policy"; +import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; +import { hireRequestSchema } from "@shared/contracts"; +import { UserInputError, UpstreamError, classifyUnknownError } from "@main/errors"; +import type { InvokeInternalToolRpcResult } from "@main/worker/runtime/internal-tools/internal-tool-rpc"; + +export interface HireAgentHandlerDeps { + db: AppDatabase; + onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; + onDomainChanged: () => void; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; +} + +export interface HireAgentHandlerContext { + companyId: string; + agentId: string; +} + +/** + * Handle a `hire_agent` tool invocation from the worker RPC. + * + * Mirrors the control flow of the HTTP handler (validate → resolve + * reportsTo → name/routing checks → connector readiness → dedup → + * create → auto-approve) and returns the `{ agentId, approvalId, + * autoApproved }` shape wrapped as a pi tool-result. + */ +export function handleHireAgentToolCall( + deps: HireAgentHandlerDeps, + ctx: HireAgentHandlerContext, + rawArgs: unknown, +): InvokeInternalToolRpcResult { + const { db } = deps; + const { companyId, agentId: requesterId } = ctx; + + if (!canRequestHire(db, companyId, requesterId)) { + throw new UserInputError({ + code: "hire_agent.permission.denied", + message: "Current agent is not allowed to request hires.", + context: { companyId, requesterId }, + }); + } + + const argObject = typeof rawArgs === "object" && rawArgs !== null + ? (rawArgs as Record) + : {}; + const reportsToProvided = Object.prototype.hasOwnProperty.call(argObject, "reportsTo"); + + const parsed = hireRequestSchema.safeParse({ + companyId, + requestedByAgentId: requesterId, + ...argObject, + }); + if (!parsed.success) { + throw new UserInputError({ + code: "hire_agent.input.invalid", + message: `Invalid hire_agent payload: ${parsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; ")}`, + context: { companyId, requesterId }, + }); + } + const input = parsed.data; + + const resolvedReportsTo = resolveHireReportsTo( + db, + companyId, + requesterId, + reportsToProvided ? input.reportsTo : undefined, + input.department ?? null, + ); + + const nameError = getHireNameValidationError({ + name: input.name, + role: input.role, + title: input.title, + }); + if (nameError) { + throw new UserInputError({ + code: "hire_agent.name.invalid", + message: nameError, + context: { name: input.name, role: input.role }, + }); + } + + const routingError = getHireRoutingValidationError( + db, + companyId, + requesterId, + resolvedReportsTo, + input.department ?? null, + ); + if (routingError) { + throw new UserInputError({ + code: "hire_agent.routing.invalid", + message: routingError, + context: { resolvedReportsTo, department: input.department ?? null }, + }); + } + + if ( + resolvedReportsTo && + resolvedReportsTo !== requesterId && + !canManageOrganization(db, companyId, requesterId) && + !db.agents.getChainOfCommand(resolvedReportsTo).some((agent) => agent.id === requesterId) + ) { + throw new UserInputError({ + code: "hire_agent.routing.not_in_chain", + message: + "Requested hire must report to you or a manager in your reporting chain.", + context: { resolvedReportsTo }, + }); + } + + const connectorRecord = (() => { + try { + return db.connectorsCrud.getConnector(input.connectorId); + } catch (error) { + throw new UserInputError({ + code: "hire_agent.connector.missing", + message: `Connector ${input.connectorId} does not exist.`, + cause: error, + }); + } + })(); + const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord); + if (connectorIssue) { + throw new UserInputError({ + code: "hire_agent.connector.not_ready", + message: connectorIssue, + context: { connectorId: input.connectorId }, + }); + } + + const existingAgents = db.agents.listExistingAgentsForHireCheck(companyId); + const duplicateByName = existingAgents.find( + (a) => a.name.toLowerCase() === input.name.toLowerCase(), + ); + if (duplicateByName) { + throw new UserInputError({ + code: "hire_agent.duplicate.existing", + message: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.`, + }); + } + + const pendingHires = db.approvalsMod.listPendingHireApprovalPayloads(companyId); + for (const pending of pendingHires) { + try { + const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record : {}; + if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) { + throw new UserInputError({ + code: "hire_agent.duplicate.pending", + message: `A hire request for "${input.name}" is already pending approval.`, + }); + } + } catch (error) { + if (error instanceof UserInputError) throw error; + deps.logger.warn( + `[hire-agent-handler] invalid pending-hire payloadJson for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const managerLabel = resolvedReportsTo + ? (() => { + try { + return db.entityCrud.getAgent(resolvedReportsTo).name; + } catch { + return resolvedReportsTo; + } + })() + : "the company board"; + + let result: { agentId: string; approvalId: string }; + try { + result = db.createHireRequest({ + companyId, + requestedByAgentId: input.requestedByAgentId ?? requesterId, + name: input.name, + role: input.role, + title: input.title, + department: input.department ?? null, + reportsTo: resolvedReportsTo, + connectorId: input.connectorId, + workspaceId: input.workspaceId ?? null, + model: input.model ?? null, + metadataJson: input.metadataJson ?? "{}", + capabilities: input.capabilities, + budgetMonthlyUsd: input.budgetMonthlyUsd, + payloadSummary: `Hire ${input.name} as ${input.role}`, + impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`, + payloadJson: JSON.stringify({ + name: input.name, + role: input.role, + title: input.title, + requestedByAgentId: input.requestedByAgentId ?? requesterId, + requestedConfigurationSnapshot: { + connectorId: input.connectorId, + workspaceId: input.workspaceId ?? null, + model: input.model ?? null, + metadataJson: input.metadataJson ?? "{}", + capabilities: input.capabilities, + budgetMonthlyUsd: input.budgetMonthlyUsd, + reportsTo: resolvedReportsTo, + department: input.department ?? null, + title: input.title, + }, + }), + }); + } catch (error) { + throw classifyUnknownError(error, { + kind: "upstream", + code: "hire_agent.create.failed", + message: error instanceof Error ? error.message : "Hire could not be created.", + context: { companyId, requesterId }, + }); + } + + try { + db.activityMod.addActivity({ + companyId, + actor: requesterId, + action: "agent.hire_requested", + entityType: "agent", + entityId: result.agentId, + detail: input.name, + }); + } catch (error) { + throw new UpstreamError({ + code: "hire_agent.activity.failed", + message: "Failed to record activity for hire request.", + cause: error, + }); + } + + let autoApproved = false; + if (shouldAutoApprove(db, companyId, "hire_agent")) { + resolveApprovalDecision({ + db, + handleApprovalResolved: deps.onApprovalResolved, + companyId, + approvalId: result.approvalId, + state: "approved", + decisionNote: "Auto-approved by company policy", + detail: `Auto-approved hire: ${input.name}`, + }); + autoApproved = true; + deps.logger.info( + `[hire-agent-handler] Auto-approved hire of ${input.name} via agent tool.`, + ); + } + + deps.onDomainChanged(); + + const summary = autoApproved + ? `Hired ${input.name} (${input.role}). Approval ${result.approvalId} was auto-approved by company policy; the new agent id is ${result.agentId}.` + : `Filed hire_agent approval ${result.approvalId} for ${input.name} (${input.role}). Pending agent id is ${result.agentId}; awaiting board decision.`; + + return { + content: [{ type: "text", text: summary }], + details: { + agentId: result.agentId, + approvalId: result.approvalId, + autoApproved, + name: input.name, + role: input.role, + }, + isError: false, + }; +} diff --git a/src/main/worker/runtime/internal-tools/hire-agent-tool.ts b/src/main/worker/runtime/internal-tools/hire-agent-tool.ts new file mode 100644 index 00000000..db4bfbef --- /dev/null +++ b/src/main/worker/runtime/internal-tools/hire-agent-tool.ts @@ -0,0 +1,147 @@ +/** + * `hire_agent` internal tool (C4 part 1/5). + * + * Replaces the pre-C4 flow where the CEO had to call the `bash` tool, + * spawn `node -e`, and POST to `/api/companies/:id/agents` on its own + * HTTP server. Every layer of that stack was an implementation detail + * leaking into the LLM prompt — if `bash` or `node` went away, the + * tool-call would silently fail. The first-class tool collapses the + * chain to a single pi tool-call whose schema the model can see and + * whose result comes back on the next turn. + * + * The tool is wire-compatible with the HTTP `POST /api/companies/:id/agents` + * endpoint (same field set, same validation order) so main's handler can + * reuse the existing `createHireRequest` DB primitive without duplicating + * the approval + auto-approval plumbing. The HTTP path stays live + * through the deprecation window documented in C4 part 5/5. + * + * Worker side: `execute` is a thin RPC proxy — no schema validation, no + * fallbacks. An invalid payload (missing field, blank string, duplicate + * name) surfaces as a `UserInputError` from main, which the RPC envelope + * converts to `{ ok: false, error }`, which the tool execute wrapper + * re-throws as an Error. Pi's loop then produces a tool-error result + * carrying the classified message — exactly what we want the CEO to see + * on its next turn so it can correct the hire. + * + * Main-side handler lives in `hire-agent-handler.ts` and is bound by the + * internal-tools router registered in `RuntimeBridge`. + */ + +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type, type Static } from "@mariozechner/pi-ai"; +import type { WorkerRpcClient } from "../../entry/rpc"; +import type { + InvokeInternalToolRpcPayload, + InvokeInternalToolRpcResult, +} from "./internal-tool-rpc"; + +export const HIRE_AGENT_TOOL_NAME = "hire_agent"; + +/** + * TypeBox schema for `hire_agent` arguments. + * + * Intentionally mirrors `shared/contracts/schemas-agents.ts :: hireRequestSchema` + * on the main side — the handler re-validates with zod, so the TypeBox + * schema here is the model-facing advertisement and the zod schema is + * the boundary. Keeping both narrow (required `name/role/connectorId/model`, + * optional `department/title/reportsTo`) rejects malformed calls before + * they hit the DB layer. + * + * `department` is a free string here rather than a Union of the canonical + * set: the schemas-agents file accepts `nullable().optional()` via the + * shared `DEPARTMENT_IDS` enum, and echoing the enum in TypeBox would + * force the drift onto two schemas. The main handler validates against + * `DEPARTMENT_IDS` so an out-of-range value fails closed. + */ +export const hireAgentSchema = Type.Object({ + name: Type.String({ + description: + "Short display name for the new direct report (e.g. 'Ada Lovelace', 'CTO Claude'). Must be unique within the company and at least 2 characters.", + minLength: 2, + maxLength: 80, + }), + role: Type.String({ + description: + "Business role title (e.g. 'Chief Technology Officer', 'Head of Marketing').", + minLength: 2, + maxLength: 80, + }), + title: Type.Optional( + Type.String({ + description: "Short title displayed in UI; defaults to empty.", + maxLength: 80, + }), + ), + department: Type.Optional( + Type.String({ + description: + "Department bucket. One of: engineering, product, design, marketing, sales, hr, finance, legal, operations, customer_support, research, executive.", + }), + ), + capabilities: Type.Optional( + Type.String({ + description: + "One-paragraph description of what this hire owns. Surfaces in the hire approval and the briefing.", + }), + ), + reportsTo: Type.Optional( + Type.String({ + description: + "UUID of the manager this hire should report to. Omit to default to the requesting agent (or the department lead if applicable).", + }), + ), + connectorId: Type.String({ + description: + "Connector id drawn from the Available Connectors block in the briefing; must be in a ready state.", + }), + model: Type.String({ + description: + "Model id allowed by the connector's enabled-models list (e.g. 'claude-4-7', 'gpt-5').", + }), + budgetMonthlyUsd: Type.Optional( + Type.Number({ + description: + "Monthly USD spend cap for this hire. Defaults to 150 if omitted.", + minimum: 0, + }), + ), +}); + +export type HireAgentToolInput = Static; + +/** + * Construct the worker-side `hire_agent` tool. + * + * The returned `AgentTool` routes every call through the internal-tool + * RPC method so main owns the DB write. No retries, no fallbacks, no + * argument coercion — the schema is the contract and any deviation + * surfaces through the pi tool-error convention. + */ +export function createHireAgentTool(rpc: WorkerRpcClient, runId: string): AgentTool { + return { + name: HIRE_AGENT_TOOL_NAME, + label: "hire agent", + description: + "File a hire_agent approval to add a new direct report to the company. Use when the CEO (or another manager) needs to fill a role. The approval is auto-approved for companies configured in hands-off mode; otherwise it goes to the board. Do not invoke for an agent that already exists.", + parameters: hireAgentSchema, + async execute(toolCallId, params, _signal?, _onUpdate?) { + const payload: InvokeInternalToolRpcPayload = { + toolName: HIRE_AGENT_TOOL_NAME, + toolCallId, + args: params, + }; + const response = await rpc.invoke(runId, "invokeInternalTool", payload); + if (!response.ok) { + // Propagate classified errors from main. pi wraps the throw into + // a tool-error result the model sees on the next turn. + throw new Error(response.error); + } + const result = response.result as InvokeInternalToolRpcResult; + return { + content: result.content, + details: result.details, + isError: result.isError, + }; + }, + }; +} diff --git a/src/main/worker/runtime/internal-tools/internal-tool-rpc.ts b/src/main/worker/runtime/internal-tools/internal-tool-rpc.ts new file mode 100644 index 00000000..e4186d76 --- /dev/null +++ b/src/main/worker/runtime/internal-tools/internal-tool-rpc.ts @@ -0,0 +1,45 @@ +/** + * RPC contract for internal (main-side) agent tools. + * + * The worker constructs pi `AgentTool` instances whose `execute` method + * proxies to main via `WorkerRpcClient.invoke(runId, "invokeInternalTool", + * payload)`. Main answers from `RuntimeBridge.handleRpc` by dispatching + * to the handler registered for `toolName`. Handlers live on main because + * they need the SQLite connection, approval subsystem, and telemetry — + * none of which the worker should replicate. + * + * This is structurally the same pattern as `beforeToolCall`/ + * `afterToolCall`/`transformContext` in `hook-rpc.ts`; what's different + * is that those are side-effect hooks around pi's own tools, whereas + * this is a primary-effect tool whose business logic lives entirely in + * main. Keeping the payload shape minimal (name + JSON args) mirrors + * pi's `ToolCall` structure so adding a new internal tool requires no + * new RPC method — only a new handler entry. + */ + +export interface InvokeInternalToolRpcPayload { + toolName: string; + toolCallId: string; + args: unknown; +} + +/** + * Result envelope returned by the main-side handler. + * + * Carries the tool's textual output (what the model sees on the next + * turn) plus an `isError` discriminator. `details` is optional + * structured context the renderer can attach to the tool-call card; + * omit it when the tool's response is purely textual. + * + * A handler that wants to surface a validation failure throws a + * `UserInputError`; `RuntimeBridge` translates the throw into an + * `{ ok: false, error }` RPC envelope, and the worker-side tool wrapper + * converts that back into a pi tool-error result. That path keeps every + * internal-tool failure mode aligned with pi's own tool-error + * convention without teaching the worker about AppError classes. + */ +export interface InvokeInternalToolRpcResult { + content: { type: "text"; text: string }[]; + details: unknown; + isError: boolean; +}