mini-SWE-agent — Phase 0 Analysis
Guiding question: HOW LITTLE AGENT SCAFFOLDING IS ACTUALLY NECESSARY?
Reference:
references/mini-swe-agent/(version 2.4.6, persrc/minisweagent/__init__.py). Built by the Princeton/Stanford SWE-bench team. README claim: >74% on SWE-bench Verified with this scaffolding.
0. Executive answer
The entire competitive core of mini-swe-agent is roughly 585 lines of Python:
| Component | File | Lines |
|---|---|---|
| Agent loop + config | src/minisweagent/agents/default.py |
190 |
| Exceptions (control flow) | src/minisweagent/exceptions.py |
26 |
| Local environment | src/minisweagent/environments/local.py |
92 |
| LiteLLM model wrapper | src/minisweagent/models/litellm_model.py |
164 |
| Tool-call parsing/formatting | src/minisweagent/models/utils/actions_toolcall.py |
113 |
Everything else in the package (~5,350 total lines) is alternatives and periphery: extra model backends (OpenRouter, Portkey, Requesty, Responses API), extra environments (Docker, Singularity, bubblewrap, SWE-ReX), run scripts, benchmark harnesses, and a trajectory inspector. The project's own AGENTS.md states its thesis explicitly: "The idea of this project is to write the simplest, smallest, most readable agent" and "The project embraces polymorphism: every individual class should be simple, but we offer alternatives."
The lesson is not "agents need 200 lines." The lesson is which 200 lines are the kernel, and that everything else can be a swappable component around it — which is exactly KHAELOR's Absolute Rule #3.
1. The core agent class (agents/default.py, 190 lines)
1.1 State
DefaultAgent.__init__ (lines 39–50) holds the complete agent state — eight fields:
self.config = config_class(**kwargs) # pydantic AgentConfig
self.messages: list[dict] = [] # the ONLY conversation state
self.model = model # Model protocol
self.env = env # Environment protocol
self.extra_template_vars = {}
self.cost = 0.0
self.n_calls = 0
self.n_consecutive_format_errors = 0
self._start_time = time.time()There is no session object, no event bus, no context engine, no tool registry, no planner. self.messages — a flat list of dicts — is the session, the history, the context, and the persistence format simultaneously.
AgentConfig (lines 19–35) is equally minimal: system_template, instance_template, step_limit (default 0 = unlimited), cost_limit (default $3.00), wall_time_limit_seconds, max_consecutive_format_errors (default 3), output_path.
1.2 The loop
run() (lines 88–124) is the fundamental loop. Reconstructed:
def run(self, task, **kwargs) -> dict:
self.messages = []
self.add_messages(system_msg, instance_msg) # Jinja2-rendered templates
while True:
try:
self.step() # step() = execute_actions(self.query())
except FormatError as e: ... # append corrective message, count strikes
except InterruptAgentFlow as e: ... # append exception's messages
except Exception as e: ... # record, re-raise
finally:
self.save(self.config.output_path) # trajectory saved EVERY step
if self.messages[-1].get("role") == "exit":
break
return self.messages[-1].get("extra", {})step() is one line (line 126–128): return self.execute_actions(self.query()). That is the whole "query model → execute actions → return observations → repeat" cycle:
query()(lines 130–152): checks step/cost/time limits (raisingLimitsExceeded/TimeExceeded), callsself.model.query(self.messages)— the entire message list, every turn — accumulates cost from real API metadata, appends the assistant message.execute_actions()(lines 154–157):outputs = [self.env.execute(action) for action in message["extra"]["actions"]], then appends observation messages formatted by the model class.
1.3 Exceptions as control flow — the key trick
exceptions.py (26 lines) defines a tiny hierarchy rooted at InterruptAgentFlow, whose constructor carries messages: Submitted, LimitsExceeded, TimeExceeded(LimitsExceeded), UserInterruption, FormatError.
Every non-linear event — task completion, budget exhaustion, malformed model output, Ctrl-C — is expressed as "raise an exception that carries the messages to append." The run() loop catches them, appends the messages, and loops. Termination is purely data-driven: the loop exits when the last message has role == "exit" (line 122). No state machine, no status enums, no is_done flags scattered across classes.
Consequences:
- Completion detection lives in the environment, not the agent.
LocalEnvironment._check_finished(environments/local.py:45–56) raisesSubmittedwhen a command's first output line is exactlyCOMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUTwith returncode 0. The agent has no completion logic at all; the prompt (config/mini.yaml) instructs the model toecho COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUTwhen done. This is a magic-string protocol — clever, fragile, and benchmark-oriented. - Format violations are self-healing.
FormatError(raised by parsers inmodels/utils/actions_toolcall.py/actions_text.py) carries a templated corrective user message (format_error_templateinmini.yaml, which even distinguishesfinish_reason == "length"truncation from genuine mistakes).run()appends it and re-queries.max_consecutive_format_errors(3) converts repeated failure into aRepeatedFormatErrorexit — a strike system in ~15 lines (default.py:100–114). Note the subtlety at line 102: the failed call's cost is still charged. - Limits are checked at the top of
query()(lines 132–147), not in a supervisor. A second global layer,GlobalModelStats(models/__init__.py:13–42, thread-safe, env-var-driven), guards multi-agent/batch runs.
1.4 Persistence
serialize()/save() (lines 159–190) dump {info, messages, trajectory_format} to JSON after every step (finally block). Crash-safe trajectories for free — but this is recording, not resumption: there is no code path that loads a trajectory back into a live agent. run/utilities/inspector.py (316 lines, Textual) is a read-only trajectory browser.
1.5 The interactive layer is a subclass, not a framework
agents/interactive.py (209 lines) shows how far the small kernel stretches: InteractiveAgent(DefaultAgent) adds three modes (human / confirm / yolo), a regex whitelist_actions list, Ctrl-C steering (step() override catches KeyboardInterrupt and raises UserInterruption carrying the user's typed comment), confirm-before-exit ("agent wants to finish — type a new task or Enter to quit", _check_for_new_task_or_submit), and slash commands (/y, /c, /u, /m, /h) — all by overriding add_messages, query, step, and execute_actions. The entire "permission system" is _should_ask_confirmation (lines 162–163): one mode check plus one regex whitelist scan.
2. The environment abstraction
The Environment protocol (src/minisweagent/__init__.py:61–70) is three methods: execute(action, cwd) -> dict, get_template_vars(), serialize(). Protocol-based duck typing — no base classes, no inheritance requirements.
2.1 LocalEnvironment (environments/local.py, 92 lines)
- Yes,
subprocesswithshell=True—_run()(lines 72–92) usessubprocess.Popen(command, shell=True, text=True, ..., stdout=PIPE, stderr=STDOUT, start_new_session=os.name == "posix"). - stderr merged into stdout — one output stream, one observation field.
- Process-group kill on timeout (default 30s):
os.killpg(process.pid, signal.SIGKILL)so children aren't orphaned, thenTimeoutExpiredis re-raised with the partial output attached — the model sees what happened before the kill. - Every command runs in a fresh subshell. No persistent shell session —
cdand env vars do not survive between actions. Rather than engineering a PTY session manager, the system prompt (config/mini.yaml:39–40) tells the model: "Directory or environment variable changes are not persistent... you can prefix any action withMY_ENV_VAR=... cd /path && ...". A hard engineering problem converted into one sentence of prompt. - Observations are plain dicts:
{"output": str, "returncode": int, "exception_info": str}. Exceptions during execution (including timeouts) are folded into the same dict shape withreturncode: -1(lines 31–41) — the model always receives a uniform observation.
2.2 Observation formatting is a config template, not code
The dict is rendered into the message by a Jinja2 observation_template owned by the model config (litellm_model.py:40–44, overridden in config/mini.yaml:112–128). The production template emits JSON-ish text and performs the only "context management" in the whole system: outputs over 10,000 chars are truncated to output_head (first 5,000) + output_tail (last 5,000) + elided_chars + a warning — in the template, not in Python.
2.3 Other environments
DockerEnvironment (environments/docker.py, 161 lines): starts a long-lived container running sleep 2h (_start_container), then each action is docker exec -w cwd <container> bash -lc <command>. Same observation shape, same _check_finished magic string, cleanup() via __del__. environments/singularity.py, extra/bubblewrap.py, extra/swerex_docker.py, extra/swerex_modal.py, extra/contree.py are drop-in alternatives. Because the interface is one execute() method, adding an environment costs ~100–150 lines each.
3. The model abstraction
The Model protocol (__init__.py:43–58): query(messages) -> dict, format_message(**kw), format_observation_messages(message, outputs, template_vars), get_template_vars(), serialize().
Notably, in v2 the model class owns action parsing and observation formatting — the agent never touches wire formats. The agent reads message["extra"]["actions"] and hands raw output dicts back to the model for formatting. This is how one 190-line agent supports both native tool-calling and text-based protocols unchanged.
3.1 LitellmModel (models/litellm_model.py, 164 lines)
litellm.completion(model=..., messages=..., tools=[BASH_TOOL], ...)— exactly one tool is advertised:BASH_TOOL(models/utils/actions_toolcall.py:11–27), schema = a single requiredcommand: string. The whole tool surface area is one function with one parameter.query()(lines 81–106): retry loop (models/utils/retry.py, 25 lines, tenacity-style withabort_exceptionsincludingContextWindowExceededErrorandAuthenticationError), cost calculated from litellm's cost calculator with a hard failure if cost cannot be computed (_calculate_cost, lines 108–126 — "never fabricate/ignore cost" as a default), parsed actions plus the full raw response stashed undermessage["extra"]._prepare_messages_for_api()strips the internalextrakey, reorders Anthropic thinking blocks (models/utils/anthropic_utils.py), and applies prompt cache control:get_model()(models/__init__.py:56–60) auto-enablesset_cache_control: "default_end"whenever the model name looks like Anthropic — cache breakpoints handled inmodels/utils/cache_control.py(67 lines).- No streaming anywhere. Blocking
litellm.completion; the interactive agent shows a Rich spinner ("Waiting for the LM to respond...",interactive.py:73).
3.2 Templating: Jinja2 + StrictUndefined everywhere
Every string the model sees is a Jinja2 template rendered with StrictUndefined (typo in a variable = crash, not silent empty string): system_template, instance_template, observation_template, format_error_template. Template variables come from DefaultAgent.get_template_vars() (default.py:52–64), which recursive_merges the agent config, environment vars (LocalEnvironment.get_template_vars includes platform.uname() and os.environ), model config, and live counters (n_model_calls, model_cost, elapsed_seconds). This lets mini.yaml do things like emit macOS-specific sed -i '' guidance ({%- if system == "Darwin" -%}, lines 69–73).
The prompt is the product. config/mini.yaml is 151 lines — comparable in size to the agent itself — encoding the workflow (reproduce → fix → verify → submit), the subshell semantics, file-creation via heredoc, editing via sed, viewing via nl | sed -n. Behavior tuning happens in YAML, never in the loop. Run scripts compose everything: run/mini.py merges config specs (-c file.yaml -c agent.mode=yolo) and does get_model(...), get_environment(...), get_agent(...), agent.run(task) — class selection is string-based dynamic import (_MODEL_CLASS_MAPPING, _AGENT_MAPPING).
3.3 Text-based fallback
models/litellm_textbased_model.py (48 lines) is the v1/original-SWE-agent protocol: no tools parameter at all; actions are extracted from the assistant text with a regex (r"```mswea_bash_command\s*\n(.*?)\n```"), exactly one action per response enforced (parse_regex_actions, models/utils/actions_text.py:15–40), observations returned as role: "user" messages. The docstring in actions_text.py notes: "As of mini-swe-agent v2.0, we strongly recommend to use toolcalls instead" — an empirical admission that native tool-calling APIs beat markdown-block parsing, while proving the agent loop is indifferent to which protocol is used.
4. What mini-swe-agent deliberately OMITS — and the consequences
| Omission | Detail | Consequence |
|---|---|---|
| Rich tool set | One tool: bash. No read/write/edit/grep/glob. Editing is sed/heredocs per the prompt. |
Works for strong models on benchmarks; brutal for interactive use — a one-character sed mistake silently corrupts files, no diffs, no ambiguity detection, no atomic writes. This is the single largest gap for a daily driver. |
| Context compaction | None. self.messages grows monotonically; full history sent every call. ContextWindowExceededError is an abort exception (litellm_model.py:54) — hitting the window kills the run. |
Fine for bounded benchmark episodes (~$3 cost cap doubles as an implicit context cap). Disqualifying for long interactive sessions. |
| Session resume | Trajectories saved every step, but write-only. No resume/branch/rewind; inspector.py is read-only. |
Every invocation starts from scratch. Unacceptable for a daily tool. |
| Streaming | Blocking completion calls; spinner while waiting. | Terrible perceived latency for interactive use; irrelevant for benchmarks. |
| Persistent shell / process manager | Fresh subshell per command, 30s timeout, no background processes. Dev servers, watchers, REPLs are impossible. | Prompt-level workaround for cwd/env; no workaround exists for long-running processes. |
| Permission engine | confirm/yolo/human mode + regex whitelist (interactive.py:162–163). No capability model, no persistence of grants, no scoping. |
Adequate for a single trusted user in a sandbox; not for a product where "always allow in this project" must persist. |
| Repository intelligence | Zero. The model runs ls, grep, find itself. |
Elegant (no index to maintain, never stale) but wasteful: repeated discovery burns tokens and turns; nothing survives between sessions. |
| Event bus | None. The message list is the event log. UI = add_messages override printing via Rich. |
Works only because there is one linear consumer. A real TUI (status line, collapsible tool calls, diff viewer, parallel processes) needs typed events. |
| Parallel/linear history | Strictly linear; one action stream; no subagents. | Simple to reason about and serialize; caps throughput and precludes delegation. |
The trade summarized: mini-swe-agent optimizes for benchmark performance per line of code and for research legibility (trivially hackable, trivially reproducible, environment-swappable for SWE-bench containers). Nearly every omission that is free on a benchmark is a first-order product defect in a daily-driver terminal agent.
5. Accidental vs. essential complexity — subsystem by subsystem
For each subsystem larger agents (Claude Code, OpenCode, Hermes, OpenHands) carry, is mini-swe-agent's absence of it viable for KHAELOR?
- Tool registry — absence NOT viable, but the lesson holds. mini proves the count of tools in big agents is largely accidental (one tool scores >74%), but bash-only is viable only because SWE-bench never shows a human the intermediate states. KHAELOR needs
read/write/edit/grep/globfor observability (diffs, headers, permission classification), safety, and token efficiency — CLAUDE.md's ~7 primitives is the right calibration. Adopt mini's registry shape: a static, tiny declaration likeBASH_TOOL, not a plugin framework. - Permission system — absence NOT viable. But mini shows the evaluator can be small: mode + whitelist is ~10 lines. KHAELOR's capability model (
file.write.project, etc.) is essential product complexity (persistence, scoping, elegant UI); a rules-engine DSL would be accidental complexity. - Context engine — absence NOT viable, and this is mini's clearest cliff. Growing a flat list until the window explodes is only survivable under a $3 cap. However, mini shows the first 80% of context management is trivial: head/tail truncation in an observation template. KHAELOR's compaction/checkpointing is essential; what would be accidental is retrieval-heavy context stuffing — mini demonstrates models navigate repositories themselves very well when given a good shell.
- TUI — absence NOT viable for KHAELOR by definition (Absolute Rule #2). mini's Rich-print interface is honest about being a research harness. But its interaction grammar — Ctrl-C becomes a steering message, "agent wants to finish" becomes a prompt for the next task, mode switching mid-run — is excellent and cheap, and KHAELOR should preserve exactly those semantics under a real TUI.
- Session persistence — absence (of resume) NOT viable. But save-full-state-every-step-in-a-
finallyis the right durability discipline: KHAELOR's event journal should be crash-safe at every loop boundary, exactly likedefault.py:120–121. - Event bus — absence viable only without streaming. The moment streaming exists (KHAELOR §7 makes it non-negotiable), messages-as-the-only-log stops working. Essential for KHAELOR; the mini lesson is to keep the kernel's view simple (kernel appends to history; the bus is a service that observes it).
- Subagents / planners / multi-agent orchestration — absence VIABLE for V1. mini plus a strong model plans in-context and scores >74% with no planner, no critic, no orchestration graph. Strong evidence this whole layer is accidental complexity at current model capability. Matches KHAELOR's "NOT V1" list.
- Memory / skills — absence VIABLE for V1. Same evidence, same conclusion.
- Model abstraction — mini's is larger than KHAELOR needs. Ten model classes + litellm exist because mini is a research harness for arbitrary models. Anthropic-only KHAELOR needs one
ModelClient— but should copy three specifics: automatic prompt cache control (cache_control.py), cost from real API metadata with loud failure when unknown (_calculate_cost), andfinish_reason-aware format-error messages.
WHAT MINI-SWE-AGENT PROVES
- A competitive agent loop is ~150 lines.
DefaultAgent— eight fields of state,run()/step()/query()/execute_actions()— scores >74% on SWE-bench Verified. Any kernel larger than a few hundred lines is carrying non-kernel work. - The model is the intelligence; scaffolding is plumbing. No planner, no self-reflection, no orchestration, no retrieval — one bash tool and a good prompt. Capability lives in the LLM; scaffolding's job is faithful transport of actions and observations.
- Three interfaces suffice to decouple everything:
Model.query(messages),Environment.execute(action), and the agent between them (theProtocols in__init__.pyare ~30 lines). Swapping local↔Docker↔Singularity or toolcall↔regex protocols requires zero kernel changes. - Exceptions-carrying-messages is a remarkably clean control-flow pattern. Completion, limits, format errors, and user interruption are all "raise with the messages to append"; the loop stays four branches and termination is data-driven (
role == "exit"). - Format errors are conversation, not crashes. Feed the parse error back as a message with a strike counter; the model self-corrects. ~15 lines replace an entire "robust output parsing" subsystem.
- Budgets are trivial and non-negotiable: step, cost, and wall-clock checks at the top of
query(), cost from real API metadata, hard failure when cost is unknowable. Ten lines buy the most important safety property an autonomous agent has. - Prompts and templates absorb enormous complexity. Non-persistent subshells, output truncation, OS-specific editing advice, submission protocol — all handled in
mini.yamlJinja2, not code. Config-as-behavior keeps the loop frozen while behavior iterates. - Durability by default is cheap: serialize full state in a
finallyevery iteration. - What it proves negatively: the exact omissions that keep it small — no streaming, no compaction, no resume, no rich tools, no processes — are precisely what separates a benchmark harness from a daily driver. Minimalism of the kernel generalizes; minimalism of the product does not.
WHAT KHAELOR'S KERNEL SHOULD PRESERVE FROM THIS MINIMALISM
- The four-verb loop, verbatim in spirit. KHAELOR's
AgentKernelshould be recognizablywhile active: query → parse → execute → observe, readable in one screen. Everything in CLAUDE.md §4's conceptual loop already maps 1:1 ontodefault.py; keep it that way. - Tiny, enumerable kernel state. mini needs eight fields; KHAELOR's kernel should need barely more (history handle, model, tool runtime, budget counters, run status). If a field isn't consulted by the loop itself, it belongs to a service.
- Kernel talks to interfaces only:
ModelClient,ToolRuntime,Workspace— the TypeScript analogue of mini's three Protocols. The kernel must never know Anthropic wire formats, tool schemas, or filesystem details (mini's agent never parses tool calls — the model layer does). - Structured interrupts as control flow. Port the
InterruptAgentFlowpattern: completion, budget exhaustion, cancellation, and steering injection are typed signals carrying the events to record, handled in one place in the loop — not booleans threaded through call stacks. (In TS: typed control-flow results/exceptions caught at the loop boundary.) - Format-violation recovery as messages + strike limit. Feed tool-call errors back to the model with
finish_reasonawareness; cap consecutive failures. - Budget checks at the top of every query, with real usage metadata only (Absolute Rule #4) and a global as well as per-session layer.
- Journal state at every loop boundary (
finally-style) so crashes never lose a session — mini's save discipline applied to KHAELOR's event log. - Behavior in templates/config, not in the loop. System prompt, observation rendering, error phrasing, truncation thresholds → configurable data, so the kernel is frozen while behavior iterates.
- No planner, no subagent framework, no memory system in V1. mini is the existence proof that these are unnecessary for strong coding performance today; KHAELOR's "NOT V1" list is validated.
- Trust the model with the repository. Grep/glob/bash as honest primitives beat premature indexing; ship repository intelligence as a context-engine service later, never as kernel logic.
WHERE KHAELOR MUST DIVERGE (AND WHY)
- Streaming is the substrate, not an add-on. mini blocks on
litellm.completionand shows a spinner — acceptable when nobody watches. KHAELOR's terminal-native identity (Absolute Rule #2, CLAUDE.md §7) requiresstream(request): AsyncIterable<ModelEvent>from day one; the kernel consumes an event stream and the TUI renders deltas. This is the single biggest structural divergence. - A typed event bus instead of "the message list is the log." mini's single linear consumer (Rich prints in
add_messages) cannot drive a status line, collapsible tool calls, diff viewers, cost display, and a journal simultaneously. KHAELOR: kernel emits typed events; history, TUI, and persistence are subscribers (services around the kernel — the mini philosophy, one level up). - First-class file tools with diffs. Bash-only editing via
sed/heredocs is unobservable, unreviewable, and unsafe outside a disposable container. KHAELOR shipsread/write/edit/grep/globas structured tools so every mutation yields a diff, a permission classification, and a header check. Keep the set mini-small (≤8 primitives, one-parameter-simple schemas likeBASH_TOOL). - A real process manager. Fresh-subshell-per-command with a 30s kill makes dev servers and watchers impossible. KHAELOR's
bashvsprocesssplit (CLAUDE.md §10) is mandatory; adopt mini's process-group-kill hygiene (local.py:_run) inside it. - Context engine with compaction. mini treats
ContextWindowExceededErroras fatal and resends full history every call. KHAELOR sessions are long-lived; budget awareness, checkpoint summaries, and cache-friendly message layout are essential. Steal mini's cheap trick (template-level head/tail truncation of tool output) as the first line of defense. - Persistent, resumable sessions. Keep mini's every-step durability, add what it lacks: load, resume, replay from the event journal (
/sessions,/resume). - Explicit completion, not a magic string.
COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUTsniffed from stdout (local.py:45–56) is spoofable by any command output and encodes completion in the prompt–environment contract. KHAELOR: completion is a structured signal validated againstCompletionEvidence(CLAUDE.md §17) — the kernel verifies before it believes. - A capability permission engine with persistence and elegant UI. mini's confirm/yolo/whitelist is the right size of evaluator but the wrong model for a product: KHAELOR needs
file.write.project-style capabilities, remembered grants, and the inline permission panel — while preserving mini's property that a rejection becomes a steering message to the model (_ask_confirmation_or_interrupt→UserRejection). - Anthropic-native model layer, not a router. Drop the ten-backend polymorphism (that's mini's research mission, not KHAELOR's); keep the three valuable behaviors buried in it: automatic prompt cache control, cost from real usage metadata with loud failure, and truncation-aware error recovery.
- Interruption and steering as first-class concurrency. mini's Ctrl-C-to-comment is the right UX seed, but it works only because everything is synchronous. With streaming + background processes, KHAELOR needs cancellation that propagates through model stream → tools → processes without corrupting the session (CLAUDE.md §14), plus queued steering — a genuinely new design, not an extension of mini's.
Bottom line: mini-swe-agent proves KHAELOR's kernel can — and therefore must — stay under a few hundred lines with three narrow interfaces and exception-style control flow. Everything KHAELOR adds beyond that (streaming, events, rich tools, processes, compaction, sessions, permissions, TUI) is justified product complexity — and every piece of it must live in services around that kernel, or we will have learned nothing from the smallest agent that works.