This article introduces how to use LangGraph to build an intelligent Agent with automatic Skill discovery, memory storage, and local command execution, plus a clean web chat interface.
Project Overview
Recently I’ve become deeply interested in AI Agent technology, especially LangGraph, a framework for building stateful AI workflows. Compared with traditional LLM calls, its biggest feature is support for loops, persistence, and conditional branching, which makes it very well suited for building complex agent systems.
Based on LangGraph, I implemented an Agent system with the following features:
✅ Documentation-driven Skill system: each Skill has detailed Markdown documentation, and the AI understands the skill’s capabilities from the docs
✅ Smart Skill selection: automatically selects the most suitable Skills based on user input, rather than loading every tool
✅ Skill auto-discovery: automatically loads feature modules under the skills/ directory
✅ Memory storage: uses MemorySaver to save conversation state and supports multi-session isolation
✅ Local command execution: safely executes system commands via a CLI Skill (such as dig domain queries)
✅ File operations: supports reading, writing, and listing files in the project directory
✅ McDonald’s service: a McDonald’s ordering assistant based on FastMCP
✅ Amap service: integrates the Amap API for geocoding, POI search, and route planning
✅ GPS location service: automatically gets the current location, supporting IP geolocation and coordinate parsing
✅ Front-end skill display: the chat interface shows the invoked skills and tools in real time
✅ FastMCP integration: compatible with the MCP protocol and can run an MCP server standalone
Documentation-Driven Skill System
Traditional Agent systems usually bind all tools to the LLM and let the LLM decide which one to call. The problems with that approach are:
Performance drops when there are too many tools: the LLM has to process a huge number of tool descriptions
Context interference: irrelevant tools affect the LLM’s judgment
Hard to extend: adding a tool requires modifying the core code
I adopted a documentation-driven approach similar to OpenClaw:
Each Skill has a Markdown document located at skills/docs/{skill_name}_skill.md:
# CLI Skill - Command-line tools
## Description Executes safe local command-line tools. Currently supports the dig command for querying a domain's DNS resolution records.
## Use cases Call this skill when the user needs the following information: - Query a domain's IP address (A record) - Query a domain's IPv6 address (AAAA record) - Query a domain's mail server (MX record) - ...
## Tool functions
### dig_query Queries a domain's DNS resolution records. **Parameters:** | Name | Type | Required | Description | |--------|------|------|------| | domain | string | yes | The domain to query | | record_type | string | no | The DNS record type |
defselect_skills(self, user_input: str) -> List[str]: """Select the Skills that should be activated based on user input""" system_prompt = f"""Based on the user's input, determine which skills need to be activated.
{self.skill_docs}
Selection rules: 1. Analyze the user's intent and needs 2. Choose the most relevant skills (you may choose more than one) 3. If the user is just chatting, return "none" 4. Return only a comma-separated list of skill names
@abstractmethod defget_tools(self) -> List[BaseTool]: """Return the list of tools this skill provides""" pass
2. Auto-Discovery Mechanism
By iterating over the skills/ directory, all skill modules are imported and registered automatically:
defdiscover_skills() -> List[Skill]: """Automatically discover and instantiate all skills""" skills = [] current_dir = os.path.dirname(os.path.abspath(__file__))
for filename insorted(os.listdir(current_dir)): if filename.endswith("_skill.py"): module_name = filename[:-3] try: module = importlib.import_module(f"." + module_name, package=__name__) for name, obj in inspect.getmembers(module): if ( inspect.isclass(obj) andissubclass(obj, Skill) and obj isnot Skill andnot inspect.isabstract(obj) ): instance = obj() skills.append(instance) print(f"[skills] loaded: {instance.name}") except Exception as e: print(f"[skills] failed to load {module_name}: {e}")
return skills
The benefit of this design is: adding a skill only requires creating a new _skill.py file under the skills/ directory, with no need to modify any other code.
3. CLI Skill - dig Domain Query
The first implemented Skill is the CLI tool, which supports querying domain resolution using the system dig command:
@tool defdig_query(domain: str, record_type: str = "A") -> str: """ Query a domain's DNS resolution records using the dig command """ ifnot shutil.which("dig"): return"Error: the dig command is not installed on this system"
# Safety check: only allow letters, digits, dots, hyphens, underscores safe_domain = "".join(c for c in domain if c.isalnum() or c in".-_") safe_record = "".join(c for c in record_type if c.isalnum())
try: result = subprocess.run( ["dig", safe_domain, safe_record, "+short"], capture_output=True, text=True, timeout=15, check=False, ) output = result.stdout.strip() ifnot output: returnf"No {safe_record} record found for {safe_domain}" returnf"dig {safe_domain}{safe_record} query result:\n{output}" except subprocess.TimeoutExpired: return"Error: dig command timed out"
Security considerations:
Apply a character whitelist filter to user input to prevent command injection
Set a timeout to avoid long hangs
Use the +short flag to return only the key information, reducing output noise
4. File Skill - File Operations
Supports safe local file read/write operations:
# The base directory allowed for operations (project root) BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
def_sanitize_path(path: str) -> str: """Restrict the path to within BASE_DIR to prevent directory traversal""" ifnot os.path.isabs(path): path = os.path.join(BASE_DIR, path) resolved = os.path.abspath(path) ifnot resolved.startswith(BASE_DIR + os.sep) and resolved != BASE_DIR: raise ValueError(f"Path out of bounds: {path}") return resolved
Tool functions provided:
read_file(path) - read file contents
write_file(path, content) - write a file
list_dir(path) - list directory contents
5. McDonald’s Skill - McDonald’s Service (based on FastMCP)
The McDonald’s Skill demonstrates how to integrate a third-party API based on the FastMCP framework:
What is FastMCP?
FastMCP is the MCP (Model Context Protocol) Python framework developed by Prefect. Its features:
🚀 Simple and easy to use: create an MCP server in a few lines of code
🛠️ Feature-rich: supports Tools, Resources, and Prompts
🔗 Standards-compatible: conforms to the Anthropic MCP protocol spec
The McDonald’s service Skill implemented on top of FastMCP, showing how to integrate a third-party API:
classMcDonaldsSkill(Skill): """McDonald's service Skill"""
@property defname(self) -> str: return"mcdonalds"
@property defdescription(self) -> str: return"McDonald's restaurant lookup, menu browsing, order service"
defget_tools(self) -> List[BaseTool]: return [ find_nearby_restaurants, # Find nearby restaurants get_menu, # Get the menu calculate_nutrition, # Calculate nutrition check_promotions # Check promotions ]
Usage example:
User: I want McDonald's, let me see what burgers there are System: 🤖 selected skills: ['mcdonalds'] AI: McDonald's has the following burgers: Big Mac (¥25.5), McSpicy Chicken Burger (¥22.0)...
Multi-Skill collaboration example:
User: Show me what files are in the current directory, then recommend a nearby McDonald's System: 🤖 selected skills: ['file', 'mcdonalds'] AI: The current directory has... To find a McDonald's, I need your location...
6. Gaode Skill - Amap Service
Integrates the Amap API to provide professional map and LBS services:
Tool
Function
Example
geocode
Geocoding
Address to coordinates
regeocode
Reverse geocoding
Coordinates to address
search_poi
POI search
Search nearby places
route_planning
Route planning
Driving/walking/transit routes
get_weather
Weather query
City weather forecast
get_district_info
Administrative divisions
Province/city/district lookup
Usage example:
User: How do I getfrom Tiananmen in Beijing to the Capital Airport System: 🤖 selected skills: ['gaode'] AI: 🗺️ Driving route planning: distance 28.5 km, estimated time 45 minutes...
7. Location Skill - GPS Location Service
Automatically gets the device location, supporting multiple positioning methods:
Tool
Function
Description
get_current_location
Get current location
IP location + GPS
get_location_by_ip
IP location
Based on network location
get_gps_coordinates
GPS coordinates
From device GPS
parse_address_from_coords
Coordinate parsing
Lat/long to address
Use case - when the user says “nearby” but doesn’t provide a location:
User: What McDonald's restaurants are nearby System: 🤖 selected skills: ['location', 'gaode', 'mcdonalds'] → location: get current coordinates 121.4737,31.2304 (Shanghai) → gaode: search for nearby McDonald's → mcdonalds: return restaurant info
AI: 📍 Got your location: Shanghai 🍟 Found 3 nearby McDonald's restaurants: 1. McDonald's Shanghai Nanjing East Road (1200 m away) 2. ...
Complete Code Implementation
Below is the complete code for each Skill file:
skills/base.py - Skill Base Class Definition
""" Skill base class definition """
from abc import ABC, abstractmethod from typing importList from langchain_core.tools import BaseTool
classSkill(ABC): """Skill base class. All skills must inherit from it and implement the following methods"""
@abstractmethod defget_tools(self) -> List[BaseTool]: """Return the list of tools this skill provides""" pass
skills/__init__.py - Auto-Discovery and Registration
""" Skill auto-discovery and registration Automatically loads all modules under the skills directory that end with _skill.py """
import os import importlib import inspect from typing importList from langchain_core.tools import BaseTool from .base import Skill
defdiscover_skills() -> List[Skill]: """Automatically discover and instantiate all skills""" skills = [] current_dir = os.path.dirname(os.path.abspath(__file__))
for filename insorted(os.listdir(current_dir)): if filename.endswith("_skill.py"): module_name = filename[:-3] try: module = importlib.import_module(f"." + module_name, package=__name__) for name, obj in inspect.getmembers(module): if ( inspect.isclass(obj) andissubclass(obj, Skill) and obj isnot Skill andnot inspect.isabstract(obj) ): instance = obj() skills.append(instance) print(f"[skills] loaded: {instance.name}") except Exception as e: print(f"[skills] failed to load {module_name}: {e}")
return skills
defget_all_tools() -> List[BaseTool]: """Get the tools provided by all skills""" tools = [] for skill in discover_skills(): tools.extend(skill.get_tools()) return tools
skills/cli_skill.py - CLI Command Tool
""" CLI Skill - supports executing safe local commands Currently supports dig for domain resolution queries """
import subprocess import shutil from typing importList from langchain_core.tools import tool, BaseTool from .base import Skill
@tool defdig_query(domain: str, record_type: str = "A") -> str: """ Query a domain's DNS resolution records using the dig command
Args: domain: the domain to query, e.g. "example.com" record_type: the DNS record type, e.g. "A", "AAAA", "MX", "NS", "TXT", "CNAME" """ ifnot shutil.which("dig"): return"Error: the dig command is not installed on this system. Please install the bind-utils or dnsutils package."
# Safety check: only allow letters, digits, dots, hyphens, underscores safe_domain = "".join(c for c in domain if c.isalnum() or c in".-_") safe_record = "".join(c for c in record_type if c.isalnum())
try: result = subprocess.run( ["dig", safe_domain, safe_record, "+short"], capture_output=True, text=True, timeout=15, check=False, ) output = result.stdout.strip() if result.returncode != 0: returnf"dig failed: {result.stderr.strip()}" ifnot output: returnf"No {safe_record} record found for {safe_domain} (or the result was empty)." returnf"dig {safe_domain}{safe_record} query result:\n{output}" except subprocess.TimeoutExpired: return"Error: dig command timed out" except Exception as e: returnf"Error: exception while running dig: {e}"
skills/file_skill.py - File Operation Tool
""" File Skill - command scripts for operating on local files Supports reading, writing, and listing directory contents """
import os from typing importList from langchain_core.tools import tool, BaseTool from .base import Skill
# The base directory allowed for operations (current working directory) BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
def_sanitize_path(path: str) -> str: """Restrict the path to within BASE_DIR to prevent directory traversal""" # First resolve to an absolute path (if relative, base it on BASE_DIR) ifnot os.path.isabs(path): path = os.path.join(BASE_DIR, path) resolved = os.path.abspath(path) # Make sure it's under BASE_DIR ifnot resolved.startswith(BASE_DIR + os.sep) and resolved != BASE_DIR: raise ValueError(f"Path out of bounds: {path}") return resolved
@tool defread_file(path: str) -> str: """ Read the contents of a text file at the specified path
Args: path: file path (relative to the project root, or an absolute path) """ try: target = _sanitize_path(path) ifnot os.path.isfile(target): returnf"Error: file does not exist: {target}" withopen(target, "r", encoding="utf-8") as f: content = f.read() returnf"Contents of [{target}]:\n```\n{content}\n```" except ValueError as e: returnf"Error: {e}" except Exception as e: returnf"Failed to read file: {e}"
@tool defwrite_file(path: str, content: str) -> str: """ Write content to a text file at the specified path (overwrite)
Args: path: file path (relative to the project root, or an absolute path) content: the text content to write """ try: target = _sanitize_path(path) os.makedirs(os.path.dirname(target), exist_ok=True) withopen(target, "w", encoding="utf-8") as f: f.write(content) returnf"Successfully wrote file: {target}" except ValueError as e: returnf"Error: {e}" except Exception as e: returnf"Failed to write file: {e}"
@tool deflist_dir(path: str = ".") -> str: """ List the files and subdirectories in the specified directory
Args: path: directory path (relative to the project root, or an absolute path); defaults to the current directory """ try: target = _sanitize_path(path) ifnot os.path.isdir(target): returnf"Error: directory does not exist: {target}" items = os.listdir(target) ifnot items: returnf"Directory {target} is empty" lines = [f"📁 Contents of {target}:"] for item insorted(items): full = os.path.join(target, item) prefix = "📂"if os.path.isdir(full) else"📄" lines.append(f" {prefix}{item}") return"\n".join(lines) except ValueError as e: returnf"Error: {e}" except Exception as e: returnf"Failed to list directory: {e}"
评论
0 条评论