Skip to content

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions app/agent_hil/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { ChatWindow } from "@/components/ChatWindow";
Copy link
Author

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


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>
);
}
153 changes: 153 additions & 0 deletions app/api/chat/agent_hil/agent_creator.ts
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>({
Copy link
Collaborator

Choose a reason for hiding this comment

The 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:

isResuming: Annotation<boolean>,
...

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"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small potential optimization: put response first. The LLM will generate it before generating a goto value and it can help steer it to better decisions.

});

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>({});
Copy link
Collaborator

Choose a reason for hiding this comment

The 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,
});
};
135 changes: 135 additions & 0 deletions app/api/chat/agent_hil/route.ts
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") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use .getType() now

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
Copy link
Author

@albertpurnama albertpurnama Jan 6, 2025

Choose a reason for hiding this comment

The 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 state.currentNode to get the string name of the node, if it is __end__ then I know it's not resumable. something like this exist?

@jacoblee93

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it just be if state.next were populated?


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 });
}
}
8 changes: 8 additions & 0 deletions app/lib/db.ts
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,
},
});
Loading