Add internal file_approval tool (C4 part 2/5)

Introduce a first-class `file_approval` pi tool definition + main-side
handler, paired to `hire_agent` from part 1/5. Replaces the
`bash + node -e + fetch POST /api/companies/:id/approvals` chain with
a single pi tool-call the CEO uses to file `approve_ceo_strategy`
after the initial hires. Handler reuses `db.approvalsMod.requestApproval`,
the `approve_ceo_strategy` ordering gate (STRATEGY_BEFORE_HIRES /
STRATEGY_ALREADY_FILED), and the auto-approval policy so the tool path
produces identical approval rows to the HTTP route.

No runtime wiring yet — the injection + RPC router land in part 3/5.

Error taxonomy:
- file_approval.input.invalid / strategy.before_hires /
  strategy.already_filed → UserInputError
- file_approval.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:18:37 -04:00
1 parent e2c839be49
commit d65f156732
2 files changed
+298

No files matched your search

@@ -0,0 +1,127 @@
/**
* `file_approval` internal tool (C4 part 2/5).
*
* Collapses the `bash + node -e + fetch POST /api/companies/:id/approvals`
* chain into a single tool-call the CEO (or any agent with request
* authority) can invoke directly. The handler in main reuses the same
* `db.approvalsMod.requestApproval` primitive and auto-approval policy
* the HTTP route uses, so both paths produce identical rows and the
* `approve_ceo_strategy` ordering gate fires identically.
*
* Like `hire_agent`, the worker-side `execute` is a thin RPC proxy.
* Validation failures come back as classified errors from main; pi's
* tool-error convention surfaces them to the model on the next turn.
*/
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 FILE_APPROVAL_TOOL_NAME = "file_approval";
/**
* TypeBox schema for `file_approval` arguments.
*
* Kept intentionally narrow (type + summaries + optional payload JSON)
* so the model sees the shape of an approval as a primitive concept
* rather than an HTTP body to marshal. `type` is an enum drawn directly
* from `shared/contracts/schemas-approvals.ts :: approvalInputSchema`;
* the handler re-validates with zod so adding a new approval type over
* there does not silently un-gate here.
*/
export const fileApprovalSchema = Type.Object({
type: Type.Union(
[
Type.Literal("hire_agent"),
Type.Literal("approve_ceo_strategy"),
Type.Literal("dangerous_command"),
Type.Literal("secret_access"),
Type.Literal("budget_request"),
Type.Literal("promotion"),
Type.Literal("termination"),
Type.Literal("vendor_approval"),
Type.Literal("document_review"),
Type.Literal("production_deploy"),
Type.Literal("incident_escalation"),
Type.Literal("social_post"),
Type.Literal("deliverable_review"),
Type.Literal("budget_override"),
Type.Literal("plan_review"),
Type.Literal("scope_review"),
Type.Literal("draft_review"),
Type.Literal("strategy_review"),
Type.Literal("exception_review"),
],
{
description:
"Approval type. Use 'approve_ceo_strategy' after filing the initial hire_agent approvals to lock in the org shape.",
},
),
payloadSummary: Type.String({
description:
"One-line human-readable summary of what is being approved (≤280 chars).",
minLength: 2,
maxLength: 280,
}),
impactSummary: Type.String({
description:
"Why this approval matters / business rationale (≤400 chars).",
minLength: 2,
maxLength: 400,
}),
payloadJson: Type.Optional(
Type.String({
description:
"Optional JSON string carrying structured approval payload. Must be valid JSON if provided.",
}),
),
relatedTaskId: Type.Optional(
Type.String({
description: "UUID of the task this approval pertains to, if any.",
}),
),
relatedAgentId: Type.Optional(
Type.String({
description: "UUID of the agent this approval pertains to, if any.",
}),
),
});
export type FileApprovalToolInput = Static<typeof fileApprovalSchema>;
/**
* Construct the worker-side `file_approval` tool.
*
* Routes every call through the internal-tool RPC method so main owns
* the approval creation + auto-approval policy.
*/
export function createFileApprovalTool(rpc: WorkerRpcClient, runId: string): AgentTool<typeof fileApprovalSchema> {
return {
name: FILE_APPROVAL_TOOL_NAME,
label: "file approval",
description:
"File an approval request with the company board. Use for 'approve_ceo_strategy' after filing initial hires, or for any other decision that requires board sign-off. The request is auto-approved when the company is in hands-off mode; otherwise it waits for the human operator.",
parameters: fileApprovalSchema,
async execute(toolCallId, params, _signal?, _onUpdate?) {
const payload: InvokeInternalToolRpcPayload = {
toolName: FILE_APPROVAL_TOOL_NAME,
toolCallId,
args: params,
};
const response = await rpc.invoke(runId, "invokeInternalTool", payload);
if (!response.ok) {
throw new Error(response.error);
}
const result = response.result as InvokeInternalToolRpcResult;
return {
content: result.content,
details: result.details,
isError: result.isError,
};
},
};
}