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