Accept more argument shapes for connected-app tools and name what is missing (#202)
Catalog execute calls now merge parameters flattened next to an empty arguments object and unwrap parameters nested under keys like parameters or args, unless the tool itself takes that key. A failed validation lists the required and received keys so the agent can correct its call, and the prompt shows the expected call shape and says connected apps need no token. Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
eb777d63ee
commit
22fa2e1606
@ -205,9 +205,10 @@ describe("formatConnectionsInstruction", () => {
|
||||
tools: [tool("weather_now", "Weather"), ...catalog],
|
||||
});
|
||||
expect(line).toContain("Connected apps: Gmail, Weather, GitHub.");
|
||||
expect(line).toContain("Never tell the user one of these is not connected.");
|
||||
expect(line).toContain("Never tell the user one of these is not connected");
|
||||
expect(line).toContain("never ask them for a token");
|
||||
expect(line).toContain(
|
||||
"For GitHub: find the tool with installed_search_tools, load it with installed_load_tool, then run it with installed_execute_tool.",
|
||||
'For GitHub: find the tool with installed_search_tools, load it with installed_load_tool, then run it with installed_execute_tool, putting its parameters inside arguments: {"id": "<tool id>", "arguments": {...}}.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -87,7 +87,7 @@ export function formatConnectionsInstruction(input: {
|
||||
for (const group of route.catalogGroups) names.add(group);
|
||||
const prefix = tool.name.replace(/_search_tools$/, "");
|
||||
catalogs.push(
|
||||
`For ${route.catalogGroups.map(escapePromptData).join(", ")}: find the tool with ${tool.name}, load it with ${prefix}_load_tool, then run it with ${prefix}_execute_tool.`,
|
||||
`For ${route.catalogGroups.map(escapePromptData).join(", ")}: find the tool with ${tool.name}, load it with ${prefix}_load_tool, then run it with ${prefix}_execute_tool, putting its parameters inside arguments: {"id": "<tool id>", "arguments": {...}}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -95,7 +95,7 @@ export function formatConnectionsInstruction(input: {
|
||||
return "No apps are connected yet. The user connects them under Settings, Integrations.";
|
||||
}
|
||||
return [
|
||||
`Connected apps: ${[...names].map(escapePromptData).join(", ")}. Use their tools for that app's data and actions (repositories, issues, mail, calendar and similar) before the computer browser or web search. Never tell the user one of these is not connected.`,
|
||||
`Connected apps: ${[...names].map(escapePromptData).join(", ")}. Use their tools for that app's data and actions (repositories, issues, mail, calendar and similar) before the computer browser or web search. Never tell the user one of these is not connected, and never ask them for a token, key or password for it: its sign-in is already set up.`,
|
||||
...catalogs,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@ -352,6 +352,25 @@ describe("lazy tool catalog", () => {
|
||||
expect(executeResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unwraps arguments nested under parameters and names what is missing", () => {
|
||||
const entries = catalogEntries(tools(1));
|
||||
const call = (args: Record<string, unknown>) => ({
|
||||
tool: "wrapper",
|
||||
args: { id: "source-0:tool-0", arguments: args },
|
||||
executionId: "exec",
|
||||
route: { connectorId: "test", toolName: "__catalog_execute" },
|
||||
});
|
||||
expect(resolveCatalogCall(call({ parameters: { value: "v" } }), entries).call.args).toEqual({
|
||||
value: "v",
|
||||
});
|
||||
expect(
|
||||
resolveCatalogCall(call({ args: '{"arguments":{"value":"v"}}' }), entries).call.args,
|
||||
).toEqual({ value: "v" });
|
||||
expect(() => resolveCatalogCall(call({ query: "v" }), entries)).toThrow(
|
||||
"Required: value. Received: query.",
|
||||
);
|
||||
});
|
||||
|
||||
it("still rejects non-object args and unknown ids", () => {
|
||||
const entries = catalogEntries(tools(1));
|
||||
expect(() =>
|
||||
@ -481,6 +500,9 @@ describe("prepareCatalogExecuteArguments", () => {
|
||||
arguments: { owner: "o" },
|
||||
});
|
||||
expect(prepareCatalogExecuteArguments({ id: "a" })).toEqual({ id: "a", arguments: {} });
|
||||
expect(
|
||||
prepareCatalogExecuteArguments({ id: "a", arguments: {}, owner: "o", repo: "r" }),
|
||||
).toEqual({ id: "a", arguments: { owner: "o", repo: "r" } });
|
||||
});
|
||||
|
||||
it("leaves unparseable strings for schema validation to reject", () => {
|
||||
|
||||
@ -244,26 +244,56 @@ function assertSchemaSize(entry: CatalogEntry): void {
|
||||
if (schemaBytes > SELECTED_SCHEMA_MAX_BYTES) throw new Error("Tool schema is too large");
|
||||
}
|
||||
|
||||
/** Keys smaller models use instead of `arguments` for the inner tool arguments. */
|
||||
const ARGUMENT_ALIASES = ["arguments", "argument", "args", "parameters", "params", "input"];
|
||||
|
||||
/**
|
||||
* Smaller models often send execute arguments as a JSON string or flatten them next to `id`;
|
||||
* accept both so the call reaches the tool instead of failing schema validation.
|
||||
* Smaller models often send execute arguments as a JSON string, flatten them next to `id`,
|
||||
* or wrap them in another key such as `parameters`; accept those shapes so the call reaches
|
||||
* the tool instead of failing schema validation.
|
||||
*/
|
||||
export function prepareCatalogExecuteArguments(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const { id, arguments: nested, ...rest } = raw;
|
||||
let args: unknown = nested;
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
args = args.trim() ? JSON.parse(args) : {};
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
if (args === undefined || args === null) args = rest;
|
||||
let args = parseArgumentValue(nested);
|
||||
if (args === undefined) return raw;
|
||||
if (args === null) args = {};
|
||||
const flattened = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
if (flattened) args = { ...flattened, ...args };
|
||||
return { id, arguments: args };
|
||||
}
|
||||
|
||||
/** Object for usable values, null for absent ones, undefined when the value cannot be read. */
|
||||
function parseArgumentValue(value: unknown): Record<string, unknown> | null | undefined {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === "string") {
|
||||
if (!value.trim()) return null;
|
||||
try {
|
||||
return parseArgumentValue(JSON.parse(value)) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Unwraps `{ parameters: {...} }`-style nesting unless the tool itself takes that key. */
|
||||
function unwrapArgumentAlias(
|
||||
inputSchema: Record<string, unknown>,
|
||||
args: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const keys = Object.keys(args);
|
||||
const properties = (inputSchema.properties ?? {}) as Record<string, unknown>;
|
||||
const key = keys[0];
|
||||
if (keys.length !== 1 || !key || !ARGUMENT_ALIASES.includes(key) || key in properties) {
|
||||
return args;
|
||||
}
|
||||
const inner = parseArgumentValue(args[key]);
|
||||
return inner ? unwrapArgumentAlias(inputSchema, inner) : args;
|
||||
}
|
||||
|
||||
export function resolveCatalogCall(
|
||||
call: ConnectorCall,
|
||||
entries: CatalogEntry[],
|
||||
@ -273,7 +303,10 @@ export function resolveCatalogCall(
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||
throw new Error("Tool arguments must be an object");
|
||||
}
|
||||
const parsed = parseConnectorToolArgs(entry.tool.inputSchema, args as Record<string, unknown>);
|
||||
const parsed = parseConnectorToolArgs(
|
||||
entry.tool.inputSchema,
|
||||
unwrapArgumentAlias(entry.tool.inputSchema, args as Record<string, unknown>),
|
||||
);
|
||||
return {
|
||||
tool: entry.tool,
|
||||
call: {
|
||||
@ -300,7 +333,12 @@ export function parseConnectorToolArgs(
|
||||
const schema = z.fromJSONSchema(inputSchema as never);
|
||||
const parsed = schema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Tool arguments are invalid: ${z.prettifyError(parsed.error)}`);
|
||||
const required = Array.isArray(inputSchema.required) ? inputSchema.required.join(", ") : "";
|
||||
const received = Object.keys(args).join(", ") || "none";
|
||||
throw new Error(
|
||||
`Tool arguments are invalid: ${z.prettifyError(parsed.error)}\n` +
|
||||
`Required: ${required || "none"}. Received: ${received}.`,
|
||||
);
|
||||
}
|
||||
return parsed.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user