From 7afa13249c82b058597e7b7ae981c150a2b061dd Mon Sep 17 00:00:00 2001 From: Yash_10 Date: Tue, 29 Sep 2026 05:01:08 +0000 Subject: [PATCH] fix(tools): declare transfer_nft.fromAddress in getToolDefinitions The native tool schema dropped the optional fromAddress that nativeSystemPrompt() advertises as fromAddress?, so a native-mode model could either send an undeclared argument or never learn the approval transfer path exists. Add it as an optional string property (not in required) and pin both the transfer_nft schema and a generic ACTIONS-vs-schema drift check so the two surfaces cannot diverge again. --- src/tools.mjs | 5 +++ test/native-tools-no-model.test.mjs | 52 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/tools.mjs b/src/tools.mjs index c6fa45b..8a9a894 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -1300,6 +1300,11 @@ export function getToolDefinitions() { type: "string", description: "Token ID of the NFT to transfer", }, + fromAddress: { + type: "string", + description: + "Optional 0x address of the NFT owner to transfer from. Omit it to transfer from the agent's own wallet; set it only for a token owned by another address that has approved this account.", + }, }, required: ["to", "contractAddress", "tokenId"], }, diff --git a/test/native-tools-no-model.test.mjs b/test/native-tools-no-model.test.mjs index 429f537..c8ac1dc 100644 --- a/test/native-tools-no-model.test.mjs +++ b/test/native-tools-no-model.test.mjs @@ -89,6 +89,58 @@ describe("Native tool-calling — schema validation", () => { assert.deepEqual(getTokenBal.parameters.required, ["token"]); assert.ok(getTokenBal.parameters.properties.token); }); + + test("transfer_nft declares optional fromAddress (issue #112)", () => { + const tools = getToolDefinitions(); + const transfer = tools.find((t) => t.name === "transfer_nft"); + assert.ok(transfer, "transfer_nft tool not found"); + + const { properties, required } = transfer.parameters; + assert.deepEqual( + new Set(required), + new Set(["to", "contractAddress", "tokenId"]), + "fromAddress must stay out of required" + ); + assert.ok(properties.fromAddress, "transfer_nft schema is missing optional fromAddress"); + assert.equal(properties.fromAddress.type, "string", "fromAddress must be a string property"); + assert.ok( + !required.includes("fromAddress"), + "fromAddress must not be required — the prompt advertises it as optional" + ); + // The prompt promises a fallback when it is omitted, so the schema must too. + assert.ok(properties.fromAddress.description, "fromAddress needs a description"); + }); + + test("no tool schema drifts from ACTIONS args + optionalArgs", () => { + // getToolDefinitions() is hand-written while systemPrompt() and + // nativeSystemPrompt() are generated from ACTIONS, so the two surfaces can + // disagree (issue #112). Every tool must declare exactly the arguments its + // prompt line advertises, with optionalArgs optional rather than required. + // hasRequiredArgs() in src/tools.mjs lets these two actions run with no + // arguments at all, so their `args` are opt-in and stay out of `required`. + const noRequiredArgs = new Set(["account", "get_nfts"]); + + for (const tool of getToolDefinitions()) { + const spec = ACTIONS[tool.name]; + assert.ok(spec, `${tool.name} has no ACTIONS entry`); + const optional = spec.optionalArgs ?? []; + const expected = [...spec.args, ...optional]; + const expectedRequired = noRequiredArgs.has(tool.name) + ? [] + : spec.args.filter((arg) => !optional.includes(arg)); + + assert.deepEqual( + Object.keys(tool.parameters.properties).sort(), + expected.sort(), + `${tool.name} properties drifted from ACTIONS` + ); + assert.deepEqual( + [...tool.parameters.required].sort(), + expectedRequired.sort(), + `${tool.name} required drifted from ACTIONS` + ); + } + }); }); // ─────────────────────────────────────────────────────────────────────────────