跪拜 Guibai
← Back to the summary

A Single Missing Curly Brace Broke a 200-Line AI Coding Agent for Two Hours

Written in front: Today I finally got the "mini Cursor" — an Agent that can create a React project, write code, install dependencies, and start a server — working. But the process was extremely painful: less than 200 lines of code in total, and I spent 2 hours finding a bug. In the end, the problem was in the data structure — the format of the parameters passed into tool.invoke(args) was inconsistent with the format expected when the tool function was defined. A single bracket cost me two hours. The furthest distance in coding is not from nothing to something, but from "it should run" to "it actually runs."


1. Today's Goal: Hand-write a "Mini Cursor"

1.1 What to implement?

The teacher said:

"Use Claude Code to complete the following task: Use Vite to create a React TodoList project and get it running."

In plain terms, let the AI do this by itself:

"Create a React TodoList for me using Vite, with full functionality, nice styling, and make it run."

To do this, what capabilities does the Agent need?

Required Capability Corresponding Tool Analogy
Create project directories and files write_file AI's hands, can write files
Modify existing files read_file + write_file AI's eyes + hands
View project structure list_directory AI's navigation
Install dependencies / start project exec_command AI's feet, can run commands

The teacher said an Agent needs to complete these steps:

"Programming task planning is three steps: ① Vite creates the project (write file Tool) → ② LLM's programming ability can write it → ③ Project runs (Tool that calls CLI commands)."

1.2 Four tools, none can be missing

In all-tools.mjs, four tools are defined:

import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import z from 'zod';

readFileTool: Read file

const readFileTool = tool(
    async ({filePath}) => {
        const content = await fs.readFile(filePath, 'utf-8');
        return content;
    },
    {
        name: 'read_file',
        description: 'Read file content',
        schema: z.object({
            filePath: z.string().describe('File path'),
        }),
    }
)

writeFileTool: Write file (auto-create directories)

const writeFileTool = tool(
    async ({filePath, content}) => {
        const dir = path.dirname(filePath);
        await fs.mkdir(dir, { recursive: true }); // Auto-create directories
        await fs.writeFile(filePath, content, 'utf-8');
        return `Successfully wrote ${filePath}`;
    },
    {
        name: 'write_file',
        description: 'Write file content, auto-create directories',
        schema: z.object({
            filePath: z.string().describe('File path'),
            content: z.string().describe('File content to write'),
        }),
    }
)

fs.mkdir(dir, { recursive: true }) is the magic for "auto-creating parent directories" — if dir doesn't exist, it will create all parent directories.

ListDirectoryTool: List directory

const ListDirectoryTool = tool(
    async ({workingDirectory}) => {
        const files = await fs.readdir(workingDirectory);
        return `Directory contents:\n ${files.join('\n')}`;
    },
    {
        name: 'list_directory',
        description: 'List all files and folders in the specified directory',
        schema: z.object({
            workingDirectory: z.string().describe('Directory path'),
        }),
    }
)

execCommandTool: Execute command

const execCommandTool = tool(
    async ({command, workingDirectory}) => {
        const cwd = workingDirectory || process.cwd();
        return new Promise((resolve, reject) => {
            const [cmd, ...args] = command.split(' ');
            const child = spawn(cmd, args, {
                cwd,
                stdio: 'inherit',
                shell: true,
            });
            // ... error handling and return
        });
    },
    {
        name: 'exec_command',
        description: 'Execute system command, supports specifying working directory',
        schema: z.object({
            command: z.string().describe('Command to execute'),
            workingDirectory: z.string().describe('Working directory'),
        }),
    }
)

2. Pitfall Diary: A data structure error took two hours to find

2.1 Where was the problem?

Look at the core code of the ReAct loop in mini-cursor.mjs:

for (const toolCall of response.tool_calls) {
    const foundTool = tools.find(t => t.name === toolCall.name);
    if (foundTool) {
        const toolResult = await foundTool.invoke(toolCall.args);
        //                          ↑ args object is passed here
        messages.push(new ToolMessage({
            content: toolResult,
            tool_call_id: toolCall.id,
        }));
    }
}

What toolCall.args passes in is an object: { filePath: "src/tool.mjs" }.

And in all-tools.mjs, the tool's async function uses destructured parameters:

async ({filePath}) => { ... }
// Destructure filePath from the object

This is correct.

But when I first wrote it, I wrote it like this:

// ❌ Wrong way
async (filePath) => { ... }
// filePath is an object { filePath: "..." }, not a string "..."

// Then I tried to use fs.readFile(filePath)
// But actually filePath = { filePath: "src/tool.mjs" }
// Reading file → error → two hours of debugging

The difference between async (filePath) and async ({filePath}) is just one curly brace, but that one curly brace cost me two hours of debugging.

2.2 Why does one bracket make such a big difference?

// What invoke passes in is: { filePath: "src/tool.mjs", content: "..." }

// ✅ Destructured parameter: directly extract filePath from the object
async ({filePath}) => {
    await fs.readFile(filePath, 'utf-8'); // filePath = "src/tool.mjs" ✅
}

// ❌ Regular parameter: filePath is the entire object
async (filePath) => {
    await fs.readFile(filePath, 'utf-8');
    // filePath = { filePath: "src/tool.mjs" }
    // fs.readFile({ filePath: "..." }) → Error ❌
}

invoke passes an object; the function must use destructuring to get the specific value inside.

2.3 A lesson learned in blood

The teacher said:

"langchain invoke outputs as-is, and also carefully prepares tools to append afterward. The convenience and readability of LLM engineering development help."

LangChain does a lot for you, but the format of parameter passing must be consistent with it. The second parameter of tool() defines the schema, and invoke will pass parameters according to the schema's structure.

When writing code, parameter types are more error-prone than logic. Logic errors can run but produce wrong results; parameter type errors directly throw errors — and the error messages are often vague.


3. The "Unwritten Rules" in the System Prompt: Prompts more important than code

There is a particularly important section in the System Prompt of mini-cursor.mjs:

new SystemMessage(`
    ...Important rules - execute_command:
    - The workingDirectory parameter will automatically switch to the specified directory
    - When using workingDirectory, absolutely do not use cd in the command
    - Wrong example: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
    This is wrong! Because workingDirectory is already in the react-todo-app directory, cd react-todo-app again will fail to find the directory
    - Correct example: { command: "pnpm install", workingDirectory: "react-todo-app" }

    Important rules - File paths:
    - Project files are in the react-todo-app/src/ directory
    - When reading/writing files, paths must include the react-todo-app/ prefix

    Important rules - Execution order:
    - Starting the dev server (pnpm run dev) must be the last step
    - Because it is a long-running process that won't exit
`)

These "unwritten rules" written in the System Prompt are more important than those written in code.

Because code is fixed, but LLM behavior is malleable. If you don't tell the LLM:

The System Prompt is the Agent's "operation manual"; the more detailed it is, the fewer mistakes the AI makes.


4. Complete ReAct Loop + Promise.all

The ReAct loop in mini-cursor.mjs is more refined than the version from the previous lesson:

async function runAgentWithTools(query, maxIterations = 30) {
    const messages = [
        new SystemMessage(`...`),
        new HumanMessage(query),
    ];

    for (let i = 0; i < maxIterations; i++) {
        const response = await modelWithTools.invoke(messages);
        messages.push(response);

        // No tool calls → return answer directly
        if (!response.tool_calls || response.tool_calls.length === 0) {
            console.log(`\n AI Final Reply:\n ${response.content} \n`);
            return response.content;
        }

        // Has tool calls → execute each tool
        for (const toolCall of response.tool_calls) {
            const foundTool = tools.find(t => t.name === toolCall.name);
            if (foundTool) {
                const toolResult = await foundTool.invoke(toolCall.args);
                messages.push(new ToolMessage({
                    content: toolResult,
                    tool_call_id: toolCall.id,
                }));
            }
        }
    }
}

Recap the three actions of the ReAct loop learned in the previous lesson:

Step Code Description
Reason modelWithTools.invoke(messages) LLM thinks about the next step
Act foundTool.invoke(toolCall.args) Execute the tool
Observe new ToolMessage({ tool_call_id }) Observe the tool result

Loop condition: response.tool_calls is not empty. Termination condition: The LLM decides no more tools are needed and directly generates an answer.


5. Case Study: Creating a TodoList Project from Scratch

The content of case1 is:

Create a feature-rich React TodoList application:
1. npm create vite@latest react-todo-app -- --template react-ts
2. Modify src/App.tsx to implement a fully functional TodoList
   - Add, delete, mark complete
   - Category filtering (All / In Progress / Completed)
   - Statistics display
   - localStorage data persistence
3. Add complex styling (gradient background, card shadows, rounded corners, hover effects)
4. Add animations (transition animations for add/delete, using CSS Transitions)
5. List directory to confirm

Note: Use pnpm, functionality must be complete, styling must be beautiful, must have animation effects

The Agent's workflow roughly looks like this:

Round Thought Action Observation
1 Need to create the project first exec_command: "npm create vite@latest..." Project created successfully
2 Need to write code write_file: "react-todo-app/src/App.tsx" File written successfully
3 Need to write styles write_file: "react-todo-app/src/App.css" Written successfully
4 Need to install dependencies exec_command: "pnpm install" Installation successful
5 Check directory structure list_directory: "react-todo-app/src" Files are all there
6 Start the server exec_command: "pnpm run dev" Server started successfully

Every step is automatically completed within the ReAct loop — no human intervention needed at all.


6. Summary: Architecture of the Mini Cursor

Layer File What it does
Tool Layer all-tools.mjs Defines 4 tools (read/write/list/execute command)
Agent Layer mini-cursor.mjs ReAct loop + tool invocation
Child Process Layer node-exec.mjs spawn executes CLI commands
System Prompt Inline in mini-cursor Operating rules for the LLM

The architecture of the "mini Cursor" follows the same idea as the real Cursor/Claude Code. It's just that real products have more robust error handling, a richer toolset, and more complex task orchestration.


Written at the End

Today's two biggest gains. The first is finally writing a programming Agent that can do work on its own — from creating a project to starting the server, fully automated. The second is spending two hours wrestling with a bracket — the difference between async (filePath) and async ({filePath}) gave me a deep understanding of "parameter destructuring."

The teacher said there's more to learn later, probably covering multi-agent collaboration (LangGraph) and more complex task orchestration. I'm ready — next time I encounter a bug, I'll check the brackets first.

Next time an interviewer asks you: "How does an Agent execute multiple tool calls?"

You can calmly say:

"The Agent registers a tool list via LangChain, and model.bindTools(tools) binds the tools to the LLM. In the ReAct loop, the tool_calls array returned by the LLM may contain multiple tool calls, just iterate and execute them. Each tool call is executed via foundTool.invoke(toolCall.args). The parameters must match the structure defined by the schema in tool() — what's passed is an object, and the function must use destructured parameters to receive it (async ({filePath}) not async (filePath)). The tool execution result is pushed into the messages context via new ToolMessage({ content, tool_call_id }), then the LLM is called again until tool_calls is empty."

Then look at the interviewer's satisfied expression and silently think: This round, nailed it again.


All code examples in this article are from classroom learning materials and are genuinely runnable.