From 3f06cef60c0bc76a1c6e147a94cb25ab482aee0f Mon Sep 17 00:00:00 2001 From: Mikko Korpela Date: Mon, 1 Apr 2024 21:29:39 +0300 Subject: [PATCH] robocorp[patch]: Fix nested arguments descriptors and tool names (#19707) Thank you for contributing to LangChain! - [x] **PR title**: "package: description" - Where "package" is whichever of langchain, community, core, experimental, etc. is being modified. Use "docs: ..." for purely docs changes, "templates: ..." for template changes, "infra: ..." for CI changes. - Example: "community: add foobar LLM" - [x] **PR message**: - **Description:** Fix argument translation from OpenAPI spec to OpenAI function call (and similar) - **Issue:** OpenGPTs failures with calling Action Server based actions. - **Dependencies:** None - **Twitter handle:** mikkorpela - [x] **Add tests and docs**: If you're adding a new integration, please include 1. a test for the integration, preferably unit tests that do not rely on network access, ~2. an example notebook showing its use. It lives in `docs/docs/integrations` directory.~ - [x] **Lint and test**: Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. See contribution guidelines for more: https://python.langchain.com/docs/contributing/ Additional guidelines: - Make sure optional dependencies are imported within a function. - Please do not add dependencies to pyproject.toml files (even optional ones) unless they are required for unit tests. - Most PRs should not touch more than one package. - Changes should be backwards compatible. - If you are adding something to community, do not re-import it in langchain. If no one reviews your PR within a few days, please @-mention one of baskaryan, efriis, eyurtsev, hwchase17. --- libs/partners/robocorp/README.md | 3 +- .../robocorp/langchain_robocorp/_common.py | 88 ++-- .../robocorp/langchain_robocorp/_prompts.py | 12 +- .../robocorp/langchain_robocorp/toolkits.py | 28 +- libs/partners/robocorp/poetry.lock | 17 +- libs/partners/robocorp/pyproject.toml | 4 +- .../tests/unit_tests/_openapi2.fixture.json | 387 ++++++++++++++++++ .../tests/unit_tests/test_toolkits.py | 111 +++++ 8 files changed, 580 insertions(+), 70 deletions(-) create mode 100644 libs/partners/robocorp/tests/unit_tests/_openapi2.fixture.json diff --git a/libs/partners/robocorp/README.md b/libs/partners/robocorp/README.md index 93181de70f..06293a8abe 100644 --- a/libs/partners/robocorp/README.md +++ b/libs/partners/robocorp/README.md @@ -1,6 +1,7 @@ # langchain-robocorp -This package contains the LangChain integrations for [Robocorp](https://github.com/robocorp/robocorp). +This package contains the LangChain integrations for [Robocorp Action Server](https://github.com/robocorp/robocorp). +Action Server enables an agent to execute actions in the real world. ## Installation diff --git a/libs/partners/robocorp/langchain_robocorp/_common.py b/libs/partners/robocorp/langchain_robocorp/_common.py index d96d89fcf9..32ce51d44e 100644 --- a/libs/partners/robocorp/langchain_robocorp/_common.py +++ b/libs/partners/robocorp/langchain_robocorp/_common.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from typing import List, Tuple +from typing import Any, Dict, List, Tuple, Union +from langchain_core.pydantic_v1 import BaseModel, Field, create_model from langchain_core.utils.json_schema import dereference_refs @@ -72,28 +73,6 @@ def reduce_openapi_spec(url: str, spec: dict) -> ReducedOpenAPISpec: ) -def get_required_param_descriptions(endpoint_spec: dict) -> str: - """Get an OpenAPI endpoint required parameter descriptions""" - descriptions = [] - - schema = ( - endpoint_spec.get("requestBody", {}) - .get("content", {}) - .get("application/json", {}) - .get("schema", {}) - ) - properties = schema.get("properties", {}) - - required_fields = schema.get("required", []) - - for key, value in properties.items(): - if "description" in value: - if value.get("required") or key in required_fields: - descriptions.append(value.get("description")) - - return ", ".join(descriptions) - - type_mapping = { "string": str, "integer": int, @@ -105,25 +84,66 @@ type_mapping = { } -def get_param_fields(endpoint_spec: dict) -> dict: - """Get an OpenAPI endpoint parameter details""" - fields = {} - - schema = ( +def get_schema(endpoint_spec: dict) -> dict: + return ( endpoint_spec.get("requestBody", {}) .get("content", {}) .get("application/json", {}) .get("schema", {}) ) + + +def create_field(schema: dict, required: bool) -> Tuple[Any, Any]: + """ + Creates a Pydantic field based on the schema definition. + """ + field_type = type_mapping.get(schema.get("type", "string"), str) + description = schema.get("description", "") + + # Handle nested objects + if schema["type"] == "object": + nested_fields = { + k: create_field(v, k in schema.get("required", [])) + for k, v in schema.get("properties", {}).items() + } + model_name = schema.get("title", "NestedModel") + nested_model = create_model(model_name, **nested_fields) # type: ignore + return nested_model, Field(... if required else None, description=description) + + # Handle arrays + elif schema["type"] == "array": + item_type, _ = create_field(schema["items"], required=True) + return List[item_type], Field( # type: ignore + ... if required else None, description=description + ) + + # Other types + return field_type, Field(... if required else None, description=description) + + +def get_param_fields(endpoint_spec: dict) -> dict: + """Get an OpenAPI endpoint parameter details""" + schema = get_schema(endpoint_spec) properties = schema.get("properties", {}) required_fields = schema.get("required", []) + fields = {} for key, value in properties.items(): - details = { - "description": value.get("description", ""), - "required": key in required_fields, - } - field_type = type_mapping[value.get("type", "string")] - fields[key] = (field_type, details) + is_required = key in required_fields + field_info = create_field(value, is_required) + fields[key] = field_info return fields + + +def model_to_dict( + item: Union[BaseModel, List, Dict[str, Any]], +) -> Any: + if isinstance(item, BaseModel): + return item.dict() + elif isinstance(item, dict): + return {key: model_to_dict(value) for key, value in item.items()} + elif isinstance(item, list): + return [model_to_dict(element) for element in item] + else: + return item diff --git a/libs/partners/robocorp/langchain_robocorp/_prompts.py b/libs/partners/robocorp/langchain_robocorp/_prompts.py index 13f4b3f6b8..fc1ea32303 100644 --- a/libs/partners/robocorp/langchain_robocorp/_prompts.py +++ b/libs/partners/robocorp/langchain_robocorp/_prompts.py @@ -1,11 +1,10 @@ -# flake8: noqa -TOOLKIT_TOOL_DESCRIPTION = """{description}. The tool must be invoked with a complete sentence starting with "{name}" and additional information on {required_params}.""" - - -API_CONTROLLER_PROMPT = """You are turning user input into a json query for an API request tool. +API_CONTROLLER_PROMPT = ( + "You are turning user input into a json query" + """ for an API request tool. The final output to the tool should be a json string with a single key "data". -The value of "data" should be a dictionary of key-value pairs you want to POST to the url. +The value of "data" should be a dictionary of key-value pairs you want """ + """to POST to the url. Always use double quotes for strings in the json string. Always respond only with the json object and nothing else. @@ -16,3 +15,4 @@ Endpoint documentation: User Input: {input} """ +) diff --git a/libs/partners/robocorp/langchain_robocorp/toolkits.py b/libs/partners/robocorp/langchain_robocorp/toolkits.py index aab04859e6..335b4d44fc 100644 --- a/libs/partners/robocorp/langchain_robocorp/toolkits.py +++ b/libs/partners/robocorp/langchain_robocorp/toolkits.py @@ -20,12 +20,11 @@ from langsmith import Client from langchain_robocorp._common import ( get_param_fields, - get_required_param_descriptions, + model_to_dict, reduce_openapi_spec, ) from langchain_robocorp._prompts import ( API_CONTROLLER_PROMPT, - TOOLKIT_TOOL_DESCRIPTION, ) MAX_RESPONSE_LENGTH = 5000 @@ -156,17 +155,9 @@ class ActionServerToolkit(BaseModel): if not endpoint.startswith("/api/actions"): continue - summary = docs["summary"] - - tool_description = TOOLKIT_TOOL_DESCRIPTION.format( - name=summary, - description=docs.get("description", summary), - required_params=get_required_param_descriptions(docs), - ) - tool_args: ToolArgs = { - "name": f"robocorp_action_server_{docs['operationId']}", - "description": tool_description, + "name": docs["operationId"], + "description": docs["description"], "callback_manager": callback_manager, } @@ -218,16 +209,17 @@ class ActionServerToolkit(BaseModel): self, endpoint: str, docs: dict, tools_args: ToolArgs ) -> BaseTool: fields = get_param_fields(docs) + _DynamicToolInputSchema = create_model("DynamicToolInputSchema", **fields) - def create_function(endpoint: str) -> Callable: - def func(**data: dict[str, Any]) -> str: - return self._action_request(endpoint, **data) + def dynamic_func(**data: dict[str, Any]) -> str: + return self._action_request(endpoint, **model_to_dict(data)) - return func + dynamic_func.__name__ = tools_args["name"] + dynamic_func.__doc__ = tools_args["description"] return StructuredTool( - func=create_function(endpoint), - args_schema=create_model("DynamicToolInputSchema", **fields), + func=dynamic_func, + args_schema=_DynamicToolInputSchema, **tools_args, ) diff --git a/libs/partners/robocorp/poetry.lock b/libs/partners/robocorp/poetry.lock index 962263ba36..87599ba4aa 100644 --- a/libs/partners/robocorp/poetry.lock +++ b/libs/partners/robocorp/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -277,13 +277,13 @@ url = "../../core" [[package]] name = "langsmith" -version = "0.1.31" +version = "0.1.33" description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." optional = false python-versions = "<4.0,>=3.8.1" files = [ - {file = "langsmith-0.1.31-py3-none-any.whl", hash = "sha256:5211a9dc00831db307eb843485a97096484b697b5d2cd1efaac34228e97ca087"}, - {file = "langsmith-0.1.31.tar.gz", hash = "sha256:efd54ccd44be7fda911bfdc0ead340473df2fdd07345c7252901834d0c4aa37e"}, + {file = "langsmith-0.1.33-py3-none-any.whl", hash = "sha256:b84642d854b8f13ab6f540bb6d1c2b0e3e897add34b6d0880f3c3682c1a657fe"}, + {file = "langsmith-0.1.33.tar.gz", hash = "sha256:d368b7817c5a871f5ef8ca73435498aec1cbe1b13419417c91a34cffa49767ad"}, ] [package.dependencies] @@ -589,17 +589,17 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy [[package]] name = "pytest-mock" -version = "3.12.0" +version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, - {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, ] [package.dependencies] -pytest = ">=5.0" +pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] @@ -658,7 +658,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, diff --git a/libs/partners/robocorp/pyproject.toml b/libs/partners/robocorp/pyproject.toml index 03edb83195..09ac2be590 100644 --- a/libs/partners/robocorp/pyproject.toml +++ b/libs/partners/robocorp/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "langchain-robocorp" -version = "0.0.4" -description = "An integration package connecting Robocorp and LangChain" +version = "0.0.5" +description = "An integration package connecting Robocorp Action Server and LangChain" authors = [] readme = "README.md" repository = "https://github.com/langchain-ai/langchain" diff --git a/libs/partners/robocorp/tests/unit_tests/_openapi2.fixture.json b/libs/partners/robocorp/tests/unit_tests/_openapi2.fixture.json new file mode 100644 index 0000000000..f97c5c680d --- /dev/null +++ b/libs/partners/robocorp/tests/unit_tests/_openapi2.fixture.json @@ -0,0 +1,387 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Robocorp Action Server", + "version": "0.1.0" + }, + "servers": [ + { + "url": "https://hosted-actions.onrender.com" + } + ], + "paths": { + "/api/actions/google-sheet-gmail/get-google-spreadsheet-schema/run": { + "post": { + "summary": "Get Google Spreadsheet Schema", + "description": "Action to get necessary information to be able to work with a Google Sheet Spreadsheets correctly.\nUse this action minimum once before anything else, to learn about the structure\nof the Spreadsheet. Method will return the first few rows of each Sheet as an example.", + "operationId": "get_google_spreadsheet_schema", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": {}, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Get Google Spreadsheet Schema", + "description": "Names of the sheets, and a couple of first rows from each sheet to explain the context." + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/actions/google-sheet-gmail/create-new-google-sheet/run": { + "post": { + "summary": "Create New Google Sheet", + "description": "Creates a new empty Sheet in user's Google Spreadsheet.", + "operationId": "create_new_google_sheet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the Sheet. You must refer to this Sheet name later when adding or reading date from the Sheet." + } + }, + "type": "object", + "required": [ + "name" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Create New Google Sheet", + "description": "True if operation was success, and False if it failed." + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/actions/google-sheet-gmail/add-sheet-rows/run": { + "post": { + "summary": "Add Sheet Rows", + "description": "Action to add multiple rows to the Google sheet. Get the sheets with get_google_spreadsheet_schema if you don't know\nthe names or data structure. Make sure the values are in correct columns (needs to be ordered the same as in the sample).\nStrictly adhere to the schema.", + "operationId": "add_sheet_rows", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "sheet": { + "type": "string", + "title": "Sheet", + "description": "Name of the sheet where the data is added to" + }, + "rows_to_add": { + "properties": { + "rows": { + "items": { + "properties": { + "columns": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Columns", + "description": "The columns that make up the row" + } + }, + "type": "object", + "required": [ + "columns" + ], + "title": "Row" + }, + "type": "array", + "title": "Rows", + "description": "The rows that need to be added" + } + }, + "type": "object", + "required": [ + "rows" + ], + "title": "Rows To Add", + "description": "the rows to be added to the sheet" + } + }, + "type": "object", + "required": [ + "sheet", + "rows_to_add" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Add Sheet Rows", + "description": "The result of the operation." + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/actions/google-sheet-gmail/get-sheet-contents/run": { + "post": { + "summary": "Get Sheet Contents", + "description": "Get all content from the chosen Google Spreadsheet Sheet.", + "operationId": "get_sheet_contents", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "sheet": { + "type": "string", + "title": "Sheet", + "description": "Name of the sheet from which to get the data" + } + }, + "type": "object", + "required": [ + "sheet" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Get Sheet Contents", + "description": "Sheet data as string, rows separated by newlines" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/actions/google-sheet-gmail/send-email-via-gmail/run": { + "post": { + "summary": "Send Email Via Gmail", + "description": "Sends an email using Gmail SMTP with an App Password for authentication.", + "operationId": "send_email_via_gmail", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "subject": { + "type": "string", + "title": "Subject", + "description": "Email subject" + }, + "body": { + "type": "string", + "title": "Body", + "description": "Email body content" + }, + "recipient": { + "type": "string", + "title": "Recipient", + "description": "Recipient email address" + } + }, + "type": "object", + "required": [ + "subject", + "body", + "recipient" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Send Email Via Gmail", + "description": "Information if the send was successful or not" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "x-openai-isConsequential": true + } + } + }, + "components": { + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "HTTPValidationError": { + "properties": { + "errors": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Errors" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/libs/partners/robocorp/tests/unit_tests/test_toolkits.py b/libs/partners/robocorp/tests/unit_tests/test_toolkits.py index f3efec4289..47b410eba0 100644 --- a/libs/partners/robocorp/tests/unit_tests/test_toolkits.py +++ b/libs/partners/robocorp/tests/unit_tests/test_toolkits.py @@ -1,4 +1,10 @@ """Test toolkit integration.""" +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from langchain_core.utils.function_calling import convert_to_openai_function + from langchain_robocorp.toolkits import ActionServerToolkit from ._fixtures import FakeChatLLMT @@ -7,3 +13,108 @@ from ._fixtures import FakeChatLLMT def test_initialization() -> None: """Test toolkit initialization.""" ActionServerToolkit(url="http://localhost", llm=FakeChatLLMT()) + + +def test_get_tools_success() -> None: + # Setup + toolkit_instance = ActionServerToolkit( + url="http://example.com", api_key="dummy_key" + ) + + fixture_path = Path(__file__).with_name("_openapi2.fixture.json") + + with patch( + "langchain_robocorp.toolkits.requests.get" + ) as mocked_get, fixture_path.open("r") as f: + data = json.load(f) # Using json.load directly on the file object + mocked_response = MagicMock() + mocked_response.json.return_value = data + mocked_response.status_code = 200 + mocked_response.headers = {"Content-Type": "application/json"} + mocked_get.return_value = mocked_response + + # Execute + tools = toolkit_instance.get_tools() + + # Verify + assert len(tools) == 5 + + tool = tools[2] + assert tool.name == "add_sheet_rows" + assert tool.description == ( + "Action to add multiple rows to the Google sheet. " + "Get the sheets with get_google_spreadsheet_schema if you don't know" + "\nthe names or data structure. Make sure the values are in correct" + """ columns (needs to be ordered the same as in the sample). +Strictly adhere to the schema.""" + ) + + openai_func_spec = convert_to_openai_function(tool) + + assert isinstance( + openai_func_spec, dict + ), "openai_func_spec should be a dictionary." + assert set(openai_func_spec.keys()) == { + "description", + "name", + "parameters", + }, "Top-level keys mismatch." + + assert openai_func_spec["description"] == tool.description + assert openai_func_spec["name"] == tool.name + + assert isinstance( + openai_func_spec["parameters"], dict + ), "Parameters should be a dictionary." + + params = openai_func_spec["parameters"] + assert set(params.keys()) == { + "type", + "properties", + "required", + }, "Parameters keys mismatch." + assert params["type"] == "object", "`type` in parameters should be 'object'." + assert isinstance( + params["properties"], dict + ), "`properties` should be a dictionary." + assert isinstance(params["required"], list), "`required` should be a list." + + assert set(params["required"]) == { + "sheet", + "rows_to_add", + }, "Required fields mismatch." + + assert set(params["properties"].keys()) == {"sheet", "rows_to_add"} + + desc = "The columns that make up the row" + expected = { + "description": "the rows to be added to the sheet", + "allOf": [ + { + "title": "Rows To Add", + "type": "object", + "properties": { + "rows": { + "title": "Rows", + "description": "The rows that need to be added", + "type": "array", + "items": { + "title": "Row", + "type": "object", + "properties": { + "columns": { + "title": "Columns", + "description": desc, + "type": "array", + "items": {"type": "string"}, + } + }, + "required": ["columns"], + }, + } + }, + "required": ["rows"], + } + ], + } + assert params["properties"]["rows_to_add"] == expected