跪拜 Guibai
← All articles
Frontend · JavaScript

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

By 默_笙 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

LangChain's `tool()` wrapper silently passes arguments as a single object matching the Zod schema, and a non-destructured parameter swallows that object whole. The resulting runtime errors are opaque because the function signature looks correct at a glance. Anyone wiring LLM tool calls to filesystem or shell operations will hit this exact footgun.

Summary

A minimal AI coding agent — dubbed "mini Cursor" — uses four LangChain tools (read_file, write_file, list_directory, exec_command) inside a ReAct loop to create a Vite + React TypeScript project, write a full TodoList with localStorage persistence and CSS animations, install dependencies, and start the dev server, all without human intervention. The entire agent fits in under 200 lines of JavaScript.

The build was derailed by a single-character mistake: writing `async (filePath)` instead of `async ({filePath})` in the tool definition. LangChain's `tool.invoke()` passes arguments as an object matching the Zod schema, so a non-destructured parameter receives the whole object rather than the string value. The resulting `fs.readFile` call received `{ filePath: "src/tool.mjs" }` instead of a path string, producing cryptic errors that took two hours to trace.

The System Prompt carries operational weight that code alone cannot. Explicit rules prevent the LLM from double-nesting `cd` commands inside a `workingDirectory`, from omitting the project-root prefix on file paths, and from starting the dev server before dependencies are installed. These constraints are not enforceable in code — they shape the model's behavior at inference time.

Takeaways
LangChain's `tool.invoke(toolCall.args)` passes an object whose shape matches the Zod schema, so tool functions must destructure parameters — `async ({filePath})` not `async (filePath)`.
A non-destructured parameter receives the entire arguments object, causing downstream functions like `fs.readFile` to fail with confusing errors.
The System Prompt must explicitly forbid `cd` inside `workingDirectory` commands, require project-root prefixes on all file paths, and mandate that the dev server runs last.
`fs.mkdir(dir, { recursive: true })` auto-creates all parent directories, eliminating manual directory scaffolding in the write_file tool.
The ReAct loop terminates when `response.tool_calls` is empty; until then, tool results are fed back as `ToolMessage` objects with matching `tool_call_id` values.
The agent completed six rounds — create project, write App.tsx, write App.css, install dependencies, list directory, start dev server — with zero manual steps.
Conclusions

Parameter destructuring errors in LLM-tool plumbing are disproportionately expensive to debug because the stack trace points at the filesystem call, not at the mismatch between `invoke`'s output shape and the function signature.

System Prompts function as a runtime policy layer that compensates for what static tool schemas cannot express: ordering constraints, path conventions, and anti-patterns the model would otherwise generate confidently.

The agent's architecture mirrors production tools like Cursor and Claude Code — a thin ReAct loop over bound tools — suggesting that the gap between a weekend prototype and a commercial coding agent is mostly tool breadth and error handling, not a fundamentally different design.

Concepts & terms
ReAct loop
A Reason-Act-Observe cycle where an LLM decides which tool to call (Reason), executes it (Act), and feeds the result back into the conversation (Observe) until no further tool calls are needed.
LangChain tool() wrapper
A function that wraps an async JavaScript function with a name, description, and Zod schema. When invoked via `tool.invoke(args)`, it passes an object matching the schema as the first argument — requiring destructuring in the function signature.
ToolMessage
A LangChain message type that carries the result of a tool execution back into the LLM's conversation context, keyed by `tool_call_id` so the model can associate the result with the specific call that produced it.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗