Skip to content

Commit

Permalink
init commit
Browse files Browse the repository at this point in the history
  • Loading branch information
bracesproul committed Nov 21, 2024
1 parent 74d1398 commit ac4488f
Show file tree
Hide file tree
Showing 17 changed files with 726 additions and 190 deletions.
2 changes: 1 addition & 1 deletion langgraph.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"node_version": "20",
"graphs": {
"agent": "./src/agent/graph.ts:graph"
"social_media_agent": "./src/agent/graph.ts:graph"
},
"env": ".env",
"dependencies": ["."]
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"clean": "rm -rf dist",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$",
"test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$",
"test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000",
"format": "prettier --write .",
"lint": "eslint src",
"format:check": "prettier --check .",
Expand All @@ -21,8 +22,11 @@
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
},
"dependencies": {
"@langchain/core": "^0.3.2",
"@langchain/langgraph": "^0.2.5"
"@langchain/anthropic": "^0.3.8",
"@langchain/core": "^0.3.18",
"@langchain/langgraph": "^0.2.22",
"@slack/web-api": "^7.7.0",
"moment": "^2.30.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
Expand Down
142 changes: 44 additions & 98 deletions src/agent/graph.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,50 @@
/**
* Starter LangGraph.js Template
* Make this code your own!
*/
import { StateGraph } from "@langchain/langgraph";
import { RunnableConfig } from "@langchain/core/runnables";
import { StateAnnotation } from "./state.js";
import { END, START, StateGraph } from "@langchain/langgraph";
import { GraphAnnotation } from "./state.js";
import { ingestData } from "./nodes/ingest-data.js";
import { generateLinkedinPost } from "./nodes/generate-post/linkedin.js";
import { generateTwitterPost } from "./nodes/generate-post/twitter.js";
import { identifyContentGraph } from "./subgraphs/identify-content/graph.js";
import { schedulePosts } from "./nodes/schedule-posts.js";

/**
* Define a node, these do the work of the graph and should have most of the logic.
* Must return a subset of the properties set in StateAnnotation.
* @param state The current state of the graph.
* @param config Extra parameters passed into the state graph.
* @returns Some subset of parameters of the graph state, used to update the state
* for the edges and nodes executed next.
*/
const callModel = async (
state: typeof StateAnnotation.State,
_config: RunnableConfig,
): Promise<typeof StateAnnotation.Update> => {
/**
* Do some work... (e.g. call an LLM)
* For example, with LangChain you could do something like:
*
* ```bash
* $ npm i @langchain/anthropic
* ```
*
* ```ts
* import { ChatAnthropic } from "@langchain/anthropic";
* const model = new ChatAnthropic({
* model: "claude-3-5-sonnet-20240620",
* apiKey: process.env.ANTHROPIC_API_KEY,
* });
* const res = await model.invoke(state.messages);
* ```
*
* Or, with an SDK directly:
*
* ```bash
* $ npm i openai
* ```
*
* ```ts
* import OpenAI from "openai";
* const openai = new OpenAI({
* apiKey: process.env.OPENAI_API_KEY,
* });
*
* const chatCompletion = await openai.chat.completions.create({
* messages: [{
* role: state.messages[0]._getType(),
* content: state.messages[0].content,
* }],
* model: "gpt-4o-mini",
* });
* ```
*/
console.log("Current state:", state);
return {
messages: [
{
role: "assistant",
content: `Hi there! How are you?`,
},
],
};
};

/**
* Routing function: Determines whether to continue research or end the builder.
* This function decides if the gathered information is satisfactory or if more research is needed.
*
* @param state - The current state of the research builder
* @returns Either "callModel" to continue research or END to finish the builder
*/
export const route = (
state: typeof StateAnnotation.State,
): "__end__" | "callModel" => {
if (state.messages.length > 0) {
return "__end__";
function routeAfterIdentifyContent(
state: typeof GraphAnnotation.State,
): "generateLinkedinPost" | typeof END {
if (state.relevantProducts && state.relevantProducts.length > 0) {
return "generateLinkedinPost";
}
// Loop back
return "callModel";
};
return END;
}

const builder = new StateGraph(GraphAnnotation)
// Ingests posts from Slack channel.
.addNode("ingestData", ingestData)
// Subgraph which identifies content is relevant to LangChain products,
// and if so it generates a report on the content.
.addNode("identifyContent", identifyContentGraph)
// Generates a post on the content for LinkedIn.
.addNode("generateLinkedinPost", generateLinkedinPost)
// Generates a post on the content for Twitter.
.addNode("generateTwitterPost", generateTwitterPost)
// Interrupts the node for human in the loop, then schedules the
// post for Twitter/LinkedIn.
.addNode("schedulePosts", schedulePosts)

// Finally, create the graph itself.
const builder = new StateGraph(StateAnnotation)
// Add the nodes to do the work.
// Chaining the nodes together in this way
// updates the types of the StateGraph instance
// so you have static type checking when it comes time
// to add the edges.
.addNode("callModel", callModel)
// Regular edges mean "always transition to node B after node A is done"
// The "__start__" and "__end__" nodes are "virtual" nodes that are always present
// and represent the beginning and end of the builder.
.addEdge("__start__", "callModel")
// Conditional edges optionally route to different nodes (or end)
.addConditionalEdges("callModel", route);
// Start node
.addEdge(START, "ingestData")
.addEdge("ingestData", "identifyContent")
// Route to post generators or end node.
.addConditionalEdges("identifyContent", routeAfterIdentifyContent, [
"generateLinkedinPost",
END,
])
// After generating the LinkedIn post, generate the Twitter post.
.addEdge("generateLinkedinPost", "generateTwitterPost")
// Finally, schedule the post. This will also throw an interrupt
// so a human can edit the post before scheduling.
.addEdge("generateTwitterPost", "schedulePosts")
// Finish after generating the Twitter post.
.addEdge("schedulePosts", END);

export const graph = builder.compile();

graph.name = "New Agent";
graph.name = "Social Media Agent";
9 changes: 9 additions & 0 deletions src/agent/nodes/generate-post/linkedin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { GraphAnnotation } from "../../state.js";

export async function generateLinkedinPost(
_state: typeof GraphAnnotation.State,
_config: LangGraphRunnableConfig,
): Promise<Partial<typeof GraphAnnotation.State>> {
throw new Error("Not implemented");
}
9 changes: 9 additions & 0 deletions src/agent/nodes/generate-post/twitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { GraphAnnotation } from "../../state.js";

export async function generateTwitterPost(
_state: typeof GraphAnnotation.State,
_config: LangGraphRunnableConfig,
): Promise<Partial<typeof GraphAnnotation.State>> {
throw new Error("Not implemented");
}
46 changes: 46 additions & 0 deletions src/agent/nodes/ingest-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { GraphAnnotation } from "../state.js";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { SlackMessageFetcher } from "../../clients/slack.js";
import { extractUrlsFromSlackText } from "../utils.js";

const getChannelIdFromConfig = async (
config: LangGraphRunnableConfig,
): Promise<string | undefined> => {
if (config.configurable?.slack.channelName) {
const client = new SlackMessageFetcher({
channelName: config.configurable.slack.channelName,
});
return await client.getChannelId();
}
return config.configurable?.slack.channelId;
};

export async function ingestData(
_state: typeof GraphAnnotation.State,
config: LangGraphRunnableConfig,
): Promise<Partial<typeof GraphAnnotation.State>> {
const channelId = await getChannelIdFromConfig(config);
if (!channelId) {
throw new Error("Channel ID not found");
}

const client = new SlackMessageFetcher({
channelId: channelId,
});

const recentMessages = await client.fetchLast24HoursMessages();
const messagesWithUrls = recentMessages.flatMap((msg) => {
const links = extractUrlsFromSlackText(msg.text);
if (!links.length) {
return [];
}
return {
...msg,
links,
};
});

return {
slackMessages: messagesWithUrls,
};
}
10 changes: 10 additions & 0 deletions src/agent/nodes/schedule-posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { GraphAnnotation } from "../state.js";

export async function schedulePosts(
_state: typeof GraphAnnotation.State,
_config: LangGraphRunnableConfig,
): Promise<Partial<typeof GraphAnnotation.State>> {
// Call `interrupt` here first
throw new Error("Not implemented");
}
78 changes: 26 additions & 52 deletions src/agent/state.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,33 @@
import { BaseMessage, BaseMessageLike } from "@langchain/core/messages";
import { Annotation, messagesStateReducer } from "@langchain/langgraph";
import { Annotation, MessagesAnnotation } from "@langchain/langgraph";
import { SimpleSlackMessage } from "../clients/slack.js";

/**
* A graph's StateAnnotation defines three main things:
* 1. The structure of the data to be passed between nodes (which "channels" to read from/write to and their types)
* 2. Default values for each field
* 3. Reducers for the state's. Reducers are functions that determine how to apply updates to the state.
* See [Reducers](https://langchain-ai.github.io/langgraphjs/concepts/low_level/#reducers) for more information.
*/
export type LangChainProduct = "langchain" | "langgraph" | "langsmith";
export type SimpleSlackMessageWithLinks = SimpleSlackMessage & {
links: string[];
};

// This is the primary state of your agent, where you can store any information
export const StateAnnotation = Annotation.Root({
export const GraphAnnotation = Annotation.Root({
...MessagesAnnotation.spec,
/**
* Messages track the primary execution state of the agent.
*
* Typically accumulates a pattern of:
*
* 1. HumanMessage - user input
* 2. AIMessage with .tool_calls - agent picking tool(s) to use to collect
* information
* 3. ToolMessage(s) - the responses (or errors) from the executed tools
*
* (... repeat steps 2 and 3 as needed ...)
* 4. AIMessage without .tool_calls - agent responding in unstructured
* format to the user.
*
* 5. HumanMessage - user responds with the next conversational turn.
*
* (... repeat steps 2-5 as needed ... )
*
* Merges two lists of messages or message-like objects with role and content,
* updating existing messages by ID.
*
* Message-like objects are automatically coerced by `messagesStateReducer` into
* LangChain message classes. If a message does not have a given id,
* LangGraph will automatically assign one.
*
* By default, this ensures the state is "append-only", unless the
* new message has the same ID as an existing message.
*
* Returns:
* A new list of messages with the messages from \`right\` merged into \`left\`.
* If a message in \`right\` has the same ID as a message in \`left\`, the
* message from \`right\` will replace the message from \`left\`.`
* The Slack messages to use for the content.
*/
messages: Annotation<BaseMessage[], BaseMessageLike[]>({
reducer: messagesStateReducer,
default: () => [],
}),
slackMessages: Annotation<SimpleSlackMessageWithLinks[]>,
/**
* Feel free to add additional attributes to your state as needed.
* Common examples include retrieved documents, extracted entities, API connections, etc.
*
* For simple fields whose value should be overwritten by the return value of a node,
* you don't need to define a reducer or default.
* The LangChain product(s) this content is relevant to.
* Undefined if it is not relevant to any product.
*/
// additionalField: Annotation<string>,
relevantProducts: Annotation<LangChainProduct[] | undefined>,
/**
* A report generated on the content. Will be used in the main
* graph when generating the post about this content.
*/
report: Annotation<string>,
/**
* The content of the linkedin post.
*/
linkedinPost: Annotation<string>,
/**
* The content of the tweet.
*/
twitterPost: Annotation<string>,
});
31 changes: 31 additions & 0 deletions src/agent/subgraphs/identify-content/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { END, START, StateGraph } from "@langchain/langgraph";
import { GraphAnnotation } from "./state.js";
import { verifyLangChainContent } from "./nodes/verify-langchain-content.js";
import { generateContentReport } from "./nodes/generate-content-report.js";

function routeIsLangChainContent(
state: typeof GraphAnnotation.State,
): "generateContentReport" | typeof END {
if (state.relevantProducts && state.relevantProducts.length > 0) {
return "generateContentReport";
}
return END;
}

// Finally, create the graph itself.
const identifyContentBuilder = new StateGraph(GraphAnnotation)
.addNode("verifyLangChainContent", verifyLangChainContent)
.addNode("generateContentReport", generateContentReport)
// Start node
.addEdge(START, "verifyLangChainContent")
// Router conditional edge
.addConditionalEdges("verifyLangChainContent", routeIsLangChainContent, [
"generateContentReport",
END,
])
// End graph after generating report.
.addEdge("generateContentReport", END);

export const identifyContentGraph = identifyContentBuilder.compile();

identifyContentGraph.name = "Identify Content Subgraph";
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { GraphAnnotation } from "../state.js";

export async function generateContentReport(
_state: typeof GraphAnnotation.State,
_config: LangGraphRunnableConfig,
): Promise<Partial<typeof GraphAnnotation.State>> {
throw new Error("Not implemented");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { GraphAnnotation } from "../state.js";

export async function verifyLangChainContent(
_state: typeof GraphAnnotation.State,
_config: LangGraphRunnableConfig,
): Promise<Partial<typeof GraphAnnotation.State>> {
throw new Error("Not implemented");
}
Loading

0 comments on commit ac4488f

Please sign in to comment.