-
Notifications
You must be signed in to change notification settings - Fork 435
WIP: Example: Human in the loop over the network #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import { ChatWindow } from "@/components/ChatWindow"; | ||
|
||
export default function AgentsPage() { | ||
const InfoCard = ( | ||
<div className="p-4 md:p-8 rounded bg-[#25252d] w-full max-h-[85%] overflow-hidden"> | ||
<h1 className="text-3xl md:text-4xl mb-4"> | ||
▲ Next.js + LangChain.js Agents 🦜🔗 | ||
</h1> | ||
<ul> | ||
<li className="text-l"> | ||
🤝 | ||
<span className="ml-2"> | ||
This template showcases a{" "} | ||
<a href="https://js.langchain.com/" target="_blank"> | ||
LangChain.js | ||
</a>{" "} | ||
agent and the Vercel{" "} | ||
<a href="https://sdk.vercel.ai/docs" target="_blank"> | ||
AI SDK | ||
</a>{" "} | ||
in a{" "} | ||
<a href="https://nextjs.org/" target="_blank"> | ||
Next.js | ||
</a>{" "} | ||
project. | ||
</span> | ||
</li> | ||
<li> | ||
🛠️ | ||
<span className="ml-2"> | ||
The agent has memory and access to a search engine and a calculator. | ||
</span> | ||
</li> | ||
<li className="hidden text-l md:block"> | ||
💻 | ||
<span className="ml-2"> | ||
You can find the prompt and model logic for this use-case in{" "} | ||
<code>app/api/chat/agents/route.ts</code>. | ||
</span> | ||
</li> | ||
<li> | ||
🦜 | ||
<span className="ml-2"> | ||
By default, the agent is pretending to be a talking parrot, but you | ||
can the prompt to whatever you want! | ||
</span> | ||
</li> | ||
<li className="hidden text-l md:block"> | ||
🎨 | ||
<span className="ml-2"> | ||
The main frontend logic is found in <code>app/agents/page.tsx</code> | ||
. | ||
</span> | ||
</li> | ||
<li className="text-l"> | ||
🐙 | ||
<span className="ml-2"> | ||
This template is open source - you can see the source code and | ||
deploy your own version{" "} | ||
<a | ||
href="https://github.com/langchain-ai/langchain-nextjs-template" | ||
target="_blank" | ||
> | ||
from the GitHub repo | ||
</a> | ||
! | ||
</span> | ||
</li> | ||
<li className="text-l"> | ||
👇 | ||
<span className="ml-2"> | ||
Try asking e.g. <code>What is the weather in Honolulu?</code> below! | ||
</span> | ||
</li> | ||
</ul> | ||
</div> | ||
); | ||
return ( | ||
<ChatWindow | ||
endpoint="api/chat/agent_hil" | ||
emptyStateComponent={InfoCard} | ||
placeholder="Squawk! I'm a conversational agent! Ask me about the current weather in Honolulu!" | ||
titleText="Polly the Agentic Parrot" | ||
emoji="🦜" | ||
showIntermediateStepsToggle={true} | ||
></ChatWindow> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; | ||
import { pool } from "@/app/lib/db"; | ||
|
||
import { z } from "zod"; | ||
|
||
import { | ||
Annotation, | ||
Command, | ||
MessagesAnnotation, | ||
StateGraph, | ||
interrupt, | ||
} from "@langchain/langgraph"; | ||
import { ChatPromptTemplate } from "@langchain/core/prompts"; | ||
import { ChatOpenAI } from "@langchain/openai"; | ||
import { AIMessage, HumanMessage } from "@langchain/core/messages"; | ||
|
||
const GraphAnnotation = Annotation.Root({ | ||
...MessagesAnnotation.spec, | ||
// isResuming is very useful if you want to ignore some workflow in some node | ||
// that you want to ignore when the graph is resuming the run for the first time | ||
// after interrupt happened | ||
isResuming: Annotation<boolean>({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you are just want to override the last value in the state, you can just do:
|
||
reducer: (currentState, updateValue) => updateValue, | ||
default: () => false, | ||
}), | ||
gotoNext: Annotation<string>({ | ||
reducer: (currentState, updateValue) => updateValue, | ||
default: () => "", | ||
}), | ||
}); | ||
|
||
const MainAgentChatPrompt = ChatPromptTemplate.fromMessages([ | ||
["system", `You're a helpful assistant.`], | ||
["placeholder", "{chat_history}"], | ||
]); | ||
|
||
const mainAgent = async (state: typeof GraphAnnotation.State) => { | ||
const { messages } = state; | ||
|
||
const structuredOutput = z.object({ | ||
goto: z | ||
.enum(["weatherAgent", "calculatorAgent", "mainAgent"]) | ||
.describe( | ||
"The next agent to run after user confirmation" + | ||
"weatherAgent helps with weather queries" + | ||
"calculatorAgent helps with calculations" + | ||
"mainAgent is the main agent that handles the user's message", | ||
), | ||
response: z.string().describe("Human readable response to user's message"), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small potential optimization: put |
||
}); | ||
|
||
const input = await MainAgentChatPrompt.invoke({ chat_history: messages }); | ||
|
||
const model = new ChatOpenAI({ | ||
model: "gpt-4o-mini", | ||
temperature: 0, | ||
}); | ||
|
||
const response = await model | ||
.withStructuredOutput(structuredOutput) | ||
.invoke(input); | ||
|
||
return new Command({ | ||
goto: "humanNode", | ||
update: { | ||
messages: [new AIMessage(response.response)], | ||
gotoNext: response.goto, | ||
}, | ||
}); | ||
}; | ||
|
||
const humanNode = async (state: typeof GraphAnnotation.State) => { | ||
const { gotoNext } = state; | ||
|
||
// we're not using the object here. we ask for string feedback. | ||
const input = await interrupt<{}, string>({}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason for the type params? |
||
|
||
if (input === "yes" || input === "y") { | ||
return new Command({ | ||
goto: gotoNext, | ||
update: { | ||
messages: [new HumanMessage(input)], | ||
gotoNext: "", // reset the gotoNext | ||
isResuming: false, | ||
}, | ||
}); | ||
} | ||
|
||
return new Command({ | ||
goto: "mainAgent", | ||
update: { | ||
messages: [new HumanMessage(input)], | ||
gotoNext: "", // reset the gotoNext | ||
}, | ||
}); | ||
}; | ||
|
||
const weatherAgent = async (state: typeof GraphAnnotation.State) => { | ||
// sleep for 2 seconds to simulate weather agent | ||
await new Promise((resolve) => setTimeout(resolve, 2000)); | ||
|
||
const weather = "It's sunny and 70 degrees"; | ||
|
||
return new Command({ | ||
goto: "mainAgent", | ||
update: { | ||
messages: [new AIMessage(weather)], | ||
}, | ||
}); | ||
}; | ||
|
||
const calculatorAgent = async (state: typeof GraphAnnotation.State) => { | ||
const calculator = "The answer is 42"; | ||
|
||
return new Command({ | ||
goto: "mainAgent", | ||
update: { | ||
messages: [new AIMessage(calculator)], | ||
}, | ||
}); | ||
}; | ||
|
||
export const setupCheckpointer = async (): Promise<void> => { | ||
const checkpointer = new PostgresSaver(pool); | ||
|
||
// NOTE: you need to call .setup() the first time you're using your checkpointer | ||
await checkpointer.setup(); | ||
}; | ||
|
||
export const createAgent = () => { | ||
// Initialize the checkpointer with the database pool | ||
const checkpointer = new PostgresSaver(pool); | ||
|
||
// Build graph. | ||
const graph = new StateGraph(GraphAnnotation) | ||
.addNode("mainAgent", mainAgent, { | ||
ends: ["humanNode", "weatherAgent", "calculatorAgent"], | ||
}) | ||
.addNode("humanNode", humanNode, { | ||
ends: ["mainAgent", "weatherAgent", "calculatorAgent"], | ||
}) | ||
.addNode("weatherAgent", weatherAgent, { | ||
ends: ["mainAgent"], | ||
}) | ||
.addNode("calculatorAgent", calculatorAgent, { | ||
ends: ["mainAgent"], | ||
}) | ||
.addEdge("__start__", "mainAgent"); | ||
|
||
return graph.compile({ | ||
checkpointer, | ||
}); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { NextRequest, NextResponse } from "next/server"; | ||
import { Message as VercelChatMessage, StreamingTextResponse } from "ai"; | ||
|
||
import { | ||
AIMessage, | ||
BaseMessage, | ||
ChatMessage, | ||
HumanMessage, | ||
} from "@langchain/core/messages"; | ||
import { createAgent } from "./agent_creator"; | ||
import { Command } from "@langchain/langgraph"; | ||
|
||
const convertVercelMessageToLangChainMessage = (message: VercelChatMessage) => { | ||
if (message.role === "user") { | ||
return new HumanMessage(message.content); | ||
} else if (message.role === "assistant") { | ||
return new AIMessage(message.content); | ||
} else { | ||
return new ChatMessage(message.content, message.role); | ||
} | ||
}; | ||
|
||
const convertLangChainMessageToVercelMessage = (message: BaseMessage) => { | ||
if (message._getType() === "human") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use |
||
return { content: message.content, role: "user" }; | ||
} else if (message._getType() === "ai") { | ||
return { | ||
content: message.content, | ||
role: "assistant", | ||
tool_calls: (message as AIMessage).tool_calls, | ||
}; | ||
} else { | ||
return { content: message.content, role: message._getType() }; | ||
} | ||
}; | ||
|
||
export async function POST(req: NextRequest) { | ||
try { | ||
const body = await req.json(); | ||
const returnIntermediateSteps = body.show_intermediate_steps; | ||
/** | ||
* We represent intermediate steps as system messages for display purposes, | ||
* but don't want them in the chat history. | ||
*/ | ||
const messages = (body.messages ?? []) | ||
.filter( | ||
(message: VercelChatMessage) => | ||
message.role === "user" || message.role === "assistant", | ||
) | ||
.map(convertVercelMessageToLangChainMessage); | ||
|
||
// Setup checkpointer DO THIS ONCE if you haven't. | ||
// await setupCheckpointer(); | ||
|
||
const agent = createAgent(); | ||
|
||
const threadId = "1999"; // TODO: get from request | ||
|
||
// Get the agent state, check whether the agent can be resumed. | ||
const state = await agent.getState({ | ||
configurable: { thread_id: threadId }, | ||
}); | ||
|
||
const isBeginning = | ||
state.next.length === 0 && Object.keys(state.values).length === 0; | ||
|
||
let runInput: { messages: (AIMessage | HumanMessage)[] } | Command = | ||
new Command({ | ||
resume: messages.findLast((m: any) => m.role === "user")?.content, | ||
update: { | ||
isResuming: true, // Important if you have workflows that you want to ignore when resuming | ||
}, | ||
}); | ||
|
||
if (isBeginning) { | ||
runInput = { | ||
messages, | ||
}; | ||
} | ||
Comment on lines
+59
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there's gotta be a simpler way to do this, I simply want to check whether the current state is resumable or not, or whether it is in its end node? I'm hoping for something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it just be if |
||
|
||
if (!returnIntermediateSteps) { | ||
/** | ||
* Stream back all generated tokens and steps from their runs. | ||
* | ||
* We do some filtering of the generated events and only stream back | ||
* the final response as a string. | ||
* | ||
* For this specific type of tool calling ReAct agents with OpenAI, we can tell when | ||
* the agent is ready to stream back final output when it no longer calls | ||
* a tool and instead streams back content. | ||
* | ||
* See: https://langchain-ai.github.io/langgraphjs/how-tos/stream-tokens/ | ||
*/ | ||
const eventStream = await agent.streamEvents(runInput, { | ||
version: "v2", | ||
configurable: { thread_id: threadId }, | ||
}); | ||
|
||
const textEncoder = new TextEncoder(); | ||
const transformStream = new ReadableStream({ | ||
async start(controller) { | ||
for await (const { event, data } of eventStream) { | ||
if (event === "on_chat_model_stream") { | ||
// Intermediate chat model generations will contain tool calls and no content | ||
if (!!data.chunk.content) { | ||
controller.enqueue(textEncoder.encode(data.chunk.content)); | ||
} | ||
} | ||
} | ||
controller.close(); | ||
}, | ||
}); | ||
|
||
return new StreamingTextResponse(transformStream); | ||
} else { | ||
/** | ||
* We could also pick intermediate steps out from `streamEvents` chunks, but | ||
* they are generated as JSON objects, so streaming and displaying them with | ||
* the AI SDK is more complicated. | ||
*/ | ||
const result = await agent.invoke(runInput, { | ||
configurable: { thread_id: threadId }, | ||
}); | ||
|
||
return NextResponse.json( | ||
{ | ||
messages: result.messages.map(convertLangChainMessageToVercelMessage), | ||
}, | ||
{ status: 200 }, | ||
); | ||
} | ||
} catch (e: any) { | ||
return NextResponse.json({ error: e.message }, { status: e.status ?? 500 }); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import { Pool } from "pg"; | ||
|
||
export const pool = new Pool({ | ||
connectionString: process.env.DATABASE_URL, | ||
ssl: { | ||
rejectUnauthorized: false, | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these are copied over from
agents/page.tsx