From 8074ebda2b841f0b0c21da8b2abb9fa217b4256f Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:50:22 +0100 Subject: [PATCH 1/3] feat(examples): Add sql_agent example --- examples/package.json | 48 ++-- examples/src/langgraph/sql_agent.ts | 375 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 2 +- 3 files changed, 400 insertions(+), 25 deletions(-) create mode 100644 examples/src/langgraph/sql_agent.ts diff --git a/examples/package.json b/examples/package.json index a6c995e24769..ee0819aed3ed 100644 --- a/examples/package.json +++ b/examples/package.json @@ -29,8 +29,11 @@ "@clickhouse/client": "^0.2.5", "@cloudflare/workers-types": "^4.20250801.0", "@elastic/elasticsearch": "^8.4.0", + "@eslint/js": "^9.36.0", "@faker-js/faker": "^8.4.1", "@getmetal/metal-sdk": "^4.0.0", + "@getzep/zep-cloud": "^1.0.12", + "@getzep/zep-js": "^0.9.0", "@gomomento/sdk": "1.51.1", "@google/generative-ai": "^0.7.0", "@lancedb/lancedb": "^0.19.1", @@ -45,6 +48,7 @@ "@langchain/community": "workspace:*", "@langchain/core": "workspace:*", "@langchain/deepseek": "workspace:*", + "@langchain/eslint": "workspace:*", "@langchain/exa": "workspace:*", "@langchain/google-cloud-sql-pg": "workspace:^", "@langchain/google-common": "workspace:*", @@ -76,19 +80,28 @@ "@rockset/client": "^0.9.1", "@supabase/supabase-js": "^2.45.0", "@tensorflow/tfjs-backend-cpu": "^4.4.0", + "@tsconfig/recommended": "^1.0.2", + "@types/js-yaml": "^4", + "@types/jsdom": "^21.1.1", "@types/pg": "^8.15.5", + "@types/uuid": "^9", + "@types/ws": "^8", "@upstash/redis": "^1.34.7", "@upstash/vector": "^1.2.1", "@vercel/kv": "^3.0.0", + "@vitest/coverage-v8": "^3.2.4", "@xata.io/client": "^0.28.0", "@zilliz/milvus2-sdk-node": "^2.3.5", "axios": "^0.26.0", + "cheerio": "1.0.0-rc.12", "chromadb": "^1.5.3", "cohere-ai": "^7.14.0", "convex": "^1.3.1", "date-fns": "^3.3.1", "dotenv": "^16.0.3", + "dpdm": "^3.14.0", "duck-duck-scrape": "^2.2.5", + "eslint": "^9.36.0", "exa-js": "^1.0.12", "firebase-admin": "^13.0.0", "graphql": "^16.6.0", @@ -96,46 +109,33 @@ "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "langchain": "workspace:*", + "langsmith": "^0.3.64", "lorem-ipsum": "^2.0.8", "lunary": "^0.8.8", "mariadb": "^3.4.0", "mem0ai": "^2.1.8", "mongodb": "^6.20.0", + "openai": "^5.1.0", + "peggy": "^3.0.2", "pg": "^8.16.3", "pickleparser": "^0.2.1", + "prettier": "^2.8.3", "prisma": "^4.11.0", "readline": "^1.3.0", "redis": "^4.6.13", - "sqlite3": "^5.1.4", - "typesense": "^1.5.3", - "uuid": "^10.0.0", - "voy-search": "0.6.2", - "weaviate-client": "^3.5.2", - "zod-to-json-schema": "^3.22.3", - "@eslint/js": "^9.36.0", - "@getzep/zep-cloud": "^1.0.12", - "@getzep/zep-js": "^0.9.0", - "@langchain/eslint": "workspace:*", - "@tsconfig/recommended": "^1.0.2", - "@types/js-yaml": "^4", - "@types/jsdom": "^21.1.1", - "@types/uuid": "^9", - "@types/ws": "^8", - "@vitest/coverage-v8": "^3.2.4", - "cheerio": "1.0.0-rc.12", - "dpdm": "^3.14.0", - "eslint": "^9.36.0", - "langsmith": "^0.3.64", - "openai": "^5.1.0", - "peggy": "^3.0.2", - "prettier": "^2.8.3", "reflect-metadata": "^0.2.2", "release-it": "^19.0.4", "release-it-pnpm": "^4.6.6", "rimraf": "^5.0.1", "rollup": "^3.19.1", + "sqlite3": "^5.1.7", "typescript": "~5.8.3", - "vitest": "^3.2.4" + "typesense": "^1.5.3", + "uuid": "^10.0.0", + "vitest": "^3.2.4", + "voy-search": "0.6.2", + "weaviate-client": "^3.5.2", + "zod-to-json-schema": "^3.22.3" }, "peerDependencies": { "@getzep/zep-cloud": "^1.0.6", diff --git a/examples/src/langgraph/sql_agent.ts b/examples/src/langgraph/sql_agent.ts new file mode 100644 index 000000000000..b2fced7c616b --- /dev/null +++ b/examples/src/langgraph/sql_agent.ts @@ -0,0 +1,375 @@ +import { SqlDatabase } from "@langchain/classic/sql_db"; +import { AIMessage, ToolMessage } from "@langchain/core/messages"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { tool } from "@langchain/core/tools"; +import { + Command, + END, + interrupt, + MemorySaver, + MessagesAnnotation, + START, + StateGraph, +} from "@langchain/langgraph"; +import { ToolNode } from "@langchain/langgraph/prebuilt"; +import { ChatOpenAI } from "@langchain/openai"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { DataSource } from "typeorm"; +import { z } from "zod"; + +// Download and setup database +const url = + "https://storage.googleapis.com/benchmarks-artifacts/chinook/Chinook.db"; +const localPath = path.resolve("Chinook.db"); + +async function resolveDbPath() { + const exists = await fs + .access(localPath) + .then(() => true) + .catch(() => false); + if (exists) { + console.log(`${localPath} already exists, skipping download.`); + return localPath; + } + const resp = await fetch(url); + if (!resp.ok) + throw new Error(`Failed to download DB. Status code: ${resp.status}`); + const buf = Buffer.from(await resp.arrayBuffer()); + await fs.writeFile(localPath, buf); + console.log(`File downloaded and saved as ${localPath}`); + return localPath; +} + +const dbPath = await resolveDbPath(); +const datasource = new DataSource({ type: "sqlite", database: dbPath }); +const db = await SqlDatabase.fromDataSourceParams({ + appDataSource: datasource, +}); + +console.log(`Dialect: ${db.appDataSourceOptions.type}`); +const tableNames = db.allTables.map((t) => t.tableName); +console.log(`Available tables: ${tableNames.join(", ")}`); +const sampleResults = await db.run("SELECT * FROM Artist LIMIT 5;"); +console.log(`Sample output: ${sampleResults}`); + +// Initialize LLM +const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); + +// Create tools +const listTablesTool = tool( + async () => { + const tableNames = db.allTables.map((t) => t.tableName); + return tableNames.join(", "); + }, + { + name: "sql_db_list_tables", + description: + "Input is an empty string, output is a comma-separated list of tables in the database.", + schema: z.object({}), + } +); + +const getSchemaTool = tool( + async ({ table_names }) => { + const tables = table_names.split(",").map((t) => t.trim()); + return await db.getTableInfo(tables); + }, + { + name: "sql_db_schema", + description: + "Input to this tool is a comma-separated list of tables, output is the schema and sample rows for those tables. Be sure that the tables actually exist by calling sql_db_list_tables first! Example Input: table1, table2, table3", + schema: z.object({ + table_names: z.string().describe("Comma-separated list of table names"), + }), + } +); + +const queryTool = tool( + async ({ query }) => { + try { + const result = await db.run(query); + return typeof result === "string" ? result : JSON.stringify(result); + } catch (error: any) { + return `Error: ${error.message}`; + } + }, + { + name: "sql_db_query", + description: + "Input to this tool is a detailed and correct SQL query, output is a result from the database. If the query is not correct, an error message will be returned. If an error is returned, rewrite the query, check the query, and try again.", + schema: z.object({ + query: z.string().describe("SQL query to execute"), + }), + } +); + +const tools = [listTablesTool, getSchemaTool, queryTool]; + +// Create tool nodes +const getSchemaNode = new ToolNode([getSchemaTool]); +const runQueryNode = new ToolNode([queryTool]); + +// Define node functions +async function listTables(state: typeof MessagesAnnotation.State) { + const toolCall = { + name: "sql_db_list_tables", + args: {}, + id: "abc123", + type: "tool_call" as const, + }; + const toolCallMessage = new AIMessage({ + content: "", + tool_calls: [toolCall], + }); + + const toolMessage = await listTablesTool.invoke({}); + const response = new AIMessage(`Available tables: ${toolMessage}`); + + return { + messages: [ + toolCallMessage, + new ToolMessage({ content: toolMessage, tool_call_id: "abc123" }), + response, + ], + }; +} + +async function callGetSchema(state: typeof MessagesAnnotation.State) { + const llmWithTools = llm.bindTools([getSchemaTool], { + tool_choice: "any", + }); + const response = await llmWithTools.invoke(state.messages); + + return { messages: [response] }; +} + +const topK = 5; + +const generateQuerySystemPrompt = ` +You are an agent designed to interact with a SQL database. +Given an input question, create a syntactically correct ${db.appDataSourceOptions.type} +query to run, then look at the results of the query and return the answer. Unless +the user specifies a specific number of examples they wish to obtain, always limit +your query to at most ${topK} results. + +You can order the results by a relevant column to return the most interesting +examples in the database. Never query for all the columns from a specific table, +only ask for the relevant columns given the question. + +DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database. +`; + +async function generateQuery(state: typeof MessagesAnnotation.State) { + const systemMessage = { + role: "system" as const, + content: generateQuerySystemPrompt, + }; + const llmWithTools = llm.bindTools([queryTool]); + const response = await llmWithTools.invoke([ + systemMessage, + ...state.messages, + ]); + + return { messages: [response] }; +} + +const checkQuerySystemPrompt = ` +You are a SQL expert with a strong attention to detail. +Double check the sqlite query for common mistakes, including: +- Using NOT IN with NULL values +- Using UNION when UNION ALL should have been used +- Using BETWEEN for exclusive ranges +- Data type mismatch in predicates +- Properly quoting identifiers +- Using the correct number of arguments for functions +- Casting to the correct data type +- Using the proper columns for joins + +If there are any of the above mistakes, rewrite the query. If there are no mistakes, +just reproduce the original query. + +You will call the appropriate tool to execute the query after running this check. +`; + +async function checkQuery(state: typeof MessagesAnnotation.State) { + const systemMessage = { + role: "system" as const, + content: checkQuerySystemPrompt, + }; + + const lastMessage = state.messages[state.messages.length - 1]; + if (!lastMessage.tool_calls || lastMessage.tool_calls.length === 0) { + throw new Error("No tool calls found in the last message"); + } + const toolCall = lastMessage.tool_calls[0]; + const userMessage = { role: "user" as const, content: toolCall.args.query }; + const llmWithTools = llm.bindTools([queryTool], { + tool_choice: "any", + }); + const response = await llmWithTools.invoke([systemMessage, userMessage]); + response.id = lastMessage.id; + + return { messages: [response] }; +} + +// Build the graph +function shouldContinue( + state: typeof MessagesAnnotation.State +): "check_query" | typeof END { + const messages = state.messages; + const lastMessage = messages[messages.length - 1]; + if (!lastMessage.tool_calls || lastMessage.tool_calls.length === 0) { + return END; + } else { + return "check_query"; + } +} + +const builder = new StateGraph(MessagesAnnotation) + .addNode("list_tables", listTables) + .addNode("call_get_schema", callGetSchema) + .addNode("get_schema", getSchemaNode) + .addNode("generate_query", generateQuery) + .addNode("check_query", checkQuery) + .addNode("run_query", runQueryNode) + .addEdge(START, "list_tables") + .addEdge("list_tables", "call_get_schema") + .addEdge("call_get_schema", "get_schema") + .addEdge("get_schema", "generate_query") + .addConditionalEdges("generate_query", shouldContinue) + .addEdge("check_query", "run_query") + .addEdge("run_query", "generate_query"); + +const agent = builder.compile(); + +// Run the agent +const question = "Which genre on average has the longest tracks?"; + +console.log("\n=== Running SQL Agent ===\n"); + +const stream = await agent.stream( + { messages: [{ role: "user", content: question }] }, + { streamMode: "values" } +); + +for await (const step of stream) { + const lastMessage = step.messages[step.messages.length - 1]; + console.log(lastMessage.toFormattedString()); +} + +console.log("\n=== Agent completed ===\n"); + +// ===== Human-in-the-loop implementation ===== + +console.log("\n=== Setting up agent with human-in-the-loop ===\n"); + +// Create a tool with interrupt for human review +const queryToolWithInterrupt = tool( + async (input, config: RunnableConfig) => { + const request = { + action: queryTool.name, + args: input, + description: "Please review the tool call", + }; + const response = interrupt([request]); + // approve the tool call + if (response.type === "accept") { + const toolResponse = await queryTool.invoke(input, config); + return toolResponse; + } + // update tool call args + else if (response.type === "edit") { + const editedInput = response.args.args; + const toolResponse = await queryTool.invoke(editedInput, config); + return toolResponse; + } + // respond to the LLM with user feedback + else if (response.type === "response") { + const userFeedback = response.args; + return userFeedback; + } else { + throw new Error(`Unsupported interrupt response type: ${response.type}`); + } + }, + { + name: queryTool.name, + description: queryTool.description, + schema: queryTool.schema, + } +); + +// Modified shouldContinue for human-in-the-loop version +function shouldContinueWithHuman( + state: typeof MessagesAnnotation.State +): "run_query" | typeof END { + const messages = state.messages; + const lastMessage = messages[messages.length - 1]; + if (!lastMessage.tool_calls || lastMessage.tool_calls.length === 0) { + return END; + } else { + return "run_query"; + } +} + +// Create tool node with interrupt +const runQueryNodeWithInterrupt = new ToolNode([queryToolWithInterrupt]); + +// Build graph with human-in-the-loop +const builderWithHuman = new StateGraph(MessagesAnnotation) + .addNode("list_tables", listTables) + .addNode("call_get_schema", callGetSchema) + .addNode("get_schema", getSchemaNode) + .addNode("generate_query", generateQuery) + .addNode("run_query", runQueryNodeWithInterrupt) + .addEdge(START, "list_tables") + .addEdge("list_tables", "call_get_schema") + .addEdge("call_get_schema", "get_schema") + .addEdge("get_schema", "generate_query") + .addConditionalEdges("generate_query", shouldContinueWithHuman) + .addEdge("run_query", "generate_query"); + +const checkpointer = new MemorySaver(); +const agentWithHuman = builderWithHuman.compile({ checkpointer }); + +// Run the agent with human-in-the-loop +const config = { configurable: { thread_id: "1" } }; + +console.log("\n=== Running SQL Agent with Human-in-the-Loop ===\n"); + +const streamWithHuman = await agentWithHuman.stream( + { messages: [{ role: "user", content: question }] }, + { ...config, streamMode: "values" } +); + +for await (const step of streamWithHuman) { + if (step.messages && step.messages.length > 0) { + const lastMessage = step.messages[step.messages.length - 1]; + console.log(lastMessage.toFormattedString()); + } +} + +// Check for interrupts +const state = await agentWithHuman.getState(config); +if (state.next.length > 0) { + console.log("\nINTERRUPTED:"); + console.log(JSON.stringify(state.tasks[0].interrupts[0], null, 2)); + + // Resume with approval + console.log("\n=== Resuming with approval ===\n"); + + const resumeStream = await agentWithHuman.stream( + new Command({ resume: { type: "accept" } }), + // new Command({ resume: { type: "edit", args: { query: "..." } } }), + { ...config, streamMode: "values" } + ); + + for await (const step of resumeStream) { + if (step.messages && step.messages.length > 0) { + const lastMessage = step.messages[step.messages.length - 1]; + console.log(lastMessage.toFormattedString()); + } + } + + console.log("\n=== Agent with human-in-the-loop completed ===\n"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56e5c35e6786..cd088843e2cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,7 +365,7 @@ importers: specifier: ^3.19.1 version: 3.29.5 sqlite3: - specifier: ^5.1.4 + specifier: ^5.1.7 version: 5.1.7 typescript: specifier: ~5.8.3 From 50bc37f66aa1a8b7b01ca9dee5f920c52b51135a Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:45:53 +0100 Subject: [PATCH 2/3] cleanup --- examples/src/langgraph/sql_agent.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/examples/src/langgraph/sql_agent.ts b/examples/src/langgraph/sql_agent.ts index b2fced7c616b..a847c950c240 100644 --- a/examples/src/langgraph/sql_agent.ts +++ b/examples/src/langgraph/sql_agent.ts @@ -1,5 +1,10 @@ import { SqlDatabase } from "@langchain/classic/sql_db"; -import { AIMessage, ToolMessage } from "@langchain/core/messages"; +import { + AIMessage, + HumanMessage, + SystemMessage, + ToolMessage, +} from "@langchain/core/messages"; import { RunnableConfig } from "@langchain/core/runnables"; import { tool } from "@langchain/core/tools"; import { @@ -161,10 +166,7 @@ DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the databa `; async function generateQuery(state: typeof MessagesAnnotation.State) { - const systemMessage = { - role: "system" as const, - content: generateQuerySystemPrompt, - }; + const systemMessage = new SystemMessage(generateQuerySystemPrompt); const llmWithTools = llm.bindTools([queryTool]); const response = await llmWithTools.invoke([ systemMessage, @@ -193,17 +195,14 @@ You will call the appropriate tool to execute the query after running this check `; async function checkQuery(state: typeof MessagesAnnotation.State) { - const systemMessage = { - role: "system" as const, - content: checkQuerySystemPrompt, - }; + const systemMessage = new SystemMessage(checkQuerySystemPrompt); const lastMessage = state.messages[state.messages.length - 1]; if (!lastMessage.tool_calls || lastMessage.tool_calls.length === 0) { throw new Error("No tool calls found in the last message"); } const toolCall = lastMessage.tool_calls[0]; - const userMessage = { role: "user" as const, content: toolCall.args.query }; + const userMessage = new HumanMessage(toolCall.args.query); const llmWithTools = llm.bindTools([queryTool], { tool_choice: "any", }); From 48af9b22d698d0e15171bddfca6aada42e7eb204 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:49:52 +0100 Subject: [PATCH 3/3] Add jsdoc --- examples/src/langgraph/sql_agent.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/examples/src/langgraph/sql_agent.ts b/examples/src/langgraph/sql_agent.ts index a847c950c240..c08b59403d6c 100644 --- a/examples/src/langgraph/sql_agent.ts +++ b/examples/src/langgraph/sql_agent.ts @@ -1,3 +1,27 @@ +/** + * Custom SQL Agent with LangGraph + * + * This example demonstrates building a custom SQL agent using LangGraph primitives. + * Unlike higher-level agent abstractions, building directly in LangGraph provides + * fine-grained control over agent behavior through: + * - Dedicated nodes for specific tool-calls (list tables, get schema, run query) + * - Custom prompts for each step (query generation, query checking) + * - Enforced workflow with conditional edges + * - Human-in-the-loop review before executing queries + * + * The agent follows a ReAct-style pattern: + * 1. List available database tables + * 2. Retrieve schema for relevant tables + * 3. Generate a SQL query based on the user's question + * 4. Check the query for common mistakes + * 5. Execute the query (with optional human review) + * 6. Return results to the user + * + * Security Note: + * This example includes human-in-the-loop review to mitigate risks of executing + * model-generated SQL queries. Always scope database permissions narrowly. + */ + import { SqlDatabase } from "@langchain/classic/sql_db"; import { AIMessage,