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:
- Tool calling. How do you describe actions to a model so that it emits machine-executable instructions instead of prose?
- The agentic loop. How do you run model, tools, and feedback until the task is done, without letting a confused model loop forever?
- Control and safety. How do you gate file writes behind human approval, restrict shell access, and detect a stuck model?
- 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:
typeselects 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.serveron 11434, oMLX on 8000, sushi on 12345.endpointis the full chat-completions URL.modelis the model identifier the server expects.api_key_envnames the environment variable that holds the key. The config file never contains secrets, only the name of the variable to read.generationholdstemperature,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.pricingholds USD rates per 1,000,000 tokens, used by the/tokenscommand. A profile withoutpricingreports token counts and prints cost asn/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 localdefault_providerwins), - search on, using the global engine
brave(the nested merge kept it), - the
mlxprofile exactly as the global file defines it, - the
fireworksprofile 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:
- CLI flags (
--provider,--model,--quiet, …) are strongest.agent.pyapplies them last. - Environment variables
CODING_AGENT_PROVIDER,CODING_AGENT_MODEL,CODING_AGENT_QUIET,CODING_AGENT_PLAIN,CODING_AGENT_DEBUGoverride config values. An env var naming an unknown provider produces a warning, not a crash. - 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:
- Render the tool definitions and attach them to the payload with
"tool_choice": "auto". - POST. Extract the assistant message.
- 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.
- If there are no
tool_calls, the turn is over. Return the text and the new history. - If there are tool calls, print any accompanying text, execute the calls, append one
toolmessage 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
prompt tokens,
of those cached, and
completion tokens, with input, cached-input, and output rates
,
, and
, the session cost is

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 withGENERAL_SYSTEM_PROMPTand search attempted regardless of the/searchtoggle, since a factual question has nowhere else to get its answer. No coding tools, no conversation history.coding: the full agentic loop withENABLED_TOOLSover the persistentmessageshistory.hybrid: the coding loop, but with search results prepended when/searchis 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
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:
- The loop is separate from the transport.
chat_loop.pyholds all agent intelligence and knows nothing about HTTP or providers. Swapping Fireworks for MLX was apost_fnargument, not an edit. If you want to add Anthropic- or Google-style clients later, you write one function, not a new loop. - 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_shellmay start, andmakeanduv runexecute 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. - 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.
- 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
- Add a
write_filetool. Register a tool taking(path, contents)that creates a new file. Reuse the approval flow frompropose_edit. Make sure""remains a valid argument and hidden paths are refused. Test it by asking the agent to generate a.gitignore. - Implement an Ollama provider profile in your global config (
type: "ollama", endpoint on port 11434). Confirm that no code changes are needed and that/providerlists the new profile. - Widen the repetition window. Change
REPEAT_WINDOWto 10 andREPEAT_LIMITto 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? - Make
/compactbudget-driven. Add an auto-compaction trigger: when the total ofmessage_char_sizeexceeds a threshold from the config file (say"auto_compact_chars": 50000), run compaction automatically before the next model call. - Add a third search engine (for example Tavily) to
search.py, return the same(url, title, snippet)triples, and wire/search tavilyplus config support for the new engine name. - Estimate cost without pricing.
session_costcurrently returnsNonewhen a profile declares nopricing. Changeprint_session_statsto also show the estimated token count of the next request using
so readers can see context growth even for unpriced profiles.
- Audit the path check.
resolve_tool_pathandpath_within_working_dirare 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 thanshlex.splitdoes – and turn each one into a test undertests/. Then tightenresolve_tool_pathuntil they pass. - Port the loop to streaming for MLX.
mlx_lm.serveralso supports SSE. Reuseparse_sse_responsefromfireworks_ai.pyto stream local responses, and print tokens as they arrive.