Skip to content
Matias Lapolla
A-02 Writing

SOLID for AI Agents: The Charter Is the Interface, and KISS Decides When to Split

21 min read AI Agents Software Design LangGraph Multi-Agent Python Harness Engineering Local LLM

SOLID for AI Agents: The Charter Is the Interface, and KISS Decides When to Split
Image might have been edited with AI.

TL;DR

  • For an agent, a responsibility is a tool group, and the unit of design is the charter: the one line a router reads to decide who gets the work. Split by tools and the router has a question it can answer. Split by verbs and it guesses.
  • A charter is a contract, and a small model fills every gap you leave in it. My file specialist had no calculator, added six numbers in its head and reported a wrong total with full confidence. One sentence of refusal in its charter fixed it. The router then did the same sum itself, and needed its own sentence.
  • Open/closed and dependency inversion paid in full. The supervisor arrived as a sibling module with the original graph byte-identical, and a backend swap to a model released five days earlier was one branch in a factory function.
  • Liskov is where agent systems break. Two chat models behind the same interface did not accept the same message list, and a 3B specialist that got 9 of 10 tasks right looped on the tenth until the recursion limit stopped it. Termination was a postcondition I had never written down, so I put it in code.
  • KISS won most of the argument. On six tasks the split matched the single agent’s accuracy, 16 of 18 each, and needed more model calls on every one of them. Split when a measurement says the split pays, and keep one agent with fewer tools until then.
  • Built in the same LangGraph lab I started measuring with evals. The charter argument is the tool description problem one level up, with agents as the callers.

Why one agent with every tool isn’t enough

The default agent is one model with every tool bound to it. Mine had eight: a calculator, web search, four file operations on a sandboxed workspace, two memory operations. One system prompt describes all of them and one loop runs them.

Right starting point, and the people who build these for a living say so too. Anthropic’s Building effective agents tells you to find “the simplest solution possible, and only increasing complexity when needed”. OpenAI’s practical guide to building agents says to “maximize a single agent’s capabilities first”. Across every task I measured, the single agent answered correctly as often as anything I replaced it with.

It is also a god object, and it fails the way god objects fail.

1. One prompt is the policy for eight jobs. The prompt says to use a tool “whenever it would make your answer more accurate than guessing”. That one sentence decides when to search, when to compute, when to read and when to remember. On the task that needs three tools in one turn (read a report, total it, remember a fact), one run read the file, stored the fact, skipped the calculator and answered 706,350. The right total is 624,400.

2. Every tool is in reach on every turn. On the plain “total this report” task, one run had the calculator bound, read the file, and added the six numbers in its head anyway: 1,002,200. A greeting carries the same eight tool schemas as a three-tool task. Nothing in the design says which tools belong to which kind of request, so the model decides that too, on every call.

3. It never lets go of what it read. read_file on a 5,316-character report puts the whole report in the message list, and it stays there. Two turns later, a one-word greeting still has the report in its context.

Sequence diagram: the monolith reads a report and then carries the whole report through every subsequent model call

Decomposition is the textbook answer to all three. So I decomposed by the textbook, one principle at a time, and kept the single agent next to it to check whether the textbook was right.


Single responsibility: one specialist per tool group

Robert C. Martin’s own restatement of the single responsibility principle is “Gather together the things that change for the same reasons.” For an agent, the things that change together are a set of tools, the rules for using them and the prompt that carries those rules. So I split the eight tools into four specialists. Each one is a name, a charter (the one-line description the supervisor reads when deciding who to call), a system prompt of its own, and a tuple of tools:

src/agent/supervisor.py
@dataclass(frozen=True)
class Specialist:
name: str
charter: str # what the supervisor reads when choosing
prompt: str # the specialist's own system prompt
tools: tuple
@property
def handoff(self) -> str:
return f"ask_{self.name}"
SPECIALISTS = (
Specialist(
name="analyst",
charter="Arithmetic. Any sum, product, percentage or total, however simple.",
# "Never compute in your head" was the first version and a 7B model ignored it
# every time, answering 17*23 = 411 from memory. What it obeys is an instruction
# about the *first action*, not about the reasoning.
prompt=(
"You are the analyst. Your first action for any arithmetic instruction is a "
"calculator call, including for a sum you are sure you already know. You may "
"not answer before the calculator has replied."
),
tools=(calculator,),
),
Specialist(
name="librarian",
charter=(
"The user's files: listing them, reading them, writing and deleting them. "
"Returns what a file says. Does no arithmetic on it."
),
prompt=(
"You are the librarian. You work inside the user's sandboxed workspace. You "
"have no calculator and you never summarize.\n"
"If the instruction is about numbers in a file, reply with every one of those "
"numbers verbatim, as a single line of `label=value` pairs, and nothing else."
),
tools=(list_workspace, read_file, write_file, delete_file),
),
# ... archivist (memory), researcher (web) ...
)

Split by tool ownership, not by verb. The tempting split is “a planner, a researcher, a writer”. It sounds like a team and routes like a coin flip, because nothing in a request tells you whether it is planning or writing. Splitting by tools gives the router a question it can answer: does this step need the filesystem, arithmetic, the web, or memory. Each specialist now has one reason to change. A new file operation touches the librarian and nothing else.

Write the refusal into the charter. The librarian’s charter ends with “Does no arithmetic on it”, and its own prompt repeats the rule in the second person so it holds even when the supervisor forgets. Before those two sentences existed, the librarian read the report and reported a total anyway. It has no calculator. It added six numbers in its head, got it wrong, and said so in the same tone it uses when it is right. An agent that cannot do the thing will fake the thing unless the contract tells it to refuse.

The router needs the same rule. Once the librarian returned honest raw numbers, the supervisor started adding them itself and skipped the analyst. Two of three runs on the single-report task did it, landing on 594,600 and 593,400. What I added was a line that names the tempting case: “You do no arithmetic yourself, ever, not even a sum of two numbers already in front of you.” Even with it, the failure comes back on some runs at temperature 0.7. A prohibition that does not name the temptation is a suggestion, and one that does is still a prompt, which is why the next sections move rules out of prompts and into code.

A responsibility also needs a caller. On an earlier run with qwen2.5:7b as the router, the supervisor never once handed work to the archivist on a task that asked it to remember something, 0 of 6. The archivist was correct and had nothing to do. A specialist the router does not pick for its own job does not exist, so test the routing to each charter, not only the specialist behind it.


Interface segregation: charter in, one line out

Interface segregation says no client should depend on methods it does not use. In an agent, the client is a model, and it depends on everything in its context. So the interface has two directions: what a caller can ask for, and what comes back.

The way in is the handoff. The supervisor holds four tools, ask_analyst, ask_librarian, ask_archivist and ask_researcher, and no tool that does real work. Their descriptions are the charters:

src/agent/supervisor.py
def _handoff_tools() -> list:
"""Schemas, not implementations.
Binding these tells the model what routing decisions exist. Nothing calls the
functions: `specialist_node` intercepts the call and runs the sub-agent. A
handoff tool that executed itself would run outside the graph, which means
outside the checkpointer and outside the trace tree.
"""
tools = []
for spec in SPECIALISTS:
def _make(spec: Specialist):
@tool(spec.handoff)
def _handoff(task: str) -> str:
"""placeholder"""
raise RuntimeError("handoff tools are routed by the graph, never invoked")
_handoff.description = (
f"Hand one instruction to the {spec.name}. {spec.charter} "
f"The {spec.name} cannot see this conversation, so `task` must be "
"self-contained."
)
return _handoff
tools.append(_make(spec))
return tools

A handoff is a schema the graph executes. The ask_* tools have no bodies. The graph intercepts the call and runs the sub-agent itself, so every specialist call lands in the checkpointer and in the trace tree. A handoff that executes itself runs outside both, and a multi-agent run you cannot resume or inspect is one you cannot debug.

The task has to stand on its own. The specialist starts blind: its own system prompt plus the one instruction the supervisor wrote. That forces the supervisor to put everything into task, including the six numbers the librarian just returned. Anthropic’s write-up of their multi-agent research system lists what a delegation needs: “an objective, an output format, guidance on the tools and sources to use, and clear task boundaries.” My charters carry the boundaries and the specialist prompts carry the output format. Without both, their subagents duplicated each other’s work, and mine faked each other’s.

Narrow what comes back too. The librarian answers in one line of label=value pairs and never pastes the file. Only that line returns to the supervisor, inside a ToolMessage. The report never enters the router’s context, so the router cannot be distracted by a 5,316-character document it has no tool to act on. I also built the opposite, a full-history handoff where the specialist inherits the whole conversation and its whole transcript comes back. That is langgraph-supervisor’s output_mode="full_history". On the three-tool task it got 1 of 3 right.

Sequence diagram: the supervisor routes to the librarian and then the analyst, and the report never enters the supervisor's context

There is a counter-argument, and it is a good one. Walden Yan’s Don’t Build Multi-Agents argues for sharing full traces, because “actions carry implicit decisions, and conflicting decisions carry bad results”. That holds when the specialists make decisions that have to fit together, like two subagents drawing a background and a bird for the same game. Mine return facts: a list of numbers, a filename, a confirmation. Facts compose. Decisions do not, so a narrow return fits when a specialist reports and a shared trace fits when it designs.


Open/closed and dependency inversion: the boundaries that cost nothing

These two principles paid outright, and they paid for each other.

The supervisor was added. src/agent/graph.py, the single agent, is byte-identical to the version before the split. So are the state reducers, the persistence layer, the tools and the eval harness. The supervisor is a sibling module that reuses the same tools and the same model factory, so one runner can invoke both architectures through the same graph.invoke({"messages": [...]}) and grade them with the same graders. The branch added 6,099 lines across 18 files and deleted 8. Open for extension, closed for modification, and it is the only reason the comparison below compares the same program twice.

The graph depends on a BaseChatModel. Halfway through, I swapped the serving layer. Ollama out, llama-server from the MBZUAI-IFM fork of llama.cpp in, running K2 Horizon 7B five days after its release. Different wire format, different client library, different model family.

src/agent/models.py
def get_model(
model: str | None = None,
*,
provider: str | None = None,
reasoning: bool | None = None,
) -> BaseChatModel:
"""Chat model factory for whichever local server serves this call.
Every argument is an override with a settings-shaped default, which is what
lets one graph run heterogeneously: the supervisor on llama-server and a
specialist on Ollama, in the same turn, without either node knowing.
"""
provider = provider or settings.provider
if provider == "llamacpp":
return init_chat_model(
model or settings.llamacpp_model,
model_provider="openai", # llama-server speaks the OpenAI wire format
base_url=settings.llamacpp_base_url,
api_key="llama.cpp-does-not-check-this",
temperature=0.7,
)
return init_chat_model(
model or settings.ollama_model,
model_provider="ollama",
base_url=settings.ollama_base_url,
temperature=0.7,
)

The backend change added lines in config.py and in this file. The graph did not change. You find out dependency inversion was worth it on the day something you did not plan for shows up. Here it was a model that upstream llama.cpp cannot load (unknown model architecture: 'k2-horizon') becoming the thing I wanted to measure.

Each charter can name its own model. The same boundary goes one level down. Specialist gained an optional model field, and one helper returns the same four charters answered by a different model:

def with_backend(model: str, *, provider: str | None = None, reasoning: bool | None = None):
"""The same four specialists, answered by a different model.
Names, charters and tool tuples are identical, so the supervisor's prompt and
its handoff schemas are byte-for-byte what they were.
"""
return tuple(replace(spec, model=model, provider=provider, reasoning=reasoning) for spec in SPECIALISTS)

The router keeps the hard job, reading a request and choosing who does it. A specialist that reads one instruction, calls one tool and reports a number has a much smaller job, and nothing in the graph has to know which model does it. That is the promise of the abstraction. The next section is what happened when I took it at its word.


Liskov: the substitute has to honour the whole contract

Liskov and Wing’s behavioural notion of subtyping says that anything you can prove about the supertype must also hold for the subtype. For agents, the supertype is “a chat model that serves this charter”, and every model you put behind it is a subtype. I broke that promise twice, and neither break was a wrong answer.

Two chat models, two different interfaces. My full-history handoff first echoed the specialist’s transcript back as a run of AIMessages. Ollama serving qwen accepted it. K2 Horizon’s chat template raises Cannot have 2 or more assistant messages at the end of the list. Both are a BaseChatModel, and they do not accept the same message lists. The fix was to fold the transcript into the ToolMessage that already goes back:

# src/agent/supervisor.py, inside specialist_node
if scope == "full":
# It rides inside the ToolMessage rather than as a run of extra AIMessages,
# because K2 Horizon's chat template refuses a list with two assistant
# messages in a row. Same tokens, legal shape.
echoed = "\n".join(f"[{spec.name}] {m.content}" for m in transcript)
answer = f"{answer}\n\n{echoed}"
out.append(ToolMessage(content=answer, tool_call_id=call["id"], name=call["name"]))

The type says what a model accepts. The chat template decides it. Test against the strictest backend you plan to run, because that one defines the interface.

A substitute that answers right and never finishes. With qwen3.5:4b behind the four charters, the system got the same 10 of 10 as K2 behind them. With llama3.2:3b, one run of the single-report task made 49 model calls and stopped on LangGraph’s recursion limit. The librarian kept returning something the supervisor could not use, and the supervisor kept routing back to it. The other nine tasks were correct. The loop was the failure. “Terminates with something the caller can use” was a precondition of every charter I wrote, and it appears in none of them.

Put the postcondition in code. A charter is a prompt, and a prompt is a request. The guarantee lives in the loop that runs the specialist:

src/agent/supervisor.py
# A specialist that has not answered in this many model calls is looping, not
# working. The supervisor gets told that, and decides what to do about it.
MAX_SPECIALIST_STEPS = 6
def run_specialist(spec: Specialist, task: str, ...) -> tuple[str, list[AnyMessage]]:
...
for _ in range(MAX_SPECIALIST_STEPS):
response = model.invoke([SystemMessage(spec.prompt), *messages], ...)
if not response.tool_calls:
return response.content, transcript
...
return (
f"The {spec.name} did not finish within {MAX_SPECIALIST_STEPS} steps. "
"Tell the user that part could not be completed.",
transcript,
)

The cap bounds one specialist. The 3B run escaped it anyway, because each visit finished inside its six steps and the supervisor kept starting new ones. The next guard belongs on the router: a limit on how many times one turn may hand the same charter the same kind of task. Every contract a substitute can break silently needs a check in code at the handoff where it would break.

The single agent’s graph already did this for tool failures, and it is the same idea. A failing tool returns a string that starts with TOOL_ERROR:, and the graph routes on that prefix instead of parsing prose. When a user denies a tool call, the result tells the model the call did not run and what to tell the user, worded hard on purpose: with a softer “Rejected by the user.”, llama3.1:8b reported the delete as successful anyway. A small model fills any gap you leave in a result, so the result has to state the outcome and the reply.

LangGraph graph: START to supervisor, supervisor to specialists node, specialists back to supervisor, with the sub-agent loop expanded

KISS: where the clean design lost

I ran six tasks through both designs, three repeats each, on K2 Horizon 7B, and graded the final answer for the expected number.

TaskSingle agent: callsCorrectSupervisor: callsCorrect
greet (no tools needed)13/333/3
calc (one tool)23/343/3
list files (one tool)23/343/3
read one report, total it32/341/3
read a report, total it, remember a fact32/3113/3
read three reports, total them33/383/3

Model calls are the median of three. Both designs got 16 of 18 right. The supervisor needed more calls on every task, and more input tokens on five of the six.

Every handoff is a model call. Routing is not free. A supervisor turn is at least three calls: one to choose a specialist, one for the specialist, one to write the answer. That holds even when the request needs no specialist at all. “Greet me in exactly one word” went through the router, out to a specialist and back, three calls for a word the single agent produced in one. No prompt fixes this. Deciding that a turn is trivial is itself a routing decision, and a routing decision is a call.

Every handoff is a write the router may repeat. On the three-tool task the supervisor beat the single agent, 3 of 3 against 2 of 3. It also called remember_fact twice in every one of those runs: it routed to the archivist, got a confirmation, then routed the same instruction again while assembling the answer. The store holds the fact under two ids, the sentence to the user is correct, and a suite that grades answers never sees it. Once a router sits in front of a write, that write has to be idempotent, because the router will retry it for reasons that have nothing to do with the write.

The split earns its keep where state crosses the handoff. The one place the supervisor clearly paid was isolation. The report the librarian read never entered the router’s context, so later turns on the same thread stayed small and the router had nothing to be distracted by. That is the shape Anthropic describes for their research system, where subagents explore and return a condensed summary. It is also the shape OpenAI’s guide points at when it says the problem with many tools “isn’t solely the number of tools, but their similarity or overlap”: split when one set of tools pollutes the context or the decisions of the others.

My tools return numbers and filenames. For tools like these, the single agent is the right design, with fewer tools and a sharper prompt. Four specialists, a router and a contract per handoff bought the same accuracy and three new failure modes. The principles were still worth applying, because they told me where the boundaries would go and what each handoff had to promise. KISS told me not to split along them yet.


Frequently Asked Questions

Does SOLID apply to LLM agents at all?

Yes, and each principle maps to something you can check. Single responsibility is one specialist per tool group. Interface segregation is the charter going in and a narrow answer coming out. Open/closed is a new architecture added beside the old graph with zero lines changed in it. Dependency inversion is a model factory. Liskov is where it breaks, because two models behind one interface rarely accept the same inputs or guarantee the same outputs.

When should I split one agent into several?

When a specialist absorbs something bulky or noisy, hands back a short result, and the conversation continues after that. Also when two groups of tools overlap enough that one prompt cannot say when to use which. If your tool outputs are single values, keep one agent and give it fewer tools. Three tools instead of eight beats any router.

How do I write the handoff between a supervisor and a sub-agent?

Make the task self-contained, because the sub-agent should start blind. Give it an objective, the output format, the tools to use and what it must refuse. Have the graph execute the handoff, so every sub-agent call is in your checkpoints and traces. Return one structured line, not a transcript.

Can I swap the model behind one agent for a smaller one?

Yes, if the smaller model honours the whole contract, including the parts you never wrote down. My 4B specialists matched the 7B ones. My 3B specialists answered correctly and never terminated. Put a step cap in the specialist loop, a repeat limit in the router, and run your eval suite against every model you plan to put behind a charter.

Should I use langgraph-supervisor or write the graph myself?

Use it if the prebuilt shape fits, which it usually does: create_supervisor(agents, model, prompt, output_mode="last_message") is the scoped handoff described here. I wrote mine by hand because per-call approval and a custom retry meant editing the internals anyway.


Closing

I have watched a four-agent system spend three model calls to say “Hello”. I have watched the same system answer a three-part question correctly in every run while writing the same fact into the store twice, and a 3B model loop until the graph gave up. All three are the decomposition working as designed. At every handoff one model summarizes another’s work in prose, and the transcript reads clean whether the summary was right or not.

What I would keep is the order. Write the charters first, even for a single agent, because they are where the refusals live. Put every promise a model might break in code at the handoff. Then split only when a measurement says the split pays.

Next in this thread is grading the handoffs themselves: a trajectory grader that fails a run where the router did the arithmetic, or stored the same fact twice, even when the final sentence to the user is perfect. Correct answers from broken dispatch are the regressions a suite score cannot see, the same problem I hit when I started measuring this agent at all.

If you are splitting an agent and want to argue about where the boundaries go, find me on X at @matiasdev_ or by email.