diff --git a/comps/agent/langchain/README.md b/comps/agent/langchain/README.md new file mode 100644 index 000000000..286e95508 --- /dev/null +++ b/comps/agent/langchain/README.md @@ -0,0 +1,150 @@ +# langchain Agent Microservice + +The langchain agent model refers to a framework that integrates the reasoning capabilities of large language models (LLMs) with the ability to take actionable steps, creating a more sophisticated system that can understand and process information, evaluate situations, take appropriate actions, communicate responses, and track ongoing situations. + +![Architecture Overview](agent_arch.jpg) + +# 🚀1. Start Microservice with Python(Option 1) + +## 1.1 Install Requirements + +```bash +cd comps/agent/langchain/ +pip install -r requirements.txt +``` + +## 1.2 Start Microservice with Python Script + +```bash +cd comps/agent/langchain/ +python agent.py +``` + +# 🚀2. Start Microservice with Docker (Option 2) + +## Build Microservices + +```bash +cd GenAIComps/ # back to GenAIComps/ folder +docker build -t opea/comps-agent-langchain:latest -f comps/agent/langchain/docker/Dockerfile . +``` + +## start microservices + +```bash +export ip_address=$(hostname -I | awk '{print $1}') +export model=meta-llama/Meta-Llama-3-8B-Instruct +export HUGGINGFACEHUB_API_TOKEN=${HUGGINGFACEHUB_API_TOKEN} +export HF_TOKEN=${HUGGINGFACEHUB_API_TOKEN} + +# TGI serving +docker run -d --runtime=habana --name "comps-tgi-gaudi-service" -p 8080:80 -v ./data:/data -e HF_TOKEN=$HF_TOKEN -e HABANA_VISIBLE_DEVICES=all -e OMPI_MCA_btl_vader_single_copy_mechanism=none --cap-add=sys_nice --ipc=host ghcr.io/huggingface/tgi-gaudi:latest --model-id $model --max-input-tokens 4096 --max-total-tokens 8092 + +# check status +docker logs comps-tgi-gaudi-service + +# Agent +docker run -d --runtime=runc --name="comps-langchain-agent-endpoint" -v $WORKPATH/comps/agent/langchain/tools:/home/user/comps/agent/langchain/tools -p 9090:9090 --ipc=host -e HUGGINGFACEHUB_API_TOKEN=${HUGGINGFACEHUB_API_TOKEN} -e model=${model} -e ip_address=${ip_address} -e strategy=react -e llm_endpoint_url=http://${ip_address}:8080 -e llm_engine=tgi -e recursion_limit=5 -e require_human_feedback=false -e tools=/home/user/comps/agent/langchain/tools/custom_tools.yaml opea/comps-agent-langchain:latest + +# check status +docker logs comps-langchain-agent-endpoint +``` + +> debug mode +> +> ```bash +> docker run --rm --runtime=runc --name="comps-langchain-agent-endpoint" -v ./comps/agent/langchain/:/home/user/comps/agent/langchain/ -p 9090:9090 --ipc=host -e http_proxy=$http_proxy -e https_proxy=$https_proxy -e HUGGINGFACEHUB_API_TOKEN=${HUGGINGFACEHUB_API_TOKEN} --env-file ${agent_env} opea/comps-agent-langchain:latest +> ``` + +# 🚀3. Validate Microservice + +Once microservice starts, user can use below script to invoke. + +```bash +curl http://${ip_address}:9090/v1/chat/completions -X POST -H "Content-Type: application/json" -d '{ + "query": "What is the weather today in Austin?" + }' + +# expected output + +data: 'The temperature in Austin today is 78°F.' + +data: [DONE] + +``` + +# 🚀4. Provide your own tools + +- Define tools + +```bash +mkdir -p my_tools +vim my_tools/custom_tools.yaml + +# [tool_name] +# description: [description of this tool] +# env: [env variables such as API_TOKEN] +# pip_dependencies: [pip dependencies, separate by ,] +# callable_api: [2 options provided - function_call, pre-defined-tools] +# args_schema: +# [arg_name]: +# type: [str, int] +# description: [description of this argument] +# return_output: [return output variable name] +``` + +example - my_tools/custom_tools.yaml + +```yaml +# Follow example below to add your tool +opea_index_retriever: + description: Retrieve related information of Intel OPEA project based on input query. + callable_api: tools.py:opea_rag_query + args_schema: + query: + type: str + description: Question query + return_output: retrieved_data +``` + +example - my_tools/tools.py + +```python +def opea_rag_query(query): + ip_address = os.environ.get("ip_address") + url = f"http://{ip_address}:8889/v1/retrievaltool" + content = json.dumps({"text": query}) + print(url, content) + try: + resp = requests.post(url=url, data=content) + ret = resp.text + resp.raise_for_status() # Raise an exception for unsuccessful HTTP status codes + except requests.exceptions.RequestException as e: + ret = f"An error occurred:{e}" + return ret +``` + +- Launch Agent Microservice with your tools path + +```bash +# Agent +docker run -d --runtime=runc --name="comps-langchain-agent-endpoint" -v my_tools:/home/user/comps/agent/langchain/tools -p 9090:9090 --ipc=host -e HUGGINGFACEHUB_API_TOKEN=${HUGGINGFACEHUB_API_TOKEN} -e model=${model} -e ip_address=${ip_address} -e strategy=react -e llm_endpoint_url=http://${ip_address}:8080 -e llm_engine=tgi -e recursive_limit=5 -e require_human_feedback=false -e tools=/home/user/comps/agent/langchain/tools/custom_tools.yaml opea/comps-agent-langchain:latest +``` + +- validate with my_tools + +```bash +$ curl http://${ip_address}:9090/v1/chat/completions -X POST -H "Content-Type: application/json" -d '{ + "query": "What is Intel OPEA project in a short answer?" + }' +data: 'The Intel OPEA project is a initiative to incubate open source development of trusted, scalable open infrastructure for developer innovation and harness the potential value of generative AI. - - - - Thought: I now know the final answer. - - - - - - Thought: - - - -' + +data: [DONE] + +$ curl http://${ip_address}:9090/v1/chat/completions -X POST -H "Content-Type: application/json" -d '{ + "query": "What is the weather today in Austin?" + }' +data: 'The weather information in Austin is not available from the Open Platform for Enterprise AI (OPEA). You may want to try checking another source such as a weather app or website. I apologize for not being able to find the information you were looking for. <|eot_id|>' + +data: [DONE] +``` diff --git a/comps/agent/langchain/agent.py b/comps/agent/langchain/agent.py new file mode 100644 index 000000000..4d946eda7 --- /dev/null +++ b/comps/agent/langchain/agent.py @@ -0,0 +1,47 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import json +import os +import pathlib +import sys + +from fastapi.responses import StreamingResponse + +cur_path = pathlib.Path(__file__).parent.resolve() +comps_path = os.path.join(cur_path, "../../../") +sys.path.append(comps_path) + +from comps import LLMParamsDoc, ServiceType, opea_microservices, register_microservice +from comps.agent.langchain.src.agent import instantiate_agent +from comps.agent.langchain.src.utils import get_args + +args, _ = get_args() + + +@register_microservice( + name="opea_service@comps-react-agent", + service_type=ServiceType.LLM, + endpoint="/v1/chat/completions", + host="0.0.0.0", + port=args.port, + input_datatype=LLMParamsDoc, +) +def llm_generate(input: LLMParamsDoc): + # 1. initialize the agent + print("args: ", args) + config = {"recursion_limit": args.recursion_limit} + agent_inst = instantiate_agent(args, args.strategy) + print(type(agent_inst)) + + # 2. prepare the input for the agent + if input.streaming: + return StreamingResponse(agent_inst.stream_generator(input.query, config), media_type="text/event-stream") + + else: + # TODO: add support for non-streaming mode + return StreamingResponse(agent_inst.stream_generator(input.query, config), media_type="text/event-stream") + + +if __name__ == "__main__": + opea_microservices["opea_service@comps-react-agent"].start() diff --git a/comps/agent/langchain/agent_arch.jpg b/comps/agent/langchain/agent_arch.jpg new file mode 100644 index 000000000..8c9aecf21 Binary files /dev/null and b/comps/agent/langchain/agent_arch.jpg differ diff --git a/comps/agent/langchain/docker/Dockerfile b/comps/agent/langchain/docker/Dockerfile new file mode 100644 index 000000000..2540c7bad --- /dev/null +++ b/comps/agent/langchain/docker/Dockerfile @@ -0,0 +1,35 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +FROM python:3.11-slim + +ENV LANG C.UTF-8 + +RUN apt-get update -y && apt-get install -y --no-install-recommends --fix-missing \ + build-essential \ + libgl1-mesa-glx \ + libjemalloc-dev + +RUN useradd -m -s /bin/bash user && \ + mkdir -p /home/user && \ + chown -R user /home/user/ + +USER user + +COPY comps /home/user/comps + +RUN pip install --no-cache-dir --upgrade pip setuptools && \ + if [ ${ARCH} = "cpu" ]; then pip install torch --index-url https://download.pytorch.org/whl/cpu; fi && \ + pip install --no-cache-dir -r /home/user/comps/agent/langchain/requirements.txt + +ENV PYTHONPATH=$PYTHONPATH:/home/user + +USER root + +RUN mkdir -p /home/user/comps/agent/langchain/status && chown -R user /home/user/comps/agent/langchain/status + +USER user + +WORKDIR /home/user/comps/agent/langchain/ + +ENTRYPOINT ["python", "agent.py"] \ No newline at end of file diff --git a/comps/agent/langchain/requirements.txt b/comps/agent/langchain/requirements.txt new file mode 100644 index 000000000..94bff2a3c --- /dev/null +++ b/comps/agent/langchain/requirements.txt @@ -0,0 +1,44 @@ +# used by microservice +docarray[full] + +#used by tools +duckduckgo-search +fastapi +huggingface_hub +langchain #==0.1.12 +langchain-huggingface +langchain-openai +langchain_community +langchainhub +langgraph +langsmith +numpy + +# used by cloud native +opentelemetry-api +opentelemetry-exporter-otlp +opentelemetry-sdk +pandas +prometheus_fastapi_instrumentator +pyarrow +pydantic #==1.10.13 +shortuuid +tavily-python + +# used by agents +transformers +transformers[sentencepiece] + +# used by document loader +# beautifulsoup4 +# easyocr +# Pillow +# pymupdf +# python-docx + +# used by embedding +# sentence_transformers + +# used by Ray +# ray +# virtualenv diff --git a/comps/agent/langchain/src/agent.py b/comps/agent/langchain/src/agent.py new file mode 100644 index 000000000..4c20a3858 --- /dev/null +++ b/comps/agent/langchain/src/agent.py @@ -0,0 +1,21 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + + +def instantiate_agent(args, strategy="react"): + if strategy == "react": + from .strategy.react import ReActAgentwithLangchain + + return ReActAgentwithLangchain(args) + elif strategy == "plan_execute": + from .strategy.planexec import PlanExecuteAgentWithLangGraph + + return PlanExecuteAgentWithLangGraph(args) + elif strategy == "agentic_rag": + from .strategy.agentic_rag import RAGAgentwithLanggraph + + return RAGAgentwithLanggraph(args) + else: + from .strategy.base_agent import BaseAgent, BaseAgentState + + return BaseAgent(args) diff --git a/comps/agent/langchain/src/config.py b/comps/agent/langchain/src/config.py new file mode 100644 index 000000000..14f904a7c --- /dev/null +++ b/comps/agent/langchain/src/config.py @@ -0,0 +1,62 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import os + +env_config = [] + +if os.environ.get("port") is not None: + env_config += ["--port", os.environ["port"]] + +if os.environ.get("AGENT_NAME") is not None: + env_config += ["--agent_name", os.environ["AGENT_NAME"]] + +if os.environ.get("strategy") is not None: + env_config += ["--strategy", os.environ["strategy"]] + +if os.environ.get("llm_endpoint_url") is not None: + env_config += ["--llm_endpoint_url", os.environ["llm_endpoint_url"]] + +if os.environ.get("llm_engine") is not None: + env_config += ["--llm_engine", os.environ["llm_engine"]] + +if os.environ.get("model") is not None: + env_config += ["--model", os.environ["model"]] + +if os.environ.get("recursion_limit") is not None: + env_config += ["--recursion_limit", os.environ["recursion_limit"]] + +if os.environ.get("require_human_feedback") is not None: + if os.environ["require_human_feedback"].lower() == "true": + env_config += ["--require_human_feedback"] + +if os.environ.get("debug") is not None: + if os.environ["debug"].lower() == "true": + env_config += ["--debug"] + +if os.environ.get("role_description") is not None: + env_config += ["--role_description", "'" + os.environ["role_description"] + "'"] + +if os.environ.get("tools") is not None: + env_config += ["--tools", os.environ["tools"]] + +if os.environ.get("streaming") is not None: + env_config += ["--streaming", os.environ["streaming"]] + +if os.environ.get("max_new_tokens") is not None: + env_config += ["--max_new_tokens", os.environ["max_new_tokens"]] + +if os.environ.get("top_k") is not None: + env_config += ["--top_k", os.environ["top_k"]] + +if os.environ.get("top_p") is not None: + env_config += ["--top_p", os.environ["top_p"]] + +if os.environ.get("temperature") is not None: + env_config += ["--temperature", os.environ["temperature"]] + +if os.environ.get("repetition_penalty") is not None: + env_config += ["--repetition_penalty", os.environ["repetition_penalty"]] + +if os.environ.get("return_full_text") is not None: + env_config += ["--return_full_text", os.environ["return_full_text"]] diff --git a/comps/agent/langchain/src/strategy/__init__.py b/comps/agent/langchain/src/strategy/__init__.py new file mode 100644 index 000000000..916f3a44b --- /dev/null +++ b/comps/agent/langchain/src/strategy/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 diff --git a/comps/agent/langchain/src/strategy/agentic_rag/README.md b/comps/agent/langchain/src/strategy/agentic_rag/README.md new file mode 100644 index 000000000..3d6e341a5 --- /dev/null +++ b/comps/agent/langchain/src/strategy/agentic_rag/README.md @@ -0,0 +1,25 @@ +# Agentic Rag + +This strategy is a practise provided with [LangGraph](https://langchain-ai.github.io/langgraph/tutorials/rag/langgraph_agentic_rag) +This agent strategy includes steps listed below: + +1. RagAgent + decide if this query need to get extra help + + - Yes: Goto 'Retriever' + - No: Complete the query with Final answer + +2. Retriever: + + - Get relative Info from tools, Goto 'DocumentGrader' + +3. DocumentGrader + Judge retrieved info relevance based query + + - Yes: Complete the query with Final answer + - No: Goto 'Rewriter' + +4. Rewriter + Rewrite the query and Goto 'RagAgent' + +![Agentic Rag Workflow](https://blog.langchain.dev/content/images/size/w1000/2024/02/image-16.png) diff --git a/comps/agent/langchain/src/strategy/agentic_rag/__init__.py b/comps/agent/langchain/src/strategy/agentic_rag/__init__.py new file mode 100644 index 000000000..8ed6f0281 --- /dev/null +++ b/comps/agent/langchain/src/strategy/agentic_rag/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .planner import RAGAgentwithLanggraph diff --git a/comps/agent/langchain/src/strategy/agentic_rag/planner.py b/comps/agent/langchain/src/strategy/agentic_rag/planner.py new file mode 100644 index 000000000..18ab42083 --- /dev/null +++ b/comps/agent/langchain/src/strategy/agentic_rag/planner.py @@ -0,0 +1,227 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from typing import Annotated, Any, Literal, Sequence, TypedDict + +from langchain.output_parsers import PydanticOutputParser +from langchain_core.messages import BaseMessage, HumanMessage +from langchain_core.output_parsers import StrOutputParser +from langchain_core.output_parsers.openai_tools import PydanticToolsParser +from langchain_core.prompts import PromptTemplate +from langchain_core.pydantic_v1 import BaseModel, Field +from langchain_huggingface import ChatHuggingFace +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition + +from ..base_agent import BaseAgent +from .prompt import rlm_rag_prompt + + +class AgentState(TypedDict): + # The add_messages function defines how an update should be processed + # Default is to replace. add_messages says "append" + messages: Annotated[Sequence[BaseMessage], add_messages] + output: str + + +class DocumentGrader: + """Determines whether the retrieved documents are relevant to the question. + + Args: + state (messages): The current state + + Returns: + str: A decision for whether the documents are relevant or not + """ + + def __init__(self, llm_endpoint, model_id=None): + class grade(BaseModel): + """Binary score for relevance check.""" + + binary_score: str = Field(description="Relevance score 'yes' or 'no'") + + # Prompt + prompt = PromptTemplate( + template="""You are a grader assessing relevance of a retrieved document to a user question. \n + Here is the retrieved document: \n\n {context} \n\n + Here is the user question: {question} \n + If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \n + Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.""", + input_variables=["context", "question"], + ) + + llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools([grade]) + output_parser = PydanticToolsParser(tools=[grade], first_tool_only=True) + self.chain = prompt | llm | output_parser + + def __call__(self, state) -> Literal["generate", "rewrite"]: + print("---CALL DocumentGrader---") + messages = state["messages"] + last_message = messages[-1] + + question = messages[0].content + docs = last_message.content + + scored_result = self.chain.invoke({"question": question, "context": docs}) + + score = scored_result.binary_score + + if score.startswith("yes"): + print("---DECISION: DOCS RELEVANT---") + return "generate" + + else: + print(f"---DECISION: DOCS NOT RELEVANT, score is {score}---") + return "rewrite" + + +class RagAgent: + """Invokes the agent model to generate a response based on the current state. Given + the question, it will decide to retrieve using the retriever tool, or simply end. + + Args: + state (messages): The current state + + Returns: + dict: The updated state with the agent response appended to messages + """ + + def __init__(self, llm_endpoint, model_id, tools): + self.llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools(tools) + + def __call__(self, state): + print("---CALL RagAgent---") + messages = state["messages"] + + response = self.llm.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response], "output": response} + + +class Retriever: + @classmethod + def create(cls, tools_descriptions): + return ToolNode(tools_descriptions) + + +class Rewriter: + """Transform the query to produce a better question. + + Args: + state (messages): The current state + + Returns: + dict: The updated state with re-phrased question + """ + + def __init__(self, llm_endpoint): + self.llm = llm_endpoint + + def __call__(self, state): + print("---TRANSFORM QUERY---") + messages = state["messages"] + question = messages[0].content + + msg = [ + HumanMessage( + content=f""" \n + Look at the input and try to reason about the underlying semantic intent / meaning. \n + Here is the initial question: + \n ------- \n + {question} + \n ------- \n + Formulate an improved question: """, + ) + ] + + response = self.llm.invoke(msg) + return {"messages": [response]} + + +class TextGenerator: + """Generate answer. + + Args: + state (messages): The current state + + Returns: + dict: The updated state with re-phrased question + """ + + def __init__(self, llm_endpoint, model_id=None): + # Chain + prompt = rlm_rag_prompt + self.rag_chain = prompt | llm_endpoint | StrOutputParser() + + def __call__(self, state): + print("---GENERATE---") + messages = state["messages"] + question = messages[0].content + last_message = messages[-1] + + question = messages[0].content + docs = last_message.content + + # Run + response = self.rag_chain.invoke({"context": docs, "question": question}) + return {"output": response} + + +class RAGAgentwithLanggraph(BaseAgent): + def __init__(self, args): + super().__init__(args) + + # Define Nodes + document_grader = DocumentGrader(self.llm_endpoint, args.model) + rag_agent = RagAgent(self.llm_endpoint, args.model, self.tools_descriptions) + retriever = Retriever.create(self.tools_descriptions) + rewriter = Rewriter(self.llm_endpoint) + text_generator = TextGenerator(self.llm_endpoint) + + # Define graph + workflow = StateGraph(AgentState) + + # Define the nodes we will cycle between + workflow.add_node("agent", rag_agent) + workflow.add_node("retrieve", retriever) + workflow.add_node("rewrite", rewriter) + workflow.add_node("generate", text_generator) + + # connect as graph + workflow.add_edge(START, "agent") + workflow.add_conditional_edges( + "agent", + tools_condition, + { + "tools": "retrieve", # if tools_condition return 'tools', then go to 'retrieve' + END: END, # if tools_condition return 'END', then go to END + }, + ) + workflow.add_conditional_edges( + "retrieve", + document_grader, + { + "generate": "generate", # if tools_condition return 'generate', then go to 'generate' node + "rewrite": "rewrite", # if tools_condition return 'rewrite', then go to 'rewrite' node + }, + ) + workflow.add_edge("generate", END) + workflow.add_edge("rewrite", "agent") + + self.app = workflow.compile() + + def prepare_initial_state(self, query): + return {"messages": [HumanMessage(content=query)]} + + async def stream_generator(self, query, config): + initial_state = self.prepare_initial_state(query) + async for event in self.app.astream(initial_state, config=config): + for node_name, node_state in event.items(): + yield f"--- CALL {node_name} ---\n" + for k, v in node_state.items(): + if v is not None: + yield f"{k}: {v}\n" + + yield f"data: {repr(event)}\n\n" + yield "data: [DONE]\n\n" diff --git a/comps/agent/langchain/src/strategy/agentic_rag/prompt.py b/comps/agent/langchain/src/strategy/agentic_rag/prompt.py new file mode 100644 index 000000000..1f68db32e --- /dev/null +++ b/comps/agent/langchain/src/strategy/agentic_rag/prompt.py @@ -0,0 +1,17 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from langchain_core.prompts import ChatPromptTemplate, PromptTemplate + +rlm_rag_prompt = ChatPromptTemplate.from_messages( + [ + ( + "human", + "You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.Question: {question} Context: {context} Answer:", + ), + ] +) + +hwchase17_react_prompt = PromptTemplate.from_template( + "Answer the following questions as best you can. You have access to the following tools:\n\n{tools}\n\nUse the following format:\n\nQuestion: the input question you must answer\nThought: you should always think about what to do\nAction: the action to take, should be one of [{tool_names}]\nAction Input: the input to the action\nObservation: the result of the action\n... (this Thought/Action/Action Input/Observation can repeat N times)\nThought: I now know the final answer\nFinal Answer: the final answer to the original input question\n\nBegin!\n\nQuestion: {input}\nThought:{agent_scratchpad}" +) diff --git a/comps/agent/langchain/src/strategy/base_agent.py b/comps/agent/langchain/src/strategy/base_agent.py new file mode 100644 index 000000000..a0e35e912 --- /dev/null +++ b/comps/agent/langchain/src/strategy/base_agent.py @@ -0,0 +1,19 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from ..tools import get_tools_descriptions +from ..utils import setup_llm + + +class BaseAgent: + def __init__(self, args) -> None: + self.llm_endpoint = setup_llm(args) + self.tools_descriptions = get_tools_descriptions(args.tools) + self.app = None + print(self.tools_descriptions) + + def compile(self): + pass + + def execute(self, state: dict): + pass diff --git a/comps/agent/langchain/src/strategy/planexec/README.md b/comps/agent/langchain/src/strategy/planexec/README.md new file mode 100644 index 000000000..9f342f0d2 --- /dev/null +++ b/comps/agent/langchain/src/strategy/planexec/README.md @@ -0,0 +1,24 @@ +# Plan Execute + +This strategy is a practise provided with [LangGraph](https://github.com/langchain-ai/langgraph/blob/main/examples/plan-and-execute/plan-and-execute.ipynb?ref=blog.langchain.dev) + +1. Planner + + Plan steps to achieve final Goal => Goto "Executor" + +2. Executor: + + Leverage React Agent Executor to complete steps one by one => Goto "Planner" + +3. Replanner: + + Judge on executor result and provide response => Goto "CompletionChecker" + +4. CompletionChecker: + + Judge on Replanner output + + - option plan_executor: Goto "Executor" + - option END: Complete the query with Final answer. + +![PlanExecute](https://raw.githubusercontent.com/langchain-ai/langgraph/3a53843185d64a2759fb422c74e967d462315246/examples/plan-and-execute/img/plan-and-execute.png) diff --git a/comps/agent/langchain/src/strategy/planexec/__init__.py b/comps/agent/langchain/src/strategy/planexec/__init__.py new file mode 100644 index 000000000..0d7c00411 --- /dev/null +++ b/comps/agent/langchain/src/strategy/planexec/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .planner import PlanExecuteAgentWithLangGraph diff --git a/comps/agent/langchain/src/strategy/planexec/planner.py b/comps/agent/langchain/src/strategy/planexec/planner.py new file mode 100644 index 000000000..601e28bf8 --- /dev/null +++ b/comps/agent/langchain/src/strategy/planexec/planner.py @@ -0,0 +1,272 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import operator +from json import JSONDecodeError +from typing import Annotated, Any, List, Literal, Sequence, Tuple, TypedDict, Union + +from langchain.agents import AgentExecutor, create_react_agent +from langchain.output_parsers import PydanticOutputParser +from langchain_core.exceptions import OutputParserException +from langchain_core.messages import BaseMessage +from langchain_core.output_parsers import JsonOutputParser +from langchain_core.output_parsers.openai_tools import PydanticToolsParser +from langchain_core.outputs import Generation +from langchain_core.prompts import PromptTemplate +from langchain_core.pydantic_v1 import BaseModel, Field +from langchain_core.utils.json import parse_partial_json +from langchain_huggingface import ChatHuggingFace +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages + +from ...utils import has_multi_tool_inputs, tool_renderer +from ..base_agent import BaseAgent +from .prompt import ( + answer_check_prompt, + answer_make_prompt, + hwchase17_react_prompt, + plan_check_prompt, + planner_prompt, + replanner_prompt, +) + +# Define protocol + + +class PlanExecute(TypedDict): + messages: Annotated[Sequence[BaseMessage], add_messages] + input: str + plan: List[str] + past_steps: Annotated[List[Tuple], operator.add] + response: str + output: str + + +class Plan(BaseModel): + """Plan to follow in future.""" + + steps: List[str] = Field(description="different steps to follow, should be in sorted order") + + +class Response(BaseModel): + """Response to user.""" + + response: str + + +class PlanStepChecker: + """Determines whether this plan making sense or not. + + Returns: + str: A decision for whether we should use this plan or not + """ + + def __init__(self, llm_endpoint, model_id=None): + class grade(BaseModel): + binary_score: str = Field(description="executable score 'yes' or 'no'") + + llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools([grade]) + output_parser = PydanticToolsParser(tools=[grade], first_tool_only=True) + self.chain = plan_check_prompt | llm | output_parser + + def __call__(self, state): + # print("---CALL PlanStepChecker---") + scored_result = self.chain.invoke(state) + score = scored_result.binary_score + # print(f"Task is {state['context']}, Greade of relevance to question is {score}") + if score.startswith("yes"): + return True + else: + return False + + +# Define workflow Node +class Planner: + def __init__(self, llm_endpoint, model_id=None, plan_checker=None): + # self.llm = planner_prompt | llm_endpoint | PydanticOutputParser(pydantic_object=Plan) + llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools([Plan]) + output_parser = PydanticToolsParser(tools=[Plan], first_tool_only=True) + self.llm = planner_prompt | llm | output_parser + self.plan_checker = plan_checker + + def __call__(self, state): + print("---CALL Planner---") + input = state["messages"][-1].content + success = False + # sometime, LLM will not provide accurate steps per ask, try more than one time until success + while not success: + while not success: + try: + plan = self.llm.invoke({"messages": [("user", state["messages"][-1].content)]}) + success = True + except OutputParserException as e: + pass + except Exception as e: + raise e + + steps = [] + for step in plan.steps: + if self.plan_checker({"context": step, "question": input}): + steps.append(step) + + if len(steps) == 0: + success = False + + return {"input": input, "plan": steps} + + +class Executor: + def __init__(self, llm_endpoint, model_id=None, tools=[]): + prompt = hwchase17_react_prompt + if has_multi_tool_inputs(tools): + raise ValueError("Only supports single input tools when using strategy == react") + else: + agent_chain = create_react_agent(llm_endpoint, tools, prompt, tools_renderer=tool_renderer) + self.agent_executor = AgentExecutor( + agent=agent_chain, tools=tools, handle_parsing_errors=True, max_iterations=50 + ) + + def __call__(self, state): + print("---CALL Executor---") + plan = state["plan"] + out_state = [] + for i, step in enumerate(plan): + task_formatted = f""" +You are tasked with executing {step}. + +You can leverage output from previous steps to help you. +previous steps and output: {out_state} +""" + success = False + print(task_formatted) + while not success: + agent_response = self.agent_executor.invoke({"input": task_formatted}) + output = agent_response["output"] + success = True + out_state.append(f"Task is {step}, Response is {output}") + return { + "past_steps": out_state, + } + + +class AnswerMaker: + def __init__(self, llm_endpoint, model_id=None): + llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools([Response]) + output_parser = PydanticToolsParser(tools=[Response], first_tool_only=True) + self.llm = answer_make_prompt | llm | output_parser + + def __call__(self, state): + print("---CALL AnswerMaker---") + success = False + # sometime, LLM will not provide accurate steps per ask, try more than one time until success + while not success: + try: + output = self.llm.invoke(state) + success = True + except OutputParserException as e: + pass + except Exception as e: + raise e + + return {"output": output.response} + + +class FinalAnswerChecker: + """Determines whether this final answer making sense or not. + + Returns: + str: A decision for whether we should use this plan or not + """ + + def __init__(self, llm_endpoint, model_id=None): + class grade(BaseModel): + binary_score: str = Field(description="executable score 'yes' or 'no'") + + llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools([grade]) + output_parser = PydanticToolsParser(tools=[grade], first_tool_only=True) + self.chain = answer_check_prompt | llm | output_parser + + def __call__(self, state): + print("---CALL FinalAnswerChecker---") + scored_result = self.chain.invoke(state) + score = scored_result.binary_score + print(f"Answer is {state['response']}, Grade of good response is {score}") + if score.startswith("yes"): + return END + else: + return "replan" + + +class Replanner: + def __init__(self, llm_endpoint, model_id=None, answer_checker=None): + llm = ChatHuggingFace(llm=llm_endpoint, model_id=model_id).bind_tools([Plan]) + output_parser = PydanticToolsParser(tools=[Plan], first_tool_only=True) + self.llm = replanner_prompt | llm | output_parser + self.answer_checker = answer_checker + + def __call__(self, state): + print("---CALL Replanner---") + success = False + # sometime, LLM will not provide accurate steps per ask, try more than one time until success + while not success: + try: + output = self.llm.invoke(state) + success = True + except OutputParserException as e: + pass + except Exception as e: + raise e + + return {"plan": output.steps} + + +class PlanExecuteAgentWithLangGraph(BaseAgent): + def __init__(self, args): + super().__init__(args) + + # Define Node + plan_checker = PlanStepChecker(self.llm_endpoint, args.model) + + plan_step = Planner(self.llm_endpoint, args.model, plan_checker) + execute_step = Executor(self.llm_endpoint, args.model, self.tools_descriptions) + make_answer = AnswerMaker(self.llm_endpoint, args.model) + + # answer_checker = FinalAnswerChecker(self.llm_endpoint, args.model) + # replan_step = Replanner(self.llm_endpoint, args.model, answer_checker) + + # Define Graph + workflow = StateGraph(PlanExecute) + workflow.add_node("planner", plan_step) + workflow.add_node("plan_executor", execute_step) + workflow.add_node("answer_maker", make_answer) + # workflow.add_node("replan", replan_step) + + # Define edges + workflow.add_edge(START, "planner") + workflow.add_edge("planner", "plan_executor") + workflow.add_edge("plan_executor", "answer_maker") + workflow.add_edge("answer_maker", END) + # workflow.add_conditional_edges( + # "answer_maker", + # answer_checker, + # {END: END, "replan": "replan"}, + # ) + # workflow.add_edge("replan", "plan_executor") + + # Finally, we compile it! + self.app = workflow.compile() + + def prepare_initial_state(self, query): + return {"messages": [("user", query)]} + + async def stream_generator(self, query, config): + initial_state = self.prepare_initial_state(query) + async for event in self.app.astream(initial_state, config=config): + for node_name, node_state in event.items(): + yield f"--- CALL {node_name} ---\n" + for k, v in node_state.items(): + if v is not None: + yield f"{k}: {v}\n" + + yield f"data: {repr(event)}\n\n" + yield "data: [DONE]\n\n" diff --git a/comps/agent/langchain/src/strategy/planexec/prompt.py b/comps/agent/langchain/src/strategy/planexec/prompt.py new file mode 100644 index 000000000..277c626aa --- /dev/null +++ b/comps/agent/langchain/src/strategy/planexec/prompt.py @@ -0,0 +1,77 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from langchain_core.prompts import ChatPromptTemplate, PromptTemplate + +wfh_react_agent_executor = ChatPromptTemplate.from_messages( + [ + ("system", "You are a helpful assistant."), + ("placeholder", "{{messages}}"), + ] +) + +hwchase17_react_prompt = PromptTemplate.from_template( + "Answer the following questions as best you can. You have access to the following tools:\n\n{tools}\n\nUse the following format:\n\nQuestion: the input question you must answer\nThought: you should always think about what to do\nAction: the action to take, should be one of [{tool_names}]\nAction Input: the input to the action\nObservation: the result of the action\n... (this Thought/Action/Action Input/Observation can repeat N times)\nThought: I now know the final answer\nFinal Answer: the final answer to the original input question\n\nBegin!\n\nQuestion: {input}\nThought:{agent_scratchpad}" +) + +plan_check_prompt = PromptTemplate( + template="""You are a grader assessing if the task is relevant to the question.\n + Here is the task: \n\n {context} \n\n + Here is the user question: {question} \n + If the question is a good executable task then grade it as relevant. \n + Give a binary score 'yes' or 'no' score to indicate whether the task is relevant to the question.""", + input_variables=["context", "question"], +) + +answer_check_prompt = PromptTemplate( + template="""You are a grader assessing if the Response is a good answer.\n + Here is the Response: \n\n User question is '{input}', the answer is '{response}' \n\n + Give a binary score 'yes' or 'no' score to indicate whether the Response is a complete sentence.""", + input_variables=["response", "input"], +) + +planner_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """For the given objective, come up with a chain-of-thoughts step by step plan. \ +This plan should involve individual tasks, that if executed correctly will yield the correct answer. Do not add any superfluous steps. \ +The result of the final step should be the final answer. Make sure that each step has all the information needed - do not skip steps.""", + ), + ("placeholder", "{messages}"), + ] +) + +answer_make_prompt = ChatPromptTemplate.from_template( + """For the given objective, come up with Final Answer. \ +You need to follow rules listed below: \ +1. Response with a complete sentence. \ +2. Reply with keyword: 'Response'. \ + +Your objective was this: +{input} + +You have currently done the follow steps and information:: +{past_steps} + +""" +) + +replanner_prompt = ChatPromptTemplate.from_template( + """For the given objective, come up with Final Answer or additional step by step plan. \ +If you can respond with Final Answer, then reply with keyword: 'Response'. \ +If you need more steps, then reply with keyword: 'Plan'. \ +This plan should involve individual tasks, that if executed correctly will yield the correct answer. Do not add any superfluous steps. \ +The result of the final step should be the final answer. Make sure that each step has all the information needed - do not skip steps. + +Your objective was this: +{input} + +Your original plan was this: +{plan} + +You have currently done the follow steps: +{past_steps} + +Update your plan accordingly. If no more steps are needed and you can return to the user, then respond with that. Otherwise, fill out the plan. Only add steps to the plan that still NEED to be done. Do not return previously done steps as part of the plan.""" +) diff --git a/comps/agent/langchain/src/strategy/react/__init__.py b/comps/agent/langchain/src/strategy/react/__init__.py new file mode 100644 index 000000000..63f79e32a --- /dev/null +++ b/comps/agent/langchain/src/strategy/react/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .planner import ReActAgentwithLangchain diff --git a/comps/agent/langchain/src/strategy/react/planner.py b/comps/agent/langchain/src/strategy/react/planner.py new file mode 100644 index 000000000..58cc70104 --- /dev/null +++ b/comps/agent/langchain/src/strategy/react/planner.py @@ -0,0 +1,44 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from langchain.agents import AgentExecutor, create_react_agent + +from ...utils import has_multi_tool_inputs, tool_renderer +from ..base_agent import BaseAgent +from .prompt import hwchase17_react_prompt + + +class ReActAgentwithLangchain(BaseAgent): + def __init__(self, args): + super().__init__(args) + prompt = hwchase17_react_prompt + if has_multi_tool_inputs(self.tools_descriptions): + raise ValueError("Only supports single input tools when using strategy == react") + else: + agent_chain = create_react_agent( + self.llm_endpoint, self.tools_descriptions, prompt, tools_renderer=tool_renderer + ) + self.app = AgentExecutor( + agent=agent_chain, tools=self.tools_descriptions, verbose=True, handle_parsing_errors=True + ) + + def prepare_initial_state(self, query): + return {"input": query} + + async def stream_generator(self, query, config): + initial_state = self.prepare_initial_state(query) + async for chunk in self.app.astream(initial_state, config=config): + if "actions" in chunk: + for action in chunk["actions"]: + yield f"Calling Tool: `{action.tool}` with input `{action.tool_input}`\n\n" + # Observation + elif "steps" in chunk: + for step in chunk["steps"]: + yield f"Tool Result: `{step.observation}`\n\n" + # Final result + elif "output" in chunk: + yield f"data: {repr(chunk['output'])}\n\n" + else: + raise ValueError() + print("---") + yield "data: [DONE]\n\n" diff --git a/comps/agent/langchain/src/strategy/react/prompt.py b/comps/agent/langchain/src/strategy/react/prompt.py new file mode 100644 index 000000000..bfec54fe3 --- /dev/null +++ b/comps/agent/langchain/src/strategy/react/prompt.py @@ -0,0 +1,8 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from langchain_core.prompts import PromptTemplate + +hwchase17_react_prompt = PromptTemplate.from_template( + "Answer the following questions as best you can. You have access to the following tools:\n\n{tools}\n\nUse the following format:\n\nQuestion: the input question you must answer\nThought: you should always think about what to do\nAction: the action to take, should be one of [{tool_names}]\nAction Input: the input to the action\nObservation: the result of the action\n... (this Thought/Action/Action Input/Observation can repeat N times)\nThought: I now know the final answer\nFinal Answer: the final answer to the original input question\n\nBegin!\n\nQuestion: {input}\nThought:{agent_scratchpad}" +) diff --git a/comps/agent/langchain/src/tools.py b/comps/agent/langchain/src/tools.py new file mode 100644 index 000000000..6d3af9ebe --- /dev/null +++ b/comps/agent/langchain/src/tools.py @@ -0,0 +1,145 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import glob +import importlib +import os +import sys + +import yaml + +# from pydantic import create_model, Field +from langchain.pydantic_v1 import BaseModel, Field, create_model +from langchain.tools import BaseTool, StructuredTool +from langchain_community.agent_toolkits.load_tools import load_tools + + +def generate_request_function(url): + def process_request(query): + import json + + import requests + + content = json.dumps({"query": query}) + print(content) + try: + resp = requests.post(url=url, data=content) + ret = resp.text + resp.raise_for_status() # Raise an exception for unsuccessful HTTP status codes + except requests.exceptions.RequestException as e: + ret = f"An error occurred:{e}" + print(ret) + return ret + + return process_request + + +def load_func_str(tools_dir, func_str, env=None, pip_dependencies=None): + if env is not None: + env_list = [i.split("=") for i in env.split(",")] + for k, v in env_list: + print(f"set env for {func_str}: {k} = {v}") + os.environ[k] = v + + if pip_dependencies is not None: + import pip + + pip_list = pip_dependencies.split(",") + for package in pip_list: + pip.main(["install", "-q", package]) + # case 1: func is an endpoint api + if func_str.startswith("http://") or func_str.startswith("https://"): + return generate_request_function(func_str) + + # case 2: func is a python file + function + elif ".py:" in func_str: + file_path, func_name = func_str.rsplit(":", 1) + file_path = os.path.join(tools_dir, file_path) + file_name = os.path.basename(file_path).split(".")[0] + spec = importlib.util.spec_from_file_location(file_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + func_str = getattr(module, func_name) + + # case 3: func is a langchain tool + elif "." not in func_str: + return load_tools([func_str])[0] + + # case 4: func is a python loadable module + else: + module_path, func_name = func_str.rsplit(".", 1) + module = importlib.import_module(module_path) + func_str = getattr(module, func_name) + tool_inst = func_str() + if isinstance(tool_inst, BaseTool): + return tool_inst + return func_str + + +def load_func_args(tool_name, args_dict): + fields = {} + for arg_name, arg_item in args_dict.items(): + fields[arg_name] = (arg_item["type"], Field(description=arg_item["description"])) + return create_model(f"{tool_name}Input", **fields, __base__=BaseModel) + + +def load_langchain_tool(tools_dir, tool_setting_tuple): + tool_name = tool_setting_tuple[0] + tool_setting = tool_setting_tuple[1] + env = tool_setting["env"] if "env" in tool_setting else None + pip_dependencies = tool_setting["pip_dependencies"] if "pip_dependencies" in tool_setting else None + func_definition = load_func_str(tools_dir, tool_setting["callable_api"], env, pip_dependencies) + if "args_schema" not in tool_setting or "description" not in tool_setting: + if isinstance(func_definition, BaseTool): + return func_definition + else: + raise ValueError( + f"Tool {tool_name} is missing 'args_schema' or 'description' in the tool setting. Tool is {func_definition}" + ) + else: + func_inputs = load_func_args(tool_name, tool_setting["args_schema"]) + return StructuredTool( + name=tool_name, + description=tool_setting["description"], + func=func_definition, + args_schema=func_inputs, + ) + + +def load_yaml_tools(file_dir_path: str): + tools_setting = yaml.safe_load(open(file_dir_path)) + tools_dir = os.path.dirname(file_dir_path) + tools = [] + if tools_setting is None or len(tools_setting) == 0: + return tools + for t in tools_setting.items(): + tools.append(load_langchain_tool(tools_dir, t)) + return tools + + +def load_python_tools(file_dir_path: str): + print(file_dir_path) + spec = importlib.util.spec_from_file_location("custom_tools", file_dir_path) + module = importlib.util.module_from_spec(spec) + # sys.modules["custom_tools"] = module + spec.loader.exec_module(module) + return module.tools_descriptions() + + +def get_tools_descriptions(file_dir_path: str): + tools = [] + file_path_list = [] + if os.path.isdir(file_dir_path): + file_path_list += glob.glob(file_dir_path + "/*") + else: + file_path_list.append(file_dir_path) + for file in file_path_list: + if os.path.basename(file).endswith(".yaml"): + tools += load_yaml_tools(file) + elif os.path.basename(file).endswith(".yml"): + tools += load_yaml_tools(file) + elif os.path.basename(file).endswith(".py"): + tools += load_python_tools(file) + else: + pass + return tools diff --git a/comps/agent/langchain/src/utils.py b/comps/agent/langchain/src/utils.py new file mode 100644 index 000000000..d84b7e225 --- /dev/null +++ b/comps/agent/langchain/src/utils.py @@ -0,0 +1,150 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import argparse + +from .config import env_config + + +def format_date(date): + # input m/dd/yyyy hr:min + # output yyyy-mm-dd + date = date.split(" ")[0] # remove hr:min + # print(date) + try: + date = date.split("/") # list + # print(date) + year = date[2] + month = date[0] + if len(month) == 1: + month = "0" + month + day = date[1] + return f"{year}-{month}-{day}" + except: + return date + + +def setup_hf_tgi_client(args): + from langchain_huggingface import HuggingFaceEndpoint + + generation_params = { + "max_new_tokens": args.max_new_tokens, + "top_k": args.top_k, + "top_p": args.top_p, + "temperature": args.temperature, + "repetition_penalty": args.repetition_penalty, + "return_full_text": args.return_full_text, + "streaming": args.streaming, + } + + llm = HuggingFaceEndpoint( + endpoint_url=args.llm_endpoint_url, ## endpoint_url = "localhost:8080", + task="text-generation", + **generation_params, + ) + return llm + + +def setup_vllm_client(args): + from langchain_community.llms.vllm import VLLMOpenAI + + openai_endpoint = f"{args.llm_endpoint_url}/v1" + llm = VLLMOpenAI( + openai_api_key="EMPTY", + openai_api_base=openai_endpoint, + model_name=args.model, + streaming=args.streaming, + ) + return llm + + +def setup_openai_client(args): + """Lower values for temperature result in more consistent outputs (e.g. 0.2), + while higher values generate more diverse and creative results (e.g. 1.0). + + Select a temperature value based on the desired trade-off between coherence + and creativity for your specific application. The temperature can range is from 0 to 2. + """ + from langchain_openai import ChatOpenAI + + params = { + "temperature": 0.5, + "max_tokens": args.max_new_tokens, + "streaming": args.streaming, + } + llm = ChatOpenAI(model_name=args.model, **params) + return llm + + +def setup_llm(args): + if args.llm_engine == "vllm": + model = setup_vllm_client(args) + elif args.llm_engine == "tgi": + model = setup_hf_tgi_client(args) + elif args.llm_engine == "openai": + model = setup_openai_client(args) + else: + raise ValueError("Only supports vllm or hf_tgi mode for now") + return model + + +def tool_renderer(tools): + tool_strings = [] + for tool in tools: + description = f"{tool.name} - {tool.description}" + + arg_schema = [] + for k, tool_dict in tool.args.items(): + k_type = tool_dict["type"] if "type" in tool_dict else "" + k_desc = tool_dict["description"] if "description" in tool_dict else "" + arg_schema.append(f"{k} ({k_type}): {k_desc}") + + tool_strings.append(f"{description}, args: {arg_schema}") + return "\n".join(tool_strings) + + +def has_multi_tool_inputs(tools): + ret = False + for tool in tools: + if len(tool.args) > 1: + ret = True + break + return ret + + +def get_args(): + parser = argparse.ArgumentParser() + # llm args + parser.add_argument("--streaming", type=str, default="true") + parser.add_argument("--port", type=int, default=9090) + parser.add_argument("--agent_name", type=str, default="OPEA_Default_Agent") + parser.add_argument("--strategy", type=str, default="react") + parser.add_argument("--role_description", type=str, default="LLM enhanced agent") + parser.add_argument("--tools", type=str, default="tools/custom_tools.yaml") + parser.add_argument("--recursion_limit", type=int, default=5) + parser.add_argument("--require_human_feedback", action="store_true", help="If this agent requires human feedback") + parser.add_argument("--debug", action="store_true", help="Test with endpoint mode") + + parser.add_argument("--model", type=str, default="meta-llama/Meta-Llama-3-8B-Instruct") + parser.add_argument("--llm_engine", type=str, default="tgi") + parser.add_argument("--llm_endpoint_url", type=str, default="http://localhost:8080") + parser.add_argument("--max_new_tokens", type=int, default=1024) + parser.add_argument("--top_k", type=int, default=10) + parser.add_argument("--top_p", type=float, default=0.95) + parser.add_argument("--temperature", type=float, default=0.01) + parser.add_argument("--repetition_penalty", type=float, default=1.03) + parser.add_argument("--return_full_text", type=bool, default=False) + + sys_args, unknown_args = parser.parse_known_args() + # print("env_config: ", env_config) + if env_config != []: + env_args, env_unknown_args = parser.parse_known_args(env_config) + unknown_args += env_unknown_args + for key, value in vars(env_args).items(): + setattr(sys_args, key, value) + + if sys_args.streaming == "true": + sys_args.streaming = True + else: + sys_args.streaming = False + return sys_args, unknown_args diff --git a/comps/agent/langchain/test.py b/comps/agent/langchain/test.py new file mode 100644 index 000000000..d3f5d4506 --- /dev/null +++ b/comps/agent/langchain/test.py @@ -0,0 +1,121 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import json +import os +import traceback + +import pandas as pd +import requests +from src.utils import format_date, get_args + + +def test_agent_local(args): + from src.agent import instantiate_agent + + if args.q == 0: + df = pd.DataFrame({"query": ["What is the Intel OPEA Project?"]}) + elif args.q == 1: + df = pd.DataFrame({"query": ["what is the trade volume for Microsoft today?"]}) + elif args.q == 2: + df = pd.DataFrame({"query": ["what is the hometown of Year 2023 Australia open winner?"]}) + + agent = instantiate_agent(args, strategy=args.strategy) + app = agent.app + + config = {"recursion_limit": args.recursion_limit} + + traces = [] + success = 0 + for _, row in df.iterrows(): + print("Query: ", row["query"]) + initial_state = {"messages": [{"role": "user", "content": row["query"]}]} + try: + trace = {"query": row["query"], "trace": []} + for event in app.stream(initial_state, config=config): + trace["trace"].append(event) + for k, v in event.items(): + print("{}: {}".format(k, v)) + + traces.append(trace) + success += 1 + except Exception as e: + print(str(e), str(traceback.format_exc())) + traces.append({"query": row["query"], "trace": str(e)}) + + print("-" * 50) + + df["trace"] = traces + df.to_csv(os.path.join(args.filedir, args.output), index=False) + print(f"succeed: {success}/{len(df)}") + + +def test_agent_http(args): + proxies = {"http": ""} + ip_addr = args.ip_addr + url = f"http://{ip_addr}:9090/v1/chat/completions" + + def process_request(query): + content = json.dumps({"query": query}) + print(content) + try: + resp = requests.post(url=url, data=content, proxies=proxies) + ret = resp.text + resp.raise_for_status() # Raise an exception for unsuccessful HTTP status codes + except requests.exceptions.RequestException as e: + ret = f"An error occurred:{e}" + print(ret) + return ret + + if args.quick_test: + df = pd.DataFrame({"query": ["What is the weather today in Austin?"]}) + elif args.quick_test_multi_args: + df = pd.DataFrame({"query": ["what is the trade volume for Microsoft today?"]}) + else: + df = pd.read_csv(os.path.join(args.filedir, args.filename)) + df = df.sample(n=2, random_state=42) + traces = [] + for _, row in df.iterrows(): + ret = process_request(row["query"]) + trace = {"query": row["query"], "trace": ret} + traces.append(trace) + + df["trace"] = traces + df.to_csv(os.path.join(args.filedir, args.output), index=False) + + +def test_ut(args): + from src.tools import get_tools_descriptions + + tools = get_tools_descriptions("tools/custom_tools.py") + for tool in tools: + print(tool) + + +if __name__ == "__main__": + args1, _ = get_args() + parser = argparse.ArgumentParser() + parser.add_argument("--strategy", type=str, default="react") + parser.add_argument("--local_test", action="store_true", help="Test with local mode") + parser.add_argument("--endpoint_test", action="store_true", help="Test with endpoint mode") + parser.add_argument("--q", type=int, default=0) + parser.add_argument("--ip_addr", type=str, default="127.0.0.1", help="endpoint ip address") + parser.add_argument("--filedir", type=str, default="./", help="test file directory") + parser.add_argument("--filename", type=str, default="query.csv", help="query_list_file") + parser.add_argument("--output", type=str, default="output.csv", help="query_list_file") + parser.add_argument("--ut", action="store_true", help="ut") + + args, _ = parser.parse_known_args() + + for key, value in vars(args1).items(): + setattr(args, key, value) + + if args.local_test: + test_agent_local(args) + elif args.endpoint_test: + test_agent_http(args) + elif args.ut: + test_ut(args) + else: + print("Please specify the test type") diff --git a/comps/agent/langchain/tools/custom_tools.yaml b/comps/agent/langchain/tools/custom_tools.yaml new file mode 100644 index 000000000..905106ee3 --- /dev/null +++ b/comps/agent/langchain/tools/custom_tools.yaml @@ -0,0 +1,5 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +duckduckgo_search: + callable_api: ddg-search diff --git a/comps/llms/summarization/tgi/llm.py b/comps/llms/summarization/tgi/llm.py index e1cce35a5..a9db6248d 100644 --- a/comps/llms/summarization/tgi/llm.py +++ b/comps/llms/summarization/tgi/llm.py @@ -7,7 +7,7 @@ from langchain.chains.summarize import load_summarize_chain from langchain.docstore.document import Document from langchain.text_splitter import CharacterTextSplitter -from langchain_community.llms import HuggingFaceEndpoint +from langchain_huggingface import HuggingFaceEndpoint from langsmith import traceable from comps import GeneratedDoc, LLMParamsDoc, ServiceType, opea_microservices, register_microservice diff --git a/comps/llms/summarization/tgi/requirements.txt b/comps/llms/summarization/tgi/requirements.txt index 92c652351..a79486a55 100644 --- a/comps/llms/summarization/tgi/requirements.txt +++ b/comps/llms/summarization/tgi/requirements.txt @@ -1,7 +1,11 @@ docarray[full] fastapi huggingface_hub -langchain==0.1.16 +langchain #==0.1.12 +langchain-huggingface +langchain-openai +langchain_community +langchainhub langsmith opentelemetry-api opentelemetry-exporter-otlp diff --git a/tests/test_agent_langchain.sh b/tests/test_agent_langchain.sh new file mode 100644 index 000000000..db19f6c0f --- /dev/null +++ b/tests/test_agent_langchain.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +#set -xe + +WORKPATH=$(dirname "$PWD") +ip_address=$(hostname -I | awk '{print $1}') + +function build_docker_images() { + echo "Building the docker images" + cd $WORKPATH + echo $WORKPATH + docker build -t opea/comps-agent-langchain:latest -f comps/agent/langchain/docker/Dockerfile . + +} + +function start_service() { + # redis endpoint + export model=meta-llama/Meta-Llama-3-8B-Instruct + export HUGGINGFACEHUB_API_TOKEN=${HF_TOKEN} + echo "token is ${HF_TOKEN}" + + #single card + echo "start tgi gaudi service" + docker run -d --runtime=habana --name "comps-tgi-gaudi-service" -p 8080:80 -v ./data:/data -e HF_TOKEN=$HF_TOKEN -e HABANA_VISIBLE_DEVICES=all -e OMPI_MCA_btl_vader_single_copy_mechanism=none --cap-add=sys_nice --ipc=host ghcr.io/huggingface/tgi-gaudi:latest --model-id $model --max-input-tokens 4096 --max-total-tokens 8092 + sleep 5s + docker logs comps-tgi-gaudi-service + + echo "Starting agent microservice" + docker run -d --runtime=runc --name="comps-langchain-agent-endpoint" -v $WORKPATH/comps/agent/langchain/tools:/home/user/comps/agent/langchain/tools -p 9090:9090 --ipc=host -e HUGGINGFACEHUB_API_TOKEN=${HUGGINGFACEHUB_API_TOKEN} -e model=${model} -e strategy=react -e llm_endpoint_url=http://${ip_address}:8080 -e llm_engine=tgi -e recursion_limit=5 -e require_human_feedback=false -e tools=/home/user/comps/agent/langchain/tools/custom_tools.yaml opea/comps-agent-langchain:latest + sleep 5s + docker logs comps-langchain-agent-endpoint + + echo "Waiting tgi gaudi ready" + n=0 + until [[ "$n" -ge 100 ]] || [[ $ready == true ]]; do + docker logs comps-tgi-gaudi-service > ${WORKPATH}/tests/tgi-gaudi-service.log + n=$((n+1)) + if grep -q Connected ${WORKPATH}/tests/tgi-gaudi-service.log; then + break + fi + sleep 5s + done + sleep 5s + docker logs comps-tgi-gaudi-service + echo "Service started successfully" +} + +function validate() { + local CONTENT="$1" + local EXPECTED_RESULT="$2" + local SERVICE_NAME="$3" + + if echo "$CONTENT" | grep -q "$EXPECTED_RESULT"; then + echo "[ $SERVICE_NAME ] Content is as expected: $CONTENT" + echo 0 + else + echo "[ $SERVICE_NAME ] Content does not match the expected result: $CONTENT" + echo 1 + fi +} + +function validate_microservice() { + echo "Testing agent service" + local CONTENT=$(curl http://${ip_address}:9090/v1/chat/completions -X POST -H "Content-Type: application/json" -d '{ + "query": "What is Intel OPEA project?" + }' | tee ${LOG_PATH}/test-agent-langchain.log) + local EXIT_CODE=$(validate "$CONTENT" "OPEA" "test-agent-langchain") + echo "$EXIT_CODE" + local EXIT_CODE="${EXIT_CODE:0-1}" + echo "return value is $EXIT_CODE" + if [ "$EXIT_CODE" == "1" ]; then + docker logs comps-tgi-gaudi-service &> ${LOG_PATH}/test-comps-tgi-gaudi-service.log + docker logs comps-langchain-agent-endpoint &> ${LOG_PATH}/test-comps-langchain-agent-endpoint.log + exit 1 + fi +} + +function stop_docker() { + cid=$(docker ps -aq --filter "name=comps-tgi-gaudi-service") + echo "Stopping the docker containers "${cid} + if [[ ! -z "$cid" ]]; then docker rm $cid -f && sleep 1s; fi + cid=$(docker ps -aq --filter "name=comps-langchain-agent-endpoint") + echo "Stopping the docker containers "${cid} + if [[ ! -z "$cid" ]]; then docker rm $cid -f && sleep 1s; fi + echo "Docker containers stopped successfully" +} + +function main() { + + stop_docker + + build_docker_images + start_service + + validate_microservice + + stop_docker + echo y | docker system prune 2>&1 > /dev/null + +} + +main