mirror of
https://github.com/Mintplex-Labs/langchain-python.git
synced 2026-08-27 09:21:30 -04:00
446c3d586c
This PR proposes an update to the OpenAPI Planner and Planner Prompts to make Patch and Delete available to the planner and executor. I followed the same patterns as for GET and POST, and made some updates to the examples available to the Planner and Orchestrator. Of note, I tried to write prompts for DELETE such that the model will only execute that job if the User specifically asks for a 'Delete' (see the Prompt_planner.py examples to see specificity), or if the User had previously authorized the Delete in the Conversation memory. Although PATCH also modifies existing data, I considered it lower risk and so did not try to enforce the same restrictions on the Planner.
272 lines
8.8 KiB
Python
272 lines
8.8 KiB
Python
"""Agent that interacts with OpenAPI APIs via a hierarchical planning approach."""
|
|
import json
|
|
import re
|
|
from typing import List, Optional
|
|
|
|
import yaml
|
|
|
|
from langchain.agents.agent import AgentExecutor
|
|
from langchain.agents.agent_toolkits.openapi.planner_prompt import (
|
|
API_CONTROLLER_PROMPT,
|
|
API_CONTROLLER_TOOL_DESCRIPTION,
|
|
API_CONTROLLER_TOOL_NAME,
|
|
API_ORCHESTRATOR_PROMPT,
|
|
API_PLANNER_PROMPT,
|
|
API_PLANNER_TOOL_DESCRIPTION,
|
|
API_PLANNER_TOOL_NAME,
|
|
PARSING_DELETE_PROMPT,
|
|
PARSING_GET_PROMPT,
|
|
PARSING_PATCH_PROMPT,
|
|
PARSING_POST_PROMPT,
|
|
REQUESTS_DELETE_TOOL_DESCRIPTION,
|
|
REQUESTS_GET_TOOL_DESCRIPTION,
|
|
REQUESTS_PATCH_TOOL_DESCRIPTION,
|
|
REQUESTS_POST_TOOL_DESCRIPTION,
|
|
)
|
|
from langchain.agents.agent_toolkits.openapi.spec import ReducedOpenAPISpec
|
|
from langchain.agents.mrkl.base import ZeroShotAgent
|
|
from langchain.agents.tools import Tool
|
|
from langchain.chains.llm import LLMChain
|
|
from langchain.llms.openai import OpenAI
|
|
from langchain.memory import ReadOnlySharedMemory
|
|
from langchain.prompts import PromptTemplate
|
|
from langchain.requests import RequestsWrapper
|
|
from langchain.schema import BaseLanguageModel
|
|
from langchain.tools.base import BaseTool
|
|
from langchain.tools.requests.tool import BaseRequestsTool
|
|
|
|
#
|
|
# Requests tools with LLM-instructed extraction of truncated responses.
|
|
#
|
|
# Of course, truncating so bluntly may lose a lot of valuable
|
|
# information in the response.
|
|
# However, the goal for now is to have only a single inference step.
|
|
MAX_RESPONSE_LENGTH = 5000
|
|
|
|
|
|
class RequestsGetToolWithParsing(BaseRequestsTool, BaseTool):
|
|
name = "requests_get"
|
|
description = REQUESTS_GET_TOOL_DESCRIPTION
|
|
response_length: Optional[int] = MAX_RESPONSE_LENGTH
|
|
llm_chain = LLMChain(
|
|
llm=OpenAI(),
|
|
prompt=PARSING_GET_PROMPT,
|
|
)
|
|
|
|
def _run(self, text: str) -> str:
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
raise e
|
|
data_params = data.get("params")
|
|
response = self.requests_wrapper.get(data["url"], params=data_params)
|
|
response = response[: self.response_length]
|
|
return self.llm_chain.predict(
|
|
response=response, instructions=data["output_instructions"]
|
|
).strip()
|
|
|
|
async def _arun(self, text: str) -> str:
|
|
raise NotImplementedError()
|
|
|
|
|
|
class RequestsPostToolWithParsing(BaseRequestsTool, BaseTool):
|
|
name = "requests_post"
|
|
description = REQUESTS_POST_TOOL_DESCRIPTION
|
|
|
|
response_length: Optional[int] = MAX_RESPONSE_LENGTH
|
|
llm_chain = LLMChain(
|
|
llm=OpenAI(),
|
|
prompt=PARSING_POST_PROMPT,
|
|
)
|
|
|
|
def _run(self, text: str) -> str:
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
raise e
|
|
response = self.requests_wrapper.post(data["url"], data["data"])
|
|
response = response[: self.response_length]
|
|
return self.llm_chain.predict(
|
|
response=response, instructions=data["output_instructions"]
|
|
).strip()
|
|
|
|
async def _arun(self, text: str) -> str:
|
|
raise NotImplementedError()
|
|
|
|
|
|
class RequestsPatchToolWithParsing(BaseRequestsTool, BaseTool):
|
|
name = "requests_patch"
|
|
description = REQUESTS_PATCH_TOOL_DESCRIPTION
|
|
|
|
response_length: Optional[int] = MAX_RESPONSE_LENGTH
|
|
llm_chain = LLMChain(
|
|
llm=OpenAI(),
|
|
prompt=PARSING_PATCH_PROMPT,
|
|
)
|
|
|
|
def _run(self, text: str) -> str:
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
raise e
|
|
response = self.requests_wrapper.patch(data["url"], data["data"])
|
|
response = response[: self.response_length]
|
|
return self.llm_chain.predict(
|
|
response=response, instructions=data["output_instructions"]
|
|
).strip()
|
|
|
|
async def _arun(self, text: str) -> str:
|
|
raise NotImplementedError()
|
|
|
|
|
|
class RequestsDeleteToolWithParsing(BaseRequestsTool, BaseTool):
|
|
name = "requests_delete"
|
|
description = REQUESTS_DELETE_TOOL_DESCRIPTION
|
|
|
|
response_length: Optional[int] = MAX_RESPONSE_LENGTH
|
|
llm_chain = LLMChain(
|
|
llm=OpenAI(),
|
|
prompt=PARSING_DELETE_PROMPT,
|
|
)
|
|
|
|
def _run(self, text: str) -> str:
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
raise e
|
|
response = self.requests_wrapper.delete(data["url"])
|
|
response = response[: self.response_length]
|
|
return self.llm_chain.predict(
|
|
response=response, instructions=data["output_instructions"]
|
|
).strip()
|
|
|
|
async def _arun(self, text: str) -> str:
|
|
raise NotImplementedError()
|
|
|
|
|
|
#
|
|
# Orchestrator, planner, controller.
|
|
#
|
|
def _create_api_planner_tool(
|
|
api_spec: ReducedOpenAPISpec, llm: BaseLanguageModel
|
|
) -> Tool:
|
|
endpoint_descriptions = [
|
|
f"{name} {description}" for name, description, _ in api_spec.endpoints
|
|
]
|
|
prompt = PromptTemplate(
|
|
template=API_PLANNER_PROMPT,
|
|
input_variables=["query"],
|
|
partial_variables={"endpoints": "- " + "- ".join(endpoint_descriptions)},
|
|
)
|
|
chain = LLMChain(llm=llm, prompt=prompt)
|
|
tool = Tool(
|
|
name=API_PLANNER_TOOL_NAME,
|
|
description=API_PLANNER_TOOL_DESCRIPTION,
|
|
func=chain.run,
|
|
)
|
|
return tool
|
|
|
|
|
|
def _create_api_controller_agent(
|
|
api_url: str,
|
|
api_docs: str,
|
|
requests_wrapper: RequestsWrapper,
|
|
llm: BaseLanguageModel,
|
|
) -> AgentExecutor:
|
|
tools: List[BaseTool] = [
|
|
RequestsGetToolWithParsing(requests_wrapper=requests_wrapper),
|
|
RequestsPostToolWithParsing(requests_wrapper=requests_wrapper),
|
|
]
|
|
prompt = PromptTemplate(
|
|
template=API_CONTROLLER_PROMPT,
|
|
input_variables=["input", "agent_scratchpad"],
|
|
partial_variables={
|
|
"api_url": api_url,
|
|
"api_docs": api_docs,
|
|
"tool_names": ", ".join([tool.name for tool in tools]),
|
|
"tool_descriptions": "\n".join(
|
|
[f"{tool.name}: {tool.description}" for tool in tools]
|
|
),
|
|
},
|
|
)
|
|
agent = ZeroShotAgent(
|
|
llm_chain=LLMChain(llm=llm, prompt=prompt),
|
|
allowed_tools=[tool.name for tool in tools],
|
|
)
|
|
return AgentExecutor.from_agent_and_tools(agent=agent, tools=tools, verbose=True)
|
|
|
|
|
|
def _create_api_controller_tool(
|
|
api_spec: ReducedOpenAPISpec,
|
|
requests_wrapper: RequestsWrapper,
|
|
llm: BaseLanguageModel,
|
|
) -> Tool:
|
|
"""Expose controller as a tool.
|
|
|
|
The tool is invoked with a plan from the planner, and dynamically
|
|
creates a controller agent with relevant documentation only to
|
|
constrain the context.
|
|
"""
|
|
|
|
base_url = api_spec.servers[0]["url"] # TODO: do better.
|
|
|
|
def _create_and_run_api_controller_agent(plan_str: str) -> str:
|
|
pattern = r"\b(GET|POST|PATCH|DELETE)\s+(/\S+)*"
|
|
matches = re.findall(pattern, plan_str)
|
|
endpoint_names = [
|
|
"{method} {route}".format(method=method, route=route.split("?")[0])
|
|
for method, route in matches
|
|
]
|
|
endpoint_docs_by_name = {name: docs for name, _, docs in api_spec.endpoints}
|
|
docs_str = ""
|
|
for endpoint_name in endpoint_names:
|
|
docs = endpoint_docs_by_name.get(endpoint_name)
|
|
if not docs:
|
|
raise ValueError(f"{endpoint_name} endpoint does not exist.")
|
|
docs_str += f"== Docs for {endpoint_name} == \n{yaml.dump(docs)}\n"
|
|
|
|
agent = _create_api_controller_agent(base_url, docs_str, requests_wrapper, llm)
|
|
return agent.run(plan_str)
|
|
|
|
return Tool(
|
|
name=API_CONTROLLER_TOOL_NAME,
|
|
func=_create_and_run_api_controller_agent,
|
|
description=API_CONTROLLER_TOOL_DESCRIPTION,
|
|
)
|
|
|
|
|
|
def create_openapi_agent(
|
|
api_spec: ReducedOpenAPISpec,
|
|
requests_wrapper: RequestsWrapper,
|
|
llm: BaseLanguageModel,
|
|
shared_memory: Optional[ReadOnlySharedMemory] = None,
|
|
verbose: bool = True,
|
|
) -> AgentExecutor:
|
|
"""Instantiate API planner and controller for a given spec.
|
|
|
|
Inject credentials via requests_wrapper.
|
|
|
|
We use a top-level "orchestrator" agent to invoke the planner and controller,
|
|
rather than a top-level planner
|
|
that invokes a controller with its plan. This is to keep the planner simple.
|
|
"""
|
|
tools = [
|
|
_create_api_planner_tool(api_spec, llm),
|
|
_create_api_controller_tool(api_spec, requests_wrapper, llm),
|
|
]
|
|
prompt = PromptTemplate(
|
|
template=API_ORCHESTRATOR_PROMPT,
|
|
input_variables=["input", "agent_scratchpad"],
|
|
partial_variables={
|
|
"tool_names": ", ".join([tool.name for tool in tools]),
|
|
"tool_descriptions": "\n".join(
|
|
[f"{tool.name}: {tool.description}" for tool in tools]
|
|
),
|
|
},
|
|
)
|
|
agent = ZeroShotAgent(
|
|
llm_chain=LLMChain(llm=llm, prompt=prompt, memory=shared_memory),
|
|
allowed_tools=[tool.name for tool in tools],
|
|
)
|
|
return AgentExecutor.from_agent_and_tools(agent=agent, tools=tools, verbose=verbose)
|