Code Agent Anatomy (04): How System Prompts Are Assembled, and Where the Agent's 'Personality' Comes From

A deep dive into MyCodeAgent's prompt assembly layer: the four-layer system message architecture — Constitution, Tool Contracts, Project Rules, and Runtime Signals — and how they combine with history projection to form the Model View sent to the LLM. Understanding that agent behavior constraints aren't 'one giant prompt', but a cacheable, fingerprintable, hot-swappable layered assembly.

·8 min read·AI Engineering

"Personality" Is Not a Single String

When most people write their first agent, they tend to cram identity, tool descriptions, and project conventions into one massive system_prompt. It works, but it's hard to maintain: changing a tool's usage requires scanning the entire text; adding project-specific rules pollutes the global persona; and whenever a Skill or MCP changes, the entire cached prompt is invalidated.

MyCodeAgent splits the "system portion sent to the model" into independently evolvable layers, assembled by ContextBuilder in runtime/prompt_builder.py. The previous post covered how model responses are normalized; this one covers the other side of the request — where the model's behavioral boundaries come from before it says a word.


The Bottom Line Upfront

Messages sent to LLM =
  [system] Constitution          ← L1: identity, tone, safety, working style
  [system] Tool Contracts        ← Natural-language usage docs for each tool
  [system] Project Rules         ← code_law.md from the repo (if present)
  [system] Runtime Signals       ← Runtime notification blocks (mutable)
  [system] Session Memory        ← Cross-turn summary prefeed (optional, injected by ContextEngine)
  + history-projected user/assistant/tool/... messages

These four layers (plus optional session memory) together form the agent's "personality + capability spec + project memory."

It's worth clarifying the relationship with Function Calling upfront:

ChannelContentWhere it goes
Tool Contracts (this post)Natural language: how to use, when to use, what responses look likeprompts/tools_prompts/*.py → system message
tools schema (next post)JSON Schema: parameter names, types, required fieldsToolRegistry.get_openai_tools() → API's tools=

The model reads both the "instruction manual" and the "machine-verifiable parameter spec." The two are complementary, not redundant copies.


The Assembly Entry Point: Working Backward from Model View

Each ReAct step builds context by calling ContextEngine.build_model_view(), which can be simplified as:

# runtime/context/engine.py — assembly order (simplified)
system_messages = self.context_builder.get_system_messages()
# optional: insert one more system message for session memory
messages = system_messages + dynamic_messages + history_messages

history_messages are the projected conversational facts; the entire personality layer comes from context_builder. So to understand prompt assembly, the entry point is ContextBuilder.get_prompt_assembly().


The Four System Message Layers

# runtime/prompt_builder.py — get_prompt_assembly() core layering
 
# ① Constitution: identity and behavioral charter (L1_system_prompt.py)
constitution_messages = [{"role": "system", "content": constitution_text}]
 
# ② Tool Contracts: scans prompts/tools_prompts/*.py, assembled into one tool manual
tool_contract_messages = [{"role": "system", "content": "# Tool Contracts\n" + ...}]
 
# ③ Project Rules: reads code_law.md / CODE_LAW.md from the project root
project_rule_messages = [{"role": "system", "content": "# Project Rules (CODE_LAW)\n" + ...}]
 
# ④ Runtime Signals: temporary notifications injected via set_runtime_system_blocks()
runtime_signal_messages = [{"role": "system", "content": block}, ...]
 
stable = constitution + tool_contracts + project_rules
all_system = stable + runtime_signals

① Constitution: The Default Persona

Source: system_prompt in prompts/agents_prompts/L1_system_prompt.py.

This layer defines things that are stable across projects: you are a CLI coding agent, you use Function Calling rather than plaintext Action strings, how to use Todo / Skills / Task, keep responses concise, refuse malicious code requests, understand the repo's conventions before modifying code...

One evolutionary artifact worth noting: the L1 text ends with a {tools} placeholder, which is intentionally cleared during assembly:

constitution_text = self._load_system_prompt().replace("{tools}", "").strip()

Tool details have been migrated to the independent Tool Contracts layer, decoupling the persona file from the tool list. The CLI's --system flag can still replace the entire Constitution via system_prompt_override, which is useful for experimentation.

② Tool Contracts: The Tool "Instruction Manual"

_load_tool_prompts() scans prompts/tools_prompts/, loads each *_prompt string, sorts them by filename, and concatenates them. For example, the Read tool's prompt tells the model: use the line-number format, how to paginate, what status=partial means, don't use cat.

Two dynamic slots:

  • Skills: The Skill tool prompt contains {{available_skills}}, replaced at runtime with a summary of the current skills directory (may be refreshed each _prepare_run call)
  • MCP / Circuit Breaker: MCP tool descriptions are appended as ## MCP Tools; circuit-broken tools are appended as ## Disabled Tools to reduce invalid calls

tool_prompt_allowlist only loads prompts for tools that are registered, preventing "tools not in the registry from still appearing in the manual."

③ Project Rules: This Repo's Specific Memory

If the project root contains code_law.md or CODE_LAW.md, the entire file is injected as Project Rules. Typical content: directory structure, common test/build commands, repo-specific invariants.

Constitution teaches "how to be a coding agent"; CODE_LAW teaches "how to operate specifically within this repo." Switch projects, swap this one layer — the persona stays untouched.

Loading is cached by mtime + content hash: if you modify code_law.md, the next assembly cycle automatically invalidates and re-reads it.

④ Runtime Signals: Notifications That Don't Pollute User Turns

Injected via set_runtime_system_blocks(). The design intent: runtime reminders go through system messages rather than being disguised as something the user just said — keeping the history turn semantics clean.

Session Memory is not part of PromptAssembly's four layers; instead, it's inserted as an additional system message in build_model_view subject to a character budget. It's a "cross-run summary prefeed," stored separately from the stable persona layers.


Fingerprints and Caching: Know at a Glance Which Layer Changed

Each layer computes a SHA256 fingerprint, and the three stable layers are combined into a system_fingerprint:

constitution_fp
tool_contracts_fp
project_rules_fp

 system_fingerprint   ← whether stable layers have changed
runtime_signals_fp    ← whether mutable layers have changed

Two uses:

  1. Caching: If the fingerprint hasn't changed, reuse _cached_assembly — no need to re-scan disk or re-concatenate large strings on every step
  2. Observability: trace_model_request_state() writes each layer's fingerprint into the trace, including changed_layers — when debugging "why did the model suddenly change its style this turn," start by checking whether the persona, tool docs, or project rules changed

Whenever Skills, MCP, or runtime blocks are updated, the corresponding setter sets _cached_assembly = None, forcing a full rebuild on the next step.


What It Looks Like in the End

The head of a typical request's messages looks something like:

[0] system  Constitution (identity and policy)
[1] system  Tool Contracts (Read/Edit/Bash/... manuals)
[2] system  Project Rules (code_law.md)
[3] system  (optional) Session Memory / Runtime Signals
[4] user    User's question
[5] assistant + tool_calls
[6] tool    Observation result
...

This is natural Message List accumulation, not the old-style "concatenate Thought/Action into a scratchpad string." Conversational facts live in history; behavioral constraints live in the system layers; the two only merge in build_model_view.


Design Highlights

  1. Stable vs. mutable separation: Constitution / Contracts / CODE_LAW are cacheable; Skills, runtime, and session memory are hot-swappable
  2. Persona decoupled from project: Switching repos mainly means swapping CODE_LAW, not touching L1
  3. Instruction manual ≠ schema: Natural language teaches strategy, JSON Schema manages parameters — the next post covers how the latter gets into tools=
  4. Fingerprintable audit trail: Prompt drift becomes comparable hashes rather than "the model just feels different"

Summary

LayerSourceStability
ConstitutionL1_system_prompt.py or --systemRelatively stable
Tool Contractstools_prompts/*.py + Skills/MCP/circuit breakerChanges when tools or skills change
Project Rulescode_law.mdChanges with repo documentation
Runtime Signals / Session MemoryInjected at runtimeFrequently mutable

An agent's "personality" = Constitution's tone and policy + Tool Contracts' capability boundaries + Project Rules' local memory. The assembler's job is to reliably place these three (plus a small amount of runtime signals) at the top of the Model View on every step.

The next post enters the tool system: how the model actually "sees" callable tools through Function Calling, and how parameters flow through the execution pipeline.


About the Source Code for This Series

All analysis in this series is based on the open-source project MyCodeAgent.

The source code has been annotated at key locations in the order the series covers them — you can follow along while reading, or clone it directly to run, modify, and extend it to build your own agent.

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 carefully curated AI Agent and skills marketplace where all content is validated against real enterprise-grade workflows. No hype, just things that actually work.

For more practical insights and interesting products, visit my personal homepage.