diff --git a/examples/langgraph-ts/.gitignore b/examples/langgraph-ts/.gitignore new file mode 100644 index 00000000..203596c1 --- /dev/null +++ b/examples/langgraph-ts/.gitignore @@ -0,0 +1,69 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.example + +# Build outputs +dist/ +build/ +out/ + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Editor directories and files +.idea/ +.vscode/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.sublime-workspace +*.sublime-project +.project +.classpath +.settings/ + +# OS specific +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# pnpm specific +.pnpm-debug.log +pnpm-lock.yaml + +# Testing +coverage/ + +# Cache +.cache/ +.turbo/ + +# LangGraph API +.langgraph_api/ + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*~ + +# Local development +.local/ diff --git a/examples/langgraph-ts/README.md b/examples/langgraph-ts/README.md new file mode 100644 index 00000000..21aee807 --- /dev/null +++ b/examples/langgraph-ts/README.md @@ -0,0 +1,107 @@ +# Arcade LangGraph.js Agent + +This project is based on the [LangChain React Agent JS](https://github.com/langchain-ai/react-agent-js/tree/main) repository. + +![LangGraph Studio](studio.png) + +This template showcases a LangGraph.js agent integrated with Arcade tools, designed for LangGraph Studio. The agent uses the ReAct pattern to execute API calls and access various tools through the Arcade API. + +## What it does + +The Arcade LangGraph agent: + +1. Takes a user **query** as input +2. Reasons about the query and decides on an action using Arcade tools +3. Executes the chosen action through the Arcade API +4. Observes the result of the action +5. Repeats steps 2-4 until it can provide a final answer + +This approach creates a flexible agent that can interact with multiple services like Google, GitHub, and other external tools through Arcade's unified API. + +## Getting Started + +1. Clone this repository + +2. Create a `.env` file: + + ```bash + cp .env.example .env + ``` + +3. Add your API keys to the `.env` file: + + ``` + OPENAI_API_KEY=your-openai-api-key + ARCADE_API_KEY=your-arcade-api-key + ARCADE_BASE_URL=https://api.arcade.dev + ``` + +4. Install dependencies: + + ```bash + pnpm install + ``` + +5. Run the development server: + + ```bash + pnpm dev + ``` + +## How it works + +The core logic is defined in `src/graph.ts` + +1. Loads Arcade tools for multiple toolkits (e.g., Google, GitHub) +2. Creates a model with the tools bound to it +3. Routes messages between tool calls and model reasoning +4. Compiles everything into a graph you can invoke and deploy + +## Project Structure + +- `src/graph.ts` - Main graph implementation showing how to create and use a LangGraph agent with Arcade tools +- `src/arcade.ts` - Utility functions for integrating Arcade with LangGraph and converting Arcade tools to LangChain tools +- `src/configuration.ts` - Configurable parameters for the agent +- `src/prompts.ts` - Default prompts used by the agent +- `src/utils.ts` - Helper functions for loading models + +## Customization + +To use different Arcade toolkits or queries: + +1. Modify the `toolkits` array in `getArcadeTools()` in `src/graph.ts` to include the desired toolkits (e.g., `["google", "github", "notion"]`) +2. Change the default model in `src/configuration.ts` +3. Update the system prompt in `src/prompts.ts` + +Currently supported Arcade toolkits: + +- GitHub +- Google +- Notion +- Reddit +- X +- And more + +You can check out our [Integrations](https://docs.arcade.dev/integrations) documentation for more information on how to integrate with other tools. + +You can also create your own custom tools and integrate them with LangGraph. Check out our [Custom Tools](https://docs.arcade.dev/home/custom-tools) documentation for more information. + +## Development + +While iterating on your graph, you can edit past state and rerun your app from past states to debug specific nodes. Local changes will be automatically applied via hot reload. + +You can create an entirely new thread, clearing previous history, using the `+` button in the top right of the LangGraph Studio interface. + +## Authorization Flow + +The integration handles authorization requirements for Arcade tools: + +- When a tool requires authentication, an authorization URL is generated +- This URL can be presented to the user to complete the authorization process + +## Prerequisites + +- Node.js (v18+) +- pnpm +- Arcade account with [API key](https://docs.arcade.dev/home/api-keys) +- OpenAI API key or other compatible LLM diff --git a/examples/langgraph-ts/langgraph.json b/examples/langgraph-ts/langgraph.json new file mode 100644 index 00000000..cf6d9577 --- /dev/null +++ b/examples/langgraph-ts/langgraph.json @@ -0,0 +1,9 @@ +{ + "node_version": "20", + "dockerfile_lines": [], + "dependencies": ["."], + "graphs": { + "agent": "./src/graph.ts:graph" + }, + "env": ".env" +} diff --git a/examples/langgraph-ts/package.json b/examples/langgraph-ts/package.json new file mode 100644 index 00000000..fb8a563c --- /dev/null +++ b/examples/langgraph-ts/package.json @@ -0,0 +1,28 @@ +{ + "name": "arcade-langgraph", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "dev": "npx @langchain/langgraph-cli dev" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.8.0", + "dependencies": { + "@arcadeai/arcadejs": "1.2.1", + "@dmitryrechkin/json-schema-to-zod": "^1.0.1", + "@langchain/community": "^0.3.27", + "@langchain/core": "^0.3.37", + "@langchain/langgraph": "^0.2.43", + "langchain": "^0.3.14", + "@langchain/openai": "^0.5.4", + "dotenv": "^16.4.7", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.14.0" + } +} diff --git a/examples/langgraph-ts/src/arcade.ts b/examples/langgraph-ts/src/arcade.ts new file mode 100644 index 00000000..37103ebb --- /dev/null +++ b/examples/langgraph-ts/src/arcade.ts @@ -0,0 +1,103 @@ +import { Arcade } from "@arcadeai/arcadejs"; +import { JSONSchemaToZod } from "@dmitryrechkin/json-schema-to-zod"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +export const arcadeClient = new Arcade({ + baseURL: process.env.ARCADE_BASE_URL, + apiKey: process.env.ARCADE_API_KEY, +}); + +const arcadeToolMinimumSchema = z.object({ + function: z.object({ + name: z.string(), + parameters: z.record(z.any()), + description: z.string(), + }), +}); + +function isAuthorizationRequiredError(error: Error) { + return ( + error?.name === "PermissionDeniedError" || + error?.message?.includes("permission denied") || + error?.message?.includes("authorization required") + ); +} + +async function getAuthorizationResponse(toolName: string, user_id: string) { + return await arcadeClient.tools.authorize({ + tool_name: toolName, + user_id, + }); +} + +type LangChainTool = ReturnType; + +export const getArcadeTools = async ({ + toolkits, + user_id, +}: { + toolkits?: string[]; + user_id: string; +}): Promise => { + // If no toolkits provided, fetch all tools + if (!toolkits || toolkits.length === 0) { + const tools = await arcadeClient.tools.formatted.list({ + format: "openai", + }); + return processTools(tools.items, user_id); + } + + // Fetch tools for each toolkit and merge them + const toolkitPromises = toolkits.map((toolkit) => + arcadeClient.tools.formatted.list({ + toolkit, + format: "openai", + }) + ); + + const toolkitResults = await Promise.all(toolkitPromises); + const allTools = toolkitResults.flatMap((result) => result.items); + + // Remove duplicates based on tool name + const uniqueTools = Array.from( + new Map(allTools.map((item) => [item.function.name, item])).values() + ); + + return processTools(uniqueTools, user_id); +}; + +// Helper function to process tools and create LangChain tools +const processTools = (tools: unknown[], user_id: string): LangChainTool[] => { + const validTools = tools + .filter((item) => arcadeToolMinimumSchema.safeParse(item).success) + .map((item) => arcadeToolMinimumSchema.parse(item)); + + return validTools.map((item) => { + const { name, description, parameters } = item.function; + const zodSchema = JSONSchemaToZod.convert(parameters); + + return tool( + async (input: unknown) => { + try { + return await arcadeClient.tools.execute({ + tool_name: name, + input: input as Record, + user_id, + }); + } catch (error) { + if (error instanceof Error && isAuthorizationRequiredError(error)) { + const response = await getAuthorizationResponse(name, user_id); + return { + authorization_required: true, + url: response.url, + message: "Forward this url to the user for authorization", + }; + } + throw error; + } + }, + { name, description, schema: zodSchema }, + ) as unknown as LangChainTool; + }); +}; diff --git a/examples/langgraph-ts/src/configuration.ts b/examples/langgraph-ts/src/configuration.ts new file mode 100644 index 00000000..4db7482e --- /dev/null +++ b/examples/langgraph-ts/src/configuration.ts @@ -0,0 +1,32 @@ +import type { RunnableConfig } from "@langchain/core/runnables"; +/** + * Define the configurable parameters for the agent. + */ +import { Annotation } from "@langchain/langgraph"; +import { SYSTEM_PROMPT_TEMPLATE } from "./prompts.js"; + +export const ConfigurationSchema = Annotation.Root({ + /** + * The system prompt to be used by the agent. + */ + systemPromptTemplate: Annotation, + + /** + * The name of the language model to be used by the agent. + */ + model: Annotation, +}); + +export function ensureConfiguration( + config: RunnableConfig, +): typeof ConfigurationSchema.State { + /** + * Ensure the defaults are populated. + */ + const configurable = config.configurable ?? {}; + return { + systemPromptTemplate: + configurable.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE, + model: configurable.model ?? "gpt-4o-mini", + }; +} diff --git a/examples/langgraph-ts/src/graph.ts b/examples/langgraph-ts/src/graph.ts new file mode 100644 index 00000000..ccea40c5 --- /dev/null +++ b/examples/langgraph-ts/src/graph.ts @@ -0,0 +1,87 @@ +import type { AIMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import { MessagesAnnotation, StateGraph } from "@langchain/langgraph"; +import { ToolNode } from "@langchain/langgraph/prebuilt"; +import { getArcadeTools } from "./arcade.ts"; +import { ConfigurationSchema, ensureConfiguration } from "./configuration.ts"; +import { loadChatModel } from "./utils.ts"; +import { ChatOpenAI } from "@langchain/openai"; + + +// Replace this with your application's user ID (e.g. email address, UUID, etc.) +const USER_ID = "user@example.com"; +// Get the Arcade tools, you can customize the toolkit (e.g. "github", "notion", "google", etc.) +const arcadeTools = await getArcadeTools({ + toolkits: ["google", "github"], + user_id: USER_ID, +}); +// Define the function that calls the model +async function callModel( + state: typeof MessagesAnnotation.State, + config: RunnableConfig, +): Promise { + /** Call the LLM powering our agent. **/ + const configuration = ensureConfiguration(config); + + /** + * Initialize the model and bind the tools + */ + const model = new ChatOpenAI({ + model: configuration.model, + apiKey: process.env.OPENAI_API_KEY, + }).bindTools(arcadeTools); + + const response = await model.invoke([ + { + role: "system", + content: configuration.systemPromptTemplate.replace( + "{system_time}", + new Date().toISOString(), + ), + }, + ...state.messages, + ]); + + // We return a list, because this will get added to the existing list + return { messages: [response] }; +} + +// Define the function that determines whether to continue or not +function routeModelOutput(state: typeof MessagesAnnotation.State): string { + const messages = state.messages; + const lastMessage = messages[messages.length - 1]; + // If the LLM is invoking tools, route there. + if ((lastMessage as AIMessage)?.tool_calls?.length) { + return "tools"; + } + // Otherwise end the graph. + + return "__end__"; +} + +// Define a new graph. We use the prebuilt MessagesAnnotation to define state: +// https://langchain-ai.github.io/langgraphjs/concepts/low_level/#messagesannotation +const workflow = new StateGraph(MessagesAnnotation, ConfigurationSchema) + // Define the two nodes we will cycle between + .addNode("callModel", callModel) + .addNode("tools", new ToolNode(arcadeTools)) + // Set the entrypoint as `callModel` + // This means that this node is the first one called + .addEdge("__start__", "callModel") + .addConditionalEdges( + // First, we define the edges' source node. We use `callModel`. + // This means these are the edges taken after the `callModel` node is called. + "callModel", + // Next, we pass in the function that will determine the sink node(s), which + // will be called after the source node is called. + routeModelOutput, + ) + // This means that after `tools` is called, `callModel` node is called next. + .addEdge("tools", "callModel"); + +// Finally, we compile it! +// This compiles it into a graph you can invoke and deploy. +export const graph = workflow.compile({ + interruptBefore: [], // if you want to update the state before calling the tools + interruptAfter: [], +}); diff --git a/examples/langgraph-ts/src/prompts.ts b/examples/langgraph-ts/src/prompts.ts new file mode 100644 index 00000000..88ce8564 --- /dev/null +++ b/examples/langgraph-ts/src/prompts.ts @@ -0,0 +1,7 @@ +/** + * Default prompts used by the agent. + */ + +export const SYSTEM_PROMPT_TEMPLATE = `You are a helpful AI assistant. + +System time: {system_time}`; diff --git a/examples/langgraph-ts/studio.png b/examples/langgraph-ts/studio.png new file mode 100644 index 00000000..b8d003a8 Binary files /dev/null and b/examples/langgraph-ts/studio.png differ