chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -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]
File diff suppressed because it is too large Load Diff