Frameworks Hide the Problems
Anyone who has used LangChain or LlamaIndex knows the feeling: the docs say you can spin up an agent in three lines of code, and it works — until something goes wrong, and you have no idea where to look. Tool call failed? Context truncated? Model didn't stop when expected? The framework swallowed all of that, and you're left guessing.
MyCodeAgent is a local coding agent with no framework magic — roughly 14,000 lines of Python, with all core logic exposed in the source. This series uses it as a dissection subject to see, layer by layer, how an agent actually runs.
This first post builds the overall map: the checkpoints a single user input passes through, from keyboard to response.
Three Phases, One Map
python main.py
│
▼
┌──────────────────────────────┐
│ Phase 1: CLI Entry Point │ app/cli.py
│ Parse args, decide run mode │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Phase 2: Dependency Assembly │ app/bootstrap.py
│ Config → LLM → Tools → Agent│
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Phase 3: ReAct Main Loop │ runtime/loop.py
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ User input → Build Model View → Call LLM │ │
│ │ ↓ │ │
│ │ tool_calls? → Execute tools → Append obs │ │
│ │ No tool_calls? → Completion gate check │ │
│ │ ↓ │ │
│ │ Pass → Output Fail → Inject feedback → continue │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Each phase has one core responsibility: CLI decides how to run, bootstrap decides what to run with, and the loop decides what gets produced.
Phase 1: CLI Entry Point
main.py is a single line:
# main.py
from app.cli import main
if __name__ == "__main__":
main()
The real logic lives in the main() function in app/cli.py. It does two things: determine the run mode, then hand control to bootstrap.
# app/cli.py — core branching in main()
args = parser.parse_args()
# -p flag present → one-shot mode (exit after running, suitable for scripting)
# no -p flag → interactive mode (while True loop, waiting for user input)
one_shot = getattr(args, "print_prompt", None) is not None
runtime = build_runtime(args, agent_class=...)
The two modes differ only in their outer shell: one-shot raises SystemExit when done, interactive mode enters a while True loop reading user input. The core agent logic is identical in both modes.
Interactive mode reads input with prompt_toolkit:
# cli.py
session = PromptSession(history=FileHistory(".chat_history"))
user_input = session.prompt(
HTML("<user>user</user> <arrow>➜</arrow> "),
style=prompt_style,
).strip()
session.prompt() is a blocking call that switches the terminal to raw mode under the hood (reading character by character, not waiting for Enter), renders a colored prompt, and handles cursor movement, backspace, and up/down arrow history navigation. It returns the full string on Enter and restores the terminal to normal mode. FileHistory persists each input to a .chat_history file, so pressing the up arrow after a restart still retrieves previous commands.
The project uses both prompt_toolkit and rich, with a clear division of labor: prompt_toolkit handles input, rich handles output (Panel, Markdown rendering, spinners). This is a common combination for Python CLI tools — Claude Code's own interactive interface uses the same stack.
There is one notable detail in interactive mode: agent_kwargs_factory:
# EnhancedUI is only needed in interactive mode
# But EnhancedUI depends on llm.model/llm.provider, and the llm object
# isn't created until bootstrap runs.
# Solution: wrap "create UI" in a lambda, called by bootstrap once llm is ready.
runtime_kwargs["agent_kwargs_factory"] = lambda config, llm, project_root: {
"ui": EnhancedUI(model=llm.model, provider=llm.provider, ...)
}
This is standard dependency injection: the caller doesn't need to know the details of what's being injected — it just provides a factory function, and bootstrap calls it at the right moment.
Phase 2: Dependency Assembly
build_runtime() in app/bootstrap.py assembles all dependencies in a fixed order. The order matters because each step depends on the results of the previous one:
# app/bootstrap.py — core steps in build_runtime()
resolved_project_root = resolve_project_root(selected_project_root) # --cwd or current dir
config = Config.from_env() # load from .env
llm = HelloAgentsLLM(model=args.model, api_key=args.api_key, ...) # CLI args take priority
tool_registry = ToolRegistry() # empty registry, filled by CodeAgent
agent = CodeAgent(llm=llm, tool_registry=tool_registry, config=config, ...)
"CLI args take priority" is worth unpacking — different parameters have different fallback behavior.
When argparse doesn't receive a given argument, args.xxx is None (because default=None). Once None is passed into HelloAgentsLLM, each parameter type is handled differently:
model, timeout: fall back with or directly to environment variables
# core/llm.py
self.model = model or self._get_env("LLM_MODEL_ID")
self.timeout = timeout or int(self._get_env("LLM_TIMEOUT", "120"))
In Python, None or xxx evaluates the right side, so passing None means "look it up in the environment."
api_key, provider, base_url: dedicated resolve methods
self.provider = self._resolve_provider(provider, api_key, base_url)
self.api_key, resolved_base_url = self._resolve_credentials(api_key, base_url)
These three involve multi-provider auto-detection and profile table lookups, so the logic is more complex and extracted into dedicated methods. They ultimately follow the same pattern: None → check environment variables → check PROVIDER_PROFILES defaults.
temperature: resolved upfront in the bootstrap layer
# bootstrap.py
temperature=(
getattr(args, "temperature", None)
if getattr(args, "temperature", None) is not None
else config.temperature # config already loaded from .env
),
temperature is typed as float, not Optional[float]. Passing None would cause float(None) to raise an exception downstream. It also doesn't read from environment variables — its only sources are the CLI argument and config — so the value must be resolved in the bootstrap layer before being passed in.
Summary of the three patterns:
model / timeout → None passed in, LLM resolves internally via or + env var
api_key / provider → None passed in, LLM resolves internally via dedicated methods
temperature → bootstrap resolves upfront with a ternary, never passes None
Once CodeAgent receives these dependencies, it continues assembling internal subcomponents in _initialize_runtime_components():
# runtime/host.py — CodeAgent._initialize_runtime_components()
# ① History management + context engine (controls how much history the model sees)
build_runtime_context(self)
# ② Persistence (trace logging + transcript crash recovery)
build_runtime_persistence(self, ...)
# ③ Tool executor (permission checks + actual invocation)
self.tool_executor = ToolExecutor(self.tool_registry, ...)
# ④ Tool orchestrator (read-only tools run concurrently, writes run serially)
self.tool_orchestrator = ToolOrchestrator(self)
# ⑤ ReAct main loop driver ← where the actual work happens
self.runner = RuntimeRunner(self)
Note the last line: RuntimeRunner(self) passes the entire CodeAgent as an argument. The runner needs access to all attributes on the agent (llm, tool_registry, history_manager...), so CodeAgent acts here as a dependency container, not as the executor of business logic.
Phase 3: The ReAct Main Loop
A user input travels through four layers from keyboard to RuntimeRunner:
session.prompt() # cli.py — blocking read of user input
→ run_interactive_turn() # cli.py — catches Ctrl+C to prevent it leaking upward
→ RichConsoleCodeAgent.run() # cli.py — controls spinner UI on/off
→ CodeAgent.run() # host.py — single line, forwards to runner
→ RuntimeRunner.run() # loop.py — ReAct loop actually begins here
RichConsoleCodeAgent is a subclass of CodeAgent used only in interactive mode. It overrides run() and _execute_tool() to inject spinner and tool call tree rendering logic. One-shot mode (-p flag) uses a bare CodeAgent with none of this overhead. This design keeps CodeAgent clean and free of any UI coupling.
CodeAgent.run() itself is a single line:
# runtime/host.py
def run(self, input_text, **kwargs):
return self.runner.run(input_text, **kwargs)
All real logic lives in RuntimeRunner. CodeAgent is purely a dependency container.
Before entering the main loop, RuntimeRunner.run() calls _prepare_run() — which handles input preprocessing (@file reference expansion), refreshes the Skills prompt, initializes trace logging, and writes the user message to history. The user input becomes pending_input for the main loop only after it has been written to history. These details are covered in post 02.
Then it enters _react_loop():
# runtime/loop.py — simplified _react_loop()
for step in range(1, host.max_steps + 1):
# 1. Build the Model View for this step (a bounded projection of full history)
state, tools_schema, messages = self._prepare_step_context(...)
# 2. Call the LLM, get back the raw response
raw_response = host.llm.invoke_raw(messages, tools=tools_schema)
# 3. Extract tool_calls and text content from the response
tool_calls = extract_tool_calls(raw_response)
response_text = extract_response_content(raw_response)
# 4. Tool calls present → execute tools → append results to history → continue
if tool_calls:
observations = host.tool_orchestrator.run(tool_calls, step=step, ...)
# append assistant message + tool result messages to history
continue
# 5. No tool calls → hand off to the completion gate
verdict = host.completion_verifier.evaluate(response_text, ...)
if verdict.verdict == CompletionGateVerdict.PASS:
return response_text # ← normal exit
# 6. Completion gate blocks → inject feedback into history → let model try again
self._append_user_message(verdict.blocking_feedback, ...)
continue
ReAct stands for "Reasoning + Acting," corresponding to the two branches in the loop: tool_calls present is Acting (executing actions), no tool_calls is Reasoning (generating thought/response).
A few design decisions that are easy to miss:
Model View is not the same as history: What gets sent to the LLM each step is not the full contents of
history_manager, but a bounded subset projected bybuild_model_view(). History is always complete; what the model sees is a token-budget-controlled view of it.The completion gate has a feedback loop: The loop doesn't exit just because there are no
tool_calls— it passes through the completion gate first. When the gate blocks, it injects something like "there are still incomplete items in the todo list" as a user message, prompting the model to reconsider. This retries up to 2 times.The state machine is immutable:
LoopStateis afrozen=Truedataclass, and every state transition calls.next()to return a new object. Every state at any point in time is an independent snapshot, which makes tracing and crash recovery straightforward.
Three Layers of Responsibility
cli.py Decides how to run (interactive vs. one-shot, UI layer)
bootstrap.py Decides what to run with (dependency assembly, factory layer)
loop.py Decides what gets produced (ReAct logic, execution layer)
The boundaries between these layers are clean: cli doesn't know how the LLM is called, bootstrap doesn't know how the loop runs, loop doesn't know how the UI renders. Each layer does exactly its own job.
Upcoming posts will go deeper layer by layer: how the LLM interface layer unifies multiple providers, how the tool protocol is designed, how the completion gate and state machine inside the ReAct loop actually work, and how context compression is triggered. The map built in this post is the coordinate system for locating all of that.
Summary
| Phase | Core File | What It Does |
|---|---|---|
| CLI Entry Point | app/cli.py |
Parses args, decides interactive/one-shot mode, delegates dependency assembly to bootstrap |
| Dependency Assembly | app/bootstrap.py |
Creates Config → LLM → ToolRegistry → CodeAgent in order |
| ReAct Main Loop | runtime/loop.py |
Builds Model View → calls LLM → executes tools → completion gate → output |
About the Source Code for This Series
All analysis in this series is based on the open-source project MyCodeAgent.
The source code already includes inline comments at key points, organized to follow the order of explanation in this series — you can read along with the code, or clone the repo and run it, modify it, and extend it yourself to build your own agent on top of it.
git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env # fill in your LLM API key
uv sync
uv run python main.py
Visit PrimeSkills — a curated marketplace for AI agents and skills, all validated against real enterprise workflows. No hype, just things that actually work.
For more practical knowledge and interesting products, visit my personal homepage.
Top comments (0)