chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.vercel
|
||||
.langgraph_api
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dockerfile_lines": [],
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"research_agent": "./research_canvas/langgraph/agent.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Demo"""
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from ag_ui_langgraph import add_langgraph_fastapi_endpoint
|
||||
from copilotkit import LangGraphAGUIAgent
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
|
||||
load_dotenv()
|
||||
os.environ["LANGGRAPH_FASTAPI"] = "true"
|
||||
from src.agent import graph # noqa: E402
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
add_langgraph_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=LangGraphAGUIAgent(
|
||||
name="research_agent", description="Research agent.", graph=graph
|
||||
),
|
||||
path="/copilotkit/agents/research_agent",
|
||||
)
|
||||
# add_langgraph_fastapi_endpoint(
|
||||
# app=app,
|
||||
# agent=LangGraphAGUIAgent(
|
||||
# name="research_agent_google_genai", description="Research agent.", graph=graph
|
||||
# ),
|
||||
# path="/copilotkit/agents/research_agent_google_genai",
|
||||
# )
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the uvicorn server."""
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
reload=True,
|
||||
reload_dirs=(
|
||||
["."]
|
||||
+ (
|
||||
["../../../../sdk-python/copilotkit"]
|
||||
if os.path.exists("../../../../sdk-python/copilotkit")
|
||||
else []
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "research_canvas"
|
||||
version = "0.0.1"
|
||||
description = "Research Canvas"
|
||||
authors = [{ name = "Markus Ecker", email = "markus.ecker@gmail.com" }]
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"copilotkit>=0.1.74",
|
||||
"langchain-openai>=1.1.6",
|
||||
"langchain-community>=0.4.1",
|
||||
"langchain-anthropic>=1.3.0",
|
||||
"langchain-google-genai>=2.0.5",
|
||||
"langchain>=0.3.4",
|
||||
"openai>=1.52.1",
|
||||
"tavily-python>=0.5.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
"uvicorn>=0.31.0",
|
||||
"requests>=2.32.3",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain-core>=0.3.25",
|
||||
"langgraph-cli[inmem]>=0.4.11",
|
||||
"langgraph-checkpoint-sqlite>=3.0.1",
|
||||
"aiosqlite>=0.20.0",
|
||||
"aiohttp>=3.9.3",
|
||||
"langgraph>=1.0.5",
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
This is the main entry point for the AI.
|
||||
It defines the workflow graph and the entry point for the agent.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from langgraph.graph import StateGraph
|
||||
|
||||
from src.lib.chat import chat_node
|
||||
from src.lib.delete import delete_node, perform_delete_node
|
||||
from src.lib.download import download_node
|
||||
from src.lib.search import search_node
|
||||
from src.lib.state import AgentState
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState)
|
||||
workflow.add_node("download", download_node)
|
||||
workflow.add_node("chat_node", chat_node)
|
||||
workflow.add_node("search_node", search_node)
|
||||
workflow.add_node("delete_node", delete_node)
|
||||
workflow.add_node("perform_delete_node", perform_delete_node)
|
||||
|
||||
|
||||
workflow.set_entry_point("download")
|
||||
workflow.add_edge("download", "chat_node")
|
||||
workflow.add_edge("delete_node", "perform_delete_node")
|
||||
workflow.add_edge("perform_delete_node", "chat_node")
|
||||
workflow.add_edge("search_node", "download")
|
||||
|
||||
# Conditionally use a checkpointer based on the environment
|
||||
# This allows compatibility with both LangGraph API and CopilotKit
|
||||
compile_kwargs = {"interrupt_after": ["delete_node"]}
|
||||
|
||||
|
||||
# Check if we're running in LangGraph API mode
|
||||
if os.environ.get("LANGGRAPH_FASTAPI", "false").lower() == "false":
|
||||
# When running in LangGraph API, don't use a custom checkpointer
|
||||
graph = workflow.compile(**compile_kwargs)
|
||||
else:
|
||||
# For CopilotKit and other contexts, use MemorySaver
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
memory = MemorySaver()
|
||||
compile_kwargs["checkpointer"] = memory
|
||||
graph = workflow.compile(**compile_kwargs)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Chat Node"""
|
||||
|
||||
from typing import List, Literal, cast
|
||||
|
||||
from copilotkit.langgraph import copilotkit_customize_config
|
||||
from langchain.tools import tool
|
||||
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.types import Command
|
||||
|
||||
from src.lib.download import get_resource
|
||||
from src.lib.model import get_model
|
||||
from src.lib.state import AgentState
|
||||
|
||||
|
||||
@tool
|
||||
def Search(queries: List[str]): # pylint: disable=invalid-name,unused-argument
|
||||
"""A list of one or more search queries to find good resources to support the research."""
|
||||
|
||||
|
||||
@tool
|
||||
def WriteReport(report: str): # pylint: disable=invalid-name,unused-argument
|
||||
"""Write the research report."""
|
||||
|
||||
|
||||
@tool
|
||||
def WriteResearchQuestion(research_question: str): # pylint: disable=invalid-name,unused-argument
|
||||
"""Write the research question."""
|
||||
|
||||
|
||||
@tool
|
||||
def DeleteResources(urls: List[str]): # pylint: disable=invalid-name,unused-argument
|
||||
"""Delete the URLs from the resources."""
|
||||
|
||||
|
||||
async def chat_node(
|
||||
state: AgentState, config: RunnableConfig
|
||||
) -> Command[Literal["search_node", "chat_node", "delete_node", "__end__"]]:
|
||||
"""
|
||||
Chat Node
|
||||
"""
|
||||
|
||||
config = copilotkit_customize_config(
|
||||
config,
|
||||
emit_intermediate_state=[
|
||||
{
|
||||
"state_key": "report",
|
||||
"tool": "WriteReport",
|
||||
"tool_argument": "report",
|
||||
},
|
||||
{
|
||||
"state_key": "research_question",
|
||||
"tool": "WriteResearchQuestion",
|
||||
"tool_argument": "research_question",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
state["resources"] = state.get("resources", [])
|
||||
research_question = state.get("research_question", "")
|
||||
report = state.get("report", "")
|
||||
|
||||
resources = []
|
||||
|
||||
for resource in state["resources"]:
|
||||
content = get_resource(resource["url"])
|
||||
if content == "ERROR":
|
||||
continue
|
||||
resources.append({**resource, "content": content})
|
||||
|
||||
model = get_model(state)
|
||||
# Prepare the kwargs for the ainvoke method
|
||||
ainvoke_kwargs = {}
|
||||
if model.__class__.__name__ in ["ChatOpenAI"]:
|
||||
ainvoke_kwargs["parallel_tool_calls"] = False
|
||||
|
||||
response = await model.bind_tools(
|
||||
[
|
||||
Search,
|
||||
WriteReport,
|
||||
WriteResearchQuestion,
|
||||
DeleteResources,
|
||||
],
|
||||
**ainvoke_kwargs, # Pass the kwargs conditionally
|
||||
).ainvoke(
|
||||
[
|
||||
SystemMessage(
|
||||
content=f"""
|
||||
You are a research assistant. You help the user with writing a research report.
|
||||
Do not recite the resources, instead use them to answer the user's question.
|
||||
You should use the search tool to get resources before answering the user's question.
|
||||
If you finished writing the report, ask the user proactively for next steps, changes etc, make it engaging.
|
||||
To write the report, you should use the WriteReport tool. Never EVER respond with the report, only use the tool.
|
||||
If a research question is provided, YOU MUST NOT ASK FOR IT AGAIN.
|
||||
|
||||
This is the research question:
|
||||
{research_question}
|
||||
|
||||
This is the research report:
|
||||
{report}
|
||||
|
||||
Here are the resources that you have available:
|
||||
{resources}
|
||||
"""
|
||||
),
|
||||
*state["messages"],
|
||||
],
|
||||
config,
|
||||
)
|
||||
|
||||
ai_message = cast(AIMessage, response)
|
||||
|
||||
if ai_message.tool_calls:
|
||||
if ai_message.tool_calls[0]["name"] == "WriteReport":
|
||||
report = ai_message.tool_calls[0]["args"].get("report", "")
|
||||
return Command(
|
||||
goto="chat_node",
|
||||
update={
|
||||
"report": report,
|
||||
"messages": [
|
||||
ai_message,
|
||||
ToolMessage(
|
||||
tool_call_id=ai_message.tool_calls[0]["id"],
|
||||
content="Report written.",
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
if ai_message.tool_calls[0]["name"] == "WriteResearchQuestion":
|
||||
return Command(
|
||||
goto="chat_node",
|
||||
update={
|
||||
"research_question": ai_message.tool_calls[0]["args"][
|
||||
"research_question"
|
||||
],
|
||||
"messages": [
|
||||
ai_message,
|
||||
ToolMessage(
|
||||
tool_call_id=ai_message.tool_calls[0]["id"],
|
||||
content="Research question written.",
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
goto = "__end__"
|
||||
if ai_message.tool_calls and ai_message.tool_calls[0]["name"] == "Search":
|
||||
goto = "search_node"
|
||||
elif (
|
||||
ai_message.tool_calls and ai_message.tool_calls[0]["name"] == "DeleteResources"
|
||||
):
|
||||
goto = "delete_node"
|
||||
|
||||
return Command(goto=goto, update={"messages": response})
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Delete Resources"""
|
||||
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from src.lib.state import AgentState
|
||||
|
||||
|
||||
async def delete_node(state: AgentState, config: RunnableConfig): # pylint: disable=unused-argument
|
||||
"""
|
||||
Delete Node
|
||||
"""
|
||||
return state
|
||||
|
||||
|
||||
async def perform_delete_node(state: AgentState, config: RunnableConfig): # pylint: disable=unused-argument
|
||||
"""
|
||||
Perform Delete Node
|
||||
"""
|
||||
ai_message = cast(AIMessage, state["messages"][-2])
|
||||
tool_message = cast(ToolMessage, state["messages"][-1])
|
||||
if tool_message.content == "YES":
|
||||
if ai_message.tool_calls:
|
||||
urls = ai_message.tool_calls[0]["args"]["urls"]
|
||||
else:
|
||||
parsed_tool_call = json.loads(
|
||||
ai_message.additional_kwargs["function_call"]["arguments"]
|
||||
)
|
||||
urls = parsed_tool_call["urls"]
|
||||
|
||||
state["resources"] = [
|
||||
resource for resource in state["resources"] if resource["url"] not in urls
|
||||
]
|
||||
|
||||
return state
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
This module contains the implementation of the download_node function.
|
||||
"""
|
||||
|
||||
import aiohttp
|
||||
import html2text
|
||||
from copilotkit.langgraph import copilotkit_emit_state
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from src.lib.state import AgentState
|
||||
|
||||
_RESOURCE_CACHE = {}
|
||||
|
||||
|
||||
def get_resource(url: str):
|
||||
"""
|
||||
Get a resource from the cache.
|
||||
"""
|
||||
return _RESOURCE_CACHE.get(url, "")
|
||||
|
||||
|
||||
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3" # pylint: disable=line-too-long
|
||||
|
||||
|
||||
async def _download_resource(url: str):
|
||||
"""
|
||||
Download a resource from the internet asynchronously.
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
html_content = await response.text()
|
||||
markdown_content = html2text.html2text(html_content)
|
||||
_RESOURCE_CACHE[url] = markdown_content
|
||||
return markdown_content
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_RESOURCE_CACHE[url] = "ERROR"
|
||||
return f"Error downloading resource: {e}"
|
||||
|
||||
|
||||
async def download_node(state: AgentState, config: RunnableConfig):
|
||||
"""
|
||||
Download resources from the internet.
|
||||
"""
|
||||
state["resources"] = state.get("resources", [])
|
||||
state["logs"] = state.get("logs", [])
|
||||
resources_to_download = []
|
||||
|
||||
logs_offset = len(state["logs"])
|
||||
|
||||
# Find resources that are not downloaded
|
||||
for resource in state["resources"]:
|
||||
if not get_resource(resource["url"]):
|
||||
resources_to_download.append(resource)
|
||||
state["logs"].append(
|
||||
{"message": f"Downloading {resource['url']}", "done": False}
|
||||
)
|
||||
|
||||
# Emit the state to let the UI update
|
||||
await copilotkit_emit_state(config, state)
|
||||
|
||||
# Download the resources
|
||||
for i, resource in enumerate(resources_to_download):
|
||||
await _download_resource(resource["url"])
|
||||
state["logs"][logs_offset + i]["done"] = True
|
||||
|
||||
# update UI
|
||||
await copilotkit_emit_state(config, state)
|
||||
|
||||
return state
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
This module provides a function to get a model based on the configuration.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
|
||||
from src.lib.state import AgentState
|
||||
|
||||
|
||||
def get_model(state: AgentState) -> BaseChatModel:
|
||||
"""
|
||||
Get a model based on the environment variable.
|
||||
"""
|
||||
|
||||
state_model = state.get("model", "openai")
|
||||
model = os.getenv("MODEL", state_model)
|
||||
|
||||
print(f"Using model: {model}")
|
||||
|
||||
if model == "openai":
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
return ChatOpenAI(temperature=0, model="gpt-4o-mini")
|
||||
if model == "anthropic":
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
return ChatAnthropic(
|
||||
temperature=0,
|
||||
model_name="claude-3-5-sonnet-20240620",
|
||||
timeout=None,
|
||||
stop=None,
|
||||
)
|
||||
if model == "google_genai":
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
return ChatGoogleGenerativeAI(
|
||||
temperature=0,
|
||||
model="gemini-1.5-pro",
|
||||
api_key=cast(Any, os.getenv("GOOGLE_API_KEY")) or None,
|
||||
)
|
||||
|
||||
raise ValueError("Invalid model specified")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
The search node is responsible for searching the internet for information.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from copilotkit.langgraph import copilotkit_customize_config, copilotkit_emit_state
|
||||
from langchain.tools import tool
|
||||
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from pydantic import BaseModel, Field
|
||||
from tavily import TavilyClient
|
||||
|
||||
from src.lib.model import get_model
|
||||
from src.lib.state import AgentState
|
||||
|
||||
|
||||
class ResourceInput(BaseModel):
|
||||
"""A resource with a short description"""
|
||||
|
||||
url: str = Field(description="The URL of the resource")
|
||||
title: str = Field(description="The title of the resource")
|
||||
description: str = Field(description="A short description of the resource")
|
||||
|
||||
|
||||
@tool
|
||||
def ExtractResources(resources: List[ResourceInput]): # pylint: disable=invalid-name,unused-argument
|
||||
"""Extract the 3-5 most relevant resources from a search result."""
|
||||
|
||||
|
||||
# Initialize Tavily API key
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
tavily_client = TavilyClient(api_key=tavily_api_key)
|
||||
|
||||
|
||||
# Async version of Tavily search that runs the synchronous client in a thread pool
|
||||
async def async_tavily_search(query: str) -> Dict[str, Any]:
|
||||
"""Asynchronous wrapper for Tavily search API"""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
# Run the synchronous tavily_client.search in a thread pool
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: tavily_client.search(
|
||||
query=query,
|
||||
search_depth="advanced",
|
||||
include_answer=True,
|
||||
max_results=10,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Tavily search failed: {str(e)}")
|
||||
|
||||
|
||||
async def search_node(state: AgentState, config: RunnableConfig):
|
||||
"""
|
||||
The search node is responsible for searching the internet for resources.
|
||||
"""
|
||||
|
||||
ai_message = cast(AIMessage, state["messages"][-1])
|
||||
|
||||
state["resources"] = state.get("resources", [])
|
||||
state["logs"] = state.get("logs", [])
|
||||
queries = ai_message.tool_calls[0]["args"]["queries"]
|
||||
|
||||
for query in queries:
|
||||
state["logs"].append({"message": f"Search for {query}", "done": False})
|
||||
|
||||
await copilotkit_emit_state(config, state)
|
||||
|
||||
search_results = []
|
||||
|
||||
# Use asyncio.gather to run multiple searches in parallel
|
||||
tasks = [async_tavily_search(query) for query in queries]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
# Handle exceptions
|
||||
search_results.append({"error": str(result)})
|
||||
else:
|
||||
search_results.append(result)
|
||||
|
||||
state["logs"][i]["done"] = True
|
||||
await copilotkit_emit_state(config, state)
|
||||
|
||||
config = copilotkit_customize_config(
|
||||
config,
|
||||
emit_intermediate_state=[
|
||||
{
|
||||
"state_key": "resources",
|
||||
"tool": "ExtractResources",
|
||||
"tool_argument": "resources",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
model = get_model(state)
|
||||
ainvoke_kwargs = {}
|
||||
if model.__class__.__name__ in ["ChatOpenAI"]:
|
||||
ainvoke_kwargs["parallel_tool_calls"] = False
|
||||
|
||||
# figure out which resources to use
|
||||
response = await model.bind_tools(
|
||||
[ExtractResources], tool_choice="ExtractResources", **ainvoke_kwargs
|
||||
).ainvoke(
|
||||
[
|
||||
SystemMessage(
|
||||
content="""
|
||||
You need to extract the 3-5 most relevant resources from the following search results.
|
||||
"""
|
||||
),
|
||||
*state["messages"],
|
||||
ToolMessage(
|
||||
tool_call_id=ai_message.tool_calls[0]["id"],
|
||||
content=f"Performed search: {search_results}",
|
||||
),
|
||||
],
|
||||
config,
|
||||
)
|
||||
|
||||
state["logs"] = []
|
||||
await copilotkit_emit_state(config, state)
|
||||
|
||||
ai_message_response = cast(AIMessage, response)
|
||||
resources = ai_message_response.tool_calls[0]["args"]["resources"]
|
||||
|
||||
state["resources"].extend(resources)
|
||||
|
||||
state["messages"].append(
|
||||
ToolMessage(
|
||||
tool_call_id=ai_message.tool_calls[0]["id"],
|
||||
content=f"Added the following resources: {resources}",
|
||||
)
|
||||
)
|
||||
|
||||
return state
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
This is the state definition for the AI.
|
||||
It defines the state of the agent and the state of the conversation.
|
||||
"""
|
||||
|
||||
from typing import List, TypedDict
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
|
||||
class Resource(TypedDict):
|
||||
"""
|
||||
Represents a resource. Give it a good title and a short description.
|
||||
"""
|
||||
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
|
||||
|
||||
class Log(TypedDict):
|
||||
"""
|
||||
Represents a log of an action performed by the agent.
|
||||
"""
|
||||
|
||||
message: str
|
||||
done: bool
|
||||
|
||||
|
||||
class AgentState(MessagesState):
|
||||
"""
|
||||
This is the state of the agent.
|
||||
It is a subclass of the MessagesState class from langgraph.
|
||||
"""
|
||||
|
||||
model: str
|
||||
research_question: str
|
||||
report: str
|
||||
resources: List[Resource]
|
||||
logs: List[Log]
|
||||
+1805
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user