From 386ef1e6549c115eb10898e4526d6529f393679a Mon Sep 17 00:00:00 2001 From: Harrison Chase Date: Wed, 20 Sep 2023 12:10:09 -0700 Subject: [PATCH] add agent output parsers (#10790) --- .../agents/openai_functions_agent/base.py | 53 ++--------- .../agents/output_parsers/__init__.py | 32 +++++++ .../langchain/agents/output_parsers/json.py | 66 +++++++++++++ .../agents/output_parsers/openai_functions.py | 84 +++++++++++++++++ .../output_parsers/react_json_single_input.py | 76 +++++++++++++++ .../output_parsers/react_single_input.py | 92 +++++++++++++++++++ .../agents/output_parsers/self_ask.py | 47 ++++++++++ .../langchain/agents/output_parsers/xml.py | 51 ++++++++++ .../self_ask_with_search/output_parser.py | 27 +----- libs/langchain/langchain/agents/xml/base.py | 26 +----- .../agents/output_parsers/__init__.py | 0 .../agents/output_parsers/test_json.py | 28 ++++++ .../output_parsers/test_openai_functions.py | 80 ++++++++++++++++ .../test_react_json_single_input.py | 34 +++++++ .../output_parsers/test_react_single_input.py | 42 +++++++++ .../agents/output_parsers/test_self_ask.py | 49 ++++++++++ .../agents/output_parsers/test_xml.py | 33 +++++++ .../agents/test_openai_functions.py | 76 --------------- 18 files changed, 728 insertions(+), 168 deletions(-) create mode 100644 libs/langchain/langchain/agents/output_parsers/__init__.py create mode 100644 libs/langchain/langchain/agents/output_parsers/json.py create mode 100644 libs/langchain/langchain/agents/output_parsers/openai_functions.py create mode 100644 libs/langchain/langchain/agents/output_parsers/react_json_single_input.py create mode 100644 libs/langchain/langchain/agents/output_parsers/react_single_input.py create mode 100644 libs/langchain/langchain/agents/output_parsers/self_ask.py create mode 100644 libs/langchain/langchain/agents/output_parsers/xml.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/__init__.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/test_json.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/test_openai_functions.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/test_react_json_single_input.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/test_react_single_input.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/test_self_ask.py create mode 100644 libs/langchain/tests/unit_tests/agents/output_parsers/test_xml.py delete mode 100644 libs/langchain/tests/unit_tests/agents/test_openai_functions.py diff --git a/libs/langchain/langchain/agents/openai_functions_agent/base.py b/libs/langchain/langchain/agents/openai_functions_agent/base.py index c75f14a341..c54ff33319 100644 --- a/libs/langchain/langchain/agents/openai_functions_agent/base.py +++ b/libs/langchain/langchain/agents/openai_functions_agent/base.py @@ -1,9 +1,11 @@ """Module implements an agent that uses OpenAI's APIs function enabled API.""" import json -from json import JSONDecodeError from typing import Any, List, Optional, Sequence, Tuple, Union from langchain.agents import BaseSingleActionAgent +from langchain.agents.output_parsers.openai_functions import ( + OpenAIFunctionsAgentOutputParser, +) from langchain.callbacks.base import BaseCallbackManager from langchain.callbacks.manager import Callbacks from langchain.chat_models.openai import ChatOpenAI @@ -18,7 +20,6 @@ from langchain.schema import ( AgentAction, AgentFinish, BasePromptTemplate, - OutputParserException, ) from langchain.schema.agent import AgentActionMessageLog from langchain.schema.language_model import BaseLanguageModel @@ -97,46 +98,6 @@ def _format_intermediate_steps( return messages -def _parse_ai_message(message: BaseMessage) -> Union[AgentAction, AgentFinish]: - """Parse an AI message.""" - if not isinstance(message, AIMessage): - raise TypeError(f"Expected an AI message got {type(message)}") - - function_call = message.additional_kwargs.get("function_call", {}) - - if function_call: - function_name = function_call["name"] - try: - _tool_input = json.loads(function_call["arguments"]) - except JSONDecodeError: - raise OutputParserException( - f"Could not parse tool input: {function_call} because " - f"the `arguments` is not valid JSON." - ) - - # HACK HACK HACK: - # The code that encodes tool input into Open AI uses a special variable - # name called `__arg1` to handle old style tools that do not expose a - # schema and expect a single string argument as an input. - # We unpack the argument here if it exists. - # Open AI does not support passing in a JSON array as an argument. - if "__arg1" in _tool_input: - tool_input = _tool_input["__arg1"] - else: - tool_input = _tool_input - - content_msg = f"responded: {message.content}\n" if message.content else "\n" - - return _FunctionsAgentAction( - tool=function_name, - tool_input=tool_input, - log=f"\nInvoking: `{function_name}` with `{tool_input}`\n{content_msg}\n", - message_log=[message], - ) - - return AgentFinish(return_values={"output": message.content}, log=message.content) - - class OpenAIFunctionsAgent(BaseSingleActionAgent): """An Agent driven by OpenAIs function powered API. @@ -216,7 +177,9 @@ class OpenAIFunctionsAgent(BaseSingleActionAgent): messages, callbacks=callbacks, ) - agent_decision = _parse_ai_message(predicted_message) + agent_decision = OpenAIFunctionsAgentOutputParser._parse_ai_message( + predicted_message + ) return agent_decision async def aplan( @@ -245,7 +208,9 @@ class OpenAIFunctionsAgent(BaseSingleActionAgent): predicted_message = await self.llm.apredict_messages( messages, functions=self.functions, callbacks=callbacks ) - agent_decision = _parse_ai_message(predicted_message) + agent_decision = OpenAIFunctionsAgentOutputParser._parse_ai_message( + predicted_message + ) return agent_decision def return_stopped_response( diff --git a/libs/langchain/langchain/agents/output_parsers/__init__.py b/libs/langchain/langchain/agents/output_parsers/__init__.py new file mode 100644 index 0000000000..ac74a22574 --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/__init__.py @@ -0,0 +1,32 @@ +"""Parsing utils to go from string to AgentAction or Agent Finish. + +AgentAction means that an action should be taken. +This contains the name of the tool to use, the input to pass to that tool, +and a `log` variable (which contains a log of the agent's thinking). + +AgentFinish means that a response should be given. +This contains a `return_values` dictionary. This usually contains a +single `output` key, but can be extended to contain more. +This also contains a `log` variable (which contains a log of the agent's thinking). +""" +from langchain.agents.output_parsers.json import JSONAgentOutputParser +from langchain.agents.output_parsers.openai_functions import ( + OpenAIFunctionsAgentOutputParser, +) +from langchain.agents.output_parsers.react_json_single_input import ( + ReActJsonSingleInputOutputParser, +) +from langchain.agents.output_parsers.react_single_input import ( + ReActSingleInputOutputParser, +) +from langchain.agents.output_parsers.self_ask import SelfAskOutputParser +from langchain.agents.output_parsers.xml import XMLAgentOutputParser + +__all__ = [ + "ReActSingleInputOutputParser", + "SelfAskOutputParser", + "ReActJsonSingleInputOutputParser", + "OpenAIFunctionsAgentOutputParser", + "XMLAgentOutputParser", + "JSONAgentOutputParser", +] diff --git a/libs/langchain/langchain/agents/output_parsers/json.py b/libs/langchain/langchain/agents/output_parsers/json.py new file mode 100644 index 0000000000..2900ad4fb9 --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/json.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +import logging +import re +from typing import Union + +from langchain.agents.agent import AgentOutputParser +from langchain.schema import AgentAction, AgentFinish, OutputParserException + +logger = logging.getLogger(__name__) + + +class JSONAgentOutputParser(AgentOutputParser): + """Parses tool invocations and final answers in XML format. + + Expects output to be in one of two formats. + + If the output signals that an action should be taken, + should be in the below format. This will result in an AgentAction + being returned. + + ``` + { + "action": "search", + "action_input": "2+2" + } + ``` + + If the output signals that a final answer should be given, + should be in the below format. This will result in an AgentFinish + being returned. + + ``` + { + "action": "Final Answer", + "action_input": "4" + } + ``` + """ + + pattern = re.compile(r"```(?:json)?\n(.*?)```", re.DOTALL) + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + try: + action_match = self.pattern.search(text) + if action_match is not None: + response = json.loads(action_match.group(1).strip(), strict=False) + if isinstance(response, list): + # gpt turbo frequently ignores the directive to emit a single action + logger.warning("Got multiple action responses: %s", response) + response = response[0] + if response["action"] == "Final Answer": + return AgentFinish({"output": response["action_input"]}, text) + else: + return AgentAction( + response["action"], response.get("action_input", {}), text + ) + else: + return AgentFinish({"output": text}, text) + except Exception as e: + raise OutputParserException(f"Could not parse LLM output: {text}") from e + + @property + def _type(self) -> str: + return "json-agent" diff --git a/libs/langchain/langchain/agents/output_parsers/openai_functions.py b/libs/langchain/langchain/agents/output_parsers/openai_functions.py new file mode 100644 index 0000000000..a99453be51 --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/openai_functions.py @@ -0,0 +1,84 @@ +import json +from json import JSONDecodeError +from typing import List, Union + +from langchain.agents.agent import AgentOutputParser +from langchain.schema import ( + AgentAction, + AgentFinish, + OutputParserException, +) +from langchain.schema.agent import AgentActionMessageLog +from langchain.schema.messages import ( + AIMessage, + BaseMessage, +) +from langchain.schema.output import ChatGeneration, Generation + + +class OpenAIFunctionsAgentOutputParser(AgentOutputParser): + """Parses a message into agent action/finish. + + Is meant to be used with OpenAI models, as it relies on the specific + function_call parameter from OpenAI to convey what tools to use. + + If a function_call parameter is passed, then that is used to get + the tool and tool input. + + If one is not passed, then the AIMessage is assumed to be the final output. + """ + + @property + def _type(self) -> str: + return "openai-functions-agent" + + @staticmethod + def _parse_ai_message(message: BaseMessage) -> Union[AgentAction, AgentFinish]: + """Parse an AI message.""" + if not isinstance(message, AIMessage): + raise TypeError(f"Expected an AI message got {type(message)}") + + function_call = message.additional_kwargs.get("function_call", {}) + + if function_call: + function_name = function_call["name"] + try: + _tool_input = json.loads(function_call["arguments"]) + except JSONDecodeError: + raise OutputParserException( + f"Could not parse tool input: {function_call} because " + f"the `arguments` is not valid JSON." + ) + + # HACK HACK HACK: + # The code that encodes tool input into Open AI uses a special variable + # name called `__arg1` to handle old style tools that do not expose a + # schema and expect a single string argument as an input. + # We unpack the argument here if it exists. + # Open AI does not support passing in a JSON array as an argument. + if "__arg1" in _tool_input: + tool_input = _tool_input["__arg1"] + else: + tool_input = _tool_input + + content_msg = f"responded: {message.content}\n" if message.content else "\n" + log = f"\nInvoking: `{function_name}` with `{tool_input}`\n{content_msg}\n" + return AgentActionMessageLog( + tool=function_name, + tool_input=tool_input, + log=log, + message_log=[message], + ) + + return AgentFinish( + return_values={"output": message.content}, log=message.content + ) + + def parse_result(self, result: List[Generation]) -> Union[AgentAction, AgentFinish]: + if not isinstance(result[0], ChatGeneration): + raise ValueError("This output parser only works on ChatGeneration output") + message = result[0].message + return self._parse_ai_message(message) + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + raise ValueError("Can only parse messages") diff --git a/libs/langchain/langchain/agents/output_parsers/react_json_single_input.py b/libs/langchain/langchain/agents/output_parsers/react_json_single_input.py new file mode 100644 index 0000000000..8878d5aee6 --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/react_json_single_input.py @@ -0,0 +1,76 @@ +import json +import re +from typing import Union + +from langchain.agents.agent import AgentOutputParser +from langchain.agents.chat.prompt import FORMAT_INSTRUCTIONS +from langchain.schema import AgentAction, AgentFinish, OutputParserException + +FINAL_ANSWER_ACTION = "Final Answer:" + + +class ReActJsonSingleInputOutputParser(AgentOutputParser): + """Parses ReAct-style LLM calls that have a single tool input in json format. + + Expects output to be in one of two formats. + + If the output signals that an action should be taken, + should be in the below format. This will result in an AgentAction + being returned. + + ``` + Thought: agent thought here + Action: + ``` + { + "action": "search", + "action_input": "what is the temperature in SF" + } + ``` + ``` + + If the output signals that a final answer should be given, + should be in the below format. This will result in an AgentFinish + being returned. + + ``` + Thought: agent thought here + Final Answer: The temperature is 100 degrees + ``` + + """ + + pattern = re.compile(r"^.*?`{3}(?:json)?\n(.*?)`{3}.*?$", re.DOTALL) + """Regex pattern to parse the output.""" + + def get_format_instructions(self) -> str: + return FORMAT_INSTRUCTIONS + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + includes_answer = FINAL_ANSWER_ACTION in text + try: + found = self.pattern.search(text) + if not found: + # Fast fail to parse Final Answer. + raise ValueError("action not found") + action = found.group(1) + response = json.loads(action.strip()) + includes_action = "action" in response + if includes_answer and includes_action: + raise OutputParserException( + "Parsing LLM output produced a final answer " + f"and a parse-able action: {text}" + ) + return AgentAction( + response["action"], response.get("action_input", {}), text + ) + + except Exception: + if not includes_answer: + raise OutputParserException(f"Could not parse LLM output: {text}") + output = text.split(FINAL_ANSWER_ACTION)[-1].strip() + return AgentFinish({"output": output}, text) + + @property + def _type(self) -> str: + return "react-json-single-input" diff --git a/libs/langchain/langchain/agents/output_parsers/react_single_input.py b/libs/langchain/langchain/agents/output_parsers/react_single_input.py new file mode 100644 index 0000000000..e5e551b5f3 --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/react_single_input.py @@ -0,0 +1,92 @@ +import re +from typing import Union + +from langchain.agents.agent import AgentOutputParser +from langchain.agents.mrkl.prompt import FORMAT_INSTRUCTIONS +from langchain.schema import AgentAction, AgentFinish, OutputParserException + +FINAL_ANSWER_ACTION = "Final Answer:" +MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE = ( + "Invalid Format: Missing 'Action:' after 'Thought:" +) +MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE = ( + "Invalid Format: Missing 'Action Input:' after 'Action:'" +) +FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE = ( + "Parsing LLM output produced both a final answer and a parse-able action:" +) + + +class ReActSingleInputOutputParser(AgentOutputParser): + """Parses ReAct-style LLM calls that have a single tool input. + + Expects output to be in one of two formats. + + If the output signals that an action should be taken, + should be in the below format. This will result in an AgentAction + being returned. + + ``` + Thought: agent thought here + Action: search + Action Input: what is the temperature in SF? + ``` + + If the output signals that a final answer should be given, + should be in the below format. This will result in an AgentFinish + being returned. + + ``` + Thought: agent thought here + Final Answer: The temperature is 100 degrees + ``` + + """ + + def get_format_instructions(self) -> str: + return FORMAT_INSTRUCTIONS + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + includes_answer = FINAL_ANSWER_ACTION in text + regex = ( + r"Action\s*\d*\s*:[\s]*(.*?)[\s]*Action\s*\d*\s*Input\s*\d*\s*:[\s]*(.*)" + ) + action_match = re.search(regex, text, re.DOTALL) + if action_match: + if includes_answer: + raise OutputParserException( + f"{FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE}: {text}" + ) + action = action_match.group(1).strip() + action_input = action_match.group(2) + tool_input = action_input.strip(" ") + + return AgentAction(action, tool_input, text) + + elif includes_answer: + return AgentFinish( + {"output": text.split(FINAL_ANSWER_ACTION)[-1].strip()}, text + ) + + if not re.search(r"Action\s*\d*\s*:[\s]*(.*?)", text, re.DOTALL): + raise OutputParserException( + f"Could not parse LLM output: `{text}`", + observation=MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE, + llm_output=text, + send_to_llm=True, + ) + elif not re.search( + r"[\s]*Action\s*\d*\s*Input\s*\d*\s*:[\s]*(.*)", text, re.DOTALL + ): + raise OutputParserException( + f"Could not parse LLM output: `{text}`", + observation=MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE, + llm_output=text, + send_to_llm=True, + ) + else: + raise OutputParserException(f"Could not parse LLM output: `{text}`") + + @property + def _type(self) -> str: + return "react-single-input" diff --git a/libs/langchain/langchain/agents/output_parsers/self_ask.py b/libs/langchain/langchain/agents/output_parsers/self_ask.py new file mode 100644 index 0000000000..6423187b77 --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/self_ask.py @@ -0,0 +1,47 @@ +from typing import Sequence, Union + +from langchain.agents.agent import AgentOutputParser +from langchain.schema import AgentAction, AgentFinish, OutputParserException + + +class SelfAskOutputParser(AgentOutputParser): + """Parses self-ask style LLM calls. + + Expects output to be in one of two formats. + + If the output signals that an action should be taken, + should be in the below format. This will result in an AgentAction + being returned. + + ``` + Thoughts go here... + Follow up: what is the temperature in SF? + ``` + + If the output signals that a final answer should be given, + should be in the below format. This will result in an AgentFinish + being returned. + + ``` + Thoughts go here... + So the final answer is: The temperature is 100 degrees + ``` + + """ + + followups: Sequence[str] = ("Follow up:", "Followup:") + finish_string: str = "So the final answer is: " + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + last_line = text.split("\n")[-1] + if not any([follow in last_line for follow in self.followups]): + if self.finish_string not in last_line: + raise OutputParserException(f"Could not parse output: {text}") + return AgentFinish({"output": last_line[len(self.finish_string) :]}, text) + + after_colon = text.split(":")[-1].strip() + return AgentAction("Intermediate Answer", after_colon, text) + + @property + def _type(self) -> str: + return "self_ask" diff --git a/libs/langchain/langchain/agents/output_parsers/xml.py b/libs/langchain/langchain/agents/output_parsers/xml.py new file mode 100644 index 0000000000..20ff928c7f --- /dev/null +++ b/libs/langchain/langchain/agents/output_parsers/xml.py @@ -0,0 +1,51 @@ +from typing import Union + +from langchain.agents import AgentOutputParser +from langchain.schema import AgentAction, AgentFinish + + +class XMLAgentOutputParser(AgentOutputParser): + """Parses tool invocations and final answers in XML format. + + Expects output to be in one of two formats. + + If the output signals that an action should be taken, + should be in the below format. This will result in an AgentAction + being returned. + + ``` + search + what is 2 + 2 + ``` + + If the output signals that a final answer should be given, + should be in the below format. This will result in an AgentFinish + being returned. + + ``` + Foo + ``` + """ + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + if "" in text: + tool, tool_input = text.split("") + _tool = tool.split("")[1] + _tool_input = tool_input.split("")[1] + if "" in _tool_input: + _tool_input = _tool_input.split("")[0] + return AgentAction(tool=_tool, tool_input=_tool_input, log=text) + elif "" in text: + _, answer = text.split("") + if "" in answer: + answer = answer.split("")[0] + return AgentFinish(return_values={"output": answer}, log=text) + else: + raise ValueError + + def get_format_instructions(self) -> str: + raise NotImplementedError + + @property + def _type(self) -> str: + return "xml-agent" diff --git a/libs/langchain/langchain/agents/self_ask_with_search/output_parser.py b/libs/langchain/langchain/agents/self_ask_with_search/output_parser.py index 1eab078232..ac35693ae9 100644 --- a/libs/langchain/langchain/agents/self_ask_with_search/output_parser.py +++ b/libs/langchain/langchain/agents/self_ask_with_search/output_parser.py @@ -1,25 +1,4 @@ -from typing import Sequence, Union +from langchain.agents.output_parsers.self_ask import SelfAskOutputParser -from langchain.agents.agent import AgentOutputParser -from langchain.schema import AgentAction, AgentFinish, OutputParserException - - -class SelfAskOutputParser(AgentOutputParser): - """Output parser for the self-ask agent.""" - - followups: Sequence[str] = ("Follow up:", "Followup:") - finish_string: str = "So the final answer is: " - - def parse(self, text: str) -> Union[AgentAction, AgentFinish]: - last_line = text.split("\n")[-1] - if not any([follow in last_line for follow in self.followups]): - if self.finish_string not in last_line: - raise OutputParserException(f"Could not parse output: {text}") - return AgentFinish({"output": last_line[len(self.finish_string) :]}, text) - - after_colon = text.split(":")[-1].strip() - return AgentAction("Intermediate Answer", after_colon, text) - - @property - def _type(self) -> str: - return "self_ask" +# For backwards compatibility +__all__ = ["SelfAskOutputParser"] diff --git a/libs/langchain/langchain/agents/xml/base.py b/libs/langchain/langchain/agents/xml/base.py index 3462ebe66d..815da7f5fe 100644 --- a/libs/langchain/langchain/agents/xml/base.py +++ b/libs/langchain/langchain/agents/xml/base.py @@ -1,6 +1,7 @@ from typing import Any, List, Tuple, Union -from langchain.agents.agent import AgentOutputParser, BaseSingleActionAgent +from langchain.agents.agent import BaseSingleActionAgent +from langchain.agents.output_parsers.xml import XMLAgentOutputParser from langchain.agents.xml.prompt import agent_instructions from langchain.callbacks.base import Callbacks from langchain.chains.llm import LLMChain @@ -9,29 +10,6 @@ from langchain.schema import AgentAction, AgentFinish from langchain.tools.base import BaseTool -class XMLAgentOutputParser(AgentOutputParser): - """Output parser for XMLAgent.""" - - def parse(self, text: str) -> Union[AgentAction, AgentFinish]: - if "" in text: - tool, tool_input = text.split("") - _tool = tool.split("")[1] - _tool_input = tool_input.split("")[1] - return AgentAction(tool=_tool, tool_input=_tool_input, log=text) - elif "" in text: - _, answer = text.split("") - return AgentFinish(return_values={"output": answer}, log=text) - else: - raise ValueError - - def get_format_instructions(self) -> str: - raise NotImplementedError - - @property - def _type(self) -> str: - return "xml-agent" - - class XMLAgent(BaseSingleActionAgent): """Agent that uses XML tags. diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/__init__.py b/libs/langchain/tests/unit_tests/agents/output_parsers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/test_json.py b/libs/langchain/tests/unit_tests/agents/output_parsers/test_json.py new file mode 100644 index 0000000000..49d57d47c9 --- /dev/null +++ b/libs/langchain/tests/unit_tests/agents/output_parsers/test_json.py @@ -0,0 +1,28 @@ +from langchain.agents.output_parsers.json import JSONAgentOutputParser +from langchain.schema.agent import AgentAction, AgentFinish + + +def test_tool_usage() -> None: + parser = JSONAgentOutputParser() + _input = """ ``` +{ + "action": "search", + "action_input": "2+2" +} +```""" + output = parser.invoke(_input) + expected_output = AgentAction(tool="search", tool_input="2+2", log=_input) + assert output == expected_output + + +def test_finish() -> None: + parser = JSONAgentOutputParser() + _input = """``` +{ + "action": "Final Answer", + "action_input": "4" +} +```""" + output = parser.invoke(_input) + expected_output = AgentFinish(return_values={"output": "4"}, log=_input) + assert output == expected_output diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/test_openai_functions.py b/libs/langchain/tests/unit_tests/agents/output_parsers/test_openai_functions.py new file mode 100644 index 0000000000..d07d4775a0 --- /dev/null +++ b/libs/langchain/tests/unit_tests/agents/output_parsers/test_openai_functions.py @@ -0,0 +1,80 @@ +import pytest + +from langchain.agents.output_parsers.openai_functions import ( + OpenAIFunctionsAgentOutputParser, +) +from langchain.schema import AgentFinish, OutputParserException +from langchain.schema.agent import AgentActionMessageLog +from langchain.schema.messages import AIMessage, SystemMessage + + +def test_not_an_ai() -> None: + parser = OpenAIFunctionsAgentOutputParser() + err = f"Expected an AI message got {str(SystemMessage)}" + with pytest.raises(TypeError, match=err): + parser.invoke(SystemMessage(content="x")) + + +# Test: Model response (not a function call). +def test_model_response() -> None: + parser = OpenAIFunctionsAgentOutputParser() + msg = AIMessage(content="Model response.") + result = parser.invoke(msg) + + assert isinstance(result, AgentFinish) + assert result.return_values == {"output": "Model response."} + assert result.log == "Model response." + + +# Test: Model response with a function call. +def test_func_call() -> None: + parser = OpenAIFunctionsAgentOutputParser() + msg = AIMessage( + content="LLM thoughts.", + additional_kwargs={ + "function_call": {"name": "foo", "arguments": '{"param": 42}'} + }, + ) + result = parser.invoke(msg) + + assert isinstance(result, AgentActionMessageLog) + assert result.tool == "foo" + assert result.tool_input == {"param": 42} + assert result.log == ( + "\nInvoking: `foo` with `{'param': 42}`\nresponded: LLM thoughts.\n\n" + ) + assert result.message_log == [msg] + + +# Test: Model response with a function call (old style tools). +def test_func_call_oldstyle() -> None: + parser = OpenAIFunctionsAgentOutputParser() + msg = AIMessage( + content="LLM thoughts.", + additional_kwargs={ + "function_call": {"name": "foo", "arguments": '{"__arg1": "42"}'} + }, + ) + result = parser.invoke(msg) + + assert isinstance(result, AgentActionMessageLog) + assert result.tool == "foo" + assert result.tool_input == "42" + assert result.log == "\nInvoking: `foo` with `42`\nresponded: LLM thoughts.\n\n" + assert result.message_log == [msg] + + +# Test: Invalid function call args. +def test_func_call_invalid() -> None: + parser = OpenAIFunctionsAgentOutputParser() + msg = AIMessage( + content="LLM thoughts.", + additional_kwargs={"function_call": {"name": "foo", "arguments": "{42]"}}, + ) + + err = ( + "Could not parse tool input: {'name': 'foo', 'arguments': '{42]'} " + "because the `arguments` is not valid JSON." + ) + with pytest.raises(OutputParserException, match=err): + parser.invoke(msg) diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/test_react_json_single_input.py b/libs/langchain/tests/unit_tests/agents/output_parsers/test_react_json_single_input.py new file mode 100644 index 0000000000..1a657a8dda --- /dev/null +++ b/libs/langchain/tests/unit_tests/agents/output_parsers/test_react_json_single_input.py @@ -0,0 +1,34 @@ +from langchain.agents.output_parsers.react_json_single_input import ( + ReActJsonSingleInputOutputParser, +) +from langchain.schema.agent import AgentAction, AgentFinish + + +def test_action() -> None: + """Test standard parsing of action/action input.""" + parser = ReActJsonSingleInputOutputParser() + _input = """Thought: agent thought here +``` +{ + "action": "search", + "action_input": "what is the temperature in SF?" +} +``` +""" + output = parser.invoke(_input) + expected_output = AgentAction( + tool="search", tool_input="what is the temperature in SF?", log=_input + ) + assert output == expected_output + + +def test_finish() -> None: + """Test standard parsing of agent finish.""" + parser = ReActJsonSingleInputOutputParser() + _input = """Thought: agent thought here +Final Answer: The temperature is 100""" + output = parser.invoke(_input) + expected_output = AgentFinish( + return_values={"output": "The temperature is 100"}, log=_input + ) + assert output == expected_output diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/test_react_single_input.py b/libs/langchain/tests/unit_tests/agents/output_parsers/test_react_single_input.py new file mode 100644 index 0000000000..3996fc3e09 --- /dev/null +++ b/libs/langchain/tests/unit_tests/agents/output_parsers/test_react_single_input.py @@ -0,0 +1,42 @@ +import pytest + +from langchain.agents.output_parsers.react_single_input import ( + ReActSingleInputOutputParser, +) +from langchain.schema.agent import AgentAction, AgentFinish +from langchain.schema.output_parser import OutputParserException + + +def test_action() -> None: + """Test standard parsing of action/action input.""" + parser = ReActSingleInputOutputParser() + _input = """Thought: agent thought here +Action: search +Action Input: what is the temperature in SF?""" + output = parser.invoke(_input) + expected_output = AgentAction( + tool="search", tool_input="what is the temperature in SF?", log=_input + ) + assert output == expected_output + + +def test_finish() -> None: + """Test standard parsing of agent finish.""" + parser = ReActSingleInputOutputParser() + _input = """Thought: agent thought here +Final Answer: The temperature is 100""" + output = parser.invoke(_input) + expected_output = AgentFinish( + return_values={"output": "The temperature is 100"}, log=_input + ) + assert output == expected_output + + +def test_action_with_finish() -> None: + """Test that if final thought is in action/action input, error is raised.""" + parser = ReActSingleInputOutputParser() + _input = """Thought: agent thought here +Action: search Final Answer: +Action Input: what is the temperature in SF?""" + with pytest.raises(OutputParserException): + parser.invoke(_input) diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/test_self_ask.py b/libs/langchain/tests/unit_tests/agents/output_parsers/test_self_ask.py new file mode 100644 index 0000000000..c369506061 --- /dev/null +++ b/libs/langchain/tests/unit_tests/agents/output_parsers/test_self_ask.py @@ -0,0 +1,49 @@ +from langchain.agents.output_parsers.self_ask import SelfAskOutputParser +from langchain.schema.agent import AgentAction, AgentFinish + + +def test_follow_up() -> None: + """Test follow up parsing.""" + parser = SelfAskOutputParser() + _input = "Follow up: what is two + 2" + output = parser.invoke(_input) + expected_output = AgentAction( + tool="Intermediate Answer", tool_input="what is two + 2", log=_input + ) + assert output == expected_output + # Test that also handles one word by default + _input = "Followup: what is two + 2" + output = parser.invoke(_input) + expected_output = AgentAction( + tool="Intermediate Answer", tool_input="what is two + 2", log=_input + ) + assert output == expected_output + + +def test_follow_up_custom() -> None: + """Test follow up parsing for custom followups.""" + parser = SelfAskOutputParser(followups=("Now:",)) + _input = "Now: what is two + 2" + output = parser.invoke(_input) + expected_output = AgentAction( + tool="Intermediate Answer", tool_input="what is two + 2", log=_input + ) + assert output == expected_output + + +def test_finish() -> None: + """Test standard finish.""" + parser = SelfAskOutputParser() + _input = "So the final answer is: 4" + output = parser.invoke(_input) + expected_output = AgentFinish(return_values={"output": "4"}, log=_input) + assert output == expected_output + + +def test_finish_custom() -> None: + """Test custom finish.""" + parser = SelfAskOutputParser(finish_string="Finally: ") + _input = "Finally: 4" + output = parser.invoke(_input) + expected_output = AgentFinish(return_values={"output": "4"}, log=_input) + assert output == expected_output diff --git a/libs/langchain/tests/unit_tests/agents/output_parsers/test_xml.py b/libs/langchain/tests/unit_tests/agents/output_parsers/test_xml.py new file mode 100644 index 0000000000..0119c931ba --- /dev/null +++ b/libs/langchain/tests/unit_tests/agents/output_parsers/test_xml.py @@ -0,0 +1,33 @@ +from langchain.agents.output_parsers.xml import XMLAgentOutputParser +from langchain.schema.agent import AgentAction, AgentFinish + + +def test_tool_usage() -> None: + parser = XMLAgentOutputParser() + # Test when final closing is included + _input = """searchfoo""" + output = parser.invoke(_input) + expected_output = AgentAction(tool="search", tool_input="foo", log=_input) + assert output == expected_output + # Test when final closing is NOT included + # This happens when it's used as a stop token + _input = """searchfoo""" + output = parser.invoke(_input) + expected_output = AgentAction(tool="search", tool_input="foo", log=_input) + assert output == expected_output + + +def test_finish() -> None: + parser = XMLAgentOutputParser() + # Test when final closing is included + _input = """bar""" + output = parser.invoke(_input) + expected_output = AgentFinish(return_values={"output": "bar"}, log=_input) + assert output == expected_output + + # Test when final closing is NOT included + # This happens when it's used as a stop token + _input = """bar""" + output = parser.invoke(_input) + expected_output = AgentFinish(return_values={"output": "bar"}, log=_input) + assert output == expected_output diff --git a/libs/langchain/tests/unit_tests/agents/test_openai_functions.py b/libs/langchain/tests/unit_tests/agents/test_openai_functions.py deleted file mode 100644 index 046f8d0a50..0000000000 --- a/libs/langchain/tests/unit_tests/agents/test_openai_functions.py +++ /dev/null @@ -1,76 +0,0 @@ -import pytest - -from langchain.agents.openai_functions_agent.base import ( - _FunctionsAgentAction, - _parse_ai_message, -) -from langchain.schema import AgentFinish, OutputParserException -from langchain.schema.messages import AIMessage, SystemMessage - - -# Test: _parse_ai_message() function. -class TestParseAIMessage: - # Test: Pass Non-AIMessage. - def test_not_an_ai(self) -> None: - err = f"Expected an AI message got {str(SystemMessage)}" - with pytest.raises(TypeError, match=err): - _parse_ai_message(SystemMessage(content="x")) - - # Test: Model response (not a function call). - def test_model_response(self) -> None: - msg = AIMessage(content="Model response.") - result = _parse_ai_message(msg) - - assert isinstance(result, AgentFinish) - assert result.return_values == {"output": "Model response."} - assert result.log == "Model response." - - # Test: Model response with a function call. - def test_func_call(self) -> None: - msg = AIMessage( - content="LLM thoughts.", - additional_kwargs={ - "function_call": {"name": "foo", "arguments": '{"param": 42}'} - }, - ) - result = _parse_ai_message(msg) - - assert isinstance(result, _FunctionsAgentAction) - assert result.tool == "foo" - assert result.tool_input == {"param": 42} - assert result.log == ( - "\nInvoking: `foo` with `{'param': 42}`\nresponded: LLM thoughts.\n\n" - ) - assert result.message_log == [msg] - - # Test: Model response with a function call (old style tools). - def test_func_call_oldstyle(self) -> None: - msg = AIMessage( - content="LLM thoughts.", - additional_kwargs={ - "function_call": {"name": "foo", "arguments": '{"__arg1": "42"}'} - }, - ) - result = _parse_ai_message(msg) - - assert isinstance(result, _FunctionsAgentAction) - assert result.tool == "foo" - assert result.tool_input == "42" - assert result.log == ( - "\nInvoking: `foo` with `42`\nresponded: LLM thoughts.\n\n" - ) - assert result.message_log == [msg] - - # Test: Invalid function call args. - def test_func_call_invalid(self) -> None: - msg = AIMessage( - content="LLM thoughts.", - additional_kwargs={"function_call": {"name": "foo", "arguments": "{42]"}}, - ) - - err = ( - "Could not parse tool input: {'name': 'foo', 'arguments': '{42]'} " - "because the `arguments` is not valid JSON." - ) - with pytest.raises(OutputParserException, match=err): - _parse_ai_message(msg)