diff --git a/src/main/agent-runtime/internal-tools/file-approval-handler.ts b/src/main/agent-runtime/internal-tools/file-approval-handler.ts new file mode 100644 index 00000000..e32d9427 --- /dev/null +++ b/src/main/agent-runtime/internal-tools/file-approval-handler.ts @@ -0,0 +1,171 @@ +/** + * Main-side handler for the `file_approval` internal tool (C4 part 2/5). + * + * Mirrors the control flow of `POST /api/companies/:id/approvals` in + * `api-server/routes-approvals.ts`: validate → enforce the + * `approve_ceo_strategy` ordering gate → request approval → auto- + * approve or notify. Both the tool path and the HTTP path funnel + * through the same `db.approvalsMod.requestApproval` + `shouldAutoApprove` + * plumbing, so the board sees identical rows regardless of entry point. + * + * No fallbacks: malformed input throws UserInputError, DB failures + * throw UpstreamError. The worker tool wrapper surfaces the classified + * message as a pi tool-error so the model can correct its call. + */ + +import type { AppDatabase } from "@main/database"; +import { approvalInputSchema } from "@shared/contracts"; +import { resolveApprovalDecision } from "@main/approval-policy"; +import { shouldAutoApprove } from "@main/execution-policy"; +import { UserInputError, UpstreamError, classifyUnknownError } from "@main/errors"; +import type { InvokeInternalToolRpcResult } from "@main/worker/runtime/internal-tools/internal-tool-rpc"; + +export interface FileApprovalHandlerDeps { + db: AppDatabase; + onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void; + 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 FileApprovalHandlerContext { + companyId: string; + agentId: string; +} + +export function handleFileApprovalToolCall( + deps: FileApprovalHandlerDeps, + ctx: FileApprovalHandlerContext, + rawArgs: unknown, +): InvokeInternalToolRpcResult { + const { db } = deps; + const { companyId, agentId: requesterId } = ctx; + + const argObject = typeof rawArgs === "object" && rawArgs !== null + ? (rawArgs as Record) + : {}; + + const parsed = approvalInputSchema.safeParse({ + companyId, + ...argObject, + requestedByAgentId: requesterId, + }); + if (!parsed.success) { + throw new UserInputError({ + code: "file_approval.input.invalid", + message: `Invalid file_approval payload: ${parsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; ")}`, + context: { companyId, requesterId }, + }); + } + const input = parsed.data; + + // Mirror the HTTP route's `approve_ceo_strategy` ordering gate: the + // strategy approval cannot exist before any hire_agent approvals do, + // and cannot be re-filed once it exists. The tool path would + // otherwise re-open the exact loop the HTTP gate was written to + // close. + if (input.type === "approve_ceo_strategy") { + const companyHireApprovals = db.approvalsMod.listCompanyApprovalsByType(companyId, "hire_agent"); + if (companyHireApprovals.length === 0) { + throw new UserInputError({ + code: "file_approval.strategy.before_hires", + message: + "approve_ceo_strategy requires at least one hire_agent approval to exist first. File the intended hires, then the strategy.", + }); + } + const existingStrategy = db.approvalsMod + .listCompanyApprovalsByType(companyId, "approve_ceo_strategy") + .find((a) => a.state === "pending" || a.state === "approved"); + if (existingStrategy) { + throw new UserInputError({ + code: "file_approval.strategy.already_filed", + message: `approve_ceo_strategy already exists for this company (id ${existingStrategy.id}); do not submit another.`, + }); + } + } + + let approvalId: string; + try { + approvalId = db.approvalsMod.requestApproval({ + companyId, + type: input.type, + relatedTaskId: input.relatedTaskId ?? null, + requestedByAgentId: requesterId, + relatedAgentId: input.relatedAgentId ?? null, + payloadSummary: input.payloadSummary, + impactSummary: input.impactSummary, + payloadJson: input.payloadJson ?? null, + }); + } catch (error) { + throw classifyUnknownError(error, { + kind: "upstream", + code: "file_approval.create.failed", + message: error instanceof Error ? error.message : "Approval could not be created.", + context: { companyId, requesterId, type: input.type }, + }); + } + + try { + db.activityMod.addActivity({ + companyId, + actor: requesterId, + action: "approval.created", + entityType: "approval", + entityId: approvalId, + detail: input.payloadSummary, + }); + } catch (error) { + throw new UpstreamError({ + code: "file_approval.activity.failed", + message: "Failed to record activity for approval.", + cause: error, + }); + } + + deps.onApprovalCreated( + approvalId, + companyId, + input.relatedTaskId ?? null, + input.type, + input.payloadSummary ?? "", + ); + + let autoApproved = false; + if (shouldAutoApprove(db, companyId, input.type)) { + resolveApprovalDecision({ + db, + handleApprovalResolved: deps.onApprovalResolved, + companyId, + approvalId, + state: "approved", + decisionNote: "Auto-approved by company policy", + detail: "Auto-approved by company policy", + }); + autoApproved = true; + deps.logger.info( + `[file-approval-handler] Auto-approved approval ${approvalId}: ${input.payloadSummary}`, + ); + } + + deps.onDomainChanged(); + + const summary = autoApproved + ? `Filed ${input.type} approval ${approvalId}. Auto-approved by company policy.` + : `Filed ${input.type} approval ${approvalId}. Pending board decision.`; + + return { + content: [{ type: "text", text: summary }], + details: { + approvalId, + autoApproved, + type: input.type, + }, + isError: false, + }; +} diff --git a/src/main/worker/runtime/internal-tools/file-approval-tool.ts b/src/main/worker/runtime/internal-tools/file-approval-tool.ts new file mode 100644 index 00000000..f19f996c --- /dev/null +++ b/src/main/worker/runtime/internal-tools/file-approval-tool.ts @@ -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; + +/** + * 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 { + 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, + }; + }, + }; +}