
Previous article:
In Part 1's minimal prototype, we built a "math-capable" Agent. Here's how it worked:
Action: calculate or Final Answer: xxxLooks simple enough. But if you actually run that code a few dozen times, you'll discover a frustrating truth: LLMs frequently break the rules.
For example, you ask "Hello" and it might output:
Hello! How can I help you today?
Action: calculate
Action Input: {"expression": "1+1"}It doesn't even need a tool, yet it insists on showing off its calculation skills.
Or worse — you ask "What is 128731 × 46291?" and it outputs:
Let me help you calculate this multiplication.
First, I'll use the calculator tool:
Action: calculate
Action Input: {"expression": "128731 * 46291"}There's a "Let me help you calculate this multiplication" preface. If our parser isn't robust enough, it might mistake this preface for part of the Action, causing a parse failure.
That's what this article addresses: how to make LLM output consistently and precisely parseable by code. This involves three layers:
Before we dive in, let's understand where this pattern comes from theoretically.
In 2022, Google DeepMind published ReAct: Synergizing Reasoning and Acting in Language Models. Its key observation: humans don't solve complex problems by "thinking through all the steps first, then executing." Instead, we "think one step, act, observe the result, then think about the next step."
Imagine cooking a dish you've never made before:
What does a plain LLM call equate to? It's like asking it to "cook Kung Pao Chicken for me" — all it can do is give you a text description. It can't turn on the stove, chop vegetables, or taste the food. Its "reasoning" and "acting" are disconnected.
The ReAct pattern solves exactly this — weaving Reasoning and Acting together:
Reasoning: I need to know the current time to answer "how much longer until work ends"
Acting: call the getCurrentTime tool
Observation: current time is 4:30 PM
Reasoning: it's 1 hour 30 minutes until 6:00 PM
Acting: give the final answerNote that "reasoning" here isn't some advanced AI capability — it's just an ordinary thinking step: "What do I know right now? What else do I need to know? What should I do next?"
For the LLM and the program to collaborate effectively, both sides need a communication protocol — a format convention that both can understand and follow.
This protocol needs to satisfy three requirements:
The design mini-agent uses involves three markers:
Action: <tool name>
Action Input: <JSON parameters>And:
Final Answer: <final response to the user>Key design points:
This format looks simple, but in practice you'll encounter countless edge cases. We'll discuss them one by one.
With the format convention in place, the next step is to write a System Prompt that the LLM understands and follows. This is the most nuanced part of the entire Agent system — the quality of your prompt directly determines the Agent's reliability.
mini-agent's System Prompt is structured into the following sections (see src/prompt.ts:24-63):
You are an intelligent assistant based on the ReAct (Reasoning + Acting) pattern.
You solve problems step by step through a cycle of "call tool → observe result"
until you reach a final answer.
Your reasoning should go in your chain-of-thought (reasoning); your output content
should contain only strict action formats.Three sentences, three things:
## Available Tools
### calculate
Evaluates a mathematical expression and returns the precise result...
Parameter definition:
```json
{
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The mathematical expression to evaluate, e.g. '12 * 17'"
}
},
"required": ["expression"]
}Gets the current date and time... ...
Each tool has three elements:
- **Name** (`name`): the identifier the LLM uses in Action
- **Description** (`description`): explains the tool's purpose and when to use it — this is the key basis for the LLM to decide "should I use this tool?"
- **Parameter Schema** (`parameters`): JSON Schema format parameter definitions, helping the LLM construct correct Action Input
Formatting parameters as JSON Schema is a critical design decision — LLMs are extremely familiar with JSON format, and the probability of generating valid JSON is far higher than generating a custom format.
### Part 3: Output Format Specification
```txt
## Output Format (must be strictly followed)
In each round, you may output only one of the following two formats.
Do not output any extra content:
Format A — when you need to call a tool:
Action: <tool name, must be one of the tools listed above>
Action Input: <tool input parameters, must be valid JSON, e.g. {"expression": "1+1"}>
Format B — when you have enough information to give a final answer:
Final Answer: <your final response to the user>
The keyword: "Do not output any extra content." This is the single most important sentence in the entire prompt. Without it, the LLM will almost certainly prefix its Action with "Sure, let me calculate that for you..." and similar pleasantries.
## Rules
1. Only call one tool at a time
2. Action must be one of the tool names listed above — do not invent tools
3. Action Input must be valid JSON with fields matching the tool's parameter definition
4. After seeing an Observation (tool result), continue to the next action
5. When enough information is available, you MUST end with Format B (Final Answer)
6. Always base your answer on the tool's Observation — do not fabricate results
7. IMPORTANT: Action, Action Input, and Final Answer must appear in your output content — never put them only in your chain-of-thought (reasoning). The system will be unable to parse an empty content body.Rule 7 deserves special attention. When using deepseek-reasoner, the model has a separate "thinking channel" (reasoning_content). Sometimes it puts Action in this channel while the content body is empty. This rule explicitly tells the LLM: the content body must not be empty; Action must be in the content body.
Even so, Rule 7 remains a common source of incidents in practice. Our parser must have a fallback for this — more on that later.
## Example
User: What is 12 times 17?
Action: calculate
Action Input: {"expression": "12 * 17"}
(You will then receive: Observation: 12 * 17 = 204)
Final Answer: 12 × 17 = 204Examples should be short and direct. The key is to show the complete interaction flow: user question → Action → Observation → Final Answer. This helps the LLM understand that this "Q&A" isn't completed in one shot — it needs to continue after Action/Observation.
A qualified System Prompt = Role Definition + Tool Catalog + Format Specification + Rule Constraints + Example.
All five parts are essential. Missing any one of them makes the LLM's behavior unpredictable.
With the format convention in place, the next step is to transform the LLM's text output into a data structure the program can use. This is the parser's job.
mini-agent's parser is in src/parser.ts, and the core function is parseLLMResponse:
export interface ParsedResponse {
action: string | undefined;
actionInput: string | undefined;
actionInputJson: Record<string, unknown> | undefined;
finalAnswer: string | undefined;
isFinal: boolean;
}
export function parseLLMResponse(text: string): ParsedResponse {
// 1. Try to match Final Answer first
const finalMatch = text.match(/Final Answer:\s*([\s\S]*?)$/);
if (finalMatch) {
return {
action: undefined,
actionInput: undefined,
actionInputJson: undefined,
finalAnswer: finalMatch[1].trim(),
isFinal: true,
};
}
// 2. Match Action (capture rest of the line)
const actionMatch = text.match(/Action:\s*([^\n\r]*)/);
const action = actionMatch?.[1]?.trim() || undefined;
// 3. Match Action Input (capture rest of the line)
const inputMatch = text.match(/Action Input:\s*([^\n\r]*)/);
const actionInput = inputMatch?.[1]?.trim() || undefined;
// 4. Try to parse JSON
let actionInputJson: Record<string, unknown> | undefined;
if (actionInput) {
try {
actionInputJson = JSON.parse(actionInput);
} catch {
// If JSON is invalid, leave undefined; caller handles fallback
}
}
return {
action,
actionInput,
actionInputJson,
finalAnswer: undefined,
isFinal: false,
};
}The parsing logic has four steps, each with a deliberate design rationale:
const finalMatch = text.match(/Final Answer:\s*([\s\S]*?)$/);Why prioritize Final Answer? Because it's a termination signal — once it appears, the current interaction round is over and Action can be ignored. If we matched Action first instead, this could happen:
Final Answer: You can use Action: calculate to compute 12 × 17.The LLM referenced the Action format inside its Final Answer (explaining usage to the user). If we matched Action first, the parser would incorrectly think "the LLM wants to call a tool," entering an infinite loop.
The regex [\s\S]*?$ uses non-greedy matching to end-of-string (greedy [\s\S]*$ works the same), ensuring we capture everything after Final Answer.
const actionMatch = text.match(/Action:\s*([^\n\r]*)/);
const inputMatch = text.match(/Action Input:\s*([^\n\r]*)/);Both regexes use [^\n\r]* — capture only the current line, no cross-line matching. This is because:
Action: and Action Input: each occupy their own line — we need precise location.* (which doesn't cross lines by default) with the dotAll flag could swallow content beyondAction: won't accidentally match Action Input: (because regex matches per line)let actionInputJson: Record<string, unknown> | undefined;
if (actionInput) {
try {
actionInputJson = JSON.parse(actionInput);
} catch {
// If JSON is invalid, leave undefined
}
}A subtle detail here: a JSON parse failure doesn't throw; it silently returns undefined.
This is because LLM-generated JSON often has minor issues: an extra comma, a missing quote, a Chinese colon... If the parser crashed on every invalid JSON, the entire Agent would be unusable. A better approach:
actionInput) for the caller to handle themselvesactionInputJson) but don't guarantee it existsThe above is the "clean" version of the parser. In practice, LLM output comes in all shapes and sizes. Here are the typical problems mini-agent encountered during development and their solutions:
deepseek-reasoner has a separate reasoning_content (chain-of-thought) channel. Sometimes the model puts Action or Final Answer in the reasoning while the content body is empty.
[reasoning_content]: The user is asking about 12 × 17. I need to use the calculator...
Action: calculate
Action Input: {"expression": "12 * 17"}
[content]: (empty)In this case, our parser extracts nothing from content.
Solution: Add a fallback parse at the Agent loop level (src/agent.ts:183-189):
// Fallback: if content is empty or unparseable, try reasoning
let parsed = parseLLMResponse(response);
if (!parsed.action && !parsed.isFinal && reasoning) {
const fromReasoning = parseLLMResponse(reasoning);
if (fromReasoning.action || fromReasoning.isFinal) {
parsed = fromReasoning;
}
}This fallback logic executes in the Agent loop, not inside the parser, keeping the parser's purity intact.
Even though the System Prompt explicitly says "Do not output any extra content," the LLM may still add pleasantries:
Sure, let me calculate that for you.
Action: calculate
Action Input: {"expression": "128731 * 46291"}Why this isn't a problem: because we use Action:\s*([^\n\r]*) which only matches the line containing Action:. The "Sure, let me calculate that for you" preceding it is simply ignored. This is the design advantage of matching per line rather than against the full text.
The JSON output by the LLM may contain syntax errors:
{"expression": "128731 * 46291",}That trailing comma will cause a standard JSON parser to fail.
Solution: catch the exception and preserve the raw string. The caller can attempt repairs (using a more lenient JSON parser), or return the error as an Observation to the LLM so it can retry.
Action: readFile
Action Input: {"path": "README.md"}
Action: writeFile
Action Input: {"path": "SUMMARY.txt", "content": "..."}The LLM wants to do two things at once.
Solution: our parser only matches the first Action: (default regex behavior) and ignores subsequent Actions. The System Prompt explicitly states "only call one tool at a time." If this becomes frequent, post-parse checks can detect extra Actions and return an Observation prompting the LLM.
Replace the manual regex matching from Part 1's minimal prototype with the parseLLMResponse function. Observe whether the code becomes cleaner and more maintainable.
Deliberately modify the System Prompt — remove the phrase "Do not output any extra content" — then run the Agent. Observe what "filler text" the LLM adds, and whether your parser handles it correctly.
Then try removing the "only call one tool at a time" rule. See if the LLM outputs multiple Actions in a single response.
The current parseLLMResponse returns undefined on JSON parse failure. Try adding recovery logic:
This will give you a deep appreciation for the fundamental difference between "dealing with LLMs" and "dealing with deterministic systems."
Keywords: ReAct, System Prompt, Communication Protocol, Action/Action Input/Final Answer, Response Parsing, Defensive Design, DeepSeek Reasoner, reasoning_content
In the next article, we'll dive into the design and implementation of the Tool system: how to define a generic Tool interface, and how to safely implement calculators, clocks, file I/O, shell execution, and more. You'll see that tool design is far more complex than "just writing a function" — it involves security hardening, error handling, parameter validation, output formatting, and a whole range of engineering considerations.
Next article: