Bốn bài đầu series này nói về concept: agent là gì, tools, control loop, memory. Bài này làm thật. Không framework, không abstraction, chỉ Anthropic SDK và khoảng 100 dòng Python.

Khi bài viết xong, bạn sẽ có một agent chạy được trên máy: đọc file, ghi file, liệt kê thư mục, chạy shell command. Giao cho nó task “đếm tổng số dòng code Python trong một repo”, nó sẽ tự đi tìm, tự đọc, tự cộng, rồi report lại. Không cần hướng dẫn từng bước.

Setup

pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."

Chỉ một dependency. Không LangChain, không CrewAI, không vector DB. Mục tiêu bài này là thấy rõ cơ chế, không bị framework che khuất.

Model dùng trong bài: claude-sonnet-4-6. Nếu bạn muốn tiết kiệm hơn khi dev, claude-haiku-4-5 cũng chạy được với tools, nhưng reasoning kém hơn với task phức tạp.

Phần 1: Định nghĩa tools

Agent có 4 tools: đọc file, ghi file, liệt kê thư mục, và chạy shell. Mỗi tool là một dict với name, description, và input_schema theo chuẩn JSON Schema.

TOOLS = [
    {
        "name": "read_file",
        "description": (
            "Read the full text content of a file. "
            "Returns the content as a string. "
            "Use this to inspect source code, config files, or any text file."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Absolute or relative path to the file",
                }
            },
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": (
            "Write text content to a file, creating it if it does not exist "
            "and overwriting it if it does. Use this to save results or create new files."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to write to"},
                "content": {"type": "string", "description": "Text content to write"},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "list_dir",
        "description": (
            "List all entries (files and subdirectories) in a directory. "
            "Returns a newline-separated list of names. "
            "Does not recurse into subdirectories."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the directory",
                }
            },
            "required": ["path"],
        },
    },
    {
        "name": "run_shell",
        "description": (
            "Run a shell command and return stdout + stderr combined. "
            "Use for counting lines, grepping, or lightweight data processing. "
            "Do NOT use for destructive operations like rm, mv, or anything that modifies "
            "files outside the target directory."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "Shell command to execute",
                }
            },
            "required": ["command"],
        },
    },
]

Một điểm quan trọng trong description của run_shell: tôi viết rõ “Do NOT use for destructive operations”. LLM đọc description khi quyết định dùng tool. Description mờ nhạt nghĩa là LLM dự đoán sai intent. Sẽ đào sâu hơn về tool design ở bài 11.

Phần 2: Tool dispatcher

Dispatcher nhận tên tool và args từ LLM, gọi function Python tương ứng, trả về string kết quả.

import os
import subprocess


def dispatch_tool(name: str, args: dict) -> str:
    """Execute a tool call and return the result as a string.

    Always returns a string, never raises. Errors are wrapped so LLM
    can read them and decide whether to retry or report to the user.
    """
    try:
        if name == "read_file":
            path = args["path"]
            with open(path, "r", encoding="utf-8") as f:
                return f.read()

        elif name == "write_file":
            path = args["path"]
            content = args["content"]
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            with open(path, "w", encoding="utf-8") as f:
                f.write(content)
            return f"Wrote {len(content)} chars to {path}"

        elif name == "list_dir":
            path = args["path"]
            entries = sorted(os.listdir(path))
            return "\n".join(entries) if entries else "(empty directory)"

        elif name == "run_shell":
            command = args["command"]
            result = subprocess.run(
                command,
                shell=True,
                capture_output=True,
                text=True,
                timeout=30,
            )
            output = result.stdout + result.stderr
            return output.strip() or "(no output)"

        else:
            return f"Unknown tool: {name}"

    except Exception as e:
        # Wrap error as string so LLM sees it in tool_result
        return f"ERROR: {type(e).__name__}: {e}"

Hai điểm thiết kế quan trọng ở đây:

Luôn trả về string, không bao giờ raise. Nếu read_file nhận path không tồn tại, dispatcher trả về "ERROR: FileNotFoundError: ..." thay vì raise exception lên loop. LLM nhìn thấy error này trong tool_result, có thể quyết định thử path khác hoặc báo lại cho user. Nếu raise lên, loop crash và user không hiểu tại sao.

timeout=30 trên run_shell. Không có timeout, một command như find / -name "*.py" có thể chạy nhiều phút. Agent đứng chờ, user không biết chuyện gì. 30 giây đủ cho hầu hết lệnh đọc. Nếu task cần lâu hơn, tăng timeout có chủ đích, không để mặc định vô hạn.

Phần 3: Control loop

Đây là phần trung tâm. Loop nhận user input, gọi LLM, chạy tools khi cần, lặp lại cho đến khi LLM trả về end_turn.

from anthropic import Anthropic

client = Anthropic()


def run_agent(user_input: str, max_iterations: int = 15) -> str:
    """Run the agent loop until LLM signals end_turn or max_iterations is hit."""

    messages = [{"role": "user", "content": user_input}]

    for iteration in range(max_iterations):
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=(
                "You are a file system assistant. You have access to tools to read files, "
                "write files, list directories, and run shell commands. "
                "Complete the user's task step by step. "
                "When you are done, summarize what you did and the final result."
            ),
            tools=TOOLS,
            messages=messages,
        )

        # Append the full assistant response to history
        messages.append({"role": "assistant", "content": response.content})

        # LLM finished without calling any tool
        if response.stop_reason == "end_turn":
            # Extract the final text block
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text
            return "(no text response)"

        # LLM wants to call one or more tools
        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = dispatch_tool(block.name, block.input)
                    tool_results.append(
                        {
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result,
                        }
                    )
            messages.append({"role": "user", "content": tool_results})
            continue

        # Unexpected stop reason
        return f"Unexpected stop_reason: {response.stop_reason}"

    return f"Agent stopped after {max_iterations} iterations without completing the task."

Vì sao max_iterations=15 chứ không phải 100? Bài 1 đã nói: max_iterations là budget, không phải safety net. 15 iteration × ~3000 token/iteration với Claude Sonnet 4.6 vào khoảng $0.10-0.15 cho một task. Đủ để làm hầu hết task file system. Nếu agent chạm 15 mà chưa xong, task đó có thể cần thiết kế lại, không phải tăng limit.

Phần 4: Entry point và demo

import sys


def main():
    if len(sys.argv) > 1:
        task = " ".join(sys.argv[1:])
    else:
        task = input("Task: ").strip()

    if not task:
        print("No task provided.")
        return

    print(f"\nRunning agent on: {task}\n{'=' * 60}")
    result = run_agent(task)
    print(result)


if __name__ == "__main__":
    main()

Chạy thử với task thật:

python agent.py "Đọc tất cả file .py trong thư mục hiện tại, đếm tổng số dòng code (không tính dòng trống và comment), rồi ghi kết quả vào report.txt"

Ví dụ session thật (tóm tắt):

Running agent on: Đọc tất cả file .py trong thư mục hiện tại...
============================================================
[Iteration 1] LLM gọi list_dir(".")
[Iteration 2] LLM gọi read_file("agent.py")
[Iteration 3] LLM gọi run_shell("wc -l *.py")
[Iteration 4] LLM gọi write_file("report.txt", "...")
[Iteration 5] LLM trả về end_turn

Kết quả:
- agent.py: 98 dòng (82 dòng code, 16 dòng comment/blank)
- Tổng: 82 dòng code thực
- Đã ghi kết quả vào report.txt

Bốn tool call, năm iteration. Agent tự quyết định flow: list trước để biết có gì, đọc file, dùng shell để đếm nhanh, ghi kết quả, báo cáo. Không có instruction nào về thứ tự. Đây là điểm khác biệt cốt lõi so với workflow tuyến tính đã nói ở bài 1.

Pitfall: shell tool và command injection

run_shell với shell=True là con dao hai lưỡi. Nếu agent nhận user input không sanitized rồi nhúng vào command, bạn có bug nghiêm trọng.

Ví dụ nguy hiểm:

# User nhập: "report.txt; rm -rf ~"
task = "Đọc file report.txt; rm -rf ~"
# LLM có thể tạo command: "cat report.txt; rm -rf ~"
# shell=True sẽ chạy cả hai lệnh

LLM thường không làm điều này nếu system prompt rõ ràng, nhưng “thường không” không phải “không bao giờ”. Đặc biệt nếu task đến từ user không tin cậy (ví dụ: external webhook, untrusted input), risk này là thật.

Ba lớp phòng thủ tối thiểu:

Lớp 1: Allowlist command prefix. Chỉ cho phép một số command cụ thể:

ALLOWED_COMMANDS = {"wc", "find", "grep", "cat", "ls", "echo", "head", "tail"}

def run_shell_safe(command: str) -> str:
    first_word = command.strip().split()[0]
    if first_word not in ALLOWED_COMMANDS:
        return f"ERROR: Command '{first_word}' not in allowlist"
    # ...

Lớp 2: Không dùng shell=True khi có thể. Truyền list thay vì string cho subprocess.run:

# Thay vì:
subprocess.run(command, shell=True, ...)

# Dùng:
import shlex
subprocess.run(shlex.split(command), shell=False, ...)

shlex.split sẽ không diễn giải ;, &&, || như operator mà coi là literal string.

Lớp 3: Sandbox. Chạy toàn bộ agent trong container hoặc VM. Sẽ đào sâu ở bài 12.

Với agent nội bộ (chỉ chạy trên máy của bạn, task từ bản thân) thì lớp 1 là đủ. Với agent nhận input từ user ngoài: cần cả ba.

Toàn bộ file

Ghép các phần lại, đây là agent.py đầy đủ dưới 120 dòng:

"""agent.py -- minimal file-system agent with Anthropic SDK.

Usage:
    python agent.py "Count lines of Python code in current directory"
    python agent.py  # interactive prompt
"""

import os
import subprocess
import sys
from anthropic import Anthropic

client = Anthropic()

# --- Tool definitions ---

TOOLS = [
    {
        "name": "read_file",
        "description": (
            "Read the full text content of a file. "
            "Returns the content as a string."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to the file"}
            },
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": "Write text content to a file, overwriting if it exists.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "list_dir",
        "description": "List entries in a directory (non-recursive).",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
    {
        "name": "run_shell",
        "description": (
            "Run a shell command and return stdout + stderr. "
            "Do NOT use for destructive operations (rm, mv, etc.)."
        ),
        "input_schema": {
            "type": "object",
            "properties": {"command": {"type": "string"}},
            "required": ["command"],
        },
    },
]

# --- Tool dispatcher ---

def dispatch_tool(name: str, args: dict) -> str:
    try:
        if name == "read_file":
            with open(args["path"], "r", encoding="utf-8") as f:
                return f.read()
        elif name == "write_file":
            os.makedirs(os.path.dirname(args["path"]) or ".", exist_ok=True)
            with open(args["path"], "w", encoding="utf-8") as f:
                f.write(args["content"])
            return f"Wrote {len(args['content'])} chars to {args['path']}"
        elif name == "list_dir":
            entries = sorted(os.listdir(args["path"]))
            return "\n".join(entries) or "(empty)"
        elif name == "run_shell":
            r = subprocess.run(
                args["command"], shell=True, capture_output=True,
                text=True, timeout=30
            )
            return (r.stdout + r.stderr).strip() or "(no output)"
        return f"Unknown tool: {name}"
    except Exception as e:
        return f"ERROR: {type(e).__name__}: {e}"

# --- Agent loop ---

def run_agent(user_input: str, max_iterations: int = 15) -> str:
    messages = [{"role": "user", "content": user_input}]
    for _ in range(max_iterations):
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=(
                "You are a file system assistant. Complete the user's task "
                "using the available tools. Summarize the final result clearly."
            ),
            tools=TOOLS,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason == "end_turn":
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text
            return "(no text)"
        if response.stop_reason == "tool_use":
            results = [
                {
                    "type": "tool_result",
                    "tool_use_id": b.id,
                    "content": dispatch_tool(b.name, b.input),
                }
                for b in response.content
                if b.type == "tool_use"
            ]
            messages.append({"role": "user", "content": results})
    return f"Stopped after {max_iterations} iterations."

# --- Entry point ---

def main():
    task = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else input("Task: ").strip()
    if not task:
        print("No task.")
        return
    print(f"\nTask: {task}\n{'=' * 60}")
    print(run_agent(task))

if __name__ == "__main__":
    main()

Đây là bản sạch để clone và chạy thử. Không cần thêm gì ngoài pip install anthropic và API key.

Cheatsheet

PhầnVai tròĐiểm hay sai
TOOLS listSchema expose cho LLMDescription mờ, LLM đoán sai intent
dispatch_toolChạy tool, trả stringRaise exception thay vì wrap error
messages listConversation history (memory)Không append tool_result, LLM mất context
stop_reason == "tool_use"LLM muốn gọi toolQuên handle, loop thoát sớm
stop_reason == "end_turn"LLM tự khai báo xongTin tuyệt đối, không verify task thật xong
max_iterationsToken budgetĐể quá cao, đốt tiền khi agent stuck
shell=TrueTiện nhưng nguy hiểmCommand injection nếu input từ user ngoài

Lời kết

Bài này kết thúc Part 1 của series. Từ bài 1 đến bài 5, chúng ta đi từ định nghĩa agent, qua từng thành phần riêng lẻ, đến một agent chạy được trên máy. Khoảng 100 dòng Python, không framework nào.

Bước tiếp theo quan trọng: copy code này về, chạy với một task thật của bạn, xem agent fail ở đâu. Đó là cách nhanh nhất để biến concept thành intuition.

Part 2 bắt đầu ở bài 6: ReAct, thought-action-observation cycle. Agent ở bài 5 phản xạ: nhận task, chọn tool, chạy. ReAct thêm một bước: nghĩ thành lời trước khi hành động. Nghe có vẻ overhead, nhưng đây là kỹ thuật làm agent giải quyết được class bài toán phức tạp hơn hẳn. Hẹn gặp ở bài 6.