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.
pull/19879/head
Mikko Korpela 6 months ago committed by GitHub
parent 48f84e253e
commit 3f06cef60c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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

@ -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}
"""
)

@ -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,
)

@ -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"},

@ -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"

@ -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"
}
}
}
}

@ -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

Loading…
Cancel
Save