# compile_loop
Autonomous compile-and-fix loop — Tool factory block.
`compile_loop.make(conf)` returns a `tool_def = {name, schema, handler}` that can be
passed to `agent.run({extra_tools = {tool_def}})`. When the calling LLM invokes the tool,
it runs an iterative edit-compile-check loop until the runner reports success or the
iteration ceiling is reached.
## API
### `compile_loop.make(conf)`
| `runner` | `function` | yes | — | See §Runner signature |
| `llm` | `table` | no | inherited | `{provider, base_url, api_key, api_key_env, model, max_tokens, temperature, disable_thinking, timeout}` |
| `max_iters` | `int` | no | `5` | Maximum iterations before giving up |
| `lang` | `string` | no | `"lua"` | Language hint for the LLM |
| `name` | `string` | no | `"compile_loop"` | Tool name registered in the tool registry |
| `system` | `string` | no | `nil` | Additional system prompt prepended to the default |
| `edit_mode` | `"full"\|"diff"` | no | `"full"` | `"full"` rewrites the entire file; `"diff"` uses SEARCH/REPLACE patches |
| `tool_mode` | `"auto"\|"read_only"\|"none"\|"adaptive"` | no | `"auto"` | Multi-file only. `"auto"` declares `read_file` / `read_file_range` / `apply_search_replace`; `"read_only"` declares just the read tools; `"none"` declares no tools (caller inlines all file contents in the spec); `"adaptive"` starts as `"auto"` and falls back to `"none"` when the declared tools stall the loop (see below) |
| `extra_tools` | `array` | no | — | Multi-file only. Caller-registered tools in the agent-layer nested form `{name, schema = {description?, input_schema}, handler}`. Declared alongside the built-in tools; dispatched inside the tool loop; built-in names are reserved. `handler(input)` returns a string; errors are propagated as recoverable tool_result text. Extra-tool calls do not count as applied edits |
**Tool inputs** (`spec`, `target_file` or `target_files`, `lang?`) are supplied by the
calling LLM at tool-call time; factory `conf` fixes the runner and LLM policy at
registration time.
### Inputs: `target_file` XOR `target_files`
The tool schema accepts **either** `target_file` **or** `target_files` — not both.
Supplying both simultaneously raises an assertion error at handler entry.
| `target_file` | `string` | Single-file mode |
| `target_files` | `array<string>` | Multi-file mode (requires `edit_mode = "diff"`) |
Internally both forms are normalised to a list before any downstream logic runs. Existing
callers that supply only `target_file` continue to work unchanged.
## Single-file mode
Classic behaviour: one target file, any `edit_mode`.
```lua
local compile_loop = require("blocks/compile_loop")
local LUA_TIMEOUT = 60
local tool = compile_loop.make({
edit_mode = "diff",
runner = function(path)
-- path is an absolute string
local res = sh.exec("lua " .. path, { timeout = LUA_TIMEOUT })
if not res.ok then
-- spawn failure or timeout: no exit code exists
return { ok = false, stdout = "", stderr = tostring(res.error), exit_code = -1 }
end
return { ok = res.code == 0, stdout = res.stdout, stderr = res.stderr, exit_code = res.code }
end,
})
local result = agent.run({
provider = "anthropic",
model = "claude-haiku-4-5",
extra_tools = { tool },
messages = {{
role = "user",
content = "Fix the script so it runs without errors.",
}},
})
```
## Multi-file mode
Multiple target files edited in a single loop. Requires `edit_mode = "diff"`.
```lua
-- pseudo (requires subtask-1 implementation)
local compile_loop = require("blocks/compile_loop")
local CARGO_TIMEOUT = 300
local tool = compile_loop.make({
edit_mode = "diff",
runner = function(paths)
-- paths is a list<string> of absolute paths
local res = sh.exec("cargo test", { timeout = CARGO_TIMEOUT })
if not res.ok then
-- spawn failure or timeout: no exit code exists
return { ok = false, stdout = "", stderr = tostring(res.error), exit_code = -1 }
end
return { ok = res.code == 0, stdout = res.stdout, stderr = res.stderr, exit_code = res.code }
end,
})
local result = agent.run({
provider = "anthropic",
model = "claude-haiku-4-5",
extra_tools = { tool },
messages = {{
role = "user",
content = "Fix the failing tests across both files.",
}},
})
-- result.modified_files contains the list of absolute paths that were written
```
### Tool channel: `apply_search_replace` (`tool_mode = "auto"`, default)
Agentic-tuned models treat declared tools as the primary way to act and may never
fall back to the SR-in-text contract. With `tool_mode = "auto"` the loop therefore
declares a write-side tool alongside the read tools, and accepts edits from
**either channel**:
- **Text channel** — SEARCH/REPLACE blocks in the response text (unchanged).
- **Tool channel** — `apply_search_replace {path, search, replace}` calls; each call
applies one SR edit (same two-stage matcher and `target_files` allowlist as the
text channel) and writes the file immediately. A mismatch returns a recoverable
error so the model can re-read and retry within the same iteration.
An iteration that applied at least one tool-channel edit proceeds to verify even
when the final response contains no SR text (the model is told to reply `DONE`).
`tool_mode = "none"` is the escape hatch for callers that inline all target-file
contents in the spec: no tools are declared at all, which measurably restores the
text contract on newer models. `"read_only"` preserves the pre-tool-channel
behaviour (read tools only).
**Adaptive channel rescue (`tool_mode = "adaptive"`)**: starts as `"auto"`.
When the declared tools stall the loop — two consecutive zero-edit iterations,
or a single response blowing past the per-iteration tool-call cap ("keeps
reading, never writes") — the loop drops all tool declarations, embeds the
current file contents into the prompt, and continues on the pure SR-text
contract. This is the runtime form of the strip-tools proxy experiment from
issue #1, where removing the `tools` field measurably restored the text
contract on tool-preferring models. The switch resets the zero-edit stagnation
counter so the new channel gets a full rescue window; the switch is one-way
within a run.
**Wire-shape tolerance (OpenAI-compatible stacks)**: the OpenAI response
normalizer accepts two observed deviations from the spec — `function.arguments`
arriving as a JSON *object* instead of a string (Ollama native `/api/chat`,
Gemini `functionCall.args`, some vLLM tool-call parsers), and a missing/empty
`id` field (Ollama native has none; pre-Gemini-3 models make it optional), for
which a deterministic `call_synth_<index>` id is synthesized and carried through
the `role="tool"` result pairing. Malformed argument *strings* still fall back
to `input={}` with an `arguments_parse_failed` hint so the model can recover.
### Multi-file examples (Anthropic)
End-to-end smoke scripts under `examples/`, runnable as `agent-block -s examples/<file>.lua` (requires `ANTHROPIC_API_KEY` in `.env`):
| `test_anthropic_compile_loop_multi.lua` | Add a function to **both** files (basic additive multi-file diff) |
| `test_anthropic_compile_loop_multi_delete.lua` | Remove a function + assertions from both files (REPLACE-empty deletion) |
| `test_anthropic_compile_loop_multi_selective.lua` | Edit one file only; verifies the untouched file is byte-identical |
| `test_anthropic_compile_loop_multi_stagnation.lua` | Forced-fail runner; asserts `max_iters` bound and `ok=false` return |
Single-file equivalents live alongside (`test_anthropic_compile_loop.lua` etc.).
## SEARCH/REPLACE format
### Single-file (`target_file`)
The LLM produces one or more SEARCH/REPLACE blocks. No path header is needed.
```
<<<<<<< SEARCH
<existing text to find>
=======
<replacement text>
>>>>>>> REPLACE
```
Path headers in single-file mode are accepted but ignored (lenient parse). All blocks are
applied to `target_file`.
### Multi-file (`target_files`)
Each group of SEARCH/REPLACE blocks must be preceded by a path header line that identifies
the target file:
```
<<< path=src/file_a.lua >>>
<<<<<<< SEARCH
<existing text in file_a>
=======
<replacement text>
>>>>>>> REPLACE
<<< path=src/file_b.lua >>>
<<<<<<< SEARCH
<existing text in file_b>
=======
<replacement text>
>>>>>>> REPLACE
```
Rules:
- The `<<< path=<relpath> >>>` line must appear **before** the first SEARCH/REPLACE block
for that file.
- Consecutive SEARCH/REPLACE blocks under the same path header all apply to that file.
- A new path header switches the active file.
- Path headers are **required** in multi-file mode. A block with no preceding path header
is a parse error.
- The path must appear in `target_files`. A path not in the allowlist is a parse error.
- Duplicate path headers (same path appearing twice) are a parse error.
## Runner signature
The runner signature differs by mode. Callers must write a runner appropriate for the mode
they select; the two signatures must **not** be unified into a single function that silently
changes behaviour when the mode changes.
**Single-file mode:**
```lua
runner = function(path) -- path: string (absolute)
-- ...
return { ok = bool, stdout = string, stderr = string, exit_code = int }
end
```
**Multi-file mode:**
```lua
runner = function(paths) -- paths: list<string> (absolute paths)
-- ...
return { ok = bool, stdout = string, stderr = string, exit_code = int }
end
```
## Return shape
`filter_for_tool_output` exposes the following fields to the calling agent:
| `ok` | `bool` | always |
| `iters` | `int` | always |
| `summary` | `string` | always |
| `artifact_path` | `string\|nil` | single-file only (absolute path of the edited file) |
| `modified_files` | `list<string>\|nil` | multi-file only (absolute paths of all written files) |
| `failure_reason` | `string\|nil` | on failure (`"max_iters"`, `"stagnation"`, or `"no_edits_applied"`) |
| `last_error` | `string\|nil` | on failure |
In multi-file mode `artifact_path` is `nil`; use `modified_files` instead.
## Constraints
- **`edit_mode = "diff"` is required for multi-file mode.** Specifying `edit_mode = "full"`
with `target_files` raises an assertion error at handler entry.
- `target_file` and `target_files` are mutually exclusive. Supplying both raises an assertion
error.
- `target_files` must be a non-empty list of strings.
- Stagnation detection: when `STAGNATION_WINDOW = 3` consecutive iterations produce identical
runner `stderr`, the loop exits immediately with `failure_reason = "stagnation"`.
- Bad stagnation: when `STAGNATION_WINDOW = 3` consecutive iterations apply zero edits (LLM
emitted no valid SEARCH/REPLACE blocks, or all blocks failed SEARCH matching), the loop exits
with `failure_reason = "no_edits_applied"`. See §Qwen path operational notes for details.
## Background
The compile_loop block was extracted from `coding_agent` to allow reuse as a standalone
Tool factory. Multi-file mode was added to address LLM context overflow (`max_model_len`
exceeded) when embedding entire large files in the prompt — diffing only the changed sections
across multiple files keeps context size bounded.
## Qwen path operational notes
These notes apply to the OpenAI provider path when targeting a Qwen vLLM endpoint
(e.g. RunPod proxy serving `qwen36-vllm-a40` or similar). The compile_loop block
itself is provider-agnostic — these are operational guidance for callers.
### Deterministic temperature
The OpenAI body defaults `temperature = 0.0` for deterministic greedy decoding,
which is the desired behaviour for code-editing loops. Callers can override via
either:
- `compile_loop.make({ llm = { temperature = <number> } })` — explicit caller value
- `COMPILE_LOOP_LLM_TEMPERATURE=<number>` — env override applied when caller does
not pass `llm.temperature`
Precedence: caller > env > `0.0` default. Setting `COMPILE_LOOP_LLM_TEMPERATURE`
to a non-numeric value falls back to `0.0` with a warning log entry.
### Disable thinking mode
For Qwen-style models that expose a chain-of-thought thinking budget, set
`disable_thinking = true` on the LLM config to suppress reasoning output and
reduce latency. Example:
```lua
local tool = compile_loop.make({
llm = {
provider = "openai",
base_url = "https://<runpod-proxy>/v1",
api_key_env = "QWEN_API_KEY",
model = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ",
disable_thinking = true, -- recommended for code-editing loops
-- temperature defaults to 0.0; set COMPILE_LOOP_LLM_TEMPERATURE
-- or pass explicit temperature here to override.
},
runner = function(path) ... end,
})
```
### Bad vs good stagnation
The loop distinguishes two failure modes when iterations do not converge:
- `failure_reason = "stagnation"` — runner produced identical `stderr` for
`STAGNATION_WINDOW = 3` consecutive iterations after at least one successful
edit. This is the "good" stagnation case: the LLM is editing, but the runner
is stuck on the same error.
- `failure_reason = "no_edits_applied"` — `STAGNATION_WINDOW = 3` consecutive
iterations produced zero successful SEARCH/REPLACE applies (parse failure or
all blocks failed to match). The "bad" stagnation case: the LLM is not making
progress in edits at all. Before terminating, the loop injects an explicit
retry message asking the LLM to emit a SEARCH/REPLACE block that actually
applies; only after the third consecutive zero-edit iteration does the loop
exit with `failure_reason = "no_edits_applied"`.
Callers should treat `no_edits_applied` as a stronger failure signal than
`stagnation` — it suggests the prompt or model is incompatible with the target
file shape, not just that the fix is hard.
### Cross-reference
For RunPod proxy operational gotchas (e.g. ~30s cold-start timeout on first
request after pod idle), consult your proxy-side documentation.