agent-block-core 0.35.1

Host runtime + Lua stdlib bridge + EventBus for agent-block
Documentation
# 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)`

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `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.

| Field | Type | Mode |
|---|---|---|
| `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`):

| Script | Scenario |
|---|---|
| `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:

| Field | Type | Present when |
|---|---|---|
| `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.