Building a Minimal Terminal Agent from Scratch: Core Mechanisms and Engineering Boundaries

    This article explains how terminal AI agents work and where their engineering boundaries lie. It reviews the shift from chat-based coding assistance to in-editor help and then to terminal agents, and shows why minimal demo code is not enough for real projects. It then presents a minimal viable architecture built around a model adapter, tool registry, context manager, and agent loop. The article also covers command execution with timeouts, output limits, risk checks, and pseudo-terminals; file tools with path constraints and atomic writes; bounded ReAct loops with repeated-action detection and failure classification; and the coexistence of human confirmation and declarative automation. Finally, it outlines production concerns such as auditing, secret isolation, sandboxing, rollback, resource limits, structured protocols, observability, and human takeover. The central point is that an agent is not a black box but a constrained software system combining a model, tools, state management, and safety boundaries.

    1. What Problem Does a Terminal Agent Actually Solve?

    AI-assisted programming has gone through roughly three stages.

    The first stage was chat-based collaboration. Developers copied error logs, code snippets, and requirements into a chat window; the model analyzed the problem and offered suggestions; then the developer manually changed local files and reran the program. This stage lowered the cost of looking up information and locating problems, but the actual execution still depended on humans.

    The second stage was in-editor assistance. The model could generate code for the current file, complete functions, and explain errors, so developers did not have to switch windows as often. However, these capabilities usually still revolved around the current editing position. The model did not automatically inspect the whole project state, nor did it continuously observe command execution results.

    The third stage is the terminal agent. The user only needs to describe a goal, for example:

    1. Locate the cause of test failures in the project, fix the relevant code, rerun the tests, and summarize the changes.

    The agent can then follow a process such as:

    1. Inspect the project directory;
    2. Search for relevant functions, configuration, or test files;
    3. Read the necessary code snippets;
    4. Analyze error messages;
    5. Modify files;
    6. Run tests or build commands;
    7. Continue fixing based on the new output;
    8. Summarize the changes when finished.

    The key here is not that the model suddenly gains “autonomous consciousness.” Instead, the system connects the model’s text reasoning ability with a set of controlled tools. The model decides the next step, the program performs the actual execution, and the execution result is fed back to the model, forming a bounded loop.

    Therefore, the basic structure of an agent can be abstracted as:

    1. User goal
    2. ↓
    3. Model analysis
    4. ↓
    5. Generate tool call
    6. ↓
    7. Local tool execution
    8. ↓
    9. Return execution result
    10. ↓
    11. Model continues to decide

    This is also the first principle for understanding terminal agents: it is not a mysterious black box, but a combined system of “model + tools + state management + safety boundaries.”

    2. Why Minimal Example Code Is Hard to Use Directly in Real Projects

    Many introductory examples only need to do three things: call the model, parse the response, and execute a command. Such code helps explain the concept, but it is difficult to use directly in a real development environment.

    1. Child Processes May Block Indefinitely

    The simplest way to execute a command is usually to wait for the child process to finish and then read the output all at once:

    1. import subprocess
    2. result = subprocess.run(
    3. command,
    4. shell=True,
    5. capture_output=True,
    6. text=True
    7. )

    This approach works for short commands, but the following situations may cause the program to hang for a long time:

    • The command waits for user input;
    • The child process starts an interactive program;
    • The child process creates a new background process;
    • The standard output buffer is filled;
    • The build task itself takes a long time;
    • The child process enters an infinite loop or waits for a lock.

    Therefore, a production-grade implementation needs at least timeout handling, real-time output, process termination, and exit-status checking.

    2. Unbounded File Reads Can Overwhelm the Context

    Logs, dependency directories, generated files, and large data files in a project can all be very large. If the agent sends complete files to the model every time, it not only increases call cost but quickly exhausts the context window.

    A more reasonable approach is to:

    • Read only a limited number of lines by default;
    • Search first, then read content near the match;
    • Compress repeated content;
    • Slice logs by time or error level;
    • Provide summaries for large files instead of full text;
    • Set a maximum output length for each tool call.

    The context window can be compared to a desk of limited size. Only the most important books and tools for the current task can fit on the desk. If you dump out the entire backpack, the content you actually need may be buried.

    3. The Model May Generate Dangerous Commands

    Once a terminal agent has execution capability, the risk increases significantly. Operations such as deleting files, overwriting configuration, changing permissions, and uploading sensitive information cannot simply be approved based on the model’s judgment.

    Therefore, the system should at least distinguish three types of operations:

    • Read-only operations: listing directories, searching text, reading files;
    • Reversible modifications: editing code, generating temporary files;
    • High-risk operations: deleting files, changing permissions, accessing external services, executing unknown scripts.

    High-risk operations should require human confirmation or run in an isolated directory. Permission control cannot rely on the model’s self-discipline; it must be enforced by the host program.

    3. Minimal Viable Architecture: Four Core Modules

    A terminal agent that does not depend on a complex framework can be divided into four modules.

    1. Model Adapter Layer

    The model adapter layer is responsible for only two things:

    • Sending the conversation history to the model;
    • Obtaining the model’s returned text or structured tool calls.

    To avoid coupling business logic with a specific model interface, define a unified method:

    1. class ModelClient:
    2. def ask(self, messages):
    3. """
    4. Accept a message list and return the model response.
    5. In a real project, HTTP requests, authentication, and retries can be wrapped here.
    6. """
    7. raise NotImplementedError

    In this way, the tool system, context management, and main loop do not need to care about the underlying call details.

    2. Tool Registration Layer

    Tools should not be scattered directly throughout the main program. Instead, they should be registered using a unified description:

    1. TOOLS = {
    2. "list_files": {
    3. "description": "List files in the specified directory",
    4. "handler": list_files,
    5. },
    6. "read_file": {
    7. "description": "Read a limited amount of file content",
    8. "handler": read_file,
    9. },
    10. "run_command": {
    11. "description": "Execute a controlled command and return output",
    12. "handler": run_command,
    13. },
    14. }

    The model can only call registered tools and cannot execute arbitrary Python code out of thin air. Each tool should also clearly define its input format, output format, and risk level.

    3. Context Management Layer

    The context manager controls the number of messages and the size of their content. A practical strategy is to set a character limit for each message:

    1. def truncate(text, limit=12000):
    2. if len(text) <= limit:
    3. return text
    4. head = limit * 2 // 3
    5. tail = limit - head
    6. return text[:head] + "\n...[content truncated]...\n" + text[-tail:]

    However, simple truncation is not always enough. A better approach is to preserve:

    • The user’s original task;
    • The most recent tool calls;
    • The latest failure information;
    • The list of files currently modified;
    • Confirmed constraints.

    Older detailed output can be compressed into summaries so that the full history is not carried in every round.

    4. Agent Main Loop

    The main loop drives the task forward:

    1. def agent_loop(goal, model, tools, max_rounds=12):
    2. messages = [
    3. {"role": "system", "content": "You are a controlled terminal development assistant."},
    4. {"role": "user", "content": goal},
    5. ]
    6. for _ in range(max_rounds):
    7. response = model.ask(messages)
    8. if response.get("type") == "final":
    9. return response.get("content", "")
    10. if response.get("type") != "tool_call":
    11. messages.append({
    12. "role": "assistant",
    13. "content": response.get("content", "")
    14. })
    15. continue
    16. name = response["name"]
    17. arguments = response.get("arguments", {})
    18. if name not in tools:
    19. result = "Error: unregistered tool"
    20. else:
    21. try:
    22. result = tools[name]["handler"](**arguments)
    23. except Exception as exc:
    24. result = f"Tool execution exception: {exc}"
    25. messages.append({"role": "assistant", "content": str(response)})
    26. messages.append({
    27. "role": "tool",
    28. "content": truncate(str(result))
    29. })
    30. return "Task reached the maximum number of rounds and has stopped."

    The max_rounds value is very important. An agent without a limit may repeatedly try the same failing action, wasting time and resources.

    4. Terminal Executor: Managing Commands Matters More Than Launching Them

    1. Basic Safety Checks

    Before executing a command, the working directory and command risk should be checked first:

    1. import os
    2. import subprocess
    3. DANGEROUS_WORDS = [
    4. "format",
    5. "shutdown",
    6. "reboot",
    7. "chmod 777",
    8. ]
    9. def run_command(command, cwd=None, timeout=30):
    10. cwd = cwd or os.getcwd()
    11. lowered = command.lower()
    12. for word in DANGEROUS_WORDS:
    13. if word in lowered:
    14. return f"Command blocked: contains a high-risk operation: {word}"
    15. try:
    16. process = subprocess.Popen(
    17. command,
    18. cwd=cwd,
    19. shell=True,
    20. stdout=subprocess.PIPE,
    21. stderr=subprocess.STDOUT,
    22. text=True,
    23. bufsize=1
    24. )
    25. lines = []
    26. try:
    27. for line in process.stdout:
    28. lines.append(line)
    29. if sum(len(x) for x in lines) > 20000:
    30. lines.append("\n...[output limit exceeded]...")
    31. process.kill()
    32. break
    33. except Exception:
    34. process.kill()
    35. raise
    36. process.wait(timeout=timeout)
    37. output = "".join(lines)
    38. return f"Exit code: {process.returncode}\n{output}"
    39. except subprocess.TimeoutExpired:
    40. process.kill()
    41. return "Command timed out; process terminated"
    42. except Exception as exc:
    43. return f"Command execution failed: {exc}"

    This code is still only a minimal implementation. A real scenario also needs to handle process groups, child-process cleanup, encoding exceptions, and operating system differences. But it already demonstrates several key principles: output limits, execution timeouts, risk blocking, and explicit return of the exit status.

    2. Why a Pseudo-Terminal Is Needed

    Some programs flush output only when connected to a real terminal, or change their behavior based on the terminal environment. Ordinary pipes are suitable for batch reading of output, but they cannot fully simulate an interactive terminal.

    A pseudo-terminal can be understood as a “fake terminal device”: the agent sends input to the program through it while reading the program’s output in real time. For tasks that require interaction, a pseudo-terminal can improve:

    • Real-time log display;
    • Handling of interactive prompts;
    • Compatibility with colored output;
    • Program recognition of the terminal environment;
    • Status observation for long-running tasks.

    However, pseudo-terminals are not implemented in exactly the same way across operating systems. Therefore, a cross-platform solution should abstract the terminal layer into a unified interface and adapt it separately on different systems. It should not assume that one underlying mechanism works in every environment.

    5. File Tool Design: Prefer Precise Reads over Blind Reads

    The most basic file tools usually include directory listing, text search, partial reading, and controlled writing.

    1. from pathlib import Path
    2. def safe_path(root, user_path):
    3. root = Path(root).resolve()
    4. target = (root / user_path).resolve()
    5. if root != target and root not in target.parents:
    6. raise ValueError("Path is outside the working directory")
    7. return target
    8. def read_file(root, filename, start=1, end=200):
    9. path = safe_path(root, filename)
    10. with path.open("r", encoding="utf-8", errors="replace") as f:
    11. lines = f.readlines()
    12. start = max(1, start)
    13. end = min(len(lines), end)
    14. selected = lines[start - 1:end]
    15. return "".join(
    16. f"{index}: {line}"
    17. for index, line in enumerate(selected, start=start)
    18. )

    The most important part here is the path constraint. A user may ask the agent to read a file, but the path generated by the model is not necessarily reliable. Without restricting the working directory, the agent could access sensitive system files or content outside the project.

    When writing files, direct overwriting of the original file should also be avoided. A temporary file plus atomic replacement can be used:

    1. Write the new content to a temporary file;
    2. Verify that the file was written successfully;
    3. Keep a backup of the old file;
    4. Use atomic replacement to update the target file.

    In this way, even if the program fails midway, it is less likely to leave a half-written file.

    6. The ReAct Loop Is a Bounded Closed Loop, Not Endless Trial and Error

    A common way for terminal agents to work can be summarized as:

    1. Observe: obtain directories, code, or error logs
    2. Think: determine the cause and next action
    3. Act: call read, edit, or execution tools
    4. Feedback: read the execution result

    But an engineering implementation cannot simply let the model repeat this process forever. The following constraints are recommended.

    1. Maximum Number of Rounds

    For example, execute at most ten to fifteen rounds. After exceeding the limit, output the current state and the reason the task is incomplete.

    2. Repeated Action Detection

    If exactly the same command is executed for several consecutive rounds and the result does not change, stop immediately and alert the user.

    3. Modification Scope Limit

    Record the files modified during the current task. If the model suddenly tries to modify a large number of unrelated files, pause and request confirmation.

    4. Test First

    After modifying code, run the smallest relevant test set first, then gradually expand the scope. This reduces meaningless long builds.

    5. Failure Classification

    Classify failures into:

    • Command not found;
    • Insufficient permissions;
    • Syntax error;
    • Test assertion failure;
    • Missing environment dependency;
    • Timeout or insufficient resources.

    Different types of errors require different handling. They cannot all be reduced to “try again.”

    7. Human Interaction and Automated Configuration Should Coexist

    A practical terminal agent usually supports two working modes.

    Human Confirmation Mode

    Whenever file modification, command execution, or a high-risk operation is involved, show the following first:

    1. Upcoming action:
    2. Command: run project tests
    3. Directory: current working directory
    4. Risk: may create temporary files
    5. Continue? [y/N]

    This mode is suitable for production environments, unfamiliar projects, and important codebases.

    Declarative Automatic Mode

    In controlled directories, continuous integration, or batch tasks, allowed operations can be declared through a configuration file:

    1. {
    2. "workspace": "./demo",
    3. "allow_read": true,
    4. "allow_write": true,
    5. "allow_commands": ["test", "build"],
    6. "max_rounds": 10,
    7. "command_timeout": 60
    8. }

    The value of declarative configuration is that permissions become auditable and reproducible, rather than depending on one-off interactive choices.

    8. What Else Is Needed to Move from a Local Script to a Production System?

    To extend a tiny agent into an engineering system usable by a team, the following should also be added:

    1. Logging and auditing: record user goals, tool calls, command output, and file changes;
    2. Secret isolation: prevent the model from directly reading sensitive credentials in environment variables;
    3. Sandbox execution: run commands in an isolated directory, container, or restricted user account;
    4. Change rollback: create a patch for each modification and support one-click undo;
    5. Resource limits: limit CPU, memory, disk, network, and execution time;
    6. Structured protocols: use explicit data structures for tool calls and avoid relying on fuzzy text parsing;
    7. Observability: track per-round latency, failure reasons, tool usage counts, and context size;
    8. Human takeover: when the model cannot decide, it should actively pause rather than continue guessing.

    Conclusion

    The core of a terminal agent is not stacking complex frameworks, nor is it giving the model unlimited permissions. What really determines system quality is tool boundaries, execution safety, context control, error recovery, and task closure.

    The most valuable takeaway from implementing a minimal Agent CLI from scratch is not a piece of example code to show off. It is understanding how the whole system works: the model proposes actions, tools execute them, the environment returns facts, the state manager filters information, the safety layer limits risk, and the main loop advances the task within a finite number of rounds.

    Once these mechanisms are separated, the so-called “agent” is no longer mysterious. It is essentially an observable, constrained, and recoverable software engineering system. Whether you connect local models, enterprise model services, or general model capabilities such as Baidu AI Cloud, the underlying engineering principles remain the same: let the model do the reasoning it is good at, let the program handle reliable execution, and keep final control in human hands.