diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index 609a102..87c031a 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -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": "", "arguments": {...}}.', ); }); diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 48e25b5..5e5b8d6 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -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": "", "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"); } diff --git a/packages/adapters/src/lazy-tool-catalog.test.ts b/packages/adapters/src/lazy-tool-catalog.test.ts index 20a43de..44cf5db 100644 --- a/packages/adapters/src/lazy-tool-catalog.test.ts +++ b/packages/adapters/src/lazy-tool-catalog.test.ts @@ -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) => ({ + 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", () => { diff --git a/packages/adapters/src/lazy-tool-catalog.ts b/packages/adapters/src/lazy-tool-catalog.ts index aa82d8c..3f46e73 100644 --- a/packages/adapters/src/lazy-tool-catalog.ts +++ b/packages/adapters/src/lazy-tool-catalog.ts @@ -244,24 +244,54 @@ 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, ): Record { const { id, arguments: nested, ...rest } = raw; - let args: unknown = nested; - if (typeof args === "string") { + 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 | null | undefined { + if (value === undefined || value === null) return null; + if (typeof value === "string") { + if (!value.trim()) return null; try { - args = args.trim() ? JSON.parse(args) : {}; + return parseArgumentValue(JSON.parse(value)) ?? undefined; } catch { - return raw; + return undefined; } } - if (args === undefined || args === null) args = rest; - return { id, arguments: args }; + if (typeof value === "object" && !Array.isArray(value)) return value as Record; + return undefined; +} + +/** Unwraps `{ parameters: {...} }`-style nesting unless the tool itself takes that key. */ +function unwrapArgumentAlias( + inputSchema: Record, + args: Record, +): Record { + const keys = Object.keys(args); + const properties = (inputSchema.properties ?? {}) as Record; + 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( @@ -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); + const parsed = parseConnectorToolArgs( + entry.tool.inputSchema, + unwrapArgumentAlias(entry.tool.inputSchema, args as Record), + ); 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; }