Building a Coding Harness Agent

Large language models can talk about code. They cannot read your files, run your tests, or edit your programs. The bridge between those two worlds is a piece of software called a harness. This chapter builds a complete, small harness from scratch: a terminal coding agent that reads a project, proposes edits as diffs, asks for your approval, runs make check after every change, and works with both hosted APIs and local models.

Along the way we will cover the four problems every harness must solve:

  1. Tool calling. How do you describe actions to a model so that it emits machine-executable instructions instead of prose?
  2. The agentic loop. How do you run model, tools, and feedback until the task is done, without letting a confused model loop forever?
  3. Control and safety. How do you gate file writes behind human approval, restrict shell access, and detect a stuck model?
  4. Configuration. How do you keep every provider-specific value (endpoint, model, API key name, pricing) in data files so that adding a provider never requires a code change?

The finished program is a Python project managed by uv. All source files are listed in full, and each listing is introduced and followed by discussion.

The Program at a Glance

The agent is nine modules, plus a test suite that exercises them offline:

File Role
agent.py CLI parsing, REPL, slash commands, intent routing, context management
harness_config.py Hierarchical JSON configuration: global file, local override file, provider profiles
chat_loop.py The provider-agnostic agentic loop (the heart of the program)
tools.py Tool registry: read_file, list_dir, grep, run_shell, propose_edit, replace_in_file
approval.py Unified diffs, ANSI coloring, y/n/s approval prompts
fireworks_ai.py Client for OpenAI-compatible hosted APIs, with SSE streaming and cost tracking
mlx_serve.py Client for local OpenAI-compatible servers (MLX, oMLX, sushi, Ollama)
search.py Brave and Exa web search backends
line_input.py Readline editing, persistent history, Tab completion, with graceful fallback
tests/ Offline smoke tests: make test

Data flows through the program like this:

 1 user input --> agent.py --> intent classifier --> chat_loop.py (agentic loop)
 2                                                     |
 3                                 fireworks_ai.py or mlx_serve.py  (HTTP transport)
 4                                                     |
 5                                        provider endpoint (hosted or local)
 6                                                     |
 7                                        tool_calls? --> tools.py executes
 8                                                     |
 9                                        approval.py gates writes
10                                                     |
11                                        results appended as tool messages, loop repeats

Two design decisions shape everything else. First, the loop in chat_loop.py knows nothing about providers: each client module passes it a post_fn callback that performs the actual HTTP work. Second, no provider-specific value is compiled into the code: endpoints, models, generation parameters, and pricing all live in JSON configuration files.

The Agentic Loop, in Theory

An OpenAI-style chat request sends the model a list of messages plus a list of tool definitions. The model replies in one of two shapes. It can answer with plain text, which ends the loop. Or it can reply with tool_calls, a list of function names plus JSON arguments:

 1 {
 2   "role": "assistant",
 3   "content": "Let me read the file first.",
 4   "tool_calls": [
 5     {
 6       "id": "call_abc123",
 7       "type": "function",
 8       "function": {
 9         "name": "read_file",
10         "arguments": "{\"path\": \"search.py\"}"
11       }
12     }
13   ]
14 }

The harness must then execute the call, append the result as a new message of role tool, and send the whole (longer) conversation back to the model:

1 {
2   "role": "tool",
3   "tool_call_id": "call_abc123",
4   "name": "read_file",
5   "content": "def normalize(s):\n    return s.strip().lower()\n"
6 }

The model sees its own action and its outcome, and decides the next step. This read-act-observe cycle, repeated until the model stops calling tools, is the agentic loop. It is the same pattern behind Claude Code, Pi, and the other coding agents you may have used.

A naive implementation of this loop fails in practice for a predictable reason: weak models get stuck. They re-issue the identical failing call dozens of times, or they stop generating mid-call. A serious harness detects these states and bails out with a message. We will see exactly how in chat_loop.py.

Configuration: The Global and Local Files

Before any code runs, you need to tell the agent which models to talk to. The harness uses a two-layer configuration system, in the style of the Pi coding harness:

  • Global file: ~/.coding_harness.json. Your machine-wide defaults: every provider profile you use, your lifestyle flags (quiet, plain, debug), and search settings.
  • Local override file: .local_coding_harness.json. A per-project file, loaded from the current working directory at startup. It changes only what this project needs: a different default model, a different temperature, search enabled.

The two files are deep-merged, with the local file winning. A project can therefore pin its own provider and generation settings without touching your global preferences.

The Global File

Create ~/.coding_harness.json. This is a realistic setup with one hosted provider and one local provider:

 1 {
 2   "default_provider": "fireworks",
 3   "providers": {
 4     "fireworks": {
 5       "type": "openai",
 6       "endpoint": "https://api.fireworks.ai/inference/v1/chat/completions",
 7       "api_key_env": "FIREWORKS_API_KEY",
 8       "model": "accounts/fireworks/models/deepseek-v4p1-flash",
 9       "generation": { "temperature": 0.6, "max_tokens": 32768 },
10       "pricing": { "input": 0.14, "cached_input": 0.028, "output": 0.28 }
11     },
12     "mlx": {
13       "type": "mlx",
14       "endpoint": "http://localhost:11434/v1/chat/completions",
15       "model": "mlx-community/gemma-4-26B-A4B-it-OptiQ-4bit",
16       "generation": { "temperature": 0.6, "max_tokens": 32768 }
17     }
18   },
19   "search": { "engine": "brave", "enabled": false },
20   "debug": false,
21   "quiet": false,
22   "plain": false
23 }

Each provider is a profile, a named bag of fields:

  • type selects the wire protocol. "openai" means OpenAI-compatible chat completions with a Bearer key (Fireworks and any compatible endpoint). "mlx" means a local OpenAI-compatible server that needs no key. The strings "ollama", "omlx", and "sushi" are also accepted and map to "mlx", because those servers all expose the same route on different ports: mlx_lm.server on 11434, oMLX on 8000, sushi on 12345.
  • endpoint is the full chat-completions URL.
  • model is the model identifier the server expects.
  • api_key_env names the environment variable that holds the key. The config file never contains secrets, only the name of the variable to read.
  • generation holds temperature, max_tokens, and any other parameter to pass through. Parameters absent here are left out of the request entirely, so the server’s own default applies.
  • pricing holds USD rates per 1,000,000 tokens, used by the /tokens command. A profile without pricing reports token counts and prints cost as n/a. Nothing is guessed.

The top-level search block picks the engine ("brave" or "exa") and whether search starts enabled. The three booleans debug, quiet, and plain are lifestyle flags applied at startup.

The Local Override File

Suppose a project works best with the local model, and you want web search on for it. Put .local_coding_harness.json in the project root and override only those keys:

1 {
2   "default_provider": "mlx",
3   "search": { "enabled": true },
4   "providers": {
5     "fireworks": {
6       "model": "accounts/fireworks/models/kimi-k2-instruct"
7     }
8   }
9 }

After merging with the global file above, the project runs with:

  • active provider mlx (the local default_provider wins),
  • search on, using the global engine brave (the nested merge kept it),
  • the mlx profile exactly as the global file defines it,
  • the fireworks profile with the global endpoint, key name, generation, and pricing, but the local model.

That last point is the payoff of recursive merging: you can change one field inside a profile and inherit everything else.

Merge Rules and Precedence

Three layers of precedence exist, applied in this order:

  1. CLI flags (--provider, --model, --quiet, …) are strongest. agent.py applies them last.
  2. Environment variables CODING_AGENT_PROVIDER, CODING_AGENT_MODEL, CODING_AGENT_QUIET, CODING_AGENT_PLAIN, CODING_AGENT_DEBUG override config values. An env var naming an unknown provider produces a warning, not a crash.
  3. Config files: local over global, key by key.

Anything the layers do not set falls back to profile defaults, and a request parameter that the profile does not declare is omitted from the wire payload. The only hard requirement is that at least one provider profile exists: the CLI refuses to start with an error message rather than silently falling back to a compiled-in default endpoint.

The module that implements all of this is short and worth reading in full:

  1 # coding_harness -- hierarchical JSON configuration for the coding harness,
  2 # in the rough style of the Pi coding harness config.  Python port of
  3 # harness-config.rkt.
  4 #
  5 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  6 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
  7 # See LICENSE file for details
  8 #
  9 # Two config layers are merged (local wins over global on conflicts):
 10 #
 11 #   Global:  ~/.coding_harness.json            (base configuration)
 12 #   Local:   .local_coding_harness.json        (optional per-project override,
 13 #                                              loaded from the current
 14 #                                              directory at startup)
 15 #
 16 # Rough format (all sections optional):
 17 #
 18 # {
 19 #   "default_provider": "mlx",
 20 #   "providers": {
 21 #     "mlx":       { "type": "mlx", "endpoint": "http://localhost:11434/v1/chat/completions",
 22 #                    "model": "mlx-community/gemma-4-26B-A4B-it-OptiQ-4bit",
 23 #                    "generation": { "temperature": 0.6, "max_tokens": 32768 } },
 24 #     "fireworks": { "type": "openai", "endpoint": "https://api.fireworks.ai/inference/v1/chat/completions",
 25 #                    "api_key_env": "FIREWORKS_API_KEY",
 26 #                    "model": "accounts/fireworks/models/deepseek-v4p1-flash",
 27 #                    "generation": { "temperature": 0.6, "max_tokens": 32768 },
 28 #                    "pricing": { "input": 0.14, "cached_input": 0.028, "output": 0.28 } }
 29 #   },
 30 #   "search": { "engine": "brave", "enabled": false },
 31 #   "debug": false, "quiet": false, "plain": false
 32 # }
 33 #
 34 # Provider "type" is either "mlx" (the local mlx-serve backend -- OpenAI-style
 35 # /v1/chat/completions served by mlx_lm.server on localhost:11434, oMLX on
 36 # port 8000, sushi on port 12345) or "openai" (OpenAI-style chat completions;
 37 # Fireworks.ai and any compatible endpoint). The type strings "ollama",
 38 # "omlx", and "sushi" are also accepted and mapped to "mlx".
 39 #
 40 # Every provider-specific value lives here: endpoint, model, api_key_env,
 41 # generation parameters, and the per-1M-token USD "pricing" rates (input,
 42 # cached_input, output) that /tokens uses. Nothing provider-specific is
 43 # compiled into the Python code, so adding or changing a provider needs no
 44 # code change. A profile with no "pricing" block reports token counts without
 45 # a cost estimate rather than guessing a rate.
 46 #
 47 # Merge rules: nested dicts merge recursively, local keys override global
 48 # keys; anything that is not a dict (strings, numbers, booleans, lists) is
 49 # replaced wholesale by the local value when present.
 50 
 51 import json
 52 import os
 53 
 54 GLOBAL_CONFIG_PATH = os.path.expanduser("~/.coding_harness.json")
 55 LOCAL_CONFIG_BASENAME = ".local_coding_harness.json"
 56 
 57 # The merged config, loaded once at startup (reloadable via load_harness_config).
 58 harness_config = {}
 59 
 60 # Mutable cell: the name of the provider section currently in use.
 61 _active_provider_name = None
 62 
 63 
 64 # ---------------------------------------------------------------------------
 65 # JSON loading helpers
 66 
 67 def _read_json_file(path):
 68     """-> config dict, or None if the file is missing / unreadable / not an object."""
 69     try:
 70         if not os.path.isfile(path):
 71             return None
 72         with open(path, "r", encoding="utf-8") as f:
 73             v = json.load(f)
 74         return v if isinstance(v, dict) else None
 75     except Exception:
 76         return None
 77 
 78 
 79 # ---------------------------------------------------------------------------
 80 # Recursive dict merge (local overrides global)
 81 
 82 def deep_merge(global_cfg, local_cfg):
 83     if not isinstance(global_cfg, dict):
 84         return local_cfg
 85     if not isinstance(local_cfg, dict):
 86         return local_cfg
 87     acc = dict(global_cfg)
 88     for k, v in local_cfg.items():
 89         if isinstance(acc.get(k), dict) and isinstance(v, dict):
 90             acc[k] = deep_merge(acc[k], v)
 91         else:
 92             acc[k] = v
 93     return acc
 94 
 95 
 96 def local_config_path():
 97     """The local override file, always looked up in the *current* directory
 98     (--cwd may have changed it since this module was imported)."""
 99     return os.path.join(os.getcwd(), LOCAL_CONFIG_BASENAME)
100 
101 
102 def load_harness_config():
103     """Load global then local, deep-merge, store, and return the result."""
104     global harness_config, _active_provider_name
105     global_cfg = _read_json_file(GLOBAL_CONFIG_PATH) or {}
106     local_cfg = _read_json_file(local_config_path()) or {}
107     harness_config = deep_merge(global_cfg, local_cfg)
108     _active_provider_name = None  # re-resolve the default profile
109     return harness_config
110 
111 
112 # ---------------------------------------------------------------------------
113 # Providers
114 
115 def config_providers():
116     p = harness_config.get("providers", {})
117     return p if isinstance(p, dict) else {}
118 
119 
120 def provider_names(cfg):
121     """-> sorted profile names declared in `cfg` (the caller's dict, not the
122     module global, so loading and defaulting can be reasoned about separately)."""
123     p = cfg.get("providers") if isinstance(cfg, dict) else None
124     return sorted(p.keys()) if isinstance(p, dict) else []
125 
126 
127 def config_provider_names():
128     return provider_names(harness_config)
129 
130 
131 def config_provider(name):
132     """-> provider dict for profile `name`, or None."""
133     if name is None:
134         return None
135     return config_providers().get(str(name))
136 
137 
138 def _pick_default_provider_name(cfg):
139     """-> name of the profile to start with, or None.
140 
141     The declared "default_provider" wins; otherwise "fireworks" if present,
142     otherwise the first name alphabetically.
143     """
144     names = provider_names(cfg)
145     declared = cfg.get("default_provider") if isinstance(cfg, dict) else None
146     if isinstance(declared, str) and declared in names:
147         return declared
148     if "fireworks" in names:
149         return "fireworks"
150     if names:
151         return names[0]
152     return None
153 
154 
155 def config_active_provider_name():
156     global _active_provider_name
157     if _active_provider_name is None:
158         _active_provider_name = _pick_default_provider_name(harness_config)
159     return _active_provider_name
160 
161 
162 def config_set_active_provider(name):
163     """Switch the active profile. Returns the resulting active name."""
164     global _active_provider_name
165     if name is not None and config_provider(name):
166         _active_provider_name = str(name)
167     return _active_provider_name
168 
169 
170 def config_active_provider():
171     """-> provider dict of the active profile, or None when there is no config."""
172     n = config_active_provider_name()
173     return config_provider(n) if n else None
174 
175 
176 # ---------------------------------------------------------------------------
177 # Provider field accessors (all tolerant of missing keys)
178 
179 def provider_type(provider):
180     """-> 'mlx' | 'openai' -- defaults to 'openai'.
181 
182     "mlx" selects the local mlx-serve backend (formerly "ollama"); "ollama",
183     "omlx", and "sushi" are also accepted and mapped to 'mlx for compatibility.
184     """
185     t = provider.get("type") if provider else None
186     if isinstance(t, str):
187         low = t.strip().lower()
188         if low in ("mlx", "ollama", "omlx", "sushi"):
189             return "mlx"
190     return "openai"
191 
192 
193 def provider_endpoint(provider):
194     e = provider.get("endpoint") if provider else None
195     return e if isinstance(e, str) and e != "" else None
196 
197 
198 def provider_model(provider):
199     m = provider.get("model") if provider else None
200     return m if isinstance(m, str) and m != "" else None
201 
202 
203 def provider_api_key_env(provider):
204     """Name of the env var holding the Bearer key for this endpoint, or None.
205 
206     Absent/empty/null means "no key" (plain local MLX).
207     """
208     k = provider.get("api_key_env") if provider else None
209     return k if isinstance(k, str) and k != "" else None
210 
211 
212 def provider_generation(provider):
213     g = provider.get("generation") if provider else None
214     return g if isinstance(g, dict) else {}
215 
216 
217 def provider_pricing(provider):
218     """-> dict of per-1M-token USD rates ('input', 'cached_input', 'output'),
219     or an empty dict when the profile declares none."""
220     g = provider.get("pricing") if provider else None
221     return g if isinstance(g, dict) else {}
222 
223 
224 def pricing_ref(pricing, key):
225     """-> number, or None when the profile does not declare that rate.
226 
227     The None result means "unknown", which callers report rather than
228     guessing a value.
229     """
230     if not isinstance(pricing, dict):
231         return None
232     if key in pricing:
233         return pricing[key]
234     return None
235 
236 
237 def generation_ref(generation, key, default=None):
238     """Fetch a generation parameter ("temperature", "max_tokens", "think", ...)."""
239     if not isinstance(generation, dict):
240         return default
241     return generation.get(key, default)
242 
243 
244 # ---------------------------------------------------------------------------
245 # Debug helper
246 
247 def print_config_summary():
248     def loaded(path):
249         return "(loaded)" if os.path.isfile(path) else "(absent)"
250     print("Config files: {} {} / {} {}".format(
251         GLOBAL_CONFIG_PATH, loaded(GLOBAL_CONFIG_PATH),
252         local_config_path(), loaded(local_config_path())))
253     print("Providers:    {}".format(", ".join(config_provider_names())))
254     print("Active:       {}".format(config_active_provider_name() or "(defaults)"))

Three details are worth pointing out.

_read_json_file returns None for a missing file, unreadable file, or a file whose top-level JSON is not an object. Bad configuration degrades to “absent”, never to an exception at import time. This is deliberate: harness_config is imported by every other module, so a crash here would make even --help fail.

deep_merge walks the two dictionaries in parallel. When both sides have a dict for the same key, it recurses. Otherwise the local value replaces the global value wholesale. That is why a local "search": { "enabled": true } keeps the global "engine": "brave": the two search dicts merge. But a list is replaced, not appended, because there is no sane general rule for merging lists.

config_active_provider_name caches the chosen profile in a module-level variable, and load_harness_config resets that cache. The selection rule when default_provider is missing or wrong: prefer a profile named fireworks, else the alphabetically first profile.

Tool Calling: The Registry in tools.py

The model can only call tools that the harness declared in the request. tools.py keeps a registry mapping tool names to a description, a parameter list, and a Python handler. render_tools converts each entry into the OpenAI JSON-schema form. For read_file the rendered definition sent over the wire looks like this:

 1 {
 2   "type": "function",
 3   "function": {
 4     "name": "read_file",
 5     "description": "Read and return the contents of a file. Refuses hidden/internal paths (~, #...#, dotfiles) and paths outside the working directory.",
 6     "parameters": {
 7       "type": "object",
 8       "properties": {
 9         "path": {
10           "type": "string",
11           "description": "File path relative to the working directory."
12         }
13       },
14       "required": ["path"]
15     }
16   }
17 }

Here is the whole module:

  1 # tools.py -- tool registry and coding-agent tools
  2 # Python port of tools.rkt (originally py-coding-agent/tools.py).
  3 #
  4 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  5 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
  6 # See LICENSE file for details
  7 #
  8 # Six tools: read_file, list_dir, grep, run_shell, propose_edit,
  9 # replace_in_file. The two edit tools show a colored diff and ask y/n/s;
 10 # propose_edit then gates on `make check`.
 11 
 12 import json
 13 import os
 14 import re
 15 import shlex
 16 import subprocess
 17 
 18 from approval import unified_diff, print_colored_diff, prompt_yes_no_skip, prompt_reason
 19 
 20 # ---------------------------------------------------------------------------
 21 # Registry
 22 
 23 registry = {}
 24 
 25 SHELL_WHITELIST = {"make", "ls", "pwd", "cat", "uv"}
 26 MAX_CHECK_OUTPUT_CHARS = 2000
 27 MAX_SHELL_OUTPUT_CHARS = 20000
 28 GREP_MAX_MATCHES = 200
 29 GREP_MAX_LINE_CHARS = 300
 30 SHELL_TIMEOUT = 300
 31 CHECK_TIMEOUT = 600
 32 TIMEOUT_EXIT_CODE = 124
 33 
 34 # CLI-controlled modes (mutated by agent.py, read by the tools)
 35 auto_approve = False
 36 dry_run = False
 37 quiet_mode = False
 38 
 39 # Set by tool_propose_edit / tool_replace_in_file when an applied change leaves
 40 # `make check` failing. agent.py reads it to pick the process exit code for a
 41 # one-shot run instead of re-scanning old tool output.
 42 make_check_failed = False
 43 
 44 
 45 def define_tool(name, params, description, handler):
 46     """params: list of (pname, ptype, pdesc) tuples."""
 47     registry[name] = {
 48         "name": name,
 49         "description": description,
 50         "parameters": params,
 51         "handler": handler,
 52     }
 53 
 54 
 55 def render_tools(names):
 56     rendered = []
 57     for name in names:
 58         tool = registry.get(name)
 59         if tool is None:
 60             raise ValueError("Undefined tool: {}".format(name))
 61         props = {}
 62         required = []
 63         for (pname, ptype, pdesc) in tool["parameters"]:
 64             props[pname] = {"type": ptype, "description": pdesc}
 65             required.append(pname)
 66         rendered.append({
 67             "type": "function",
 68             "function": {
 69                 "name": tool["name"],
 70                 "description": tool["description"],
 71                 "parameters": {
 72                     "type": "object",
 73                     "properties": props,
 74                     "required": required,
 75                 },
 76             },
 77         })
 78     return rendered
 79 
 80 
 81 # ---------------------------------------------------------------------------
 82 # Tool dispatch
 83 
 84 def call_tool(name, args):
 85     tool = registry.get(name)
 86     if tool is None:
 87         return "Error: unknown tool '{}'. Available tools: {}".format(
 88             name, ", ".join(sorted(registry)))
 89     params = tool["parameters"]
 90     # Missing required args? Return an actionable error describing the expected
 91     # argument list -- small models frequently emit malformed/truncated
 92     # arguments, and silently receiving None tends to send them into retry loops.
 93     # Match the Racket semantics: only a missing key or a JSON null counts as
 94     # missing -- the empty string is a VALID value (propose_edit passes ""
 95     # and replace_in_file may pass an empty replacement).
 96     missing = [p[0] for p in params if args.get(p[0]) is None]
 97     if missing:
 98         return ("Error: tool '{}' missing required argument(s): {}. "
 99                 "Expected arguments (JSON object): {}".format(
100                     name, ", ".join(missing),
101                     ", ".join(p[0] for p in params)))
102     positional = [args.get(p[0]) for p in params]
103     try:
104         result = tool["handler"](*positional)
105         return str(result) if result is not None else ""
106     except Exception as e:  # noqa: BLE001
107         return "Error: tool '{}' raised: {}  (check argument types/values)".format(name, e)
108 
109 
110 def execute_tool_calls(tool_calls):
111     """tool-calls: list of dicts with 'id', 'function' {name, arguments}.
112 
113     Returns list of (call_id, name, result_str) tuples.
114     """
115     results = []
116     for call in tool_calls:
117         call_id = call.get("id", "")
118         func = call.get("function") or {}
119         name = func.get("name", "")
120         name = name if isinstance(name, str) else str(name)
121         args_json = func.get("arguments", "{}")
122         if args_json is None or not isinstance(args_json, str):
123             args_json = "{}" if args_json is None else json.dumps(args_json)
124         short = args_json if len(args_json) <= 120 else args_json[:117] + "..."
125         if not quiet_mode:
126             print("* {} {}".format(name, short))
127         bad_json = None
128         try:
129             parsed = json.loads(args_json)
130             args_parsed = parsed if isinstance(parsed, dict) else "NOT-OBJECT"
131         except Exception as e:  # noqa: BLE001
132             # The decode error itself ("Expecting ',' delimiter: line 3 ...")
133             # tells the model what to fix, so pass it through.
134             bad_json = str(e)
135             args_parsed = "BAD-JSON"
136         if isinstance(args_parsed, dict):
137             # Coerce non-string values to strings so handlers behave like the
138             # Racket version's format-based rendering.
139             args_parsed = {k: (v if isinstance(v, str) else json.dumps(v))
140                            for k, v in args_parsed.items()}
141         if str(name).strip() == "":
142             # Truncated tool call -- the model stopped mid-generation, so no
143             # function name survived. Feed that back instead of crashing.
144             result = ("Error: the model's tool call was truncated mid-generation "
145                       "(no function name provided). Received arguments: {}".format(short))
146         elif bad_json is not None:
147             result = "Error: invalid JSON in arguments for tool '{}': {}. Received: {}".format(
148                 name, bad_json, short)
149         elif args_parsed == "NOT-OBJECT":
150             result = "Error: arguments for tool '{}' must be a JSON object. Received: {}".format(name, short)
151         else:
152             result = call_tool(name, args_parsed)
153         # Guarantee a usable id: some local servers omit tool_call ids, and the
154         # matching role:"tool" message needs one to line up.
155         if not isinstance(call_id, str) or call_id == "":
156             call_id = "call_{}".format(len(results))
157         results.append((call_id, name, result))
158     return results
159 
160 
161 # ---------------------------------------------------------------------------
162 # Helpers: run subprocess and capture combined output
163 
164 def run_external(exe, args, timeout=SHELL_TIMEOUT):
165     """exe: str, args: list of str -> (combined_output, exit_code).
166 
167     A timeout is reported as exit code 124 rather than raised, so a hung
168     command becomes tool feedback instead of an exception.
169     """
170     try:
171         proc = subprocess.run([exe] + list(args),
172                               stdin=subprocess.DEVNULL,
173                               stdout=subprocess.PIPE, stderr=subprocess.PIPE,
174                               text=True, errors="replace", timeout=timeout)
175     except subprocess.TimeoutExpired:
176         return ("command timed out after {}s: {} {}".format(
177             timeout, exe, " ".join(str(a) for a in args)), TIMEOUT_EXIT_CODE)
178     return (proc.stdout + proc.stderr), proc.returncode
179 
180 
181 def truncate_string(s, max_len):
182     if len(s) > max_len:
183         return s[:max_len] + "\n... (truncated, {} total chars)".format(len(s))
184     return s
185 
186 
187 def strip_shell_quotes(s):
188     if len(s) >= 2 and ((s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'")):
189         return s[1:-1]
190     return s
191 
192 
193 # Hidden files (ignored from listings and rejected on read/search/run):
194 #   - names ending in ~  (e.g. foo.rkt~)
195 #   - names wrapped in #...#  (e.g. #foo.rkt#)
196 #   - names starting with .  (e.g. .git, .gitignore, .env)
197 def hidden_file(name):
198     return (name.endswith("~")
199             or (name.startswith("#") and name.endswith("#"))
200             or name.startswith("."))
201 
202 
203 def hidden_in_path(path):
204     """True when any component of `path` names a hidden/internal file.
205 
206     Checking every component (not just the basename) is what keeps the tools
207     out of directories such as .git/ or ~/.ssh/. "." and ".." are ordinary
208     path syntax, not hidden names, so they are skipped here.
209     """
210     for part in re.split(r"[\\/]+", str(path)):
211         if part in ("", ".", ".."):
212             continue
213         if hidden_file(part):
214             return True
215     return False
216 
217 
218 def hidden_arg(s):
219     cleaned = strip_shell_quotes(s)
220     return (not cleaned.startswith("-")) and hidden_in_path(cleaned)
221 
222 
223 def working_dir():
224     """The directory the agent is allowed to touch (established by --cwd)."""
225     return os.path.realpath(os.getcwd())
226 
227 
228 def path_within_working_dir(path):
229     """True when `path` resolves to the working directory or something inside it."""
230     root = working_dir()
231     full = os.path.realpath(os.path.join(root, os.path.expanduser(path)))
232     return full == root or full.startswith(root + os.sep)
233 
234 
235 def resolve_tool_path(path):
236     """-> (absolute path, None) or (None, refusal message).
237 
238     Every tool that touches the filesystem funnels through this: hidden
239     components are refused, and anything outside the working directory (../,
240     absolute paths, symlinks that escape) is refused as well.
241     """
242     if not isinstance(path, str) or path.strip() == "":
243         return None, "refusing: empty path"
244     if hidden_in_path(path):
245         return None, "refusing to touch hidden/internal path: {}".format(path)
246     full = os.path.realpath(os.path.join(working_dir(), os.path.expanduser(path)))
247     if full != working_dir() and not full.startswith(working_dir() + os.sep):
248         return None, ("refusing: {} is outside the working directory {}. "
249                       "Use a path relative to it.").format(path, working_dir())
250     return full, None
251 
252 
253 def looks_like_long_ls_line(line):
254     """True for `ls -l` rows: permissions, links, owner, group, size, date, name."""
255     parts = line.split(None, 1)
256     if not parts:
257         return False
258     mode = parts[0]
259     return len(mode) >= 10 and mode[0] in "-dlbcps" and set(mode[1:10]) <= set("rwxSsTt-")
260 
261 
262 def filter_ls_output(out):
263     """Drop `total N` headers, the . / .. rows, and hidden names.
264 
265     Long-format rows are split into at most eight fields so that a filename
266     containing spaces survives intact; short-format rows are split into one
267     name per whitespace-separated token.
268     """
269     result_lines = []
270     for line in out.split("\n"):
271         t = line.strip()
272         if t == "" or t.startswith("total "):
273             continue
274         if looks_like_long_ls_line(t):
275             fields = t.split(None, 8)
276             fname = fields[8] if len(fields) > 8 else fields[-1]
277             if fname in (".", "..") or hidden_file(fname):
278                 continue
279         else:
280             names = [n for n in t.split()
281                      if n not in (".", "..") and not hidden_file(n)]
282             if not names:
283                 continue
284             line = " ".join(names)
285         result_lines.append(line)
286     return "\n".join(result_lines)
287 
288 
289 # ---------------------------------------------------------------------------
290 # Tool implementations
291 
292 def tool_read_file(path):
293     full, refusal = resolve_tool_path(path)
294     if refusal:
295         return refusal
296     try:
297         with open(full, "r", encoding="utf-8", errors="replace") as f:
298             return f.read()
299     except Exception as e:  # noqa: BLE001
300         return "Error reading {}: {}".format(path, e)
301 
302 
303 def tool_list_dir(path):
304     full, refusal = resolve_tool_path(path)
305     if refusal:
306         return refusal
307     try:
308         entries = sorted(os.listdir(full))
309         lines = []
310         for e in entries:
311             if hidden_file(e):
312                 continue
313             child = os.path.join(full, e)
314             lines.append(e + "/" if os.path.isdir(child) else e)
315         return "\n".join(lines)
316     except Exception as e:  # noqa: BLE001
317         return "Error listing {}: {}".format(path, e)
318 
319 
320 def tool_grep(pattern, path):
321     """Recursive regex search that skips hidden files and directories.
322 
323     Implemented with os.walk + re instead of shelling out to `grep -rnE` for
324     three reasons: hidden directories (.git/) stay out of the model's context,
325     a pattern beginning with "-" cannot be mistaken for a flag, and the result
326     can be capped and truncated instead of flooding the window.
327     """
328     root, refusal = resolve_tool_path(path)
329     if refusal:
330         return refusal
331     try:
332         rx = re.compile(pattern)
333     except re.error as e:
334         return "Error: invalid regular expression '{}': {}".format(pattern, e)
335 
336     targets = [root] if os.path.isfile(root) else None
337     if targets is None and not os.path.isdir(root):
338         return "Error: no such file or directory: {}".format(path)
339 
340     matches = []
341     truncated = False
342 
343     def add_match(text):
344         nonlocal truncated
345         if len(matches) >= GREP_MAX_MATCHES:
346             truncated = True
347             return
348         matches.append(truncate_string(text, GREP_MAX_LINE_CHARS))
349 
350     if targets:
351         files = targets
352     else:
353         files = []
354         for dirpath, dirnames, filenames in os.walk(root):
355             dirnames[:] = sorted(d for d in dirnames if not hidden_file(d))
356             for fname in sorted(filenames):
357                 if not hidden_file(fname):
358                     files.append(os.path.join(dirpath, fname))
359 
360     for fname in files:
361         try:
362             with open(fname, "r", encoding="utf-8", errors="replace") as f:
363                 for lineno, line in enumerate(f, 1):
364                     if rx.search(line):
365                         rel = os.path.relpath(fname, working_dir())
366                         add_match("{}:{}:{}".format(rel, lineno, line.rstrip("\n")))
367                         if truncated:
368                             break
369         except (OSError, UnicodeError):
370             continue
371         if truncated:
372             break
373     if not matches:
374         return "no matches for {!r} under {}".format(pattern, os.path.relpath(root, working_dir()))
375     if truncated:
376         matches.append("... (stopped after {} matches; narrow the pattern or path)".format(
377             GREP_MAX_MATCHES))
378     return "\n".join(matches)
379 
380 
381 def tool_run_shell(command):
382     command = command.strip()
383     try:
384         tokens = shlex.split(command)
385     except ValueError:
386         tokens = command.split()
387     if not tokens:
388         return "empty command"
389     cmd = tokens[0]
390     if cmd not in SHELL_WHITELIST:
391         return "Command '{}' not whitelisted. Allowed: {}".format(
392             cmd, ", ".join(sorted(SHELL_WHITELIST)))
393     for a in tokens[1:]:
394         if hidden_arg(a):
395             return "refusing to run command referencing hidden/internal file: {}".format(a)
396         # Note: the whitelist limits WHICH programs run. It does not sandbox
397         # what they do -- `uv run` and `make` execute arbitrary code by design.
398         try:
399             inside = path_within_working_dir(strip_shell_quotes(a))
400         except (OSError, ValueError):
401             inside = False
402         if not inside:
403             return ("refusing to run command referencing a path outside the working "
404                     "directory: {}".format(a))
405     try:
406         out, code = run_external(cmd, tokens[1:], timeout=SHELL_TIMEOUT)
407         filtered = filter_ls_output(out) if cmd == "ls" else out
408         return "{}(exit {})".format(truncate_string(filtered, MAX_SHELL_OUTPUT_CHARS), code)
409     except Exception as e:  # noqa: BLE001
410         return "Error running command: {}".format(e)
411 
412 
413 def run_make_check():
414     try:
415         return run_external("make", ["check"], timeout=CHECK_TIMEOUT)
416     except Exception as e:  # noqa: BLE001
417         return "make check error: {}".format(e), 1
418 
419 
420 def apply_change(path, full, new):
421     """Write the approved contents and report the `make check` verdict."""
422     global make_check_failed
423     parent = os.path.dirname(full)
424     if parent:
425         os.makedirs(parent, exist_ok=True)
426     with open(full, "w", encoding="utf-8") as f:
427         f.write(new)
428     out, status = run_make_check()
429     if status == 0:
430         return "applied; make check passed"
431     make_check_failed = True
432     return "applied; make check FAILED (exit {}):\n{}".format(
433         status, truncate_string(out, MAX_CHECK_OUTPUT_CHARS))
434 
435 
436 def gate_write(path, full, current, new, heading):
437     """Shared approval flow: diff, then y/n/s, then write + `make check`."""
438     diff_text = unified_diff(current, new, "a/" + path, "b/" + path)
439     print("")
440     if heading:
441         print(heading)
442     print_colored_diff(diff_text)
443 
444     if dry_run:
445         return "dry-run: diff shown, file not written (use without --dry-run to apply)"
446     if auto_approve:
447         # Safety: still show diff above, then auto-apply without prompting
448         if not quiet_mode:
449             print("[auto-approve: applying change without prompt]")
450         return apply_change(path, full, new).replace(
451             "applied;", "applied (auto-approved);", 1)
452     answer = prompt_yes_no_skip()
453     if answer == "no":
454         return "user rejected the change"
455     if answer == "skip":
456         reason = prompt_reason()
457         return "user skipped: {}".format(reason)
458     return apply_change(path, full, new)
459 
460 
461 def tool_propose_edit(path, old, new):
462     """Whole-file edit: `old` must match the current contents exactly ("" to create)."""
463     full, refusal = resolve_tool_path(path)
464     if refusal:
465         return refusal
466     exists = os.path.isfile(full)
467     if exists:
468         try:
469             with open(full, "r", encoding="utf-8", errors="replace") as f:
470                 current = f.read()
471         except Exception as e:  # noqa: BLE001
472             return "Error reading {}: {}".format(path, e)
473     else:
474         current = ""
475 
476     if exists and current != old:
477         return ("stale base: on-disk contents of {} do not match the 'old' you "
478                 "provided. Read the file again and retry.".format(path))
479     if exists and current == new:
480         return "no changes (proposed content matches current file)"
481     if not exists and new == "":
482         return "refused: cannot create an empty file"
483 
484     heading = "(new file: {})".format(path) if not exists else None
485     return gate_write(path, full, current, new, heading)
486 
487 
488 def tool_replace_in_file(path, old_string, new_string):
489     """Compact edit: replace one unique snippet instead of echoing the whole file."""
490     full, refusal = resolve_tool_path(path)
491     if refusal:
492         return refusal
493     if old_string == "":
494         return "refused: old_string is empty; use propose_edit to create a file"
495     try:
496         with open(full, "r", encoding="utf-8", errors="replace") as f:
497             current = f.read()
498     except Exception as e:  # noqa: BLE001
499         return "Error reading {}: {}".format(path, e)
500 
501     hits = current.count(old_string)
502     if hits == 0:
503         return ("old_string not found in {}. Read the file again and copy a unique "
504                 "snippet exactly, including indentation.".format(path))
505     if hits > 1:
506         return ("old_string appears {} times in {}. Include more surrounding "
507                 "context so the snippet is unique.".format(hits, path))
508     new = current.replace(old_string, new_string, 1)
509     if new == current:
510         return "no changes (replacement is identical to the matched text)"
511     return gate_write(path, full, current, new, None)
512 
513 
514 # ---------------------------------------------------------------------------
515 # Registration
516 
517 def register_all():
518     define_tool(
519         "read_file",
520         [("path", "string", "File path relative to the working directory.")],
521         "Read and return the contents of a file. Refuses hidden/internal paths (~, #...#, dotfiles) and paths outside the working directory.",
522         tool_read_file)
523     define_tool(
524         "list_dir",
525         [("path", "string", 'Directory path. Use "." for the working directory.')],
526         "List files and subdirectories (with trailing /) in a directory. Hidden/internal files (~, #...#, and dotfiles) are excluded.",
527         tool_list_dir)
528     define_tool(
529         "grep",
530         [("pattern", "string", "Python regular expression to search for."),
531          ("path", "string", "File or directory path to search, relative to the working directory.")],
532         "Recursively search files for PATTERN and return matching lines as path:line:text. Hidden/internal files and directories (dotfiles, .git) are skipped, and the result is capped at {} matches.".format(GREP_MAX_MATCHES),
533         tool_grep)
534     define_tool(
535         "run_shell",
536         [("command", "string", "Shell command. Only whitelisted commands may run: make, ls, pwd, cat, uv.")],
537         "Run a whitelisted shell command and return its combined output. Refuses hidden/internal paths and paths outside the working directory. The whitelist limits which programs run, not what they do: make and uv execute project code.",
538         tool_run_shell)
539     define_tool(
540         "propose_edit",
541         [("path", "string", "Path to the file to edit or create."),
542          ("old", "string", "For an existing file: the exact current contents. For a new file: pass empty string."),
543          ("new", "string", "The proposed new contents of the file, in full.")],
544         "Propose a whole-file edit or new-file creation. The user is shown a unified diff and asked to approve. On approval the file is written and `make check` is run. Prefer replace_in_file for small changes to an existing file.",
545         tool_propose_edit)
546     define_tool(
547         "replace_in_file",
548         [("path", "string", "Path to an existing file inside the working directory."),
549          ("old_string", "string", "The exact snippet to replace. It must appear exactly once in the file."),
550          ("new_string", "string", "The replacement text. Pass empty string to delete the snippet.")],
551         "Propose a surgical edit: replace one unique snippet of an existing file. Far cheaper than propose_edit when the file is large. The user is shown a unified diff and asked to approve; on approval the file is written and `make check` is run.",
552         tool_replace_in_file)
553 
554 
555 ENABLED_TOOLS = ["read_file", "list_dir", "grep", "run_shell", "propose_edit",
556                  "replace_in_file"]

The six tools divide into two groups. The observation tools (read_file, list_dir, grep) cannot change anything, and are restricted only by the path rules. The action tools (run_shell, propose_edit, replace_in_file) can change the world, so all three are gated: run_shell accepts only make, ls, pwd, cat, and uv, and both edit tools require your approval. Two edit tools exist because a whole-file rewrite is the wrong shape for a one-line change. propose_edit makes the model echo the entire file as old; replace_in_file takes a unique snippet instead. On a large file that is the difference between a few thousand tokens per edit and a few dozen.

Five ideas in this file carry most of the teaching value.

Errors are feedback, not exceptions. Look at call_tool and execute_tool_calls. Missing arguments, malformed JSON, truncated tool calls, and unknown tool names all return an error string that goes back to the model in a tool message. Small models produce these failures constantly. Returning actionable text (“missing required argument(s): path; Expected arguments: path”) lets the model correct itself. Raising an exception would abort the whole session. The JSON decoder’s own complaint is passed through too, because “Expecting ‘,’ delimiter: line 1 column 12” tells the model exactly what to fix.

The empty string is a valid argument. When the model creates a new file it passes "" as old. So call_tool treats only a missing key or JSON null as a missing argument. A careless if not value check would reject the one argument value that matters most.

Every filesystem tool funnels through one path check. resolve_tool_path is the single gate: it refuses any path with a hidden component, and refuses anything that resolves outside the working directory, so ../secrets, /etc/passwd, and a symlink pointing out of the tree are all rejected with a message the model can act on. hidden_in_path inspects every component rather than the basename, which is what keeps .git/config and ~/.ssh/id_rsa out of reach; “.” and “..” are path syntax rather than hidden names, so they pass through to the containment test. The predicate hidden_file treats names ending in ~, names wrapped in #...#, and dotfiles as hidden. The observation tools skip those entries, grep never walks into them, and shell arguments that reference them are rejected. Secrets in .env and repository internals in .git stay out of the model’s context.

grep is written in Python rather than shelled out. Wrapping grep -rnE was three lines, but it leaked: a search from the project root returned matches from inside .git, and a pattern beginning with - could be read as a flag. Walking the tree with os.walk and matching with the re module fixes both, and adds a cap (GREP_MAX_MATCHES) plus per-line truncation so one broad search cannot flood the context window. A bad pattern becomes feedback (“invalid regular expression”) instead of a silent empty result.

Edits are checked against a fresh read of the file. In tool_propose_edit, if the model’s old string does not match the current on-disk contents, the tool returns a stale base error telling the model to read the file again and retry. replace_in_file gets the same protection from the other direction: the snippet must appear exactly once, so zero matches (“copy a unique snippet exactly, including indentation”) and multiple matches (“include more surrounding context”) are both refused before any diff is shown. Together they kill an entire class of hallucinated-edit bugs, where a model edits from memory of a file it read twenty messages ago.

When the user approves, apply_change writes the file and runs make check. A failed check returns its output (truncated to 2000 characters) as the tool result, so the model sees the compile or test errors and can react. It also sets the module-level make_check_failed flag, which the CLI turns into a nonzero exit code in one-shot mode so shell scripts can detect a bad edit. run_external adds a timeout to every subprocess, so a hung make becomes a tool result (“command timed out after 600s”) with exit code 124 rather than a terminal that never comes back. And nothing here decides policy on its own: auto_approve, dry_run, and quiet_mode are module globals that agent.py sets from the CLI flags, which is how the same tool code serves both a -y script and an interactive session.

Human Approval: approval.py

Nothing reaches your disk without passing through the approval prompt. approval.py computes a unified diff with difflib, colors it with ANSI codes, and asks y, n, or s. The s answer is the interesting one: it skips the change and collects a one-line reason, which becomes part of the tool result:

1 user skipped: do not touch the public API, add a wrapper instead

The model reads that sentence and changes its plan. Free-text feedback is cheaper for the model to use than a bare rejection.

 1 # approval.py -- colored diffs and y/n/s approval prompts
 2 # Python port of approval.rkt (mirrors py-coding-agent/approval.py).
 3 #
 4 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
 5 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
 6 # See LICENSE file for details
 7 
 8 import difflib
 9 import sys
10 
11 # ANSI colours
12 
13 ANSI_RED = "\033[31m"
14 ANSI_GREEN = "\033[32m"
15 ANSI_CYAN = "\033[36m"
16 ANSI_RESET = "\033[0m"
17 
18 # When False, print diffs without ANSI (for --plain / --no-color / piped output)
19 color_enabled = True
20 
21 
22 def set_color_enabled(enabled):
23     """Turn ANSI colors on/off (used by --plain and the plain/quiet config)."""
24     global color_enabled
25     color_enabled = bool(enabled)
26 
27 
28 # ---------------------------------------------------------------------------
29 # unified_diff : str str str str -> str
30 # difflib-based replacement for running `diff -u` on two temporary files.
31 
32 def unified_diff(old_content, new_content, old_label, new_label):
33     old_lines = old_content.splitlines(keepends=True)
34     new_lines = new_content.splitlines(keepends=True)
35     # difflib omits the trailing newline on the last line; add one so the
36     # rendered text matches `diff -u` output line for line.
37     diff = difflib.unified_diff(old_lines, new_lines,
38                                 fromfile=old_label, tofile=new_label)
39     text = "".join(diff)
40     if text and not text.endswith("\n"):
41         text += "\n"
42     return text
43 
44 
45 # ---------------------------------------------------------------------------
46 # print_colored_diff : str -> None
47 
48 def print_colored_diff(diff_text):
49     for line in diff_text.split("\n"):
50         if color_enabled and (line.startswith("+++") or line.startswith("---")
51                               or line.startswith("@@")):
52             print(ANSI_CYAN + line + ANSI_RESET)
53         elif color_enabled and line.startswith("+"):
54             print(ANSI_GREEN + line + ANSI_RESET)
55         elif color_enabled and line.startswith("-"):
56             print(ANSI_RED + line + ANSI_RESET)
57         else:
58             print(line)
59 
60 
61 # ---------------------------------------------------------------------------
62 # prompt_yes_no_skip : -> 'yes' | 'no' | 'skip'
63 
64 def prompt_yes_no_skip():
65     """-> 'yes' | 'no' | 'skip'. EOF (no human present) counts as 'no'."""
66     while True:
67         sys.stdout.write("\nApply this change? [y]es / [n]o / [s]kip and tell the model why: ")
68         sys.stdout.flush()
69         try:
70             line = input("")
71         except EOFError:
72             print("\n(no answer available: refusing the change)")
73             return "no"
74         norm = (line or "").strip().lower()
75         if norm in ("y", "yes"):
76             return "yes"
77         if norm in ("n", "no"):
78             return "no"
79         if norm in ("s", "skip"):
80             return "skip"
81         print("Please answer y, n, or s.")
82 
83 
84 # ---------------------------------------------------------------------------
85 # prompt_reason : -> str
86 
87 def prompt_reason():
88     sys.stdout.write("Reason (one line): ")
89     sys.stdout.flush()
90     try:
91         return input("")
92     except EOFError:
93         return ""

The coloring logic is intentionally trivial: cyan for headers and @@ hunk markers, green for + lines, red for - lines. The module-level color_enabled flag is what --plain and --no-color turn off.

One small fidelity detail: unified_diff appends a trailing newline when difflib omits one, so the rendered text matches what the command-line diff -u produces line for line.

The Provider-Agnostic Loop: chat_loop.py

This module is the heart of the harness and is fully independent of any provider. Its interface is the post_fn callback: given a chat-completions payload, return the parsed response as a dictionary with choices[0].message and usage. Both clients provide that shape, so both share one loop:

  1 # chat_loop.py -- provider-agnostic agentic tool-calling loop, shared by
  2 # fireworks_ai.py and mlx_serve.py. Python port of chat-loop.rkt.
  3 #
  4 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  5 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
  6 # See LICENSE file for details
  7 #
  8 # The `post_fn` argument adapts an OpenAI-style chat-completions payload to a
  9 # specific backend and returns a normalized response dict:
 10 #   { 'choices': [ { 'message': <assistant msg>, 'finish_reason': ... } ],
 11 #     'usage':   { 'prompt_tokens': n, 'completion_tokens': n, ... } }
 12 # where <assistant msg> is { 'role': "assistant", 'content': <string> }
 13 # plus, when the model called tools, 'tool_calls' -- a list of
 14 #   { 'id': <string>, 'type': "function",
 15 #     'function': { 'name': <string>, 'arguments': <json string> } }
 16 
 17 from tools import render_tools, execute_tool_calls
 18 
 19 # Repetition detection: track the last few tool-call signatures so a model
 20 # stuck issuing the identical failing call is stopped early rather than
 21 # burning all max-iterations. A signature is (name + args-json) per call,
 22 # sorted, so multi-call batches compare as a set.
 23 REPEAT_WINDOW = 5   # remember the last N batches
 24 REPEAT_LIMIT = 2    # the same batch seen this many times => stuck
 25 
 26 MAX_ITERATIONS_DEFAULT = 20
 27 
 28 
 29 # ---------------------------------------------------------------------------
 30 # Helpers
 31 
 32 def response_message(data):
 33     """Extract the assistant message dict from a normalized response, or None."""
 34     choices = data.get("choices")
 35     if isinstance(choices, list) and choices:
 36         choice = choices[0]
 37         if isinstance(choice, dict):
 38             return choice.get("message")
 39     return None
 40 
 41 
 42 def msg_content(msg):
 43     """The assistant message's 'content', coerced to a string.
 44 
 45     Some OpenAI-compatible servers (e.g. mlx_lm.server) emit "content": null
 46     on tool-only responses; coerce any non-string value to "" here.
 47     """
 48     c = msg.get("content", "") if msg else ""
 49     return c if isinstance(c, str) else ""
 50 
 51 
 52 def clean_assistant_message(msg):
 53     """A copy of an assistant message safe to send back to a strict server.
 54 
 55     Two normalizations happen here. A null or non-string 'content' becomes ""
 56     (mlx_lm.server sends "" on tool-only responses, the OpenAI spec sends
 57     null, and replaying the null back can be rejected). A missing tool-call id
 58     gets a generated one at index 0, 1, ... -- the matching role:"tool" reply
 59     is addressed by that id, so an empty id breaks the pairing.
 60     """
 61     tool_calls = msg.get("tool_calls")
 62     has_calls = isinstance(tool_calls, list) and bool(tool_calls)
 63     out = {"role": "assistant", "content": msg_content(msg)}
 64     if not has_calls:
 65         return out
 66     cleaned = []
 67     for i, tc in enumerate(tool_calls):
 68         func = tc.get("function") or {}
 69         call_id = tc.get("id")
 70         cleaned.append({
 71             "id": call_id if isinstance(call_id, str) and call_id != ""
 72                   else "call_{}".format(i),
 73             "type": tc.get("type") or "function",
 74             "function": {"name": func.get("name", ""),
 75                          "arguments": func.get("arguments", "{}")},
 76         })
 77     out["tool_calls"] = cleaned
 78     reasoning = msg.get("reasoning_content")
 79     if isinstance(reasoning, str) and reasoning != "":
 80         out["reasoning_content"] = reasoning
 81     return out
 82 
 83 
 84 # ---------------------------------------------------------------------------
 85 # Request payload
 86 
 87 def request_payload(model_id, max_tokens, temperature, messages):
 88     """Build a request body, omitting generation parameters the active
 89     provider profile did not declare. No max_tokens/temperature default is
 90     compiled in here; the provider config is the only source for them."""
 91     payload = {"model": model_id, "messages": messages}
 92     if max_tokens is not None:
 93         payload["max_tokens"] = max_tokens
 94     if temperature is not None:
 95         payload["temperature"] = temperature
 96     return payload
 97 
 98 
 99 # ---------------------------------------------------------------------------
100 # chat* : post_fn messages ... -> str
101 
102 def chat(post_fn, messages, model_id, max_tokens=None, temperature=None):
103     payload = request_payload(model_id, max_tokens, temperature, messages)
104     data = post_fn(payload)
105     msg = response_message(data)
106     content = msg_content(msg)
107     return content if content else "No response content"
108 
109 
110 # ---------------------------------------------------------------------------
111 # chat_with_tools : -> (final_text, final_messages)
112 # Multi-turn agentic loop.
113 
114 def chat_with_tools(post_fn, messages, tools, model_id,
115                     max_tokens=None, temperature=None,
116                     max_iterations=MAX_ITERATIONS_DEFAULT):
117     tools_rendered = render_tools(tools)
118     current_messages = list(messages)
119     recent_signatures = []
120 
121     def call_signature(tool_calls):
122         sig = []
123         for tc in tool_calls:
124             f = tc.get("function") or {}
125             sig.append("{}|{}".format(f.get("name", ""), f.get("arguments", "")))
126         return sorted(sig)
127 
128     def remember_and_count(sig):
129         """Record this batch and return how often it has now been seen.
130 
131         The batch is recorded BEFORE the count, so the second identical batch
132         reports 2 and trips REPEAT_LIMIT.
133         """
134         recent_signatures.append(sig)
135         del recent_signatures[:-REPEAT_WINDOW]
136         return sum(1 for s in recent_signatures if s == sig)
137 
138     def append_tool_results(results):
139         for (call_id, name, result_str) in results:
140             current_messages.append({
141                 "role": "tool",
142                 "tool_call_id": call_id,
143                 "name": name,
144                 "content": result_str,
145             })
146 
147     iter_ = 0
148     while True:
149         if iter_ >= max_iterations:
150             # Max iterations -- one final no-tools call for a summary.
151             payload = request_payload(model_id, max_tokens, temperature, current_messages)
152             try:
153                 data = post_fn(payload)
154                 msg = response_message(data)
155                 content = msg_content(msg)
156                 if content == "":
157                     content = "(no summary from model)"
158                 if msg:
159                     current_messages.append(clean_assistant_message(msg))
160                 return content, current_messages
161             except Exception:
162                 return "(max tool iterations reached)", current_messages
163         iter_ += 1
164 
165         payload = request_payload(model_id, max_tokens, temperature, current_messages)
166         if tools_rendered:
167             payload["tools"] = tools_rendered
168             payload["tool_choice"] = "auto"
169         data = post_fn(payload)
170         msg = response_message(data)
171         if msg is None:
172             raise RuntimeError("response has no 'message'. Raw: {}".format(data))
173         tool_calls = msg.get("tool_calls")
174         content = msg_content(msg)
175         assistant_msg = clean_assistant_message(msg)
176         current_messages.append(assistant_msg)
177 
178         if not tool_calls:
179             return (content if content else "(empty response from model)"), current_messages
180 
181         # Echo any reasoning the model emitted alongside its tool calls, then
182         # run the calls. Results are appended before the stuck-model check so
183         # the transcript keeps a role:"tool" reply for every call.
184         if content.strip():
185             print("")
186             print(content.strip())
187         batch = call_signature(tool_calls)
188         results = execute_tool_calls(tool_calls)
189         append_tool_results(results)
190         seen = remember_and_count(batch)
191         if seen >= REPEAT_LIMIT:
192             # The model is stuck re-issuing the identical call(s) -- bail out
193             # with an explanation instead of looping to max-iterations.
194             return ("(stopped: the model repeated the identical tool call(s) "
195                     "{} times without making progress; it may be too weak for "
196                     "this task or its arguments are malformed)".format(seen),
197                     current_messages)

Follow chat_with_tools iteration by iteration:

  1. Render the tool definitions and attach them to the payload with "tool_choice": "auto".
  2. POST. Extract the assistant message.
  3. Append a normalized copy of the assistant message to the history. The history grows to be a faithful transcript: user text, assistant text, tool calls, tool results.
  4. If there are no tool_calls, the turn is over. Return the text and the new history.
  5. If there are tool calls, print any accompanying text, execute the calls, append one tool message per call, and go back to step 1.

Three safeguards stand out.

Repetition detection. Each batch of tool calls is reduced to a signature: a sorted list of name|arguments-json strings. Sorting makes multi-call batches compare as a set, so the order the model emits them does not hide a repeat. remember_and_count records the new signature before counting how often it has appeared in the last REPEAT_WINDOW = 5 batches, so the second identical batch reports 2 and trips REPEAT_LIMIT = 2. Order matters here: count first and the same batch has to appear a third time before the loop notices. When the limit trips, the loop stops and says so, and a model stuck on a malformed argument no longer burns twenty iterations of your token budget.

A stopped loop is still a valid transcript. The repetition check runs after the tool results are appended, and the reason is a protocol detail that bites anyone who writes this loop: an assistant message carrying tool_calls must be followed by one role: "tool" message per call. Returning early before executing the batch would leave that assistant message dangling, and a strict server rejects the next request with a 400. The test test_repeated_batch_stops_after_the_second_attempt asserts that the counts stay equal.

Bounded iterations with a graceful ending. After max_iterations turns (the default MAX_ITERATIONS_DEFAULT = 20, overridable per profile), the loop makes one final request without tools, forcing the model to produce a text summary instead of another tool call. If even that fails, it returns a placeholder string. The loop always returns a (text, messages) pair; the session never hangs on the loop itself.

The code also patches over real-world server bugs, and the helper that does it is small enough to quote. clean_assistant_message copies the assistant turn before it goes back on the wire: a missing tool-call id becomes call_0, "content": null becomes "", and an empty reasoning_content is dropped. Either of the first two can make a strict server reject the whole transcript on the next request, so normalizing once at the boundary is cheaper than discovering it in production. The payload builder omits max_tokens and temperature unless they were configured, because some local servers reject unknown or unwanted parameters.

A Hosted Client with Streaming and Cost Accounting: fireworks_ai.py

The name is historical; the module talks to any OpenAI-compatible endpoint named by the active profile. It adds two things the shared loop does not do: it reads the config for endpoint, key variable, model, and generation parameters, and it consumes SSE streaming responses.

A streaming response arrives as a series of data: lines, each holding a small JSON chunk. Content arrives in fragments, and so do tool calls, split across chunks by index:

1 data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"grep"}}]}}]}
2 data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"pat"}}]}}]}
3 data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"tern\": \"foo\"}"}}]}}]}
4 data: [DONE]

parse_sse_response reassembles these fragments into one ordinary, non-streaming-shaped response dict, so the rest of the program never knows streaming happened. It accumulates content, reasoning_content (emitted by DeepSeek-style models), and per-index tool-call fragments, and keeps the last usage object, which the server sends at the end of the stream when stream_options: {"include_usage": true} is set.

  1 # fireworks_ai.py -- OpenAI-compatible (Fireworks AI) streaming API client,
  2 # session stats, chat helpers. Python port of fireworks-ai.rkt
  3 # (originally py-coding-agent/fireworks_ai.py).
  4 #
  5 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  6 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
  7 # See LICENSE file for details
  8 #
  9 # Endpoint, model, api_key_env, generation parameters, and pricing all come
 10 # from the active provider profile in the harness config (~/.coding_harness.json
 11 # and/or .local_coding_harness.json); no provider-specific value is compiled
 12 # in here.
 13 
 14 import json
 15 import os
 16 import threading
 17 
 18 import requests
 19 
 20 from harness_config import (config_active_provider, provider_api_key_env,
 21                             provider_endpoint, provider_generation,
 22                             provider_model, provider_pricing, pricing_ref,
 23                             generation_ref)
 24 from chat_loop import chat as chat_star
 25 from chat_loop import chat_with_tools as chat_with_tools_star
 26 from chat_loop import MAX_ITERATIONS_DEFAULT
 27 
 28 # ---------------------------------------------------------------------------
 29 # Modes and timeouts
 30 
 31 debug_log = False  # shared /debug toggle (agent.py flips this)
 32 
 33 # Requests use SSE streaming ("stream": true), so there is NO total
 34 # wall-clock cap on generation: a long response that keeps producing
 35 # tokens simply keeps streaming. The only remaining timeouts are:
 36 #   CONNECT_MAX_TIME    -- seconds to establish the TCP connection.
 37 #   STREAM_IDLE_TIMEOUT -- seconds the server may stay silent, both while
 38 #                          waiting for response headers (time to first token)
 39 #                          and between chunks. Tokens arriving periodically
 40 #                          never trip this; only a stalled connection does.
 41 CONNECT_MAX_TIME = 10
 42 STREAM_IDLE_TIMEOUT = 300
 43 
 44 # ---------------------------------------------------------------------------
 45 # Pricing -- read from the active provider profile's "pricing" block (USD per
 46 # 1M tokens). A profile that declares no pricing yields None rates, and
 47 # callers report the cost as unknown instead of inventing a number.
 48 
 49 def active_pricing():
 50     return provider_pricing(config_active_provider())
 51 
 52 
 53 # ---------------------------------------------------------------------------
 54 # Session stats (thread-safe)
 55 
 56 _stats_lock = threading.Lock()
 57 _session_prompt_tokens = 0
 58 _session_completion_tokens = 0
 59 _session_total_tokens = 0
 60 _session_cached_tokens = 0
 61 
 62 
 63 def reset_session_stats():
 64     global _session_prompt_tokens, _session_completion_tokens
 65     global _session_total_tokens, _session_cached_tokens
 66     with _stats_lock:
 67         _session_prompt_tokens = 0
 68         _session_completion_tokens = 0
 69         _session_total_tokens = 0
 70         _session_cached_tokens = 0
 71 
 72 
 73 # Costs use the active provider's configured rates. Each returns None when
 74 # the profile declares no such rate, so callers can report "unknown" instead
 75 # of a misleading $0.00.
 76 
 77 def rate_cost(tokens, rate):
 78     return (tokens * rate / 1_000_000) if rate is not None else None
 79 
 80 
 81 def prompt_cost(tokens):
 82     return rate_cost(tokens, pricing_ref(active_pricing(), "input"))
 83 
 84 
 85 def cached_cost(tokens):
 86     return rate_cost(tokens, pricing_ref(active_pricing(), "cached_input"))
 87 
 88 
 89 def completion_cost(tokens):
 90     return rate_cost(tokens, pricing_ref(active_pricing(), "output"))
 91 
 92 
 93 def session_snapshot():
 94     """-> (prompt, cached, completion, total) token totals, read under one lock."""
 95     with _stats_lock:
 96         return (_session_prompt_tokens, _session_cached_tokens,
 97                 _session_completion_tokens, _session_total_tokens)
 98 
 99 
100 # Cached input tokens are reported by the server in
101 # usage.prompt_tokens_details.cached_tokens and are part of prompt_tokens;
102 # bill them at the discounted rate and subtract them from the uncached pool.
103 def session_cost(snapshot=None):
104     """-> number, or None when the active profile declares no pricing at all."""
105     rates = active_pricing()
106     inp = pricing_ref(rates, "input")
107     cached = pricing_ref(rates, "cached_input")
108     out = pricing_ref(rates, "output")
109     if inp is None and cached is None and out is None:
110         return None
111     pt, ca, ct = snapshot or session_snapshot()[:3]
112     return ((rate_cost(max(0, pt - ca), inp) or 0)
113             + (rate_cost(ca, cached) or 0)
114             + (rate_cost(ct, out) or 0))
115 
116 
117 def print_session_stats():
118     pt, ca, ct, tt = session_snapshot()
119     cost = session_cost((pt, ca, ct))
120     rates = active_pricing()
121     print("")
122     print("Session token usage:")
123     print("  Prompt tokens:     {}".format(pt))
124     print("  Completion tokens: {}".format(ct))
125     print("  Total tokens:      {}".format(tt))
126     if ca > 0:
127         pct = 100.0 * ca / max(1, pt)
128         print("  Cached tokens:     {} ({:.1f}% of prompt)".format(ca, pct))
129     if cost is not None:
130         print("  Estimated cost:    ${:.6f}  (${:.4f}/M input, ${:.4f}/M cached input, ${:.4f}/M output)".format(
131             cost,
132             pricing_ref(rates, "input") or 0,
133             pricing_ref(rates, "cached_input") or 0,
134             pricing_ref(rates, "output") or 0))
135     else:
136         print('  Estimated cost:    n/a (no "pricing" block for this provider)')
137 
138 
139 def accumulate_usage(data):
140     global _session_prompt_tokens, _session_completion_tokens
141     global _session_total_tokens, _session_cached_tokens
142     usage = data.get("usage") or {}
143     if isinstance(usage, dict) and usage:
144         with _stats_lock:
145             _session_prompt_tokens += usage.get("prompt_tokens", 0)
146             _session_completion_tokens += usage.get("completion_tokens", 0)
147             _session_total_tokens += usage.get("total_tokens", 0)
148             details = usage.get("prompt_tokens_details") or {}
149             if isinstance(details, dict):
150                 _session_cached_tokens += details.get("cached_tokens", 0)
151 
152 
153 # ---------------------------------------------------------------------------
154 # API key
155 #
156 # The env var name comes from the active provider profile's api_key_env when
157 # a harness config is loaded; falls back to FIREWORKS_API_KEY.
158 
159 
160 def get_api_key():
161     p = config_active_provider()
162     env_name = (provider_api_key_env(p) if p else None) or "FIREWORKS_API_KEY"
163     key = os.environ.get(env_name, "")
164     if not key:
165         raise RuntimeError("fireworks-ai: {} environment variable not set".format(env_name))
166     return key
167 
168 
169 # ---------------------------------------------------------------------------
170 # SSE streaming helpers
171 
172 def parse_sse_chunk(body):
173     """Parse one SSE "data: {...}" body into a dict (or None on bad JSON)."""
174     try:
175         return json.loads(body)
176     except Exception:
177         return None
178 
179 
180 def parse_sse_response(resp):
181     """Reconstruct the equivalent non-streaming chat-completions response from
182     an SSE stream:
183 
184         { 'id': ..., 'model': ...,
185           'choices': [ { 'message': <assistant msg>, 'finish_reason': ... } ],
186           'usage': {...} }
187 
188     `message` carries accumulated 'content', (deepseek) 'reasoning_content',
189     and (when present) a list of 'tool_calls' dicts exactly like a
190     non-streaming response.
191     """
192     content_parts = []
193     reasoning_parts = []
194     tool_calls_by_index = {}  # index -> {'id','type','name','arguments'}
195     usage = None
196     finish_reason = None
197     message_id = ""
198     message_model = ""
199 
200     for raw in resp.iter_lines():
201         if raw is None:
202             continue
203         line = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
204         trimmed = line.strip()
205         if trimmed == "" or trimmed.startswith(":"):  # comment / keep-alive
206             continue
207         if not trimmed.startswith("data:"):
208             continue
209         body = trimmed[5:].strip()
210         if body == "[DONE]":
211             continue
212         chunk = parse_sse_chunk(body)
213         if not isinstance(chunk, dict):
214             continue
215         # API-level error inside the stream
216         if "error" in chunk:
217             err = chunk["error"]
218             msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
219             raise RuntimeError("API error: {}".format(msg))
220         if "id" in chunk:
221             message_id = chunk.get("id", "")
222         if "model" in chunk:
223             message_model = chunk.get("model", "")
224         chunk_usage = chunk.get("usage")
225         if isinstance(chunk_usage, dict):
226             usage = chunk_usage
227         for c in chunk.get("choices") or []:
228             delta = c.get("delta") or {}
229             fr = c.get("finish_reason")
230             if fr is not None and fr != finish_reason:
231                 finish_reason = fr
232             c_delta = delta.get("content")
233             if isinstance(c_delta, str):
234                 content_parts.append(c_delta)
235             r_delta = delta.get("reasoning_content")
236             if isinstance(r_delta, str):
237                 reasoning_parts.append(r_delta)
238             tc = delta.get("tool_calls")
239             if isinstance(tc, list):
240                 for t in tc:
241                     idx = t.get("index", 0)
242                     entry = tool_calls_by_index.get(idx)
243                     if entry is None:
244                         entry = {"id": "", "type": "function", "name": "", "arguments": ""}
245                         tool_calls_by_index[idx] = entry
246                     t_id = t.get("id")
247                     if isinstance(t_id, str) and t_id != "":
248                         entry["id"] = t_id
249                     t_type = t.get("type")
250                     if isinstance(t_type, str):
251                         entry["type"] = t_type
252                     f = t.get("function")
253                     if isinstance(f, dict):
254                         f_name = f.get("name")
255                         if isinstance(f_name, str) and f_name != "":
256                             entry["name"] = f_name
257                         f_args = f.get("arguments")
258                         if isinstance(f_args, str) and f_args != "":
259                             entry["arguments"] += f_args
260 
261     content = "".join(content_parts)
262     reasoning = "".join(reasoning_parts)
263     idxs = sorted(tool_calls_by_index.keys())
264     tool_calls = None
265     if idxs:
266         tool_calls = []
267         for idx in idxs:
268             e = tool_calls_by_index[idx]
269             tool_calls.append({
270                 "id": e["id"],
271                 "type": e["type"],
272                 "function": {"name": e["name"], "arguments": e["arguments"]},
273             })
274     if tool_calls:
275         message = {"role": "assistant", "content": content,
276                    "reasoning_content": reasoning, "tool_calls": tool_calls}
277     else:
278         message = {"role": "assistant", "content": content,
279                    "reasoning_content": reasoning}
280     return {
281         "id": message_id,
282         "model": message_model,
283         "choices": [{"message": message, "finish_reason": finish_reason}],
284         "usage": usage or {},
285     }
286 
287 
288 # ---------------------------------------------------------------------------
289 # Low-level POST (streaming)
290 
291 def post_fireworks(payload):
292     api_key = get_api_key()
293     p = config_active_provider()
294     endpoint = (provider_endpoint(p) if p else None)
295     if not endpoint:
296         raise RuntimeError('fireworks-ai: active provider profile has no "endpoint"; '
297                            "set it in the harness config")
298     headers = {
299         "content-type": "application/json",
300         "accept": "application/json",
301         "authorization": "Bearer " + api_key,
302     }
303     stream_payload = dict(payload)
304     stream_payload["stream"] = True
305     stream_payload["stream_options"] = {"include_usage": True}
306     if debug_log:
307         dbg = {k: v for k, v in stream_payload.items() if k != "messages"}
308         print("[DEBUG] request: {}".format(json.dumps(dbg)))
309     try:
310         resp = requests.post(endpoint, headers=headers, json=stream_payload,
311                              stream=True,
312                              timeout=(CONNECT_MAX_TIME, STREAM_IDLE_TIMEOUT))
313         data = parse_sse_response(resp)
314         resp.close()
315     except (KeyboardInterrupt, SystemExit):
316         raise  # let Ctrl-C reach the REPL as a cancelled turn
317     except Exception as e:  # noqa: BLE001
318         raise RuntimeError("fireworks-ai: HTTP error: {}".format(e))
319     if debug_log:
320         print("[DEBUG] response: {}".format(json.dumps(data)))
321     if isinstance(data, dict) and "error" in data:
322         err = data["error"]
323         msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
324         raise RuntimeError("API error: {}".format(msg))
325     accumulate_usage(data)
326     if "choices" not in data:
327         raise RuntimeError("fireworks-ai: response has no 'choices'. Raw: {}".format(json.dumps(data)))
328     return data
329 
330 
331 # ---------------------------------------------------------------------------
332 # chat / chat_with_tools -- thin wrappers over the shared provider-agnostic
333 # loop in chat_loop.py (also used by mlx_serve.py).
334 #
335 # Generation defaults come from the active provider profile's "generation"
336 # section when a harness config is loaded; explicit keyword args win.
337 # A missing model is an error; missing generation parameters are left out of
338 # the request so the server's own default applies.
339 
340 def active_model_id():
341     p = config_active_provider()
342     m = provider_model(p) if p else None
343     if not m:
344         raise RuntimeError('fireworks-ai: active provider profile has no "model"; '
345                            "set it in the harness config")
346     return m
347 
348 
349 def gen_param(key):
350     return generation_ref(provider_generation(config_active_provider()), key, None)
351 
352 
353 def gen_max_iterations():
354     """Loop budget: profile "generation": {"max_iterations": N} or the default."""
355     n = gen_param("max_iterations")
356     return n if isinstance(n, int) and n > 0 else MAX_ITERATIONS_DEFAULT
357 
358 
359 def chat(messages, model_id=None, max_tokens=None, temperature=None):
360     return chat_star(post_fireworks, messages,
361                      model_id=model_id or active_model_id(),
362                      max_tokens=max_tokens if max_tokens is not None else gen_param("max_tokens"),
363                      temperature=temperature if temperature is not None else gen_param("temperature"))
364 
365 
366 def chat_with_tools(messages, tools, model_id=None, max_tokens=None,
367                     temperature=None, max_iterations=None):
368     return chat_with_tools_star(post_fireworks, messages, tools,
369                                 model_id=model_id or active_model_id(),
370                                 max_tokens=max_tokens if max_tokens is not None else gen_param("max_tokens"),
371                                 temperature=temperature if temperature is not None else gen_param("temperature"),
372                                 max_iterations=max_iterations or gen_max_iterations())

Two aspects deserve attention.

Timeouts for streaming. A total wall-clock timeout would kill long generations, which is wrong for a model that is still producing tokens. The module instead passes requests a (connect, read) pair: CONNECT_MAX_TIME = 10 seconds to open the TCP connection, and STREAM_IDLE_TIMEOUT = 300 as the read timeout, which requests applies per chunk. Because the first streamed byte is itself a read, that same 300-second budget doubles as the time-to-first-token allowance. A response that keeps producing tokens never trips it; only a truly stalled connection does.

Cost accounting. Each response’s usage is added to thread-safe session counters. The profile’s pricing block gives USD rates per 1,000,000 tokens. For Code Test prompt tokens, Code Test of those cached, and Code Test completion tokens, with input, cached-input, and output rates Code Test, Code Test, and Code Test, the session cost is

math

Cached prompt tokens are part of prompt_tokens, so the formula bills them at the discounted rate by subtracting them from the uncached pool. This matters in an agentic loop because the same long message prefix is resent on every iteration, and prompt caching typically turns 60 to 90 percent of a session’s prompt tokens into cached ones, at one fifth the price.

The functions prompt_cost, cached_cost, and completion_cost each return None when the profile omits that rate. session_cost returns None when the whole pricing block is missing, and the printer reports n/a. The lesson: a missing price is unknown, not zero, and reporting an unknown cost as $0.00 is a bug users will not forgive.

The Local Client: mlx_serve.py

Local inference needs neither keys nor streaming. mlx_lm.server and its siblings already speak the OpenAI protocol that chat_loop.py consumes, so this module is thin: forward the payload, return the JSON, and accumulate usage. It mirrors fireworks_ai.py function for function so agent.py can swap providers by changing which module it calls.

  1 # mlx_serve.py -- local MLX (mlx_lm.server) API client, session stats, chat
  2 # helpers. Python port of mlx-serve.rkt. Mirrors the interface of
  3 # fireworks_ai.py so agent.py can swap providers with /provider or
  4 # AGENT_PROVIDER=mlx.
  5 #
  6 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  7 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
  8 # See LICENSE file for details
  9 #
 10 # The endpoint is the OpenAI-compatible /v1/chat/completions route served by
 11 # mlx_lm.server (port 11434), oMLX (port 8000), sushi (port 12345), and
 12 # Ollama's own OpenAI shim. That protocol already matches what chat_loop.py
 13 # consumes (choices[].message with optional tool_calls, usage.prompt_tokens /
 14 # completion_tokens), so there is no message re-shaping here: we forward the
 15 # OpenAI payload and return the response as-is. A Bearer key from the provider
 16 # profile is passed along but ignored by a local server.
 17 #
 18 # Endpoint, model, api_key_env, and generation parameters all come from the
 19 # active provider profile in the harness config; nothing provider-specific is
 20 # compiled in here.
 21 
 22 import json
 23 import os
 24 import threading
 25 
 26 import requests
 27 
 28 from harness_config import (config_active_provider, provider_api_key_env,
 29                             provider_endpoint, provider_generation,
 30                             provider_model, generation_ref)
 31 from chat_loop import chat as chat_star
 32 from chat_loop import chat_with_tools as chat_with_tools_star
 33 from chat_loop import MAX_ITERATIONS_DEFAULT
 34 import fireworks_ai  # for the shared debug_log toggle
 35 
 36 # The provider dict MLX requests consult for endpoint/model/generation.
 37 # agent.py sets this to the active profile (None = use the harness config's
 38 # active provider).
 39 mlx_active_provider = None
 40 
 41 # The OpenAI-compatible endpoint returns reasoning in the assistant message's
 42 # 'reasoning' field, which chat_loop.py ignores (it only reads 'content' and
 43 # 'tool_calls'), so there is no separate thinking toggle to wire up here.
 44 
 45 # Non-streaming request: the whole generation must complete within this
 46 # window. Local models on large weights can be slow, so be generous.
 47 MLX_MAX_TIME = 900
 48 MLX_CONNECT_TIME = 10
 49 
 50 
 51 def current_provider_json():
 52     return mlx_active_provider or config_active_provider() or {}
 53 
 54 
 55 # ---------------------------------------------------------------------------
 56 # Session stats (thread-safe). mlx_lm.server reports prompt_tokens /
 57 # completion_tokens on every /v1/chat/completions response. Local inference is
 58 # free, so stats are informational only -- there is no cost estimate here.
 59 # Both a reset and a print function exist so this module mirrors the
 60 # fireworks_ai.py interface that agent.py calls.
 61 
 62 _stats_lock = threading.Lock()
 63 _session_prompt_tokens = 0
 64 _session_completion_tokens = 0
 65 
 66 
 67 def mlx_reset_session_stats():
 68     global _session_prompt_tokens, _session_completion_tokens
 69     with _stats_lock:
 70         _session_prompt_tokens = 0
 71         _session_completion_tokens = 0
 72 
 73 
 74 def mlx_accumulate_usage(usage):
 75     global _session_prompt_tokens, _session_completion_tokens
 76     if isinstance(usage, dict) and usage:
 77         with _stats_lock:
 78             _session_prompt_tokens += usage.get("prompt_tokens", 0)
 79             _session_completion_tokens += usage.get("completion_tokens", 0)
 80 
 81 
 82 def mlx_print_session_stats():
 83     with _stats_lock:
 84         pt = _session_prompt_tokens
 85         ct = _session_completion_tokens
 86     print("")
 87     print("Session token usage (local MLX -- no API cost):")
 88     print("  Prompt tokens:     {}".format(pt))
 89     print("  Completion tokens: {}".format(ct))
 90     model = provider_model(current_provider_json()) or "?"
 91     print("  Estimated cost:    $0  (local model {})".format(model))
 92 
 93 
 94 # ---------------------------------------------------------------------------
 95 # Low-level POST (non-streaming, OpenAI-compatible)
 96 
 97 def mlx_api_key(provider):
 98     env_name = provider_api_key_env(provider)
 99     return os.environ.get(env_name) if env_name else None
100 
101 
102 def post_mlx(payload):
103     provider = current_provider_json()
104     # chat_loop.py already builds a complete OpenAI-shaped body, including
105     # tools/tool_choice when present, so it is forwarded as-is.
106     request_body = payload
107     endpoint = provider_endpoint(provider)
108     if not endpoint:
109         raise RuntimeError('mlx-serve: active provider profile has no "endpoint"; '
110                            "set it in the harness config")
111     key = mlx_api_key(provider)
112     headers = {"content-type": "application/json"}
113     if key:
114         headers["authorization"] = "Bearer " + key
115     if fireworks_ai.debug_log:
116         print("[DEBUG] mlx request ({}): {}".format(endpoint, json_dumps(request_body)))
117     try:
118         resp = requests.post(endpoint, headers=headers, json=request_body,
119                              timeout=(MLX_CONNECT_TIME, MLX_MAX_TIME))
120         data = resp.json()
121     except (KeyboardInterrupt, SystemExit):
122         raise  # let Ctrl-C reach the REPL as a cancelled turn
123     except Exception as e:  # noqa: BLE001
124         raise RuntimeError("mlx-serve: HTTP error: {}".format(e))
125     if fireworks_ai.debug_log:
126         print("[DEBUG] mlx response: {}".format(json_dumps(data)))
127     if isinstance(data, dict) and "error" in data:
128         err = data["error"]
129         msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
130         raise RuntimeError("MLX API error: {}".format(msg))
131     if "choices" not in data:
132         raise RuntimeError("MLX response has no 'choices'. Raw: {}".format(json_dumps(data)))
133     mlx_accumulate_usage(data.get("usage") or {})
134     return data
135 
136 
137 def json_dumps(x):
138     try:
139         return json.dumps(x)
140     except Exception:
141         return str(x)
142 
143 
144 # ---------------------------------------------------------------------------
145 # mlx_chat / mlx_chat_with_tools -- same signatures as fireworks_ai.py.
146 # Generation parameters resolve from the active profile; a missing model is
147 # an error, and missing generation parameters are simply left out of the
148 # request.
149 
150 def m_gen_param(key):
151     return generation_ref(provider_generation(current_provider_json()), key, None)
152 
153 
154 def m_max_iterations():
155     """Loop budget: profile "generation": {"max_iterations": N} or the default."""
156     n = m_gen_param("max_iterations")
157     return n if isinstance(n, int) and n > 0 else MAX_ITERATIONS_DEFAULT
158 
159 
160 def m_model_id():
161     m = provider_model(current_provider_json())
162     if not m:
163         raise RuntimeError('mlx-serve: active provider profile has no "model"; '
164                            "set it in the harness config")
165     return m
166 
167 
168 def mlx_chat(messages, model_id=None, max_tokens=None, temperature=None):
169     return chat_star(post_mlx, messages,
170                      model_id=model_id or m_model_id(),
171                      max_tokens=max_tokens if max_tokens is not None else m_gen_param("max_tokens"),
172                      temperature=temperature if temperature is not None else m_gen_param("temperature"))
173 
174 
175 def mlx_chat_with_tools(messages, tools, model_id=None, max_tokens=None,
176                         temperature=None, max_iterations=None):
177     return chat_with_tools_star(post_mlx, messages, tools,
178                                 model_id=model_id or m_model_id(),
179                                 max_tokens=max_tokens if max_tokens is not None else m_gen_param("max_tokens"),
180                                 temperature=temperature if temperature is not None else m_gen_param("temperature"),
181                                 max_iterations=max_iterations or m_max_iterations())

Note MLX_MAX_TIME = 900. A non-streaming local request must finish generation inside one read window, and large quantized models on a laptop can be slow. Be generous.

Note too that print_session_stats here always reports $0. Local inference is free per token; you paid for it in hardware and electricity. The counts are still printed, because they tell you whether the context window is getting large.

Web Search: search.py

Coding questions are often really documentation questions (“what changed in this library’s API since version 2”). The harness can optionally search the web and insert the results into the prompt. Two backends are implemented, both returning (url, title, snippet) triples.

The Brave API returns results under a nested web.results key:

1 {
2   "web": {
3     "results": [
4       { "url": "https://docs.example.com/api",
5         "title": "HTTP Client Reference",
6         "description": "Configuration options, timeouts, and session reuse." }
7     ]
8   }
9 }

Exa returns a flat results list with a highlights array per result, and the code takes the first highlight as the snippet. Here is the module:

 1 # search.py -- Brave Search and Exa AI search backends
 2 # Python port of search.rkt (originally py-coding-agent/search.py).
 3 #
 4 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
 5 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
 6 # See LICENSE file for details
 7 
 8 import os
 9 
10 import requests
11 
12 EXA_ENDPOINT = "https://api.exa.ai/search"
13 
14 
15 # ---------------------------------------------------------------------------
16 # Brave Search
17 # Returns list of (url, title, description)
18 
19 def brave_search(query, num_results=5):
20     api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
21     if not api_key:
22         raise RuntimeError("brave-search: BRAVE_SEARCH_API_KEY environment variable not set")
23     url = ("https://api.search.brave.com/res/v1/web/search?q={}&count={}"
24            .format(requests.utils.quote(query), num_results))
25     headers = {
26         "X-Subscription-Token": api_key,
27         "content-type": "application/json",
28         "accept": "application/json",
29     }
30     resp = requests.get(url, headers=headers, timeout=30)
31     data = resp.json()
32     web = data.get("web", {})
33     results = web.get("results", [])
34     return [(r.get("url", ""), r.get("title", ""), r.get("description", ""))
35             for r in results]
36 
37 
38 # ---------------------------------------------------------------------------
39 # Exa AI Search
40 # Returns list of (url, title, highlight)
41 
42 def exa_search(query, num_results=5):
43     api_key = os.environ.get("EXA_SEARCH_API_KEY", "")
44     if not api_key:
45         raise RuntimeError("exa-search: EXA_SEARCH_API_KEY environment variable not set")
46     payload = {
47         "query": query,
48         "type": "auto",
49         "numResults": num_results,
50         "contents": {"highlights": True},
51     }
52     headers = {
53         "content-type": "application/json",
54         "authorization": "Bearer " + api_key,
55     }
56     resp = requests.post(EXA_ENDPOINT, headers=headers, json=payload, timeout=30)
57     data = resp.json()
58     results = data.get("results", [])
59     out = []
60     for r in results:
61         hl = r.get("highlights") or []
62         out.append((r.get("url", ""), r.get("title", ""),
63                     hl[0] if isinstance(hl, list) and hl else ""))
64     return out

When search is on, format_search_results in agent.py turns the triples into a numbered block and prepends it to the user’s question, so the model receives the evidence and the ask together.

The REPL: agent.py

The main module wires everything together: config loading, environment overrides, CLI parsing, the REPL, slash commands, intent routing, and context management. Read it in full, then we will take the tour.

   1 # agent.py -- main REPL loop and CLI entry point
   2 # Python port of agent.rkt (originally py-coding-agent/agent.py).
   3 #
   4 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
   5 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
   6 # See LICENSE file for details
   7 #
   8 # Configuration comes from the hierarchical harness config:
   9 #   global  ~/.coding_harness.json
  10 #   local   .local_coding_harness.json  (overrides the global file)
  11 # Precedence: CLI flags > env > harness/legacy config file > profile defaults.
  12 
  13 import argparse
  14 import os
  15 import sys
  16 
  17 import approval
  18 import fireworks_ai
  19 import harness_config as hc
  20 import line_input
  21 import mlx_serve
  22 import search as search_mod
  23 import tools
  24 from harness_config import (config_active_provider, config_active_provider_name,
  25                             config_provider, config_provider_names,
  26                             config_set_active_provider, generation_ref,
  27                             provider_generation, provider_model, provider_type)
  28 from tools import ENABLED_TOOLS, register_all
  29 
  30 
  31 def version():
  32     """Prefer the installed distribution's version so pyproject.toml is the
  33     single source of truth; fall back for a source checkout that is not
  34     installed (plain `python agent.py`)."""
  35     try:
  36         from importlib.metadata import PackageNotFoundError, version as dist_version
  37         try:
  38             return dist_version("coding-harness-agent")
  39         except PackageNotFoundError:
  40             pass
  41     except Exception:  # noqa: BLE001
  42         pass
  43     return "0.2.0"
  44 
  45 
  46 VERSION = version()
  47 
  48 # ---------------------------------------------------------------------------
  49 # Prompts
  50 
  51 SYSTEM_PROMPT_TEMPLATE = (
  52     "You are an interactive coding assistant working in the directory {cwd}.\n\n"
  53     "Rules:\n"
  54     "- Use read_file, list_dir, and grep to understand the code BEFORE proposing edits.\n"
  55     "- To EDIT an existing file: read_file it first. Then use replace_in_file with a\n"
  56     "  unique snippet copied exactly (including indentation) as old_string. This is\n"
  57     "  the cheap path and the one you should prefer.\n"
  58     "- Use propose_edit only when you are rewriting most of a file, and then pass its\n"
  59     "  exact current contents as `old`.\n"
  60     "- To CREATE a new file: call propose_edit with the empty string \"\" as `old` and\n"
  61     "  the full desired contents as `new`. Do not call read_file first for a file that\n"
  62     "  does not exist yet.\n"
  63     "- One change per call. Keep diffs small and focused.\n"
  64     "- Every edit is gated: the user sees a diff and approves it, and `make check`\n"
  65     "  runs afterwards. If an edit is rejected or `make check` fails, read the error\n"
  66     "  and fix it rather than re-sending the same change.\n"
  67     "- run_shell only accepts whitelisted commands: make, ls, pwd, cat, uv.\n"
  68     "- When you are done, reply with a short natural-language summary of what changed."
  69 )
  70 
  71 GENERAL_SYSTEM_PROMPT = (
  72     "You are a helpful assistant. Answer the user's question clearly and concisely "
  73     "using the web search results provided. Do not reference files, directories, or "
  74     "code editing tools unless the user explicitly asks about code."
  75 )
  76 
  77 COMPACT_SYSTEM_PROMPT = (
  78     "You are a context compactor for a coding assistant. Summarize the conversation "
  79     "transcript into a compact brief that will replace it. Preserve: the user's goals "
  80     "and instructions, decisions made, files created or modified (with paths), "
  81     "important code and tool-output details, and outstanding tasks. "
  82     "Write dense bullets, no preamble."
  83 )
  84 
  85 GENERAL_KEYWORDS = [
  86     "movie", "film", "cinema", "theater", "theatre", "showing", "playing", "showtime",
  87     "weather", "forecast", "rain", "snow", "temperature outside",
  88     "restaurant", "recipe", "menu", "where to eat",
  89     "news", "sports", "score", "standings",
  90     "near me", "nearby", "directions to",
  91     "hotel", "flight", "travel", "vacation",
  92     "population of", "history of", "capital of",
  93     "who is ", "who was ", "where is ", "when is ", "when does ",
  94     "price of", "cost of", "how much does",
  95 ]
  96 
  97 CODING_KEYWORDS = [
  98     ".lisp", ".py", ".js", ".ts", ".java", ".cpp", ".go", ".rb", ".rs", ".c ",
  99     "def ", "class ", "function ", "refactor", "implement ", "compile", "makefile",
 100     "stacktrace", "segfault", "git commit", "git push", "git pull",
 101     "unit test", "pull request", "fix the bug", "add a function", "write a function",
 102 ]
 103 
 104 # ---------------------------------------------------------------------------
 105 # LLM provider dispatch
 106 #
 107 # Providers are named profiles declared in ~/.coding_harness.json and/or
 108 # .local_coding_harness.json (see harness_config.py). Two wire types are
 109 # supported: "mlx" (local mlx_lm.server -- OpenAI-compatible
 110 # /v1/chat/completions, no API key) and "openai" (Fireworks.ai and compatible
 111 # endpoints). Session-level model override set by /model or --model; None
 112 # means "use the model declared by the active provider profile".
 113 
 114 _model_override = None
 115 
 116 
 117 def config_loaded():
 118     return bool(hc.harness_config)
 119 
 120 
 121 def active_provider_hash():
 122     return config_active_provider() if config_loaded() else None
 123 
 124 
 125 def require_provider(who):
 126     """-> provider dict, or a clear error when nothing is configured."""
 127     p = active_provider_hash()
 128     if p is None:
 129         raise RuntimeError(
 130             "{}: no active provider profile; define \"providers\" in "
 131             "~/.coding_harness.json or .local_coding_harness.json".format(who))
 132     return p
 133 
 134 
 135 def active_provider_type():
 136     """-> 'mlx' | 'openai'  (wire format of the active chat provider)"""
 137     p = active_provider_hash()
 138     return provider_type(p) if p else "openai"
 139 
 140 
 141 def using_mlx():
 142     return active_provider_type() == "mlx"
 143 
 144 
 145 def current_provider_name_or_legacy():
 146     return config_active_provider_name() or "?"
 147 
 148 
 149 def current_model_id():
 150     return (_model_override
 151             or (provider_model(active_provider_hash()) if active_provider_hash() else None)
 152             or "?")
 153 
 154 
 155 def set_current_model(m):
 156     """/model <id> or --model: override the active profile's model this session."""
 157     global _model_override
 158     _model_override = m
 159 
 160 
 161 def switch_provider(name):
 162     """Select a profile and drop any model override so the new profile's own
 163     model takes effect (provider selection is always applied before --model)."""
 164     global _model_override
 165     active = config_set_active_provider(name)
 166     _model_override = None
 167     return active
 168 
 169 
 170 def profile_gen(provider, key, explicit):
 171     return explicit if explicit is not None else generation_ref(provider_generation(provider), key, None)
 172 
 173 
 174 # Mutable session state
 175 messages = []
 176 search_enabled = False
 177 search_engine = "brave"  # "brave" or "exa"
 178 
 179 # CLI / config state
 180 cli_quiet = False
 181 cli_plain = False
 182 
 183 # Exit codes
 184 EXIT_OK = 0
 185 EXIT_MODEL_ERROR = 1
 186 EXIT_CHECK_FAILED = 2
 187 EXIT_REJECTED = 3
 188 EXIT_BAD_ARGS = 5
 189 
 190 
 191 def exit_with_code(code):
 192     sys.stdout.flush()
 193     sys.stderr.flush()
 194     sys.exit(code)
 195 
 196 
 197 # ---------------------------------------------------------------------------
 198 # Config file + env
 199 # Precedence: CLI flags > env > harness config > compiled defaults
 200 # Legacy env namespaced aliases (override config, still below CLI):
 201 #   AGENT_PROVIDER / CODING_AGENT_PROVIDER, CODING_AGENT_MODEL,
 202 #   CODING_AGENT_QUIET, CODING_AGENT_PLAIN, CODING_AGENT_DEBUG
 203 
 204 def apply_harness_flags():
 205     """Apply top-level lifestyle flags from the harness JSON config
 206     (quiet/plain/debug/search). Provider sections are consumed lazily by the
 207     dispatch functions; here we just set the display/session flags."""
 208     global cli_quiet, cli_plain, search_enabled, search_engine
 209     cfg = hc.harness_config
 210     if cfg.get("quiet"):
 211         cli_quiet = True
 212         tools.quiet_mode = True
 213     if cfg.get("plain"):
 214         cli_plain = True
 215         approval.set_color_enabled(False)
 216     if cfg.get("debug"):
 217         fireworks_ai.debug_log = True
 218     s = cfg.get("search")
 219     if isinstance(s, dict):
 220         se = s.get("engine")
 221         if isinstance(se, str) and se.lower() in ("brave", "exa"):
 222             search_engine = se.lower()
 223         if "enabled" in s:
 224             search_enabled = bool(s.get("enabled"))
 225 
 226 
 227 def apply_env_overrides():
 228     global cli_quiet, cli_plain
 229     env_provider = os.environ.get("CODING_AGENT_PROVIDER") or os.environ.get("AGENT_PROVIDER")
 230     if env_provider and env_provider.strip() != "":
 231         low = env_provider.strip().lower()
 232         if config_provider(low):
 233             switch_provider(low)
 234         else:
 235             names = config_provider_names()
 236             sys.stderr.write(
 237                 "warning: ignoring unknown provider '{}' from the environment{}\n".format(
 238                     env_provider,
 239                     " (available: {})".format(", ".join(names)) if names else " (no providers configured)"))
 240     env_model = os.environ.get("CODING_AGENT_MODEL")
 241     if env_model:
 242         set_current_model(env_model)
 243     if os.environ.get("CODING_AGENT_QUIET"):
 244         cli_quiet = True
 245         tools.quiet_mode = True
 246     if os.environ.get("CODING_AGENT_PLAIN"):
 247         cli_plain = True
 248         approval.set_color_enabled(False)
 249     if os.environ.get("CODING_AGENT_DEBUG"):
 250         fireworks_ai.debug_log = True
 251 
 252 
 253 # ---------------------------------------------------------------------------
 254 # CLI helpers
 255 
 256 def read_all_stdin():
 257     try:
 258         return sys.stdin.read()
 259     except Exception:
 260         return ""
 261 
 262 
 263 def stdin_is_pipe():
 264     """True when stdin is not an interactive terminal (pipe, file, CI)."""
 265     try:
 266         return not sys.stdin.isatty()
 267     except Exception:  # noqa: BLE001
 268         return False
 269 
 270 
 271 def build_prompt(positional, prompt_parts, stdin_text):
 272     """Join all sources with newlines; trim; return "" if nothing."""
 273     parts = []
 274     if stdin_text and stdin_text.strip() != "":
 275         parts.append(stdin_text.strip())
 276     parts.extend(prompt_parts)
 277     parts.extend(positional)
 278     parts = [p for p in parts if isinstance(p, str) and p.strip() != ""]
 279     return "\n\n".join(parts).strip()
 280 
 281 
 282 def resolve_cwd(dir_str):
 283     if dir_str:
 284         if not os.path.isdir(dir_str):
 285             sys.stderr.write("error: --cwd directory does not exist: {}\n".format(dir_str))
 286             exit_with_code(EXIT_BAD_ARGS)
 287         os.chdir(dir_str)
 288 
 289 
 290 # ---------------------------------------------------------------------------
 291 # Skills:  ~/.agents/skills/<name>/SKILL.md
 292 
 293 SKILLS_DIR = os.path.expanduser("~/.agents/skills")
 294 
 295 
 296 def list_skills():
 297     if not os.path.isdir(SKILLS_DIR):
 298         return []
 299     names = []
 300     for e in sorted(os.listdir(SKILLS_DIR)):
 301         if os.path.isfile(os.path.join(SKILLS_DIR, e, "SKILL.md")):
 302             names.append(e)
 303     return names
 304 
 305 
 306 def skill_file(name):
 307     return os.path.join(SKILLS_DIR, name, "SKILL.md")
 308 
 309 
 310 def skill_exists(name):
 311     return os.path.isfile(skill_file(name))
 312 
 313 
 314 def skill_description(name):
 315     """Parse the YAML frontmatter for a `description:` field; return None if not found."""
 316     try:
 317         with open(skill_file(name), "r", encoding="utf-8", errors="replace") as f:
 318             text = f.read()
 319         lines = text.split("\n")
 320         if lines and lines[0].strip() == "---":
 321             for line in lines[1:]:
 322                 if line.strip() == "---":
 323                     break
 324                 if line.startswith("description:"):
 325                     return line[len("description:"):].strip()
 326         return None
 327     except Exception:
 328         return None
 329 
 330 
 331 def show_skills():
 332     skills = list_skills()
 333     if not skills:
 334         print("No skills found in {}".format(SKILLS_DIR))
 335     else:
 336         print("Available skills (from {}):".format(SKILLS_DIR))
 337         for s in skills:
 338             desc = skill_description(s)
 339             print("  /{} — {}".format(s, desc) if desc else "  /{}".format(s))
 340         print("\nType /<skill-name> to load a skill into the conversation.")
 341 
 342 
 343 def load_skill(name):
 344     if not skill_exists(name):
 345         print("Unknown command or skill: /{}  (try /skills)".format(name))
 346         return
 347     try:
 348         with open(skill_file(name), "r", encoding="utf-8", errors="replace") as f:
 349             content = f.read()
 350     except Exception as e:  # noqa: BLE001
 351         print("Error loading skill '{}': {}".format(name, e))
 352         return
 353     system_msg = {
 354         "role": "system",
 355         "content": ("The user has loaded the following skill: '{}'. ".format(name)
 356                     + "Use it as authoritative reference and guidance for subsequent "
 357                       "responses in this conversation.\n\n" + content),
 358     }
 359     messages.append(system_msg)
 360     desc = skill_description(name)
 361     print("Loaded skill: {}  ({} chars)".format(name, len(content)))
 362     if desc:
 363         print("  {}".format(desc))
 364     print("\nAsking the model to load the skill into context...")
 365     try:
 366         reply = llm_chat(list(messages))
 367     except Exception as e:  # noqa: BLE001
 368         reply = "(model call failed: {})".format(e)
 369     print("\n{}".format(clean(reply)))
 370 
 371 
 372 # ---------------------------------------------------------------------------
 373 # Helpers
 374 
 375 def clean(text):
 376     return text.strip() if isinstance(text, str) else str(text).strip()
 377 
 378 
 379 def reset_conversation():
 380     cwd = os.getcwd()
 381     prompt = SYSTEM_PROMPT_TEMPLATE.replace("{cwd}", cwd)
 382     messages.clear()
 383     messages.append({"role": "system", "content": prompt})
 384     tools.make_check_failed = False
 385 
 386 
 387 def print_banner():
 388     if not cli_quiet:
 389         print("")
 390         print("Coding Agent REPL.  /help for commands, /quit to exit.")
 391         print("  cwd:      {}".format(os.getcwd()))
 392         print("  provider: {}".format(current_provider_name_or_legacy()))
 393         print("  model:    {}".format(current_model_id()))
 394         print("")
 395 
 396 
 397 def show_history():
 398     for msg in messages:
 399         role = msg.get("role", "?")
 400         content = msg.get("content") or "(no content)"
 401         print("\n--- {} ---\n{}".format(role, content))
 402 
 403 
 404 def message_char_size(msg):
 405     """Approximate size of a message's contribution to the model context."""
 406     def slen(v):
 407         return len(v) if isinstance(v, str) else 0
 408     total = slen(msg.get("content", "")) + slen(msg.get("reasoning_content", ""))
 409     tcs = msg.get("tool_calls")
 410     if isinstance(tcs, list):
 411         for tc in tcs:
 412             f = tc.get("function") or {}
 413             total += slen(f.get("name", "")) + slen(f.get("arguments", ""))
 414     return total
 415 
 416 
 417 def message_preview(msg):
 418     """Full single-line preview text (whitespace collapsed); wrapping is done
 419     by wrap_preview at display time."""
 420     if msg.get("role") == "tool":
 421         raw = "[{}] {}".format(msg.get("name", "?"), msg.get("content") or "")
 422     else:
 423         content = msg.get("content", "")
 424         tcs = msg.get("tool_calls")
 425         if isinstance(content, str) and content.strip() != "":
 426             raw = content
 427         elif isinstance(tcs, list) and tcs:
 428             raw = "[tool calls: {}]".format(
 429                 ", ".join((tc.get("function") or {}).get("name", "?") for tc in tcs))
 430         else:
 431             raw = "(no content)"
 432     return " ".join(str(raw).split())
 433 
 434 
 435 PREVIEW_WIDTH = 60
 436 PREVIEW_MAX_LINES = 3
 437 
 438 
 439 def transcript_entry(msg):
 440     """One message rendered for the /compact transcript.
 441 
 442     Tool-call arguments and tool output are included: they are exactly the
 443     details (paths, snippets, errors) the summary must carry forward."""
 444     role = msg.get("role", "?")
 445     header = "### {}".format(role)
 446     if role == "tool" and msg.get("name"):
 447         header += " {}".format(msg["name"])
 448     body = []
 449     content = msg.get("content", "")
 450     if isinstance(content, str) and content.strip() != "":
 451         body.append(content)
 452     tcs = msg.get("tool_calls")
 453     if isinstance(tcs, list):
 454         for tc in tcs:
 455             f = tc.get("function") or {}
 456             body.append("tool call {} {}".format(f.get("name", "?"), f.get("arguments", "")))
 457     return "{}\n{}".format(header, "\n".join(body) if body else "(no content)")
 458 
 459 
 460 def wrap_preview(s):
 461     """Wrap s at PREVIEW_WIDTH (breaking on the last space in the window when
 462     possible) into at most PREVIEW_MAX_LINES lines; "…" marks text that still
 463     does not fit."""
 464     n = len(s)
 465     lines = []
 466     start = 0
 467     while True:
 468         if start >= n:
 469             return lines
 470         if n - start <= PREVIEW_WIDTH:
 471             lines.append(s[start:])
 472             return lines
 473         if len(lines) == PREVIEW_MAX_LINES - 1:
 474             lines.append(s[start:start + PREVIEW_WIDTH - 1] + "…")
 475             return lines
 476         window = s[start:start + PREVIEW_WIDTH]
 477         bp = None
 478         for i, ch in enumerate(window):
 479             if ch == " ":
 480                 bp = i + 1
 481         use = bp if bp else PREVIEW_WIDTH
 482         lines.append(s[start:start + use].strip())
 483         start += use
 484 
 485 
 486 def show_context():
 487     total = sum(message_char_size(m) for m in messages)
 488     print("")
 489     print("Context: {} message{}, {} chars, {} tokens (est.)".format(
 490         len(messages), "" if len(messages) == 1 else "s", total, total // 4))
 491     print("")
 492     print(" {:>3}  {:<9}  {:>7}  {}".format("#", "role", "chars", "preview"))
 493     print(" {:<3}  {:<9}  {:<7}  {:<50}".format("---", "---------", "-------", "-" * 50))
 494     for i, m in enumerate(messages, 1):
 495         lines = wrap_preview(message_preview(m))
 496         print(" {:>3}  {:<9}  {:>7}  {}".format(i, m.get("role", "?"),
 497                                                 message_char_size(m), lines[0]))
 498         for extra in lines[1:]:
 499             print(" {:<3}  {:<9}  {:<7}  {}".format("", "", "", extra))
 500     print("")
 501 
 502 
 503 def compact_context():
 504     if len(messages) <= 2:
 505         print("Nothing to compact — conversation is already short.")
 506         return
 507     before = sum(message_char_size(m) for m in messages)
 508     print("Compacting {} messages ({} chars)…".format(len(messages), before))
 509     transcript_parts = []
 510     for m in messages:
 511         transcript_parts.append(transcript_entry(m))
 512     transcript = "\n\n".join(transcript_parts)
 513     try:
 514         summary = llm_chat([
 515             {"role": "system", "content": COMPACT_SYSTEM_PROMPT},
 516             {"role": "user", "content": transcript},
 517         ])
 518     except Exception as e:  # noqa: BLE001
 519         print("Compaction failed: {}".format(e))
 520         return
 521     if summary:
 522         # Keep the original system prompt; replace the rest with the summary.
 523         messages[:] = [
 524             messages[0],
 525             {"role": "user",
 526              "content": ("[Earlier conversation compacted to this summary. "
 527                          "Continue from where it left off.]\n\n" + summary)},
 528         ]
 529         after = sum(message_char_size(m) for m in messages)
 530         print("Compacted: {} → {} chars.".format(before, after))
 531         show_context()
 532 
 533 
 534 # ---------------------------------------------------------------------------
 535 # Slash commands
 536 
 537 HELP_TEXT = """
 538 Commands:
 539   /reset            clear conversation
 540   /history          dump message log
 541   /context          show a formatted summary of the current context
 542   /compact          compact history into a summary, then show the new context
 543   /model <id>       switch model (for the current provider)
 544   /provider         show current LLM provider and available profiles
 545   /provider <name>  switch provider profile (or fireworks/mlx w/o config)
 546   /debug            toggle raw request/response logging
 547   /search           toggle web search on/off
 548   /search brave     enable Brave search
 549   /search exa       enable Exa search
 550   /tokens           show session token usage and estimated cost
 551   /tokens reset     zero the session token counters
 552   /skills           list available skills in ~/.agents/skills
 553   /<skill-name>     load that skill into the conversation
 554   /quit             exit
 555 """
 556 
 557 
 558 def handle_slash_command(line):
 559     """Returns 'quit', 'continue', or None (not a command)."""
 560     global search_enabled, search_engine
 561     if line == "" or line == "/quit":
 562         return "quit"
 563     if line == "/reset":
 564         reset_conversation()
 565         print("Conversation reset.")
 566         return "continue"
 567     if line == "/history":
 568         show_history()
 569         return "continue"
 570     if line == "/context":
 571         show_context()
 572         return "continue"
 573     if line == "/compact":
 574         compact_context()
 575         return "continue"
 576     if line.startswith("/model "):
 577         new_model = line[len("/model "):].strip()
 578         set_current_model(new_model)
 579         print("Model set to {}".format(new_model))
 580         return "continue"
 581     if line == "/provider":
 582         print("Current provider: {} (model: {})".format(
 583             current_provider_name_or_legacy(), current_model_id()))
 584         if config_loaded():
 585             print("Available profiles: {}".format(", ".join(config_provider_names())))
 586         return "continue"
 587     if line.startswith("/provider "):
 588         p = line[len("/provider "):].strip().lower()
 589         if config_provider(p):
 590             switch_provider(p)
 591             print("Provider set to profile '{}' (model: {})".format(p, current_model_id()))
 592         else:
 593             names = config_provider_names()
 594             print("Unknown provider '{}'{}".format(
 595                 p,
 596                 " -- use one of: {}".format(", ".join(names)) if names else " -- no providers configured"))
 597         return "continue"
 598     if line == "/debug":
 599         fireworks_ai.debug_log = not fireworks_ai.debug_log
 600         print("Debug logging {}".format("ON" if fireworks_ai.debug_log else "OFF"))
 601         return "continue"
 602     if line == "/tokens" or line == "/tokens reset":
 603         if line.endswith(" reset"):
 604             fireworks_ai.reset_session_stats()
 605             mlx_serve.mlx_reset_session_stats()
 606             print("Session token counters reset.")
 607             return "continue"
 608         if using_mlx():
 609             mlx_serve.mlx_print_session_stats()
 610         else:
 611             fireworks_ai.print_session_stats()
 612         return "continue"
 613     if line == "/search":
 614         search_enabled = not search_enabled
 615         print("Web search {} (engine: {})".format("ON" if search_enabled else "OFF", search_engine))
 616         return "continue"
 617     if line.startswith("/search "):
 618         engine = line[len("/search "):].strip().lower()
 619         if engine in ("brave", "exa"):
 620             search_engine = engine
 621             search_enabled = True
 622             print("Web search ON (engine: {})".format(engine))
 623         else:
 624             print("Unknown engine '{}' — use 'brave' or 'exa'".format(engine))
 625         return "continue"
 626     if line == "/help":
 627         print(HELP_TEXT)
 628         return "continue"
 629     if line == "/skills":
 630         show_skills()
 631         return "continue"
 632     if line.startswith("/"):
 633         # Any other /xxx  --  treat as a skill name lookup.
 634         load_skill(line[1:])
 635         return "continue"
 636     return None
 637 
 638 
 639 # ---------------------------------------------------------------------------
 640 # Intent classification
 641 
 642 def heuristic_classify(lower):
 643     if any(kw in lower for kw in GENERAL_KEYWORDS):
 644         return "general"
 645     if any(kw in lower for kw in CODING_KEYWORDS):
 646         return "coding"
 647     return None
 648 
 649 
 650 def llm_classify(user_line):
 651     try:
 652         msgs = [
 653             {"role": "system",
 654              "content": "You are a one-word query classifier. Reply with exactly one word and nothing else."},
 655             {"role": "user",
 656              "content": ("Classify this query as exactly one word — GENERAL, CODING, or HYBRID:\n"
 657                          "GENERAL = factual or informational; nothing to do with writing, editing, or debugging code.\n"
 658                          "CODING  = writing, editing, refactoring, or debugging code or files.\n"
 659                          "HYBRID  = coding question that benefits from web docs or library references.\n"
 660                          "Query: {}\n"
 661                          "One-word answer:").format(user_line)},
 662         ]
 663         raw = llm_chat(msgs, max_tokens=10, temperature=0.0)
 664     except Exception as e:  # noqa: BLE001
 665         print("[Classifier LLM error: {} — defaulting to coding]".format(e))
 666         return "coding"
 667     up = raw.strip().upper()
 668     if "GENERAL" in up:
 669         return "general"
 670     if "HYBRID" in up:
 671         return "hybrid"
 672     return "coding"
 673 
 674 
 675 def classify_intent(user_line):
 676     return heuristic_classify(user_line.lower()) or llm_classify(user_line)
 677 
 678 
 679 # ---------------------------------------------------------------------------
 680 # Search integration
 681 
 682 def run_search(query):
 683     if search_engine == "exa":
 684         return search_mod.exa_search(query)
 685     return search_mod.brave_search(query)
 686 
 687 
 688 def format_search_results(query, results):
 689     lines = ['[Web search results for: "{}"]'.format(query)]
 690     for i, r in enumerate(results, 1):
 691         lines.append("{}. {}\n   {}\n   {}".format(i, r[1], r[0], r[2] or ""))
 692     return "\n".join(lines) + "\n---"
 693 
 694 
 695 def maybe_search(user_line, force):
 696     if not (force or search_enabled):
 697         return None
 698     try:
 699         results = run_search(user_line)
 700         if results:
 701             return format_search_results(user_line, results) + "\n" + user_line
 702         return None
 703     except Exception as e:  # noqa: BLE001
 704         print("[Search error ({}): {}]".format(search_engine, e))
 705         return None
 706 
 707 
 708 # ---------------------------------------------------------------------------
 709 # LLM dispatch (provider-profile-aware)
 710 
 711 def llm_chat(msgs, max_tokens=None, temperature=None):
 712     """Plain (no-tools) chat: dispatches on the active provider profile."""
 713     p = require_provider("llm-chat")
 714     mt = profile_gen(p, "max_tokens", max_tokens)
 715     tp = profile_gen(p, "temperature", temperature)
 716     if provider_type(p) == "mlx":
 717         mlx_serve.mlx_active_provider = p
 718         return mlx_serve.mlx_chat(msgs, model_id=current_model_id(),
 719                                   max_tokens=mt, temperature=tp)
 720     return fireworks_ai.chat(msgs, model_id=current_model_id(),
 721                              max_tokens=mt, temperature=tp)
 722 
 723 
 724 def llm_chat_with_tools(msgs, tools_list):
 725     """Agentic (tool-calling) chat -- uses the same active provider as plain chat."""
 726     p = require_provider("llm-chat-with-tools")
 727     if provider_type(p) == "mlx":
 728         mlx_serve.mlx_active_provider = p
 729         return mlx_serve.mlx_chat_with_tools(msgs, tools_list, model_id=current_model_id())
 730     return fireworks_ai.chat_with_tools(msgs, tools_list, model_id=current_model_id())
 731 
 732 
 733 # ---------------------------------------------------------------------------
 734 # Send to model (intent-routed)
 735 
 736 INTENT_LABELS = {
 737     "general": "web search, no coding tools",
 738     "coding": "coding tools, no search",
 739     "hybrid": "coding tools + web search if /search is on",
 740 }
 741 
 742 
 743 def messages_contain(msgs, substr):
 744     """Case-insensitive substring search over message content."""
 745     sub = substr.lower()
 746     return any(sub in str(m.get("content", "")).lower() for m in msgs)
 747 
 748 
 749 def infer_exit_code(turn_messages):
 750     """Map a one-shot turn onto a process exit code.
 751 
 752     tools.make_check_failed is the authoritative signal for a failed check:
 753     tools.py sets it when an approved write leaves `make check` non-zero, so
 754     the code does not depend on matching words inside old tool output. Any
 755     rejection anywhere in the turn counts, even if a later turn carried on,
 756     because the change the run was asked for was refused.
 757     """
 758     if tools.make_check_failed:
 759         return EXIT_CHECK_FAILED
 760     if messages_contain(turn_messages, "user rejected") or messages_contain(turn_messages, "user skipped"):
 761         return EXIT_REJECTED
 762     return EXIT_OK
 763 
 764 
 765 def rejected_last_turn(turn_messages):
 766     """True when a turn ENDED with the user rejecting or skipping a change.
 767 
 768     This is the narrower test used to decide whether a one-shot retry is
 769     worthwhile: a rejection the model already worked around is not.
 770     """
 771     tool_msgs = [m for m in turn_messages if m.get("role") == "tool"]
 772     if not tool_msgs:
 773         return False
 774     last = str(tool_msgs[-1].get("content", "")).lower()
 775     return "user rejected" in last or "user skipped" in last
 776 
 777 
 778 ONE_SHOT_RETRY_PROMPT = (
 779     "The previous edit was rejected. Make a smaller, safer change and propose "
 780     "it again in one call."
 781 )
 782 
 783 
 784 def send_to_model(user_line):
 785     """Run one turn and return the messages that turn added to the transcript.
 786 
 787     A coding or hybrid turn appends to the persistent `messages` history; a
 788     general turn is stateless and returns [] so the coding transcript is not
 789     touched.
 790     """
 791     global messages
 792     intent = classify_intent(user_line)
 793     if not cli_quiet:
 794         print("[intent: {} → {}]".format(intent,
 795                                          INTENT_LABELS.get(intent, "coding tools")))
 796     if intent == "general":
 797         # A general question is answered statelessly: no coding system prompt
 798         # and no tools, so the coding transcript is left untouched.
 799         content = maybe_search(user_line, True) or user_line
 800         msgs = [
 801             {"role": "system", "content": GENERAL_SYSTEM_PROMPT},
 802             {"role": "user", "content": content},
 803         ]
 804         reply = llm_chat(msgs)
 805         print("\n{}".format(clean(reply)))
 806         return []
 807 
 808     content = maybe_search(user_line, intent == "hybrid" and search_enabled) or user_line
 809     updated = messages + [{"role": "user", "content": content}]
 810     start = len(updated)
 811     reply, new_messages = llm_chat_with_tools(updated, ENABLED_TOOLS)
 812     messages = new_messages
 813     print("\n{}".format(clean(reply)))
 814     return new_messages[start:]
 815 
 816 
 817 def run_one_shot(prompt):
 818     """Prompt is already non-empty string. Run single task, print reply, exit."""
 819     register_all()
 820     reset_conversation()
 821     # One-shot still respects quiet/plain but banner is suppressed anyway
 822     retried = False
 823     try:
 824         turn = send_to_model(prompt)
 825         if rejected_last_turn(turn) and not retried:
 826             # A one-shot run has no human to steer, so give the model exactly
 827             # one chance to come back with a smaller change.
 828             retried = True
 829             print("\n[change rejected — retrying once with a smaller request]")
 830             turn = send_to_model(ONE_SHOT_RETRY_PROMPT)
 831         exit_code = infer_exit_code(turn)
 832     except SystemExit:
 833         raise
 834     except KeyboardInterrupt:
 835         sys.stderr.write("\ncancelled\n")
 836         exit_with_code(EXIT_MODEL_ERROR)
 837     except Exception as e:  # noqa: BLE001
 838         sys.stderr.write("Error talking to model: {}\n".format(e))
 839         exit_with_code(EXIT_MODEL_ERROR)
 840     if exit_code == EXIT_CHECK_FAILED:
 841         if not cli_quiet:
 842             print("\n[make check failed]")
 843         exit_with_code(EXIT_CHECK_FAILED)
 844     if exit_code == EXIT_REJECTED:
 845         if not cli_quiet:
 846             print("\n[change rejected or skipped]")
 847         exit_with_code(EXIT_REJECTED)
 848     exit_with_code(EXIT_OK)
 849 
 850 
 851 # ---------------------------------------------------------------------------
 852 # Interactive line editing (readline when available)
 853 
 854 SLASH_COMMANDS = ["/reset", "/history", "/context", "/compact", "/model", "/provider",
 855                   "/debug", "/search", "/tokens", "/help", "/skills", "/quit"]
 856 
 857 SEARCH_ENGINES = ["brave", "exa"]
 858 
 859 
 860 def configured_model_ids():
 861     if config_loaded():
 862         ids = []
 863         for name in config_provider_names():
 864             p = config_provider(name)
 865             m = provider_model(p) if p else None
 866             if m:
 867                 ids.append(m)
 868         return ids
 869     return []
 870 
 871 
 872 def completion_candidates(word):
 873     """-> list of completion strings for the word under the cursor.
 874 
 875     Readline completes the word as *it* splits it, and the split differs
 876     between the two implementations the harness runs on. GNU Readline keeps a
 877     leading "/" in the word, so completing "/t" hands us "/t" and expects
 878     "/tokens" back. libedit (the macOS backend) treats "/" as a word
 879     separator, so the same keystrokes hand us "t" and expect "tokens" back --
 880     the slash stays on the line either way.
 881 
 882     Getting this wrong used to be visible rather than merely unhelpful: with
 883     "t" matching no command, libedit fell back to *filename* completion and
 884     inserted a file name into the middle of "/tokens". So both spellings are
 885     accepted here, and the slash is put back only when it came with the word.
 886     """
 887     had_slash = word.startswith("/")
 888     variants = [word, "/" + word] if not had_slash else [word]
 889     names = config_provider_names() + SEARCH_ENGINES + configured_model_ids()
 890     known = (SLASH_COMMANDS if any(v.startswith("/") for v in variants) else []) + names
 891     out = []
 892     for candidate in known:
 893         if candidate in out or not any(candidate.startswith(v) for v in variants):
 894             continue
 895         out.append(candidate if had_slash or not candidate.startswith("/")
 896                    else candidate[1:])
 897     return out
 898 
 899 
 900 def setup_line_input():
 901     """Enable readline editing, persisted history, and Tab completion when
 902     stdin is a terminal and the readline backend is present. Returns True
 903     when active."""
 904     if not line_input.line_input_available():
 905         return False
 906     line_input.set_history_file(line_input.default_history_file())
 907     line_input.install_completer(completion_candidates)
 908     return True
 909 
 910 
 911 # ---------------------------------------------------------------------------
 912 # Main REPL + CLI dispatch
 913 
 914 def run_repl():
 915     if not config_loaded():
 916         hc.load_harness_config()
 917         apply_harness_flags()
 918     register_all()
 919     reset_conversation()
 920     setup_line_input()
 921     print_banner()
 922     while True:
 923         line = line_input.read_input_line("\n> ")
 924         if line is None:
 925             line_input.save_history()
 926             print("")
 927             return
 928         trimmed = line.strip()
 929         if trimmed == "":
 930             continue
 931         cmd = handle_slash_command(trimmed)
 932         if cmd == "quit":
 933             line_input.save_history()
 934             return
 935         if cmd == "continue":
 936             continue
 937         try:
 938             send_to_model(trimmed)
 939         except KeyboardInterrupt:
 940             # Ctrl-C cancels the turn, not the session; the transcript keeps
 941             # whatever was already committed to it.
 942             print("\n[cancelled]")
 943             sys.stdout.flush()
 944         except Exception as e:  # noqa: BLE001
 945             print("\nError talking to model: {}".format(e))
 946             sys.stdout.flush()
 947 
 948 
 949 def run():
 950     """Back-compat alias."""
 951     run_repl()
 952 
 953 
 954 # ---------------------------------------------------------------------------
 955 # CLI entry
 956 
 957 def build_arg_parser():
 958     parser = argparse.ArgumentParser(
 959         prog="coding-agent",
 960         description="AI pair programmer in your terminal: multi-turn agentic "
 961                     "coding loop with approved colored diffs.",
 962         formatter_class=argparse.ArgumentDefaultsHelpFormatter)
 963     parser.add_argument("-p", "--prompt", action="append", default=[],
 964                         metavar="TEXT", help="prompt text, repeatable, joined with newlines")
 965     parser.add_argument("--stdin", action="store_true",
 966                         help="read prompt text from stdin (pipe/heredoc)")
 967     parser.add_argument("-y", "--yes", action="store_true",
 968                         help="auto-approve edits after showing the diff")
 969     parser.add_argument("--dry-run", action="store_true",
 970                         help="show diffs but do not write files")
 971     parser.add_argument("--provider", metavar="NAME",
 972                         help="provider profile from the harness config (e.g. fireworks, mlx)")
 973     parser.add_argument("--model", metavar="ID",
 974                         help="override model for this provider")
 975     parser.add_argument("--cwd", metavar="DIR",
 976                         help="chdir before running")
 977     parser.add_argument("--debug", action="store_true",
 978                         help="enable debug logging (same as /debug)")
 979     parser.add_argument("-q", "--quiet", action="store_true",
 980                         help="no banner, no [intent] line, less tool chatter")
 981     parser.add_argument("--plain", "--no-color", dest="plain", action="store_true",
 982                         help="no ANSI colors in diffs")
 983     parser.add_argument("-v", "--version", action="store_true",
 984                         help="show version and exit")
 985     parser.add_argument("positional", nargs="*", metavar="PROMPT",
 986                         help="one-shot prompt text")
 987     return parser
 988 
 989 
 990 def cli_main(argv=None):
 991     global cli_quiet, cli_plain
 992     # 1a) harness JSON config: ~/.coding_harness.json + .local_coding_harness.json
 993     hc.load_harness_config()
 994     apply_harness_flags()
 995     # 1b) env overrides (harness JSON takes precedence for provider unless
 996     #     CODING_AGENT_PROVIDER/AGENT_PROVIDER names a known profile)
 997     apply_env_overrides()
 998     # 2) parse CLI; if a prompt is present, run one-shot, else REPL
 999     parser = build_arg_parser()
1000     args = parser.parse_args(argv)
1001     # --version early exit
1002     if args.version:
1003         print("coding-agent {}".format(VERSION))
1004         exit_with_code(EXIT_OK)
1005     # Provider data lives only in the harness config, so refuse to run with none
1006     # rather than falling back to compiled-in defaults.
1007     if not config_provider_names():
1008         sys.stderr.write(
1009             "error: no providers configured.\n"
1010             "  Add a \"providers\" section to ~/.coding_harness.json or\n"
1011             "  .local_coding_harness.json (see README.md for the format).\n")
1012         exit_with_code(EXIT_BAD_ARGS)
1013     # Apply CLI overrides (highest precedence)
1014     if args.debug:
1015         fireworks_ai.debug_log = True
1016     if args.quiet:
1017         cli_quiet = True
1018         tools.quiet_mode = True
1019     if args.plain:
1020         cli_plain = True
1021         approval.set_color_enabled(False)
1022     if args.yes:
1023         tools.auto_approve = True
1024     if args.dry_run:
1025         tools.dry_run = True
1026     if args.provider:
1027         low = args.provider.strip().lower()
1028         if config_provider(low):
1029             switch_provider(low)
1030         else:
1031             names = config_provider_names()
1032             sys.stderr.write("error: unknown provider '{}'{}\n".format(
1033                 args.provider,
1034                 " — available profiles: {}".format(", ".join(names)) if names else " — no providers configured"))
1035             exit_with_code(EXIT_BAD_ARGS)
1036     if args.model:
1037         if args.model.strip() == "":
1038             sys.stderr.write("error: --model requires a non-empty value\n")
1039             exit_with_code(EXIT_BAD_ARGS)
1040         set_current_model(args.model.strip())
1041     if args.cwd:
1042         resolve_cwd(args.cwd)
1043     # Build the prompt from every source. Reading stdin when it is a pipe is
1044     # implied even without --stdin, so `git diff | coding-agent -p "..."` works
1045     # either way.
1046     piped = stdin_is_pipe()
1047     stdin_text = read_all_stdin() if (args.stdin or piped) else None
1048     prompt = build_prompt(args.positional, args.prompt, stdin_text)
1049     if prompt != "":
1050         run_one_shot(prompt)
1051     elif args.stdin:
1052         # `--stdin` with nothing on the pipe would otherwise look like a
1053         # request for the interactive REPL while stdin is already at EOF.
1054         sys.stderr.write("error: --stdin was given but stdin is empty\n")
1055         exit_with_code(EXIT_BAD_ARGS)
1056     else:
1057         run_repl()
1058 
1059 
1060 if __name__ == "__main__":
1061     cli_main()

The system prompt is a policy document

SYSTEM_PROMPT_TEMPLATE is where the behavioral contract lives. It tells the model to explore before editing, to prefer replace_in_file with a unique snippet over rewriting a whole file, to use propose_edit when it really is rewriting most of a file or creating one, to pass "" as old for new files, and to read the error and adjust rather than re-sending a rejected change. Compare that text with tools.py: the rules that matter are backed by enforcement in code. A snippet that does not match exactly is refused by replace_in_file; paths outside the working directory are refused by resolve_tool_path; unrecognized commands are refused by run_shell’s whitelist. The prompt teaches the model the rules, the tools make the common ways of breaking them fail loudly, and your approval stands behind the rest. That pairing is the single most important habit in harness design, as long as you are honest about which of the three is doing the work for any given rule.

Intent routing

Not every message belongs in a coding loop. Before calling the model, send_to_model classifies the input:

  • general (weather, movies, “who is”): a stateless call with GENERAL_SYSTEM_PROMPT and search attempted regardless of the /search toggle, since a factual question has nowhere else to get its answer. No coding tools, no conversation history.
  • coding: the full agentic loop with ENABLED_TOOLS over the persistent messages history.
  • hybrid: the coding loop, but with search results prepended when /search is on.

A failure to search is not a failure to answer: maybe_search returns None on a missing key or a network error and the original question is sent unchanged. The general and hybrid paths differ in exactly one way that matters in practice – the general path returns [] instead of touching messages, which is why a weather question cannot consume or pollute your coding transcript.

classify_intent uses a cheap two-stage design: heuristic_classify scans the lower-cased input for keyword lists (GENERAL_KEYWORDS, CODING_KEYWORDS). Only when neither list matches does it spend one LLM call, with max_tokens=10 and temperature=0.0, asking for one word. Errors in that classifier call default to coding, so the assistant degrades to useful behavior instead of refusing to act, and INTENT_LABELS.get(...) keeps an unexpected label from raising a KeyError in the middle of a turn.

Context management: /context and /compact

An agentic session accumulates messages fast: each tool call adds the assistant message and at least one tool result. message_char_size counts characters of content, reasoning, and tool arguments, and /context prints a table with one line per message. The token estimate divides characters by 4, the standard heuristic Code Test for English text and code.

/compact is the interesting one. transcript_entry renders each message for the summarizer – not just content, but the tool-call arguments and every role: "tool" result, because the file paths, snippets, and error text are exactly what the next turn needs and what a naive transcript drops. It sends that with COMPACT_SYSTEM_PROMPT asking for a dense summary, then replaces everything except the original system prompt with the summary as a single user message. A 40,000-character session becomes a 2,000-character brief. The model keeps working from the summary: goals, decisions, files touched, and open tasks survive; the giant file dumps do not.

Slash commands, skills, and Tab completion

handle_slash_command dispatches /reset, /history, /context, /compact, /model, /provider, /debug, /search, /tokens (and /tokens reset), /help, /skills, and /quit. Any other /name is treated as a skill lookup under ~/.agents/skills/<name>/SKILL.md: the file is read, injected as a system message, and the model is asked to acknowledge it. This is the same skill mechanism the bigger agents use, in about sixty lines.

completion_candidates powers Tab completion. Readline completes the word under the cursor, so the function merges slash commands (when the word starts with /) with provider profile names, search engines, and configured model IDs, filtered by prefix.

One-shot mode and exit codes

With a prompt (from -p, positional words, or piped stdin), the agent runs one task and exits. run_one_shot keeps the messages the turn added, hands them to infer_exit_code, and maps the outcome to a distinct exit code:

Code Meaning
0 success
1 model/API error, or Ctrl-C during the turn
2 edit applied but make check failed
3 user rejected or skipped the change
5 bad arguments or no providers configured

Two design points are worth copying. First, the failed-check signal is a flag (tools.make_check_failed) set by the module that actually ran make check, not a substring search over old tool output – the program asks the component that knows rather than grepping its own transcript. Second, a one-shot run has no human to steer, so a rejected edit gets exactly one retry with a shorter instruction before the run gives up. A rejection anywhere in the turn still exits 3 even if the retry succeeded, because the change the caller asked for was refused; infer_exit_code and rejected_last_turn split those two questions on purpose.

This turns the agent into a Unix citizen. A CI script can run coding-agent -p "fix the lint errors"; test $? -eq 0 and know not just that the agent finished, but whether the result compiles.

Line Editing: line_input.py

A REPL without cursor keys feels broken. Python ships a readline module, but there are two failure modes: the import can be absent, and stdin may not be a terminal (piped input, --stdin, CI). This module handles both, degrading to a plain hand-printed prompt.

  1 # line_input.py -- rlwrap-style line editing for the coding-agent REPL.
  2 # Python port of line-input.rkt.
  3 #
  4 # Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  5 # Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
  6 # See LICENSE file for details
  7 #
  8 # Uses Python's bundled `readline` module (GNU Readline on Linux, libedit on
  9 # macOS) to give the REPL cursor editing, in-session history (arrows,
 10 # Ctrl-R), and Tab completion.
 11 #
 12 # Two things can go wrong, and both degrade to a plain input() instead of
 13 # failing the harness:
 14 #
 15 #   * `import readline` raises when no readline backend is present. This
 16 #     module therefore loads it inside a handler, and every entry point works
 17 #     without it.
 18 #
 19 #   * stdin may not be a terminal (pipes, --stdin, CI). Editing is skipped
 20 #     and the prompt is printed by hand so piped output is byte-identical to
 21 #     plain read-line behavior.
 22 
 23 import os
 24 import sys
 25 
 26 readline = None
 27 _backend_tried = False
 28 
 29 MAX_HISTORY = 1000
 30 
 31 
 32 def _load_backend():
 33     global readline, _backend_tried
 34     if not _backend_tried:
 35         _backend_tried = True
 36         try:
 37             import readline as _rl  # noqa: F401
 38             readline = _rl
 39         except ImportError:
 40             readline = None
 41     return readline is not None
 42 
 43 
 44 def terminal_stdin():
 45     try:
 46         return sys.stdin.isatty()
 47     except Exception:
 48         return False
 49 
 50 
 51 def line_input_available():
 52     """-> bool. Loads the backend on first use, and only engages editing when
 53     stdin is a terminal, so piped/--stdin runs never touch readline at all."""
 54     return terminal_stdin() and _load_backend()
 55 
 56 
 57 # ---------------------------------------------------------------------------
 58 # History file
 59 
 60 history_file = None
 61 
 62 
 63 def default_history_file():
 64     return os.path.expanduser("~/.coding_agent_history")
 65 
 66 
 67 def set_history_file(path):
 68     """Point at `path`, loading any existing entries. None disables persistence."""
 69     global history_file
 70     history_file = path
 71     if readline and path and os.path.isfile(path):
 72         try:
 73             with open(path, "r", encoding="utf-8", errors="replace") as f:
 74                 for line in f:
 75                     line = line.rstrip("\n")
 76                     if line.strip() != "":
 77                         readline.add_history(line)
 78         except Exception:
 79             pass
 80 
 81 
 82 def save_history():
 83     if not readline or not history_file:
 84         return
 85     try:
 86         n = readline.get_current_history_length()
 87         count = min(n, MAX_HISTORY)
 88         if count > 0:
 89             lines = []
 90             for i in range(n - count + 1, n + 1):
 91                 item = readline.get_history_item(i)
 92                 if item is not None:
 93                     lines.append(item)
 94             tmp = history_file + "~tmp"
 95             with open(tmp, "w", encoding="utf-8") as f:
 96                 for line in lines:
 97                     f.write(line + "\n")
 98             os.replace(tmp, history_file)
 99     except Exception:
100         pass
101 
102 
103 # ---------------------------------------------------------------------------
104 # Completion
105 
106 # Readline asks for one candidate at a time via completer(text, state), so the
107 # filtered list is built once (at state 0) and cached here between calls.
108 _completions = []
109 
110 
111 def install_completer(candidates_fn):
112     """candidates_fn: str -> list of completion strings, for the word under the
113     cursor. Ignored when the backend is unavailable."""
114     if not line_input_available():
115         return
116 
117     def completer(text, state):
118         # Pure string work: no I/O, threads, or subprocesses (libedit calls
119         # this while the interpreter is effectively in atomic mode).
120         if state == 0:
121             _completions.clear()
122             try:
123                 matches = candidates_fn(text) or []
124             except Exception:
125                 matches = []
126             # Readline replaces the word it passed us, so a candidate that
127             # does not begin with that word would be inserted verbatim and
128             # leave stray text on the line. Drop those instead of guessing.
129             for m in matches:
130                 if isinstance(m, str) and m and m.startswith(text) and m not in _completions:
131                     _completions.append(m)
132         if state < len(_completions):
133             return _completions[state]
134         return None
135 
136     readline.set_completer(completer)
137     readline.set_completer_delims(" \t\n\"'")
138     # macOS ships libedit behind the readline module; the bind syntax differs.
139     if "libedit" in (readline.__doc__ or ""):
140         readline.parse_and_bind("bind ^I rl_complete")
141     else:
142         readline.parse_and_bind("tab: complete")
143 
144 
145 # ---------------------------------------------------------------------------
146 # Reading
147 
148 def read_input_line(prompt):
149     """-> str, or None on EOF.
150 
151     Ctrl-C cancels the current line and returns "" (the caller re-prompts);
152     Ctrl-D returns None, which ends the REPL. Always prints the prompt, with
153     readline handling editing on a terminal and a plain read-line used
154     otherwise."""
155     if line_input_available():
156         try:
157             line = input(prompt)
158             # The backend records the line as it is accepted, so re-adding it
159             # would put every command in the history twice.
160             n = readline.get_current_history_length()
161             if n == 0 or readline.get_history_item(n) != line:
162                 readline.add_history(line)
163             return line
164         except EOFError:
165             return None
166         except KeyboardInterrupt:
167             print("")  # move past the ^C echo; the caller re-prompts
168             return ""
169     sys.stdout.write(prompt)
170     sys.stdout.flush()
171     try:
172         line = sys.stdin.readline()
173     except KeyboardInterrupt:
174         print("")
175         return ""
176     except Exception:
177         return None
178     if line == "":  # EOF
179         return None
180     return line.rstrip("\n")

Two portability details. On macOS the readline module is backed by libedit, not GNU Readline, and libedit uses different bind syntax, which is why install_completer checks "libedit" in readline.__doc__ and issues bind ^I rl_complete there instead of tab: complete. And history saving writes to a temporary file and calls os.replace, which is atomic: a crash mid-save cannot leave you with a half-written history. The completer wrapper keeps the callback free of I/O and subprocesses, because libedit calls it while the interpreter is effectively in atomic mode.

Running the Agent

The project is managed by uv. pyproject.toml declares one runtime dependency, requests, and a console script coding-agent pointing at agent:cli_main.

1 cd source-code/coding-harness-agent
2 uv sync                       # creates .venv, installs requests
3 export FIREWORKS_API_KEY=your-key-here
4 uv run agent.py               # interactive REPL

The Makefile wraps the usual work:

1 make run          # uv run agent.py
2 make sync         # create/update .venv
3 make check        # byte-compile all sources
4 make test         # run the smoke tests under tests/
5 make install      # uv tool install .  -> global coding-agent command
6 make build        # uv build -> sdist + wheel in dist/
7 make clean

make test runs offline. The loop takes a post_fn callback, so the tests give it a fake model – a list of canned responses – and exercise repetition detection, malformed arguments, missing tool-call ids, path containment, and the one-shot exit codes without a network or an API key. That seam is not only good design for the book; it is the reason the behaviors described in this chapter can be tested at all.

Try the modes, in order:

1 coding-agent --provider mlx -q -p "add a docstring to normalize in search.py"
2 echo "what is the capital of Norway" | coding-agent --provider fireworks
3 git diff | coding-agent -p "review this diff"      # --stdin is implied for pipes
4 coding-agent --dry-run --debug -p "refactor grep tool to exclude binary files"

A REPL Session, Start to Finish

Below is a transcript of a real session shape, with a Fireworks provider configured as shown earlier. User input begins after the > prompt.

 1 Coding Agent REPL.  /help for commands, /quit to exit.
 2   cwd:      /Users/markw/GITHUB/demo/normalize-util
 3   provider: fireworks
 4   model:    accounts/fireworks/models/deepseek-v4p1-flash
 5 
 6 > add a docstring to normalize in search.py and run make check
 7 
 8 [intent: coding → coding tools, no search]
 9 
10 I'll read the file first, then propose the edit.
11 * read_file search.py
12 * replace_in_file {"path": "search.py", "old_string": "def normalize(s):\n    return s.strip().…
13 
14 --- a/search.py
15 +++ b/search.py
16 @@ -1,4 +1,7 @@
17  import os
18 
19  def normalize(s):
20 +    """Lower-case, whitespace-stripped form of s.
21 +
22 +    Used as the comparison key for cache lookups.
23 +    """
24      return s.strip().lower()
25 
26 Apply this change? [y]es / [n]o / [s]kip and tell the model why: y
27 make check passed. Added a three-line docstring to normalize in search.py.
28 
29 > /tokens
30 
31 Session token usage:
32   Prompt tokens:     18432
33   Completion tokens: 921
34   Total tokens:      19353
35   Cached tokens:     12288 (66.7% of prompt)
36   Estimated cost:    $0.001462  ($0.1400/M input, $0.0280/M cached input, $0.2800/M output)
37 
38 > /quit

Reading the transcript against the code: [intent: coding ...] is send_to_model reporting the classifier. The * read_file search.py lines come from execute_tool_calls in quiet-off mode. The assistant text printed before each * line is content from the same message that carried the tool calls, which the loop prints before executing. The diff and the y/n/s prompt are approval.py. The final sentence is the last iteration’s content, the one that carried no tool calls. And notice the size of the edit call: the model sent one snippet, not the whole file, which is why replace_in_file is the tool the system prompt tells it to reach for first.

Reading /context

After a few exchanges, /context prints the message table:

 1 Context: 7 messages, 6834 chars, 1708 tokens (est.)
 2 
 3    #  role       chars  preview
 4   ---  ---------  -------  --------------------------------------------------
 5    1  system       812  You are an interactive coding assistant working in t…
 6    2  user         184  add a docstring to normalize in search.py and run ma…
 7    3  assistant     212  [tool calls: read_file]
 8    4  tool        4412  [read_file] import os\n\ndef normalize(s):\n    retur…
 9    5  assistant     148  [tool calls: replace_in_file]
10    6  tool         980  [replace_in_file] applied; make check passed
11    7  assistant      86  make check passed. Added a three-line docstring to no…

The table immediately shows where your tokens go. Message 4 is a whole file echo, and it costs four times what the edit that followed it cost. The system prompt (resent every turn) is why prompt caching pays off here: it is a fixed prefix, so the server bills it at the cached rate.

One-shot and piped output

One-shot mode is REPL output minus the banner, plus an exit code:

1 $ coding-agent -q --dry-run -p "rename normalize to norm_key in search.py"
2 [dry-run only] I would change the function name in search.py and update its
3 two call sites...
4 $ echo $?
5 0

Pipe input to feed the agent command output. --stdin is accepted, and implied whenever stdin is not a terminal, which is how the git diff example worked earlier in the chapter:

1 $ git diff origin/main | coding-agent --stdin --provider fireworks \
2     -p "summarize these changes for the release notes"

If the user rejects an edit in a one-shot run, the exit code is 3 and the phrase [change rejected or skipped] prints, unless -q suppressed it. The run first gives the model one chance to propose something smaller; if that is rejected too, the run reports the rejection.

Wrap Up

This chapter built a complete coding agent in nine small Python modules. The finished harness does four things that generalize to every agent you will ever build or use:

  1. The loop is separate from the transport. chat_loop.py holds all agent intelligence and knows nothing about HTTP or providers. Swapping Fireworks for MLX was a post_fn argument, not an edit. If you want to add Anthropic- or Google-style clients later, you write one function, not a new loop.
  2. Behavior lives in two places: the prompt and the tools. Instructions tell the model what to do; code guarantees what may happen. Path containment, hidden-file filters, stale-base rejection, and the approval gate keep the model from changing files you did not agree to change. Be precise about the strength of that claim, though: the whitelist limits which programs run_shell may start, and make and uv run execute project code by design, so the real guarantee is “nothing is written without approval”, not “nothing bad can happen”. Whenever you write a rule in a system prompt, ask which tool enforces it.
  3. Failures of small models are design inputs, not exceptions. Malformed JSON, null content, truncated tool calls, and repetition loops all have explicit handling that turns them into feedback the model can act on. Stuck-model detection with signatures bounds the cost of confusion.
  4. Configuration is data. Endpoints, models, keys-by-name, generation parameters, and pricing all live in the global and local JSON files, merged with a twelve-line deep_merge. Unknown values are reported as unknown. New providers need no code change, which is exactly the test of whether you separated policy from mechanism.

You now understand the core architecture of the coding agents in this book at a level that a user of those agents does not reach by reading their documentation. The agentic loop, the approval gate, and the hierarchical config are all reusable in your own projects, from CI bots to home-lab automation.

One last habit is worth taking from this project. The chapter prints nine modules in full, and that is only honest if the printed code is the code that runs, so the repository keeps a small script (tools/sync_listings.py) that rewrites each listing from its source file and fails the build when the two drift. Every listing you read here was generated that way.

Exercises for the Reader

  1. Add a write_file tool. Register a tool taking (path, contents) that creates a new file. Reuse the approval flow from propose_edit. Make sure "" remains a valid argument and hidden paths are refused. Test it by asking the agent to generate a .gitignore.
  2. Implement an Ollama provider profile in your global config (type: "ollama", endpoint on port 11434). Confirm that no code changes are needed and that /provider lists the new profile.
  3. Widen the repetition window. Change REPEAT_WINDOW to 10 and REPEAT_LIMIT to 3. Run an intentionally weak small model on a task that fails, and compare the token usage (/tokens) with the default settings. What is the tradeoff?
  4. Make /compact budget-driven. Add an auto-compaction trigger: when the total of message_char_size exceeds a threshold from the config file (say "auto_compact_chars": 50000), run compaction automatically before the next model call.
  5. Add a third search engine (for example Tavily) to search.py, return the same (url, title, snippet) triples, and wire /search tavily plus config support for the new engine name.
  6. Estimate cost without pricing. session_cost currently returns None when a profile declares no pricing. Change print_session_stats to also show the estimated token count of the next request using Code Test so readers can see context growth even for unpriced profiles.
  7. Audit the path check. resolve_tool_path and path_within_working_dir are the only things standing between the model and the rest of your filesystem. Write down every way you can think of to reach a file outside the working directory – a symlink, a mount point, a path the shell expands differently than shlex.split does – and turn each one into a test under tests/. Then tighten resolve_tool_path until they pass.
  8. Port the loop to streaming for MLX. mlx_lm.server also supports SSE. Reuse parse_sse_response from fireworks_ai.py to stream local responses, and print tokens as they arrive.