Files
wehub-resource-sync e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

576 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Assistant Transport Backend with LangGraph - FastAPI + assistant-stream + LangGraph server
"""
import json
import os
from collections.abc import Sequence
from contextlib import asynccontextmanager
from typing import Annotated, Any, TypedDict
from uuid import uuid4
import uvicorn
from assistant_stream import RunController, create_run
from assistant_stream.modules.langgraph import append_langgraph_event, get_tool_call_subgraph_state
from assistant_stream.serialization import DataStreamResponse
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.channels import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, StateGraph, add_messages
from langgraph.graph.state import CompiledStateGraph
from pydantic import BaseModel, ConfigDict, Field
# Load environment variables
load_dotenv()
_postgres_checkpointer_context = None
class MessagePart(BaseModel):
"""A part of a user message."""
type: str = Field(..., description="The type of message part")
text: str | None = Field(None, description="Text content")
image: str | None = Field(None, description="Image URL or data")
class UserMessage(BaseModel):
"""A user message."""
role: str = Field(default="user", description="Message role")
parts: list[MessagePart] = Field(..., description="Message parts")
class AddMessageCommand(BaseModel):
"""Command to add a new message to the conversation."""
type: str = Field(default="add-message", description="Command type")
message: UserMessage = Field(..., description="User message")
class AddToolResultCommand(BaseModel):
"""Command to add a tool result to the conversation."""
model_config = ConfigDict(populate_by_name=True)
type: str = Field(default="add-tool-result", description="Command type")
tool_call_id: str = Field(..., alias="toolCallId", description="ID of the tool call")
tool_name: str | None = Field(None, alias="toolName", description="Name of the tool")
result: Any = Field(..., description="Tool execution result")
is_error: bool | None = Field(None, alias="isError", description="Whether the tool failed")
artifact: Any | None = Field(None, description="UI-only tool result artifact")
model_content: Any | None = Field(
None, alias="modelContent", description="Tool result content for the model"
)
class ChatRequest(BaseModel):
"""Request payload for the chat endpoint."""
model_config = ConfigDict(populate_by_name=True)
commands: list[AddMessageCommand | AddToolResultCommand] = Field(
..., description="List of commands to execute"
)
system: str | None = Field(None, description="System prompt")
tools: dict[str, Any] | None = Field(None, description="Available tools")
run_config: dict[str, Any] | None = Field(
None, alias="runConfig", description="Run configuration"
)
thread_id: str | None = Field(None, alias="threadId", description="Assistant UI thread ID")
state: dict[str, Any] | None = Field(None, description="State")
def get_thread_id(request: ChatRequest) -> str:
"""Use persistent checkpoints only when the AssistantTransport request identifies a thread."""
return request.thread_id or f"anonymous-{uuid4()}"
def add_messages_delta(
state: Sequence[BaseMessage],
writes: Sequence[BaseMessage | Sequence[BaseMessage]],
) -> list[BaseMessage]:
result = list(state)
for write in writes:
if isinstance(write, BaseMessage):
result = add_messages(result, [write])
else:
result = add_messages(result, list(write))
return result
def request_tool_schemas(tools: dict[str, Any] | None) -> list[dict[str, Any]]:
if not tools:
return []
schemas = []
for name, tool_definition in tools.items():
if (
name in TOOL_BY_NAME
or not isinstance(tool_definition, dict)
or tool_definition.get("disabled") is True
):
continue
parameters = tool_definition.get("parameters") or {
"type": "object",
"properties": {},
}
schemas.append(
{
"type": "function",
"function": {
"name": name,
"description": tool_definition.get("description", ""),
"parameters": parameters,
},
}
)
return schemas
def bindable_tools(tools: dict[str, Any] | None) -> list[Any]:
return [*TOOLS, *request_tool_schemas(tools)]
def tool_result_content(command: AddToolResultCommand) -> str:
content = command.model_content if command.model_content is not None else command.result
return content if isinstance(content, str) else json.dumps(content)
# Define LangGraph state
class GraphState(TypedDict, total=False):
"""State for the conversation graph."""
messages: Annotated[
Sequence[BaseMessage],
DeltaChannel(reducer=add_messages_delta, snapshot_frequency=50),
]
tools: dict[str, Any] | None
# Define subagent state
class SubagentState(TypedDict):
"""State for the subagent."""
messages: Annotated[
Sequence[BaseMessage],
DeltaChannel(reducer=add_messages_delta, snapshot_frequency=50),
]
task: str
result: str
# Create the Task tool
@tool
def task_tool(task_description: str) -> str:
"""
Execute a complex task using a subagent.
Args:
task_description: Description of the task to perform
Returns:
The result of the task execution
"""
# This is a placeholder - the actual execution will be handled by the subgraph
return f"Task '{task_description}' will be executed by the subagent."
@tool
def calculate_sum(numbers: list[float]) -> dict[str, Any]:
"""
Add a list of numbers.
Args:
numbers: Numbers to add together.
"""
return {
"numbers": numbers,
"sum": sum(numbers),
"count": len(numbers),
}
@tool
def save_note(title: str, body: str) -> dict[str, Any]:
"""
Save a sample note and return a note ID.
Args:
title: Short note title.
body: Note body.
"""
note_seed = f"{title}\n{body}"
note_id = sum((index + 1) * ord(char) for index, char in enumerate(note_seed))
return {
"id": f"note-{note_id % 100000}",
"title": title,
"body": body,
"saved": True,
}
TOOLS = [task_tool, calculate_sum, save_note]
TOOL_BY_NAME = {tool.name: tool for tool in TOOLS}
# Subagent node for executing tasks
async def subagent_node(state: SubagentState) -> dict[str, Any]:
"""Subagent that executes the task."""
task = state.get("task", "")
# Create a prompt for the subagent
subagent_messages = [
SystemMessage(content=f"You are a helpful subagent. Execute this task: {task}"),
HumanMessage(content=f"Please complete the following task: {task}")
]
# Generate response
if os.getenv("OPENAI_API_KEY"):
# Initialize a simpler LLM for the subagent
llm = ChatOpenAI(
model="gpt-5.4-nano",
temperature=0.7,
streaming=True
)
response = await llm.ainvoke(subagent_messages)
result = response.content
else:
result = f"Mock subagent result for task: {task}"
return {
"messages": [AIMessage(content=result)],
"result": result
}
def create_subagent_graph() -> CompiledStateGraph:
"""Create the subagent graph."""
workflow = StateGraph(SubagentState)
# Add the subagent node
workflow.add_node("execute_task", subagent_node)
# Set entry and exit points
workflow.set_entry_point("execute_task")
workflow.add_edge("execute_task", END)
return workflow.compile()
async def agent_node(state: GraphState) -> dict[str, Any]:
"""Main agent node that can call tools."""
messages = state.get("messages", [])
tools = state.get("tools")
# Check if OpenAI API key is set
if os.getenv("OPENAI_API_KEY"):
# Initialize the LLM with tool binding
llm = ChatOpenAI(
model="gpt-5.4-nano",
temperature=0.7,
streaming=True,
)
# Bind server tools plus request-provided frontend tool declarations.
llm_with_tools = llm.bind_tools(bindable_tools(tools))
response = await llm_with_tools.ainvoke(messages)
elif messages and isinstance(messages[-1], ToolMessage):
response = AIMessage(
content=f"Task complete: {messages[-1].content}",
)
else:
# Mock response with a tool call for testing
print("⚠️ No OpenAI API key found - using mock response with tool call")
last_content = messages[-1].content if messages else ""
if "weather" in str(last_content).lower() and tools and "get_weather" in tools:
tool_call = {
"id": "weather_001",
"name": "get_weather",
"args": {"location": "San Francisco", "unit": "fahrenheit"},
}
elif "sum" in str(last_content).lower() or "add" in str(last_content).lower():
tool_call = {
"id": "sum_001",
"name": "calculate_sum",
"args": {"numbers": [2, 3, 5]},
}
elif "note" in str(last_content).lower():
tool_call = {
"id": "note_001",
"name": "save_note",
"args": {"title": "Smoke test", "body": "Saved from mock mode"},
}
else:
tool_call = {
"id": "task_001",
"name": "task_tool",
"args": {"task_description": "Complete the requested task"},
}
response = AIMessage(content="I'll call a tool for that.", tool_calls=[tool_call])
return {"messages": [response]}
def should_call_tools(state: GraphState) -> str:
"""Run only backend-owned tools. Frontend tools are returned to the client."""
messages = state.get("messages", [])
if not messages:
return "end"
last_message = messages[-1]
if (
hasattr(last_message, 'tool_calls')
and last_message.tool_calls
and any(tool_call["name"] in TOOL_BY_NAME for tool_call in last_message.tool_calls)
):
return "tools"
return "end"
async def tool_executor_node(state: GraphState) -> dict[str, Any]:
"""Execute tool calls, including Task tool which spawns subagents."""
messages = state.get("messages", [])
if not messages:
return {"messages": []}
last_message = messages[-1]
if not hasattr(last_message, 'tool_calls') or not last_message.tool_calls:
return {"messages": []}
# Process each tool call
tool_messages = []
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
if tool_name == "task_tool":
# Extract task description
task_description = tool_call["args"].get("task_description", "")
# Create and run the subagent graph
# Initialize subagent state
subagent_state = {
"messages": [],
"task": task_description,
"result": ""
}
# Run the subagent
final_state = await subagent_graph.ainvoke(subagent_state)
# Create tool message with the result
tool_message = ToolMessage(
content=final_state.get("result", "Task completed"),
tool_call_id=tool_call["id"],
artifact={"subgraph_state": final_state}
)
tool_messages.append(tool_message)
else:
tool = TOOL_BY_NAME.get(tool_name)
if tool is None:
result = {"error": f"Unknown tool: {tool_name}"}
else:
result = tool.invoke(tool_call.get("args", {}))
tool_message = ToolMessage(
content=json.dumps(result),
tool_call_id=tool_call["id"],
name=tool_name,
artifact=result,
)
tool_messages.append(tool_message)
return {"messages": tool_messages}
subagent_graph = create_subagent_graph()
async def configure_checkpointer_from_env() -> None:
"""Switch the graph to the configured persistent checkpointer, when present."""
postgres_url = os.getenv("LANGGRAPH_POSTGRES_URL") or os.getenv("DATABASE_URL")
if not postgres_url:
return
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
global _postgres_checkpointer_context, graph
if _postgres_checkpointer_context is not None:
return
_postgres_checkpointer_context = AsyncPostgresSaver.from_conn_string(postgres_url)
checkpointer = await _postgres_checkpointer_context.__aenter__()
await checkpointer.setup()
graph = create_graph(checkpointer)
def create_graph(checkpointer=None) -> CompiledStateGraph:
"""Create and compile the LangGraph with subgraph support."""
# Create the main workflow
workflow = StateGraph(GraphState)
# Add nodes
workflow.add_node("agent", agent_node)
workflow.add_node("tools", tool_executor_node)
# Set entry point
workflow.set_entry_point("agent")
# Add conditional edges
workflow.add_conditional_edges(
"agent",
should_call_tools,
{
"tools": "tools",
"end": END
}
)
# After tools, go back to agent for potential follow-up
workflow.add_edge("tools", "agent")
# Compile with a checkpointer so DeltaChannel is exercised across thread turns.
return workflow.compile(checkpointer=checkpointer or InMemorySaver())
graph = create_graph()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
print("🚀 Assistant Transport Backend with LangGraph starting up...")
await configure_checkpointer_from_env()
try:
yield
finally:
if _postgres_checkpointer_context is not None:
await _postgres_checkpointer_context.__aexit__(None, None, None)
print("🛑 Assistant Transport Backend with LangGraph shutting down...")
# Create FastAPI app
app = FastAPI(
title="Assistant Transport Backend with LangGraph",
description=(
"A server implementing the assistant-transport protocol with LangGraph and subgraphs"
),
version="0.2.0",
lifespan=lifespan,
)
# Configure CORS
cors_origins = ["*"] # Allow all origins
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
@app.post("/assistant")
async def chat_endpoint(request: ChatRequest):
"""Chat endpoint using LangGraph with streaming and subgraph support."""
async def run_callback(controller: RunController):
"""Callback function for the run controller."""
# Initialize controller state if needed
if controller.state is None:
controller.state = {}
if "messages" not in controller.state:
controller.state["messages"] = []
input_messages = []
# Process commands
for command in request.commands:
if command.type == "add-message":
# Extract text from parts
text_parts = [
part.text for part in command.message.parts
if part.type == "text" and part.text
]
if text_parts:
input_messages.append(HumanMessage(content=" ".join(text_parts)))
elif command.type == "add-tool-result":
# Handle tool results
input_messages.append(ToolMessage(
content=tool_result_content(command),
tool_call_id=command.tool_call_id,
name=command.tool_name,
artifact=command.artifact if command.artifact is not None else command.result,
status="error" if command.is_error else "success",
))
# Add messages to controller state
for message in input_messages:
controller.state["messages"].append(message.model_dump())
# Create initial state for LangGraph
input_state = {"messages": input_messages, "tools": request.tools}
# Stream with subgraph support
config = {
"configurable": {
"thread_id": get_thread_id(request),
}
}
async for namespace, event_type, chunk in graph.astream(
input_state,
config=config,
stream_mode=["messages", "updates"],
subgraphs=True
):
state = get_tool_call_subgraph_state(
controller,
subgraph_node="tools",
namespace=namespace,
artifact_field_name="subgraph_state",
default_state={}
)
# Append the event normally
append_langgraph_event(
state,
namespace,
event_type,
chunk
)
# Create streaming response using assistant-stream
stream = create_run(run_callback, state=request.state)
return DataStreamResponse(stream)
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "assistant-transport-backend-langgraph"}
def main():
"""Main entry point for running the server."""
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "8010"))
debug = os.getenv("DEBUG", "false").lower() == "true"
log_level = os.getenv("LOG_LEVEL", "info").lower()
print(f"🌟 Starting Assistant Transport Backend with LangGraph on {host}:{port}")
print(f"🎯 Debug mode: {debug}")
print(f"🌍 CORS origins: {cors_origins}")
uvicorn.run(
"main:app",
host=host,
port=port,
reload=debug,
log_level=log_level,
access_log=True,
)
if __name__ == "__main__":
main()