From 53e84c0737028432e8646f99a38eea16e0c77813 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Tue, 18 Jun 2024 18:45:51 -0400 Subject: [PATCH] Tools can have multiple functions. --- CHANGELOG.md | 13 +++++ README.md | 8 +-- TODO.md | 2 + src/experts/assistant.js | 4 +- src/experts/run.js | 19 ++++++- test/fixtures.js | 4 ++ test/fixtures/accountsAssistant.js | 72 +++++++++++++++++++++++++ test/fixtures/echoTool.js | 2 +- test/fixtures/noLLMToolAssistant.js | 4 +- test/fixtures/outputsAssistant.js | 8 +-- test/fixtures/productsAssistant.js | 2 +- test/fixtures/productsOpenSearchTool.js | 2 +- test/fixtures/productsTool.js | 4 +- test/fixtures/toolboxAssistant.js | 4 +- test/uat/multiTool.test.js | 17 ++++++ 15 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 TODO.md create mode 100644 test/fixtures/accountsAssistant.js create mode 100644 test/uat/multiTool.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ac040a7..da2c048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ See this http://keepachangelog.com link for information on how we want this documented formatted. +## v1.3.0 + +### Fixed + +Now `parentsTools` can now have multiple functions present. This should have worked all along but was overlooked. See changes around `MyTool.toolName` below. + +### Changed + +No documented usage of `MyTool.toolName`. It is still used internally for a Tool's thread meta. The function is still available for use, but it is not recommended. + +> [!CAUTION] +> It is critical that your tool's function name be unique across its parent's entire set of tool names. + ## v1.2.0 ### Changed diff --git a/README.md b/README.md index 13b150f..6b47cce 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ class EchoTool extends Tool { { type: "function", function: { - name: EchoTool.toolName, + name: "echo", description: description, parameters: { type: "object", @@ -170,7 +170,7 @@ class EchoTool extends Tool { ``` > [!CAUTION] -> It is critical that your tool's function name use the `toolName` getter. Experts.js converts this to a snake_case string and uses the name to find the the right tool and call it. +> It is critical that your tool's function name be unique across its parent's entire set of tool names. As such, Tool class names are important and help OpenAI's models decide which tool to call. So pick a good name for your tool class. For example, `ProductsOpenSearchTool` will be `products_open_search` and clearly helps the model infer along with the tool's description what role it performs. @@ -282,7 +282,7 @@ class CarpenterAssistant extends Assistant { run_options: { tool_choice: { type: "function", - function: { name: MyTool.toolName }, + function: { name: "my_tool_name" }, }, }, }); @@ -296,7 +296,7 @@ Alternatively, you can pass an options object to the `ask` method to be used for ```javascript await assistant.ask("...", "thread_abc123", { run: { - tool_choice: { type: "function", function: { name: MyTool.toolName } }, + tool_choice: { type: "function", function: { name: "my_tool_name" } }, additional_instructions: "...", additional_messages: [...], }, diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6483efe --- /dev/null +++ b/TODO.md @@ -0,0 +1,2 @@ + +* Add a buffered output tool (append, prepend) to support bespoke user interfaces. diff --git a/src/experts/assistant.js b/src/experts/assistant.js index ec6d33d..f13963d 100644 --- a/src/experts/assistant.js +++ b/src/experts/assistant.js @@ -38,7 +38,7 @@ class Assistant { this.temperature = options.temperature !== undefined ? options.temperature : 1.0; this.top_p = options.top_p !== undefined ? options.top_p : 1.0; - this.experts = {}; + this.experts = []; this.tools = options.tools || []; this.tool_resources = options.tool_resources || {}; this._metadata = options.metadata; @@ -104,7 +104,7 @@ class Assistant { addAssistantTool(toolClass) { const assistantTool = new toolClass(); - this.experts[assistantTool.toolName] = assistantTool; + this.experts.push(assistantTool); if (assistantTool.isParentsTools) { for (const tool of assistantTool.parentsTools) { this.tools.push(tool); diff --git a/src/experts/run.js b/src/experts/run.js index 37b4f72..78d5cbd 100644 --- a/src/experts/run.js +++ b/src/experts/run.js @@ -89,7 +89,7 @@ class Run { debug("🪚 " + JSON.stringify(toolCall)); if (toolCall.type === "function") { const toolOutput = { tool_call_id: toolCall.id }; - const toolCaller = this.assistant.experts[toolCall.function.name]; + const toolCaller = this.#findExpertByToolName(toolCall.function.name); if (toolCaller && typeof toolCaller.ask === "function") { const output = await toolCaller.ask( toolCall.function.arguments, @@ -123,6 +123,23 @@ class Run { }); } } + + #findExpertByToolName(functionName) { + let toolCaller; + this.assistant.experts.forEach((expert) => { + if (expert.isParentsTools) { + expert.parentsTools.forEach((parentTool) => { + if ( + parentTool.type === "function" && + parentTool.function.name === functionName + ) { + toolCaller = expert; + } + }); + } + }); + return toolCaller; + } } export { Run }; diff --git a/test/fixtures.js b/test/fixtures.js index a89b452..ce71869 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -8,3 +8,7 @@ export { ProductsAssistant } from "./fixtures/productsAssistant.js"; export { NoLLMToolAssistant } from "./fixtures/noLLMToolAssistant.js"; export { OutputsAssistant } from "./fixtures/outputsAssistant.js"; export { EventedAssistant } from "./fixtures/eventedAssistant.js"; +export { + AccountsAssistant, + AccountsTool, +} from "./fixtures/accountsAssistant.js"; diff --git a/test/fixtures/accountsAssistant.js b/test/fixtures/accountsAssistant.js new file mode 100644 index 0000000..b108263 --- /dev/null +++ b/test/fixtures/accountsAssistant.js @@ -0,0 +1,72 @@ +import { Tool } from "../../src/experts/tool.js"; +import { Assistant } from "../../src/experts/assistant.js"; + +class AccountsTool extends Tool { + constructor() { + super({ + llm: false, + parentsTools: [ + { + type: "function", + function: { + name: "accounts_find_by_id", + description: "Find an account by ID", + parameters: { + type: "object", + properties: { id: { type: "integer" } }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "accounts_find_by_email", + description: "Find an account by Email", + parameters: { + type: "object", + properties: { email: { type: "string" } }, + required: ["email"], + }, + }, + }, + ], + }); + } + + async ask(message) { + const params = JSON.parse(message); + if (params.id) { + return this.#findByID(params.id); + } else if (params.email) { + return this.#findByEmail(params.email); + } + } + + #findByID(id) { + return JSON.stringify({ + id: id, + email: "user@example.com", + name: "Jordan Whitaker", + }); + } + + #findByEmail(email) { + return JSON.stringify({ + id: 838282, + email: email, + name: "Elena Prescott", + }); + } +} + +class AccountsAssistant extends Assistant { + constructor() { + super({ + instructions: "Routes messages to the right tool.", + }); + this.addAssistantTool(AccountsTool); + } +} + +export { AccountsAssistant, AccountsTool }; diff --git a/test/fixtures/echoTool.js b/test/fixtures/echoTool.js index eaf7494..a69a3eb 100644 --- a/test/fixtures/echoTool.js +++ b/test/fixtures/echoTool.js @@ -10,7 +10,7 @@ class EchoTool extends Tool { { type: "function", function: { - name: EchoTool.toolName, + name: "echo", description: "Echo", parameters: { type: "object", diff --git a/test/fixtures/noLLMToolAssistant.js b/test/fixtures/noLLMToolAssistant.js index af7a46a..bea0097 100644 --- a/test/fixtures/noLLMToolAssistant.js +++ b/test/fixtures/noLLMToolAssistant.js @@ -9,7 +9,7 @@ class AnswerTool extends Tool { { type: "function", function: { - name: AnswerTool.toolName, + name: "answer", description: "Answers to messages.", parameters: { type: "object", @@ -32,7 +32,7 @@ class NoLLMToolAssistant extends Assistant { super({ name: helperName("NoLLMToolAssistant"), description: "Answers to messages.", - instructions: `You must route /tool messages in full to your '${AnswerTool.toolName}' tool. Never respond without first using that tool. Never! Ex: When asked what color the sky is, use the '${AnswerTool.toolName}' tool first.`, + instructions: `You must route /tool messages in full to your 'answer' tool. Never respond without first using that tool. Never! Ex: When asked what color the sky is, use the 'answer' tool first.`, }); this.addAssistantTool(AnswerTool); } diff --git a/test/fixtures/outputsAssistant.js b/test/fixtures/outputsAssistant.js index 27c5264..8d0262c 100644 --- a/test/fixtures/outputsAssistant.js +++ b/test/fixtures/outputsAssistant.js @@ -9,7 +9,7 @@ class AnswerTwoTool extends Tool { { type: "function", function: { - name: AnswerTwoTool.toolName, + name: "answer_two", description: "Answers to messages.", parameters: { type: "object", @@ -32,14 +32,14 @@ class AnswerOneTool extends Tool { super({ name: helperName("AnswerOneTool"), description: "Answers to messages.", - instructions: `You must route the message in full to your '${AnswerTwoTool.toolName}' tool. Never respond without first using that tool. Never! Ex: When asked what is my favorite food, use the '${AnswerTwoTool.toolName}' tool first. Lastly, tespond only with the single word 'Success' to the question.`, + instructions: `You must route the message in full to your 'answer_two' tool. Never respond without first using that tool. Never! Ex: When asked what is my favorite food, use the 'answer_two' tool first. Lastly, tespond only with the single word 'Success' to the question.`, temperature: 0.1, outputs: "tools", // THIS: Focus of the test. Combined with only success response. parentsTools: [ { type: "function", function: { - name: AnswerOneTool.toolName, + name: "answer_one", description: "Answers to messages.", parameters: { type: "object", @@ -59,7 +59,7 @@ class OutputsAssistant extends Assistant { super({ name: helperName("OutputsAssistant"), description: "Answers to messages.", - instructions: `You must route the message in full to your '${AnswerOneTool.toolName}' tool. Never respond without first using that tool. Never! Ex: When asked what is my favorite food, use the '${AnswerOneTool.toolName}' tool first.`, + instructions: `You must route the message in full to your 'answer_one' tool. Never respond without first using that tool. Never! Ex: When asked what is my favorite food, use the 'answer_one' tool first.`, temperature: 0.1, }); this.addAssistantTool(AnswerOneTool); diff --git a/test/fixtures/productsAssistant.js b/test/fixtures/productsAssistant.js index 8ecfd71..c0936f8 100644 --- a/test/fixtures/productsAssistant.js +++ b/test/fixtures/productsAssistant.js @@ -7,7 +7,7 @@ You are an assistant for an apparel company that orchestrates customer messages Follow these rules: -1. When using your '${ProductsTool.toolName}' tool, send verbose messaegs. +1. When using your 'products' tool, send verbose messaegs. 2. Do not mention download links in the response. Assume images are always shown. 3. Always show images using markdown to the customer. `.trim(); diff --git a/test/fixtures/productsOpenSearchTool.js b/test/fixtures/productsOpenSearchTool.js index e53b957..af82100 100644 --- a/test/fixtures/productsOpenSearchTool.js +++ b/test/fixtures/productsOpenSearchTool.js @@ -21,7 +21,7 @@ class ProductsOpenSearchTool extends Tool { { type: "function", function: { - name: ProductsOpenSearchTool.toolName, + name: "products_open_search", description: "Can turn customer's requests into search queries and return aggregate or itemized product data. Please be verbose and submit the customer's complete message or conversation summary needed to fulfill their latest request.", parameters: { diff --git a/test/fixtures/productsTool.js b/test/fixtures/productsTool.js index 2a3f49c..bee2f19 100644 --- a/test/fixtures/productsTool.js +++ b/test/fixtures/productsTool.js @@ -11,7 +11,7 @@ You are part of a product catalog panel of experts that responds to a customer's Follow these rules: -1. Use your '${ProductsOpenSearchTool.toolName}' to search for product data. +1. Use your 'products_open_search' to search for product data. 2. Always use your 'code_interpreter' tool to generate images for charts or graphs. 3. Respond only with the single word "Success" to the customer. `.trim(); @@ -29,7 +29,7 @@ class ProductsTool extends Tool { { type: "function", function: { - name: ProductsTool.toolName, + name: "products", description: "Can search and analyze the apparel product data. Please be verbose and submit the customer's complete message or conversation summary needed to fulfill their latest request.", parameters: { diff --git a/test/fixtures/toolboxAssistant.js b/test/fixtures/toolboxAssistant.js index 4e9ffd3..7751df4 100644 --- a/test/fixtures/toolboxAssistant.js +++ b/test/fixtures/toolboxAssistant.js @@ -10,7 +10,7 @@ class DrillTool extends Tool { { type: "function", function: { - name: DrillTool.toolName, + name: "drill", description: "A drill tool.", parameters: { type: "object", @@ -40,7 +40,7 @@ class CarpenterAssistant extends Assistant { run_options: { tool_choice: { type: "function", - function: { name: DrillTool.toolName }, + function: { name: "drill" }, }, }, }); diff --git a/test/uat/multiTool.test.js b/test/uat/multiTool.test.js new file mode 100644 index 0000000..134333a --- /dev/null +++ b/test/uat/multiTool.test.js @@ -0,0 +1,17 @@ +import { helperThreadID } from "../helpers.js"; +import { AccountsAssistant } from "../fixtures.js"; + +test("can find an expert that has many tools", async () => { + const assistant = await AccountsAssistant.create(); + const threadID = await helperThreadID(); + const output = await assistant.ask( + "What is the name of the user with an id of 32916?", + threadID + ); + expect(output).toMatch(/Jordan Whitaker/i); + const output2 = await assistant.ask( + "What is the name of the user with email of person@company.com?", + threadID + ); + expect(output2).toMatch(/Elena Prescott/i); +}, 20000);