284 lines
8.8 KiB
TypeScript
284 lines
8.8 KiB
TypeScript
import type {
|
|
AgentTool,
|
|
AgentToolResult,
|
|
AgentToolUpdateCallback,
|
|
} from "@mariozechner/pi-agent-core";
|
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
import { logDebug, logError } from "../logger.js";
|
|
import { isPlainObject } from "../utils.js";
|
|
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
|
|
import type { HookContext } from "./pi-tools.before-tool-call.js";
|
|
import {
|
|
isToolWrappedWithBeforeToolCallHook,
|
|
runBeforeToolCallHook,
|
|
} from "./pi-tools.before-tool-call.js";
|
|
import { normalizeToolName } from "./tool-policy.js";
|
|
import { jsonResult } from "./tools/common.js";
|
|
|
|
type AnyAgentTool = AgentTool;
|
|
|
|
type ToolExecuteArgsCurrent = [
|
|
string,
|
|
unknown,
|
|
AbortSignal | undefined,
|
|
AgentToolUpdateCallback<unknown> | undefined,
|
|
unknown,
|
|
];
|
|
type ToolExecuteArgsLegacy = [
|
|
string,
|
|
unknown,
|
|
AgentToolUpdateCallback<unknown> | undefined,
|
|
unknown,
|
|
AbortSignal | undefined,
|
|
];
|
|
type ToolExecuteArgs = ToolDefinition["execute"] extends (...args: infer P) => unknown
|
|
? P
|
|
: ToolExecuteArgsCurrent;
|
|
type ToolExecuteArgsAny = ToolExecuteArgs | ToolExecuteArgsLegacy | ToolExecuteArgsCurrent;
|
|
|
|
function isAbortSignal(value: unknown): value is AbortSignal {
|
|
return typeof value === "object" && value !== null && "aborted" in value;
|
|
}
|
|
|
|
function isLegacyToolExecuteArgs(args: ToolExecuteArgsAny): args is ToolExecuteArgsLegacy {
|
|
const third = args[2];
|
|
const fifth = args[4];
|
|
if (typeof third === "function") {
|
|
return true;
|
|
}
|
|
return isAbortSignal(fifth);
|
|
}
|
|
|
|
function describeToolExecutionError(err: unknown): {
|
|
message: string;
|
|
stack?: string;
|
|
} {
|
|
if (err instanceof Error) {
|
|
const message = err.message?.trim() ? err.message : String(err);
|
|
return { message, stack: err.stack };
|
|
}
|
|
return { message: String(err) };
|
|
}
|
|
|
|
function stringifyToolPayload(payload: unknown): string {
|
|
if (typeof payload === "string") {
|
|
return payload;
|
|
}
|
|
try {
|
|
const encoded = JSON.stringify(payload, null, 2);
|
|
if (typeof encoded === "string") {
|
|
return encoded;
|
|
}
|
|
} catch {
|
|
// Fall through to String(payload) for non-serializable values.
|
|
}
|
|
return String(payload);
|
|
}
|
|
|
|
function normalizeToolExecutionResult(params: {
|
|
toolName: string;
|
|
result: unknown;
|
|
}): AgentToolResult<unknown> {
|
|
const { toolName, result } = params;
|
|
if (result && typeof result === "object") {
|
|
const record = result as Record<string, unknown>;
|
|
if (Array.isArray(record.content)) {
|
|
return result as AgentToolResult<unknown>;
|
|
}
|
|
logDebug(`tools: ${toolName} returned non-standard result (missing content[]); coercing`);
|
|
const details = "details" in record ? record.details : record;
|
|
const safeDetails = details ?? { status: "ok", tool: toolName };
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: stringifyToolPayload(safeDetails),
|
|
},
|
|
],
|
|
details: safeDetails,
|
|
};
|
|
}
|
|
const safeDetails = result ?? { status: "ok", tool: toolName };
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: stringifyToolPayload(safeDetails),
|
|
},
|
|
],
|
|
details: safeDetails,
|
|
};
|
|
}
|
|
|
|
function splitToolExecuteArgs(args: ToolExecuteArgsAny): {
|
|
toolCallId: string;
|
|
params: unknown;
|
|
onUpdate: AgentToolUpdateCallback<unknown> | undefined;
|
|
signal: AbortSignal | undefined;
|
|
} {
|
|
if (isLegacyToolExecuteArgs(args)) {
|
|
const [toolCallId, params, onUpdate, _ctx, signal] = args;
|
|
return {
|
|
toolCallId,
|
|
params,
|
|
onUpdate,
|
|
signal,
|
|
};
|
|
}
|
|
const [toolCallId, params, signal, onUpdate] = args;
|
|
return {
|
|
toolCallId,
|
|
params,
|
|
onUpdate,
|
|
signal,
|
|
};
|
|
}
|
|
|
|
export const CLIENT_TOOL_NAME_CONFLICT_PREFIX = "client tool name conflict:";
|
|
|
|
export function findClientToolNameConflicts(params: {
|
|
tools: ClientToolDefinition[];
|
|
existingToolNames?: Iterable<string>;
|
|
}): string[] {
|
|
const existingNormalized = new Set<string>();
|
|
for (const name of params.existingToolNames ?? []) {
|
|
const trimmed = String(name).trim();
|
|
if (trimmed) {
|
|
existingNormalized.add(normalizeToolName(trimmed));
|
|
}
|
|
}
|
|
|
|
const conflicts = new Set<string>();
|
|
const seenClientNames = new Map<string, string>();
|
|
for (const tool of params.tools) {
|
|
const rawName = String(tool.function?.name ?? "").trim();
|
|
if (!rawName) {
|
|
continue;
|
|
}
|
|
const normalizedName = normalizeToolName(rawName);
|
|
if (existingNormalized.has(normalizedName)) {
|
|
conflicts.add(rawName);
|
|
}
|
|
// Keep the first client-provided spelling for each normalized name so every
|
|
// later duplicate is reported against a stable original entry, even when
|
|
// the later name also collides with an existing built-in tool.
|
|
const priorClientName = seenClientNames.get(normalizedName);
|
|
if (priorClientName) {
|
|
conflicts.add(priorClientName);
|
|
conflicts.add(rawName);
|
|
continue;
|
|
}
|
|
seenClientNames.set(normalizedName, rawName);
|
|
}
|
|
return Array.from(conflicts);
|
|
}
|
|
|
|
export function createClientToolNameConflictError(conflicts: string[]): Error {
|
|
return new Error(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} ${conflicts.join(", ")}`);
|
|
}
|
|
|
|
export function isClientToolNameConflictError(err: unknown): err is Error {
|
|
return err instanceof Error && err.message.startsWith(CLIENT_TOOL_NAME_CONFLICT_PREFIX);
|
|
}
|
|
|
|
export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
|
return tools.map((tool) => {
|
|
const name = tool.name || "tool";
|
|
const normalizedName = normalizeToolName(name);
|
|
const beforeHookWrapped = isToolWrappedWithBeforeToolCallHook(tool);
|
|
return {
|
|
name,
|
|
label: tool.label ?? name,
|
|
description: tool.description ?? "",
|
|
parameters: tool.parameters,
|
|
execute: async (...args: ToolExecuteArgs): Promise<AgentToolResult<unknown>> => {
|
|
const { toolCallId, params, onUpdate, signal } = splitToolExecuteArgs(args);
|
|
let executeParams = params;
|
|
try {
|
|
if (!beforeHookWrapped) {
|
|
const hookOutcome = await runBeforeToolCallHook({
|
|
toolName: name,
|
|
params,
|
|
toolCallId,
|
|
});
|
|
if (hookOutcome.blocked) {
|
|
throw new Error(hookOutcome.reason);
|
|
}
|
|
executeParams = hookOutcome.params;
|
|
}
|
|
const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate);
|
|
const result = normalizeToolExecutionResult({
|
|
toolName: normalizedName,
|
|
result: rawResult,
|
|
});
|
|
return result;
|
|
} catch (err) {
|
|
if (signal?.aborted) {
|
|
throw err;
|
|
}
|
|
const name =
|
|
err && typeof err === "object" && "name" in err
|
|
? String((err as { name?: unknown }).name)
|
|
: "";
|
|
if (name === "AbortError") {
|
|
throw err;
|
|
}
|
|
const described = describeToolExecutionError(err);
|
|
if (described.stack && described.stack !== described.message) {
|
|
logDebug(`tools: ${normalizedName} failed stack:\n${described.stack}`);
|
|
}
|
|
logError(`[tools] ${normalizedName} failed: ${described.message}`);
|
|
|
|
return jsonResult({
|
|
status: "error",
|
|
tool: normalizedName,
|
|
error: described.message,
|
|
});
|
|
}
|
|
},
|
|
} satisfies ToolDefinition;
|
|
});
|
|
}
|
|
|
|
// Convert client tools (OpenResponses hosted tools) to ToolDefinition format
|
|
// These tools are intercepted to return a "pending" result instead of executing
|
|
export function toClientToolDefinitions(
|
|
tools: ClientToolDefinition[],
|
|
onClientToolCall?: (toolName: string, params: Record<string, unknown>) => void,
|
|
hookContext?: HookContext,
|
|
): ToolDefinition[] {
|
|
return tools.map((tool) => {
|
|
const func = tool.function;
|
|
return {
|
|
name: func.name,
|
|
label: func.name,
|
|
description: func.description ?? "",
|
|
parameters: func.parameters as ToolDefinition["parameters"],
|
|
execute: async (...args: ToolExecuteArgs): Promise<AgentToolResult<unknown>> => {
|
|
const { toolCallId, params } = splitToolExecuteArgs(args);
|
|
const outcome = await runBeforeToolCallHook({
|
|
toolName: func.name,
|
|
params,
|
|
toolCallId,
|
|
ctx: hookContext,
|
|
});
|
|
if (outcome.blocked) {
|
|
throw new Error(outcome.reason);
|
|
}
|
|
const adjustedParams = outcome.params;
|
|
const paramsRecord = isPlainObject(adjustedParams) ? adjustedParams : {};
|
|
// Notify handler that a client tool was called
|
|
if (onClientToolCall) {
|
|
onClientToolCall(func.name, paramsRecord);
|
|
}
|
|
// Return a pending result - the client will execute this tool
|
|
return jsonResult({
|
|
status: "pending",
|
|
tool: func.name,
|
|
message: "Tool execution delegated to client",
|
|
});
|
|
},
|
|
} satisfies ToolDefinition;
|
|
});
|
|
}
|