Add internal hire_agent tool (C4 part 1/5)

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
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 14:17:03 -04:00
1 parent 409bec2b25
commit e2c839be49
3 files changed
+497

No files matched your search

@@ -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<typeof hireAgentSchema>;
/**
* 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<typeof hireAgentSchema> {
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,
};
},
};
}
@@ -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;
}