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:
1 parent
e2c839be49
commit
d65f156732
2 files changed
+298
No files matched your search
@@ -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<string, unknown>)
|
||||
: {};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user