local M = {}
local agent = require("agent")
local STAGNATION_WINDOW = 3
local function env_true(name)
local v = std.env.get(name)
if not v then return false end
v = string.lower(tostring(v))
return v == "1" or v == "true" or v == "yes" or v == "on"
end
local function normalize_dump_mode(v)
if not v or v == "" then return nil end
v = string.lower(tostring(v))
if v == "off" or v == "none" then return "off" end
if v == "meta" then return "meta" end
if v == "full" then return "full" end
return "off"
end
local function resolve_dump_mode()
local mode = normalize_dump_mode(std.env.get("AGENT_BLOCK_LLM_DUMP"))
if not mode then
local rust_log = string.lower(std.env.get_or("RUST_LOG", ""))
if rust_log:find("trace", 1, true) or rust_log:find("debug", 1, true) then
mode = "meta"
else
mode = "off"
end
end
if mode == "full" then
local env_name = string.lower(std.env.get_or("AGENT_BLOCK_ENV", ""))
local is_prod = env_name == "prod" or env_name == "production"
if is_prod and not env_true("AGENT_BLOCK_LLM_DUMP_ALLOW_PROD") then
log.warn("compile_loop: AGENT_BLOCK_LLM_DUMP=full blocked in production env; downgraded to meta")
mode = "meta"
end
end
return mode
end
local LLM_DUMP_PREFIX = "ab.obs"
local function kv_escape(v)
if v == nil then return "nil" end
if type(v) == "boolean" or type(v) == "number" then
return tostring(v)
end
local s = tostring(v)
if s == "" then return '""' end
if s:find("[%s=]") then
return std.json.encode(s)
end
return s
end
local function format_kv(parts)
local out = {}
for i, pair in ipairs(parts) do
out[i] = tostring(pair[1]) .. "=" .. kv_escape(pair[2])
end
return table.concat(out, " ")
end
local function obs_event(mode, event_name, fields)
if mode == "off" then return end
local entries = {
{ "prefix", LLM_DUMP_PREFIX },
{ "event", event_name },
{ "component", "compile_loop" },
}
for _, f in ipairs(fields or {}) do
table.insert(entries, f)
end
log.info(format_kv(entries))
end
local DEFAULT_SYSTEM = [[You are an expert programmer.
You will be given a spec and asked to write code that runs and passes its self-checks.
Output ONLY the complete file contents in a single fenced code block (e.g. ```lua\n...\n```).
No prose before or after the block.
On retry, output the WHOLE corrected file (not a diff). Keep changes minimal.]]
local DIFF_SYSTEM = [[You are an expert programmer editing an existing file.
Output only SEARCH/REPLACE blocks in this exact format:
<<<<<<< SEARCH
<existing text to replace, character-exact>
=======
<replacement text>
>>>>>>> REPLACE
- Multiple blocks allowed.
- SEARCH text must match the file character-exactly (whitespace included).
- Do NOT output the full file. Do NOT use code fences.
- Make the SMALLEST changes that satisfy the spec.]]
local DIFF_SYSTEM_MULTI = [[You are an expert programmer editing multiple existing files simultaneously.
Output SEARCH/REPLACE blocks grouped by file. Each group must start with a path header:
<<< path=<file_path> >>>
<<<<<<< SEARCH
<existing text to replace, character-exact>
=======
<replacement text>
>>>>>>> REPLACE
Rules:
- Every SEARCH/REPLACE block MUST be preceded by a <<< path=... >>> header.
- The path must exactly match one of the target files provided.
- Multiple SEARCH/REPLACE blocks for the same file: repeat the path header before each block, or place all blocks consecutively under one header.
- SEARCH text must match the file character-exactly (whitespace included).
- Do NOT output full file contents. Do NOT use code fences.
- Make the SMALLEST changes that satisfy the spec.]]
local function to_abs(path)
if path:sub(1, 1) == "/" then
return path
end
return (os.getenv("PWD") or ".") .. "/" .. path
end
local function make_summary(ok, iters, max_iters, reason)
if ok then
return string.format("PASS in %d iters", iters)
end
if reason == "stagnation" then
return string.format(
"give-up: stagnation at iter %d/%d (stderr identical %dx)",
iters, max_iters, STAGNATION_WINDOW
)
elseif reason == "max_iters" then
return string.format("give-up: max_iters reached (%d)", max_iters)
elseif reason == "llm_call" then
return string.format("give-up: llm_call failed at iter %d/%d", iters, max_iters)
elseif reason == "open_target_file" then
return string.format("give-up: open_target_file failed at iter %d/%d", iters, max_iters)
else
return string.format("give-up: %s", tostring(reason))
end
end
local function is_stagnant(history)
if #history < STAGNATION_WINDOW then
return false
end
local ref = ((history[#history].result) or {}).stderr or ""
for i = #history - STAGNATION_WINDOW + 1, #history do
if (((history[i].result) or {}).stderr or "") ~= ref then
return false
end
end
return true
end
local function fnv1a_hash(s)
s = s or ""
local hash = 2166136261 for i = 1, #s do
local byte = string.byte(s, i)
hash = (hash ~ byte) * 16777619
hash = hash & 0xFFFFFFFF
end
return tostring(hash)
end
local function compute_sr_hash(sr_text)
local text = tostring(sr_text or "")
text = text:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
return fnv1a_hash(text)
end
local function is_stagnant_v2(state, last_verify_failed)
assert(type(state) == "table", "state required")
assert(type(state.sr_history) == "table", "state.sr_history must be initialized as table")
if #state.sr_history < STAGNATION_WINDOW then return false end
if not last_verify_failed then return false end
local recent = {}
for i = #state.sr_history - STAGNATION_WINDOW + 1, #state.sr_history do
recent[#recent + 1] = state.sr_history[i]
end
local counts = {}
for _, h in ipairs(recent) do
counts[h] = (counts[h] or 0) + 1
end
for _, c in pairs(counts) do
if c >= STAGNATION_WINDOW then return true end
end
return false
end
local function collect_modified_paths(set)
local paths = {}
for path in pairs(set) do
paths[#paths + 1] = path
end
table.sort(paths)
return paths
end
local function update_state(state, opts)
if opts.last_err ~= nil then
local s = tostring(opts.last_err)
state.last_err = s:sub(-2000)
end
if opts.sr_digest_prev ~= nil then
local s = tostring(opts.sr_digest_prev)
state.sr_digest_prev = s:sub(1, 500)
end
if opts.sr_hash_append ~= nil then
table.insert(state.sr_history, opts.sr_hash_append)
end
if opts.iter ~= nil then
state.iter = opts.iter
end
end
local function extract_code(text, lang)
lang = lang or "lua"
local m = text:match("```" .. lang .. "%s*\n(.-)\n```")
if m then return m end
m = text:match("```%w*%s*\n(.-)\n```")
if m then return m end
return text
end
local function cl_oai_map_finish_reason(finish_reason)
if finish_reason == "stop" then
return "end_turn"
elseif finish_reason == "tool_calls" then
return "tool_use"
elseif finish_reason == "length" then
return "max_tokens"
else
return tostring(finish_reason or "end_turn")
end
end
local function cl_oai_normalize(raw)
if not raw or not raw.choices or #raw.choices == 0 then
return nil, "invalid OpenAI response: missing choices"
end
local choice = raw.choices[1]
local message = choice and choice.message
if not message then
return nil, "invalid OpenAI response: missing choices[0].message"
end
local text_parts = {}
local tool_use_blocks = {}
local text = message.content
if text and text ~= "" then
table.insert(text_parts, text)
end
if std.env.get("AGENT_BLOCK_DEBUG_RAW") == "1" then
local tc_count = #(message.tool_calls or {})
local preview = (text or ""):sub(1, 1500)
log.info("[DEBUG_RAW] content_len=" .. tostring(text and #text or 0)
.. " tool_calls=" .. tostring(tc_count)
.. " content_preview<<<" .. preview .. ">>>")
for i, tc in ipairs(message.tool_calls or {}) do
local fn = tc["function"] or {}
log.info("[DEBUG_RAW] tool_call[" .. i .. "] name=" .. tostring(fn.name)
.. " args=" .. tostring(fn.arguments or ""):sub(1, 500))
end
end
for _, tc in ipairs(message.tool_calls or {}) do
local fn = tc["function"] or {}
local input = {}
local ok, parsed = pcall(std.json.decode, fn.arguments or "{}")
if ok and type(parsed) == "table" then
input = parsed
else
log.warn("compile_loop: OpenAI tool_call arguments JSON parse failed for tool '"
.. tostring(fn.name) .. "'; using empty input")
table.insert(tool_use_blocks, {
id = tc.id,
name = fn.name or "",
input = {},
is_error_hint = "arguments_parse_failed",
})
goto continue_tc
end
table.insert(tool_use_blocks, {
id = tc.id,
name = fn.name or "",
input = input,
})
::continue_tc::
end
local joined = table.concat(text_parts, "\n")
return {
choices = {
{
message = {
content = joined,
tool_use_blocks = tool_use_blocks,
stop_reason = cl_oai_map_finish_reason(choice.finish_reason),
},
},
},
}, nil
end
local function cl_oai_convert_messages(messages, system)
local out = {}
if system and system ~= "" then
table.insert(out, { role = "system", content = system })
end
for _, msg in ipairs(messages) do
if type(msg.content) == "string" then
table.insert(out, { role = msg.role, content = msg.content })
elseif type(msg.content) == "table" then
if msg.role == "assistant" then
local a_text_parts = {}
local tool_calls = {}
for _, block in ipairs(msg.content) do
if block.type == "text" then
table.insert(a_text_parts, block.text or "")
elseif block.type == "tool_use" then
table.insert(tool_calls, {
id = block.id,
type = "function",
["function"] = {
name = block.name,
arguments = std.json.encode(block.input or {}),
},
})
end
end
local text_content = #a_text_parts > 0 and table.concat(a_text_parts, "\n") or nil
local oai_msg = { role = "assistant" }
if text_content then oai_msg.content = text_content end
if #tool_calls > 0 then oai_msg.tool_calls = tool_calls end
table.insert(out, oai_msg)
elseif msg.role == "user" then
local has_tool_result = false
for _, block in ipairs(msg.content) do
if block.type == "tool_result" then
has_tool_result = true
break
end
end
if has_tool_result then
for _, block in ipairs(msg.content) do
if block.type == "tool_result" then
table.insert(out, {
role = "tool",
tool_call_id = block.tool_use_id,
content = tostring(block.content or ""),
})
end
end
else
local parts = {}
for _, block in ipairs(msg.content) do
if block.type == "text" then
table.insert(parts, block.text or "")
end
end
table.insert(out, { role = "user", content = table.concat(parts, "\n") })
end
else
table.insert(out, { role = msg.role, content = msg.content })
end
end
end
return out
end
local _llm_call_override = nil
local function llm_call(opts, messages)
if _llm_call_override then
return _llm_call_override(opts, messages)
end
local provider = opts.provider or "openai"
if provider == "anthropic" then
local api_key = opts.api_key
if not api_key or api_key == "" then
api_key = std.env.get(opts.api_key_env or "ANTHROPIC_API_KEY")
end
if not api_key or api_key == "" then
return nil, "no api_key (opts.api_key or ANTHROPIC_API_KEY env)"
end
local model = opts.model or std.env.get_or("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001")
local sys_text = nil
local body_messages = {}
for _, msg in ipairs(messages) do
if msg.role == "system" and sys_text == nil then
sys_text = msg.content
else
table.insert(body_messages, msg)
end
end
local body = {
model = model,
max_tokens = opts.max_tokens or 4096,
messages = body_messages,
}
if sys_text then
body.system = sys_text
end
if opts.tools ~= nil then
body.tools = opts.tools
end
local headers = {
["x-api-key"] = api_key,
["anthropic-version"] = "2023-06-01",
["content-type"] = "application/json",
}
local base_url = opts.base_url or "https://api.anthropic.com"
local resp = http.request(base_url .. "/v1/messages", {
method = "POST",
headers = headers,
body = std.json.encode(body),
timeout = opts.timeout or 120,
})
if resp.status ~= 200 then
return nil, "API error " .. tostring(resp.status) .. " body=" .. tostring(resp.body or "")
end
local ok, decoded = pcall(std.json.decode, resp.body)
if not ok or type(decoded) ~= "table" then
return nil, "decode failed: " .. tostring(decoded)
end
if type(decoded.content) ~= "table" or #decoded.content == 0 then
return nil, "anthropic response missing content blocks"
end
local text_parts = {}
local tool_use_blocks = {}
for _, block in ipairs(decoded.content) do
if block.type == "text" then
table.insert(text_parts, block.text or "")
elseif block.type == "tool_use" then
table.insert(tool_use_blocks, {
id = block.id,
name = block.name,
input = block.input or {},
})
end
end
local joined = table.concat(text_parts, "\n")
local stop_reason = decoded.stop_reason
if joined == "" and #tool_use_blocks == 0 then
return nil, "anthropic response missing content blocks"
end
local msg_shape = { content = joined }
if opts.tools ~= nil then
msg_shape.tool_use_blocks = tool_use_blocks
msg_shape.stop_reason = stop_reason
end
return { choices = { { message = msg_shape } } }
elseif provider ~= "openai" then
return nil, "provider " .. provider .. " not yet supported in compile_loop"
end
local api_key = opts.api_key
if not api_key or api_key == "" then
api_key = std.env.get(opts.api_key_env or "OPENAI_API_KEY")
end
if not api_key or api_key == "" then
return nil, "no api_key (opts.api_key or OPENAI_API_KEY env)"
end
local sys_text = nil
local body_messages_raw = {}
for _, msg in ipairs(messages) do
if msg.role == "system" and sys_text == nil then
sys_text = msg.content
else
table.insert(body_messages_raw, msg)
end
end
local oai_messages = cl_oai_convert_messages(body_messages_raw, sys_text)
local base_url = opts.base_url or "https://api.openai.com/v1"
local body = {
model = opts.model or "gpt-4o-mini",
max_tokens = opts.max_tokens or 4096,
temperature = opts.temperature or 0.2,
messages = oai_messages,
}
if opts.disable_thinking then
body.chat_template_kwargs = { enable_thinking = false }
end
if opts.tools and #opts.tools > 0 then
local oai_tools = {}
for _, t in ipairs(opts.tools) do
local fn_def = {
name = t.name,
description = t.description or "",
parameters = t.input_schema or { type = "object", properties = {} },
}
table.insert(oai_tools, { type = "function", ["function"] = fn_def })
end
body.tools = oai_tools
end
local headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. api_key,
["User-Agent"] = "Mozilla/5.0", }
local resp = http.request(base_url .. "/chat/completions", {
method = "POST",
headers = headers,
body = std.json.encode(body),
timeout = opts.timeout or 120,
})
if resp.status ~= 200 then
return nil, "API error " .. tostring(resp.status) .. " body=" .. tostring(resp.body or "")
end
local ok, decoded = pcall(std.json.decode, resp.body)
if not ok or type(decoded) ~= "table" then
return nil, "decode failed: " .. tostring(decoded)
end
if opts.tools == nil then
return decoded
end
return cl_oai_normalize(decoded)
end
local function parse_search_replace(text, multi_file, target_files_set)
local blocks = {}
local pos = 1
local len = #text
local current_path = nil
while pos <= len do
local ph_start, ph_end, ph_path = text:find("<<<%s*path=([^>]+)%s*>>>", pos)
local s_start, s_end = text:find("<<<<<<< SEARCH\n", pos, true)
if ph_start and (not s_start or ph_start < s_start) then
local raw_path = ph_path:match("^%s*(.-)%s*$") if multi_file then
if not target_files_set[raw_path] then
return nil, "path '" .. raw_path .. "' not in target_files allowlist"
end
end
if multi_file then
current_path = raw_path
end
pos = ph_end + 1
if pos <= len and text:sub(pos, pos) == "\n" then
pos = pos + 1
end
elseif s_start then
if multi_file and current_path == nil then
return nil, "missing path header for multi-file mode at offset " .. tostring(s_start)
end
local sep_start, sep_end = text:find("\n=======\n", s_end + 1, true)
if not sep_start then
return nil, "malformed SEARCH/REPLACE block: missing ======= separator"
end
local rep_start, rep_end = text:find("\n>>>>>>> REPLACE", sep_end + 1, true)
if not rep_start then
return nil, "malformed SEARCH/REPLACE block: missing >>>>>>> REPLACE marker"
end
local search_text = text:sub(s_end + 1, sep_start - 1)
local replace_text = text:sub(sep_end + 1, rep_start - 1)
table.insert(blocks, { path = current_path, search = search_text, replace = replace_text })
pos = rep_end + 1
else
break
end
end
if #blocks == 0 then
return nil, "no SEARCH/REPLACE blocks found"
end
return blocks, nil
end
local function ws_normalize(s)
return (s:gsub("%s+", " "):match("^%s*(.-)%s*$"))
end
local function apply_blocks(content, blocks)
local failed_indices = {}
local current = content
for i, block in ipairs(blocks) do
local search = block.search
local replace = block.replace
local found_s, found_e = current:find(search, 1, true)
if found_s then
current = current:sub(1, found_s - 1) .. replace .. current:sub(found_e + 1)
else
local norm_search = ws_normalize(search)
local matched = false
local cur_len = #current
local search_len = #search
local cpos = 1
while cpos <= cur_len do
local min_win = math.max(1, search_len - math.floor(search_len / 2))
local max_win = search_len + math.floor(search_len / 2) + 10
if max_win > cur_len - cpos + 1 then
max_win = cur_len - cpos + 1
end
local found_window = false
for wlen = min_win, max_win do
local window = current:sub(cpos, cpos + wlen - 1)
if ws_normalize(window) == norm_search then
current = current:sub(1, cpos - 1) .. replace .. current:sub(cpos + wlen)
matched = true
found_window = true
break
end
end
if found_window then break end
cpos = cpos + 1
end
if not matched then
table.insert(failed_indices, i)
end
end
end
return current, failed_indices
end
local function build_edit_failure_msg(failed_indices, blocks, current_content)
local parts = {}
for _, idx in ipairs(failed_indices) do
local blk = blocks[idx]
table.insert(parts, string.format(
"Edit FAILED: block %d could not be applied. The SEARCH text did not match.\n=== SEARCH (block %d) ===\n%s",
idx, idx, blk and blk.search or "(nil)"
))
end
table.insert(parts, "=== Current file content ===\n" .. (current_content or ""))
table.insert(parts, "Re-emit ALL blocks from scratch with corrected SEARCH text.")
return table.concat(parts, "\n\n")
end
local function read_target_if_exists(path)
local abs_path = to_abs(path)
local f, _ = io.open(abs_path, "r")
if not f then return nil end
local content = f:read("*a")
f:close()
if not content or content == "" then return nil end
return content
end
local function build_failure_msg(lang, rr)
return string.format(
"Run FAILED. Fix the code and re-output the WHOLE corrected file in a single ```%s ... ``` block.\n\n=== stdout ===\n%s\n\n=== stderr ===\n%s\n\n=== exit_code ===\n%s",
lang,
tostring(rr.stdout or ""),
tostring(rr.stderr or ""),
tostring(rr.exit_code or "unknown")
)
end
local function filter_for_tool_output(res)
return {
ok = res.ok,
artifact_path = res.artifact_path, modified_files = res.modified_files, iters = res.iters,
summary = res.summary,
failure_reason = res.failure_reason,
last_error = res.last_error,
}
end
local MAX_TOOL_CALLS_PER_ITER = 8
local READ_FILE_FULL_THRESHOLD = 10000
local DISTILL_CHUNK_LINES = 200
local DISTILL_DIGEST_MAX_CHARS = 4000
local DISTILL_CHUNK_DIGEST_MAX_CHARS = 400
local CACHE_AUTO_TTL_SEC = 10
local READ_FILE_RANGE_MAX_LINES = 500
local READ_FILE_TOOL = {
name = "read_file",
description = "Read the current content of a target file. " ..
"For files <= READ_FILE_FULL_THRESHOLD bytes, returns full content. " ..
"For larger files, returns a distilled digest with line index hints; " ..
"use read_file_range to fetch verbatim ranges as needed.",
input_schema = {
type = "object",
required = { "path" },
properties = {
path = {
type = "string",
description = "Absolute path. Must be one of the target_files paths provided in the spec.",
},
},
},
}
local READ_FILE_RANGE_TOOL = {
name = "read_file_range",
description = "Read a verbatim line range of a target file. " ..
"Use this after read_file returned a distilled digest, to fetch a specific section. " ..
"1-indexed, inclusive; line_end - line_start + 1 must be <= READ_FILE_RANGE_MAX_LINES.",
input_schema = {
type = "object",
required = { "path", "line_start", "line_end" },
properties = {
path = {
type = "string",
description = "Absolute path. Must be in target_files.",
},
line_start = {
type = "integer",
description = "1-indexed start line, inclusive.",
},
line_end = {
type = "integer",
description = "1-indexed end line, inclusive.",
},
},
},
}
local function file_mtime(path)
local ok, meta = pcall(function() return std.fs.metadata(path) end)
if ok and meta and meta.modified then
return meta.modified
end
return os.time()
end
local function should_use_cache(cached, cur_mtime, refresh_mode)
if cached == nil then return false end
if refresh_mode == "always" then return false end
if refresh_mode == "manual" then return true end local mtime_match = (cached.mtime == cur_mtime)
if refresh_mode == "files" then return mtime_match end
return mtime_match and (os.time() - cached.cached_at) < CACHE_AUTO_TTL_SEC
end
local function format_digest_response(cached)
local parts = {}
table.insert(parts, "[Distilled digest]\n" .. tostring(cached.digest or ""))
if cached.line_index and cached.line_index ~= "" then
table.insert(parts, "\n[Line index]\n" .. tostring(cached.line_index))
end
table.insert(parts, "\n[Use read_file_range to fetch verbatim line ranges.]")
return table.concat(parts, "")
end
local function truncate_with_warning(content, err)
local head = content:sub(1, READ_FILE_FULL_THRESHOLD)
local warn = "\n\n[WARNING: file exceeded size threshold; content truncated"
if err and err ~= "" then
warn = warn .. " (distill error: " .. tostring(err) .. ")"
end
warn = warn .. "]"
return head .. warn
end
local DISTILL_CHUNK_PROMPT =
"You are summarizing a chunk of a source code file for a coding assistant.\n" ..
"Your summary will be used as a digest that lets the assistant understand the code\n" ..
"without seeing the full file.\n\n" ..
"File: %s\n" ..
"Chunk: lines %d-%d (of %d total)\n" ..
"Recent build error (if any): %s\n" ..
"Target function (if any): %s\n\n" ..
"Code chunk:\n" ..
"```\n%s\n```\n\n" ..
"Instructions:\n" ..
"- Write a concise technical summary of what this chunk defines and does.\n" ..
"- Emphasize any definitions, exports, or logic relevant to the build error or target function.\n" ..
"- Include key function/class/variable names so the assistant can ask for specific lines.\n" ..
"- Keep the summary under %d characters.\n" ..
"- Output ONLY the summary text, no preamble."
local function split_lines(content)
local lines = {}
for line in (content .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
return lines
end
local function chunk_by_lines(lines, chunk_size)
local chunks = {}
local total = #lines
local i = 1
while i <= total do
local natural_end = math.min(i + chunk_size - 1, total)
local adjusted_end = natural_end
if natural_end < total then
local scan_limit = math.min(natural_end + 20, total)
for j = natural_end + 1, scan_limit do
local line = lines[j]
if line:match("^function ") or line:match("^local function ")
or line:match("^def ") or line:match("^fn ") then
adjusted_end = j - 1
break
end
end
end
local chunk_lines = {}
for k = i, adjusted_end do
table.insert(chunk_lines, lines[k])
end
table.insert(chunks, {
start = i,
end_ = adjusted_end,
total_lines = total,
text = table.concat(chunk_lines, "\n"),
})
i = adjusted_end + 1
end
return chunks
end
local function extract_text(resp)
if not resp then return nil end
local choices = resp.choices
if not choices or not choices[1] then return nil end
local msg = choices[1].message
if not msg then return nil end
return msg.content end
local function call_distill_llm(path, chunk, mf_state, conf)
local distill_conf = {
provider = conf.provider,
model = conf.model,
base_url = conf.base_url,
api_key = conf.api_key,
api_key_env = conf.api_key_env,
}
local target_func_str = "(none)"
if conf.target_func and type(conf.target_func) == "string" then
target_func_str = conf.target_func
end
local prompt = string.format(
DISTILL_CHUNK_PROMPT,
path,
chunk.start,
chunk.end_,
chunk.total_lines,
mf_state.last_err or "(none)",
target_func_str,
chunk.text,
DISTILL_CHUNK_DIGEST_MAX_CHARS
)
local messages = {
{ role = "user", content = prompt },
}
local resp, call_err = llm_call(distill_conf, messages) if not resp then
return nil
end
local text = extract_text(resp)
return text end
local function binary_search_pack(chunk_digests, max_chars, tolerance)
tolerance = tolerance or 0.15
if #chunk_digests == 0 then return "" end
local total_len = 0
for _, cd in ipairs(chunk_digests) do
total_len = total_len + #(cd.digest or "")
end
local selected
if total_len <= max_chars then
selected = {}
for _, cd in ipairs(chunk_digests) do
table.insert(selected, cd)
end
else
local lo, hi = 0, #chunk_digests
local best_k = 0
local lower_bound = max_chars * (1 - tolerance)
while lo <= hi do
local mid = math.floor((lo + hi) / 2)
local sum = 0
for k = 1, mid do
sum = sum + #(chunk_digests[k].digest or "")
end
if sum <= max_chars then
best_k = mid
if sum >= lower_bound then
break
end
lo = mid + 1
else
hi = mid - 1
end
end
selected = {}
for k = 1, best_k do
table.insert(selected, chunk_digests[k])
end
end
table.sort(selected, function(a, b) return a.start < b.start end)
local parts = {}
for _, cd in ipairs(selected) do
table.insert(parts, cd.digest or "")
end
return table.concat(parts, "\n")
end
local function build_line_index(chunk_digests)
local lines = {}
for _, cd in ipairs(chunk_digests) do
local first_line = ""
for line in (tostring(cd.digest or "") .. "\n"):gmatch("([^\n]*)\n") do
if line ~= "" then
first_line = line
break
end
end
if #first_line > 80 then
first_line = first_line:sub(1, 80)
end
table.insert(lines, "L" .. cd.start .. "-" .. cd.end_ .. ": " .. first_line)
end
return table.concat(lines, "\n")
end
local _distill_subloop_override = nil
local function distill_subloop(path, content, mf_state, conf)
if _distill_subloop_override then
return _distill_subloop_override(path, content, mf_state, conf)
end
local lines = split_lines(content)
local chunks = chunk_by_lines(lines, DISTILL_CHUNK_LINES)
local chunk_digests = {}
for _, chunk in ipairs(chunks) do
local digest = call_distill_llm(path, chunk, mf_state, conf)
if digest then
table.insert(chunk_digests, {
start = chunk.start,
end_ = chunk.end_,
digest = digest,
})
end
end
if #chunk_digests == 0 then
return nil, nil, "distill_subloop: all chunks failed (no LLM response)"
end
local err_line = nil
if mf_state.last_err then
local m = mf_state.last_err:match(":(%d+)")
if m then
err_line = tonumber(m)
end
end
local target_func = nil
if conf and conf.target_func and type(conf.target_func) == "string" then
target_func = conf.target_func
end
local function chunk_priority(cd)
if err_line and cd.start <= err_line and err_line <= cd.end_ then
return 1
end
if target_func and cd.digest:find(target_func, 1, true) then
return 2
end
return 3
end
local indexed = {}
for idx, cd in ipairs(chunk_digests) do
table.insert(indexed, { cd = cd, prio = chunk_priority(cd), orig = idx })
end
table.sort(indexed, function(a, b)
if a.prio ~= b.prio then return a.prio < b.prio end
return a.orig < b.orig
end)
local sorted_digests = {}
for _, entry in ipairs(indexed) do
table.insert(sorted_digests, entry.cd)
end
local digest = binary_search_pack(sorted_digests, DISTILL_DIGEST_MAX_CHARS, 0.15)
local line_index = build_line_index(chunk_digests)
return digest, line_index, nil
end
local function read_file_range_tool_handler(path, line_start, line_end, target_files_set)
if not target_files_set[path] then
return { ok = false, error = "path '" .. tostring(path) .. "' not in target_files allowlist" }
end
if type(line_start) ~= "number" or type(line_end) ~= "number"
or math.floor(line_start) ~= line_start
or math.floor(line_end) ~= line_end then
return { ok = false, error = "line_start and line_end must be integers" }
end
line_start = math.floor(line_start)
line_end = math.floor(line_end)
if line_start < 1 or line_end < line_start then
return { ok = false, error = "invalid range: require 1 <= line_start <= line_end" }
end
if (line_end - line_start + 1) > READ_FILE_RANGE_MAX_LINES then
return { ok = false, error = string.format(
"range %d-%d exceeds READ_FILE_RANGE_MAX_LINES=%d",
line_start, line_end, READ_FILE_RANGE_MAX_LINES
)}
end
local f, open_err = io.open(path, "r")
if not f then
return { ok = false, error = "cannot open: " .. tostring(open_err) }
end
local lines = {}
local cur = 0
for line in f:lines() do
cur = cur + 1
if cur >= line_start then
table.insert(lines, line)
end
if cur >= line_end then
break
end
end
f:close()
if cur < line_start then
return { ok = false, error = string.format(
"file has %d lines; line_start=%d out of range", cur, line_start
)}
end
return { ok = true, content = table.concat(lines, "\n") }
end
local function read_file_tool_handler(path, target_files_set, mf_state, conf)
if not target_files_set[path] then
return { ok = false, error = "path '" .. tostring(path) .. "' not in target_files allowlist" }
end
local f, err = io.open(path, "r")
if not f then
return { ok = false, error = "cannot open: " .. tostring(err) }
end
local content = f:read("*a")
f:close()
content = content or ""
if #content <= READ_FILE_FULL_THRESHOLD then
return { ok = true, content = content }
end
if not mf_state or type(mf_state.file_digest) ~= "table" then
return { ok = true, content = truncate_with_warning(content, nil) }
end
local refresh_mode = mf_state.file_digest_refresh or "auto"
local cur_mtime = file_mtime(path)
local cached = mf_state.file_digest[path]
if should_use_cache(cached, cur_mtime, refresh_mode) then
return { ok = true, content = format_digest_response(cached) }
end
local digest, line_index, distill_err = distill_subloop(path, content, mf_state, conf)
if distill_err then
return { ok = true, content = truncate_with_warning(content, distill_err) }
end
mf_state.file_digest[path] = {
digest = digest,
line_index = line_index,
mtime = cur_mtime,
cached_at = os.time(),
}
return { ok = true, content = format_digest_response(mf_state.file_digest[path]) }
end
local function group_blocks_by_path(blocks)
local grouped = {}
for _, block in ipairs(blocks) do
local key = block.path or false
if not grouped[key] then
grouped[key] = {}
end
table.insert(grouped[key], block)
end
return grouped
end
local function iterate_files(target_files, grouped, existing_map)
local new_contents_map = {}
local all_failed = {}
local write_err = nil
for _, abs_path in ipairs(target_files) do
local file_blocks = grouped[abs_path]
if file_blocks and #file_blocks > 0 then
local current = read_target_if_exists(abs_path) or ""
local new_content, failed_indices = apply_blocks(current, file_blocks)
if #failed_indices > 0 then
table.insert(all_failed, { path = abs_path, indices = failed_indices, blocks = file_blocks, current_content = current })
else
local f, werr = io.open(abs_path, "w")
if not f then
write_err = abs_path .. ": " .. tostring(werr)
break
end
f:write(new_content)
f:close()
new_contents_map[abs_path] = new_content
end
end
end
return new_contents_map, all_failed, write_err
end
local function build_multifile_edit_failure_msg(all_failed, existing_map)
local parts = {}
for _, entry in ipairs(all_failed) do
for _, idx in ipairs(entry.indices) do
local blk = entry.blocks[idx]
table.insert(parts, string.format(
"Edit FAILED in %s: block %d could not be applied. The SEARCH text did not match.\n=== SEARCH (block %d) ===\n%s",
entry.path, idx, idx, blk and blk.search or "(nil)"
))
end
table.insert(parts, "=== Current file content (" .. entry.path .. ") ===\n" .. (existing_map[entry.path] or ""))
end
table.insert(parts, "Re-emit ALL blocks from scratch with corrected SEARCH text.")
return table.concat(parts, "\n\n")
end
local function run_loop(conf)
assert(type(conf) == "table", "conf table required")
assert(conf.target_files and #conf.target_files > 0, "conf.target_files (non-empty list) required")
assert(conf.spec, "conf.spec required")
assert(type(conf.runner) == "function", "conf.runner (function) required")
local lang = conf.lang or "lua"
local max_iters = conf.max_iters or 5
local multi_file = conf.multi_file or false
local mode = resolve_dump_mode()
local artifact_path = (not multi_file) and conf.target_files[1] or nil
local target_files_set = {}
for _, p in ipairs(conf.target_files) do
target_files_set[p] = true
end
local edit_mode = conf.edit_mode or "full"
local existing_map = {}
if not multi_file then
for _, p in ipairs(conf.target_files) do
existing_map[p] = read_target_if_exists(p)
end
end
if not multi_file and edit_mode == "diff" and not existing_map[conf.target_files[1]] then
log.warn("compile_loop: edit_mode=diff requires an existing non-empty target_file; falling back to full")
edit_mode = "full"
end
local system
if edit_mode == "diff" then
if multi_file then
system = conf.system or DIFF_SYSTEM_MULTI
else
system = conf.system or DIFF_SYSTEM
end
else
system = conf.system or DEFAULT_SYSTEM
end
local multi_initial_user_content
if multi_file then
local path_lines = {}
for _, p in ipairs(conf.target_files) do
table.insert(path_lines, " " .. p)
end
multi_initial_user_content = conf.spec
.. "\n\nFiles:\n"
.. table.concat(path_lines, "\n")
.. "\n\nUse the read_file tool to fetch file content when needed."
end
local single_initial_user_content
if not multi_file then
if edit_mode == "diff" then
single_initial_user_content = conf.spec
.. "\n\n=== Current file content ===\n"
.. (existing_map[conf.target_files[1]] or "")
else
local existing = existing_map[conf.target_files[1]]
if existing then
single_initial_user_content = conf.spec
.. "\n\n=== Current file content ===\n```" .. lang .. "\n"
.. existing
.. "\n```"
else
single_initial_user_content = conf.spec
end
end
end
local mf_state = {
iter = 0,
last_err = nil, sr_digest_prev = nil, sr_history = {}, file_digest = {},
file_digest_refresh = "auto",
modified_set = {},
}
assert(type(mf_state.sr_history) == "table", "mf_state.sr_history must be initialized")
assert(type(mf_state.file_digest) == "table", "mf_state.file_digest must be initialized")
assert(mf_state.file_digest_refresh == "auto", "mf_state.file_digest_refresh must default to 'auto'")
assert(type(mf_state.modified_set) == "table", "mf_state.modified_set must be initialized")
local messages
if not multi_file then
messages = {
{ role = "system", content = system },
{ role = "user", content = single_initial_user_content },
}
end
local history = {}
for iter = 1, max_iters do
local obs_target = artifact_path or table.concat(conf.target_files, ",")
obs_event(mode, "iter_start", { { "iter", iter }, { "target_file", obs_target } })
if multi_file then
mf_state.iter = iter
local user_parts = { multi_initial_user_content }
if mf_state.last_err and mf_state.last_err ~= "" then
table.insert(user_parts, "\n=== Last verify error (trimmed) ===\n" .. mf_state.last_err)
end
if mf_state.sr_digest_prev and mf_state.sr_digest_prev ~= "" then
table.insert(user_parts, "\n=== Previous SR digest ===\n" .. mf_state.sr_digest_prev)
end
local iter_user_content = table.concat(user_parts, "")
messages = {
{ role = "system", content = system },
{ role = "user", content = iter_user_content },
}
obs_event(mode, "iter_messages_size", {
{ "iter", iter },
{ "messages_len", #messages },
{ "user_len", #iter_user_content },
})
end
local call_opts = conf
if multi_file then
call_opts = {}
for k, v in pairs(conf) do call_opts[k] = v end
call_opts.tools = { READ_FILE_TOOL, READ_FILE_RANGE_TOOL }
end
local resp, err = llm_call(call_opts, messages)
if not resp then
local err_str = tostring(err)
return {
ok = false,
failure_reason = "llm_call",
last_error = err_str:sub(-800),
iters = iter - 1,
summary = make_summary(false, iter - 1, max_iters, "llm_call"),
artifact_path = artifact_path,
history = history,
}
end
if multi_file then
existing_map = {}
local tool_call_count = 0
local cur_resp = resp
while true do
local cur_choice = (cur_resp.choices or {})[1] or {}
local cur_msg = cur_choice.message or {}
local cur_tool_blocks = cur_msg.tool_use_blocks or {}
if #cur_tool_blocks == 0 then
resp = cur_resp
break
end
if tool_call_count + #cur_tool_blocks > MAX_TOOL_CALLS_PER_ITER then
obs_event(mode, "tool_loop_giveup", { { "iter", iter }, { "count", tool_call_count } })
local giveup_err = "exceeded MAX_TOOL_CALLS_PER_ITER=" .. MAX_TOOL_CALLS_PER_ITER .. " within a single iter"
return {
ok = false,
failure_reason = "tool_loop",
last_error = giveup_err,
iters = iter,
summary = make_summary(false, iter, max_iters, "tool_loop"),
artifact_path = nil,
history = history,
}
end
local assistant_content = {}
if cur_msg.content and cur_msg.content ~= "" then
table.insert(assistant_content, { type = "text", text = cur_msg.content })
end
for _, tb in ipairs(cur_tool_blocks) do
table.insert(assistant_content, {
type = "tool_use",
id = tb.id,
name = tb.name,
input = tb.input,
})
end
table.insert(messages, { role = "assistant", content = assistant_content })
local tool_result_content = {}
for _, tb in ipairs(cur_tool_blocks) do
tool_call_count = tool_call_count + 1
if tb.name == "read_file" then
local path = (tb.input or {}).path or ""
local cached = existing_map[path]
local dispatch_result
if cached ~= nil then
dispatch_result = { ok = true, content = cached }
obs_event(mode, "tool_use", {
{ "iter", iter },
{ "path", path },
{ "ok", true },
{ "cached", true },
})
else
dispatch_result = read_file_tool_handler(path, target_files_set, mf_state, conf)
if dispatch_result.ok then
existing_map[path] = dispatch_result.content
obs_event(mode, "tool_use", {
{ "iter", iter },
{ "path", path },
{ "ok", true },
})
else
obs_event(mode, "tool_use_fail", {
{ "iter", iter },
{ "path", path },
{ "err", dispatch_result.error },
})
end
end
local result_text
if dispatch_result.ok then
result_text = dispatch_result.content
else
result_text = "ERROR: " .. tostring(dispatch_result.error)
end
table.insert(tool_result_content, {
type = "tool_result",
tool_use_id = tb.id,
content = result_text,
})
elseif tb.name == "read_file_range" then
local inp = tb.input or {}
local path = inp.path or ""
local line_start = inp.line_start
local line_end = inp.line_end
local rr_result = read_file_range_tool_handler(
path, line_start, line_end, target_files_set
)
local rr_text
if rr_result.ok then
rr_text = rr_result.content
obs_event(mode, "tool_use", {
{ "iter", iter },
{ "path", path },
{ "tool", "read_file_range" },
{ "line_start", tostring(line_start) },
{ "line_end", tostring(line_end) },
{ "ok", true },
})
else
rr_text = "ERROR: " .. tostring(rr_result.error)
obs_event(mode, "tool_use_fail", {
{ "iter", iter },
{ "path", path },
{ "tool", "read_file_range" },
{ "err", rr_result.error },
})
end
table.insert(tool_result_content, {
type = "tool_result",
tool_use_id = tb.id,
content = rr_text,
})
else
obs_event(mode, "tool_use_fail", {
{ "iter", iter },
{ "path", tostring((tb.input or {}).path or "") },
{ "err", "unknown tool: " .. tostring(tb.name) },
})
table.insert(tool_result_content, {
type = "tool_result",
tool_use_id = tb.id,
content = "ERROR: unknown tool '" .. tostring(tb.name) .. "'",
})
end
end
table.insert(messages, { role = "user", content = tool_result_content })
local resp2, err2 = llm_call(call_opts, messages)
if not resp2 then
local err_str = tostring(err2)
return {
ok = false,
failure_reason = "llm_call",
last_error = err_str:sub(-800),
iters = iter,
summary = make_summary(false, iter, max_iters, "llm_call"),
artifact_path = nil,
history = history,
}
end
cur_resp = resp2
end
end
local choice = (resp.choices or {})[1] or {}
local msg_obj = choice.message or {}
local content = msg_obj.content or ""
if edit_mode == "diff" then
local blocks, parse_err = parse_search_replace(content, multi_file, target_files_set)
if not blocks then
local fmt_msg = "Output format invalid: " .. tostring(parse_err)
.. "\nRe-emit blocks correctly."
local entry = { iter = iter, code = nil, result = { ok = false, stderr = fmt_msg, stdout = "", exit_code = -1 }, raw = content }
table.insert(history, entry)
obs_event(mode, "iter_result", {
{ "iter", iter },
{ "ok", false },
{ "exit_code", -1 },
{ "stderr_len", #fmt_msg },
})
if conf.on_iter then
local cb_ok, cb_err = pcall(conf.on_iter, entry)
if not cb_ok then
log.warn("compile_loop: on_iter callback error: " .. tostring(cb_err))
end
end
if multi_file then
local parse_sr_hash = compute_sr_hash("<parse_err:" .. compute_sr_hash(fmt_msg) .. ">")
update_state(mf_state, {
last_err = fmt_msg,
sr_hash_append = parse_sr_hash,
})
if is_stagnant_v2(mf_state, true) then
obs_event(mode, "stagnation_v2", {
{ "iter", iter },
{ "sr_hash_recent", parse_sr_hash:sub(1, 8) },
{ "reason", "sr_history_repeat" },
})
return {
ok = false,
failure_reason = "stagnation",
last_error = mf_state.last_err or "",
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = nil,
modified_files = collect_modified_paths(mf_state.modified_set),
history = history,
}
end
else
if is_stagnant(history) then
obs_event(mode, "stagnation", { { "iters", iter } })
return {
ok = false,
failure_reason = "stagnation",
last_error = fmt_msg:sub(-800),
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = artifact_path,
history = history,
}
end
table.insert(messages, { role = "assistant", content = content })
table.insert(messages, { role = "user", content = fmt_msg })
end
elseif multi_file then
local grouped = group_blocks_by_path(blocks)
local new_contents_map, all_failed, write_err = iterate_files(conf.target_files, grouped, existing_map)
if new_contents_map then
for path in pairs(new_contents_map) do
mf_state.modified_set[path] = true
end
end
if write_err then
local werr_str = tostring(write_err)
return {
ok = false,
failure_reason = "open_target_file",
last_error = werr_str,
iters = iter,
summary = make_summary(false, iter, max_iters, "open_target_file"),
artifact_path = nil,
modified_files = collect_modified_paths(mf_state.modified_set),
history = history,
}
end
if #all_failed > 0 then
local fail_msg = build_multifile_edit_failure_msg(all_failed, existing_map)
local entry = { iter = iter, code = nil, result = { ok = false, stderr = fail_msg, stdout = "", exit_code = -1 }, raw = content }
table.insert(history, entry)
obs_event(mode, "iter_result", {
{ "iter", iter },
{ "ok", false },
{ "exit_code", -1 },
{ "stderr_len", #fail_msg },
})
if conf.on_iter then
local cb_ok, cb_err = pcall(conf.on_iter, entry)
if not cb_ok then
log.warn("compile_loop: on_iter callback error: " .. tostring(cb_err))
end
end
local apply_sr_hash = compute_sr_hash(content)
update_state(mf_state, {
last_err = fail_msg,
sr_digest_prev = content,
sr_hash_append = apply_sr_hash,
})
if is_stagnant_v2(mf_state, true) then
obs_event(mode, "stagnation_v2", {
{ "iter", iter },
{ "sr_hash_recent", apply_sr_hash:sub(1, 8) },
{ "reason", "sr_history_repeat" },
})
return {
ok = false,
failure_reason = "stagnation",
last_error = mf_state.last_err or "",
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = nil,
modified_files = collect_modified_paths(mf_state.modified_set),
history = history,
}
end
else
local rr = conf.runner(conf.target_files) or {}
local entry = { iter = iter, code = nil, result = rr, raw = content }
table.insert(history, entry)
obs_event(mode, "iter_result", {
{ "iter", iter },
{ "ok", rr.ok and true or false },
{ "exit_code", rr.exit_code },
{ "stderr_len", #(tostring(rr.stderr or "")) },
})
if conf.on_iter then
local cb_ok, cb_err = pcall(conf.on_iter, entry)
if not cb_ok then
log.warn("compile_loop: on_iter callback error: " .. tostring(cb_err))
end
end
if rr.ok then
update_state(mf_state, { sr_hash_append = compute_sr_hash(content) })
obs_event(mode, "converged", { { "iters", iter } })
return {
ok = true,
artifact_path = nil,
modified_files = collect_modified_paths(mf_state.modified_set),
iters = iter,
summary = make_summary(true, iter, max_iters, nil),
history = history,
}
end
local rr_stderr = tostring(rr.stderr or "")
local runner_sr_hash = compute_sr_hash(content)
update_state(mf_state, {
last_err = rr_stderr,
sr_digest_prev = content,
sr_hash_append = runner_sr_hash,
})
local runner_failed = (rr.ok == false)
if is_stagnant_v2(mf_state, runner_failed) then
obs_event(mode, "stagnation_v2", {
{ "iter", iter },
{ "sr_hash_recent", runner_sr_hash:sub(1, 8) },
{ "reason", "sr_history_repeat" },
})
return {
ok = false,
failure_reason = "stagnation",
last_error = mf_state.last_err or "",
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = nil,
modified_files = collect_modified_paths(mf_state.modified_set),
history = history,
}
end
end
else
local single_path = conf.target_files[1]
local current_content = read_target_if_exists(single_path) or existing_map[single_path]
local new_content, failed_indices = apply_blocks(current_content, blocks)
if #failed_indices > 0 then
local fail_msg = build_edit_failure_msg(failed_indices, blocks, current_content)
local entry = { iter = iter, code = nil, result = { ok = false, stderr = fail_msg, stdout = "", exit_code = -1 }, raw = content }
table.insert(history, entry)
obs_event(mode, "iter_result", {
{ "iter", iter },
{ "ok", false },
{ "exit_code", -1 },
{ "stderr_len", #fail_msg },
})
if conf.on_iter then
local cb_ok, cb_err = pcall(conf.on_iter, entry)
if not cb_ok then
log.warn("compile_loop: on_iter callback error: " .. tostring(cb_err))
end
end
if is_stagnant(history) then
obs_event(mode, "stagnation", { { "iters", iter } })
return {
ok = false,
failure_reason = "stagnation",
last_error = fail_msg:sub(-800),
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = artifact_path,
history = history,
}
end
table.insert(messages, { role = "assistant", content = content })
table.insert(messages, { role = "user", content = fail_msg })
else
local f, werr = io.open(single_path, "w")
if not f then
local werr_str = tostring(werr)
return {
ok = false,
failure_reason = "open_target_file",
last_error = werr_str,
iters = iter,
summary = make_summary(false, iter, max_iters, "open_target_file"),
artifact_path = artifact_path,
history = history,
}
end
f:write(new_content)
f:close()
local rr = conf.runner(single_path) or {}
local entry = { iter = iter, code = new_content, result = rr, raw = content }
table.insert(history, entry)
obs_event(mode, "iter_result", {
{ "iter", iter },
{ "ok", rr.ok and true or false },
{ "exit_code", rr.exit_code },
{ "stderr_len", #(tostring(rr.stderr or "")) },
})
if conf.on_iter then
local cb_ok, cb_err = pcall(conf.on_iter, entry)
if not cb_ok then
log.warn("compile_loop: on_iter callback error: " .. tostring(cb_err))
end
end
if rr.ok then
obs_event(mode, "converged", { { "iters", iter } })
return {
ok = true,
code = new_content,
artifact_path = artifact_path,
iters = iter,
summary = make_summary(true, iter, max_iters, nil),
history = history,
}
end
if is_stagnant(history) then
local last_stderr = tostring((rr.stderr) or ""):sub(-800)
obs_event(mode, "stagnation", { { "iters", iter } })
return {
ok = false,
failure_reason = "stagnation",
last_error = last_stderr,
code = new_content,
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = artifact_path,
history = history,
}
end
table.insert(messages, { role = "assistant", content = content })
table.insert(messages, { role = "user", content = build_failure_msg(lang, rr) })
end
end
else
local single_path = conf.target_files[1]
local code = extract_code(content, lang)
local f, werr = io.open(single_path, "w")
if not f then
local werr_str = tostring(werr)
return {
ok = false,
failure_reason = "open_target_file",
last_error = werr_str,
iters = iter,
summary = make_summary(false, iter, max_iters, "open_target_file"),
artifact_path = artifact_path,
history = history,
}
end
f:write(code)
f:close()
local rr = conf.runner(single_path) or {}
local entry = { iter = iter, code = code, result = rr, raw = content }
table.insert(history, entry)
obs_event(mode, "iter_result", {
{ "iter", iter },
{ "ok", rr.ok and true or false },
{ "exit_code", rr.exit_code },
{ "stderr_len", #(tostring(rr.stderr or "")) },
})
if conf.on_iter then
local cb_ok, cb_err = pcall(conf.on_iter, entry)
if not cb_ok then
log.warn("compile_loop: on_iter callback error: " .. tostring(cb_err))
end
end
if rr.ok then
obs_event(mode, "converged", { { "iters", iter } })
return {
ok = true,
code = code,
artifact_path = artifact_path,
iters = iter,
summary = make_summary(true, iter, max_iters, nil),
history = history,
}
end
if is_stagnant(history) then
local last_stderr = tostring((rr.stderr) or ""):sub(-800)
obs_event(mode, "stagnation", { { "iters", iter } })
return {
ok = false,
failure_reason = "stagnation",
last_error = last_stderr,
code = code,
iters = iter,
summary = make_summary(false, iter, max_iters, "stagnation"),
artifact_path = artifact_path,
history = history,
}
end
table.insert(messages, { role = "assistant", content = content })
table.insert(messages, { role = "user", content = build_failure_msg(lang, rr) })
end
end
local last = history[#history] or {}
local last_stderr = tostring(((last.result) or {}).stderr or ""):sub(-800)
obs_event(mode, "max_iters_reached", { { "iters", max_iters } })
local max_iters_result = {
ok = false,
failure_reason = "max_iters",
last_error = last_stderr,
code = last.code,
iters = max_iters,
summary = make_summary(false, max_iters, max_iters, "max_iters"),
artifact_path = artifact_path,
history = history,
}
if multi_file then
max_iters_result.modified_files = collect_modified_paths(mf_state.modified_set)
end
return max_iters_result
end
function M.make(conf)
assert(type(conf) == "table", "conf table required")
assert(type(conf.runner) == "function", "conf.runner function required")
local name = conf.name or "compile_loop"
local schema = {
description = [[Run an autonomous compile-and-fix loop: a child LLM emits the
complete target file on every iteration, the runner executes it, and on
failure the stderr is fed back until the run passes or the give-up gate
triggers. Returns ok/iters/summary and, on failure, failure_reason/last_error.
Single-file mode: provide target_file (string).
Multi-file mode: provide target_files (array of absolute paths). Requires edit_mode=diff.
target_file and target_files are mutually exclusive.]],
input_schema = {
type = "object",
required = { "spec" },
properties = {
spec = {
type = "string",
description = "Full specification the child LLM must satisfy.",
},
target_file = {
type = "string",
description = "Absolute path of the file (single-file mode). Read on entry if it already exists, then written on each iteration. Mutually exclusive with target_files.",
},
target_files = {
type = "array",
items = { type = "string" },
description = "Array of absolute paths (multi-file mode). Mutually exclusive with target_file. Multi-file mode requires edit_mode=diff.",
},
lang = {
type = "string",
description = "Code fence language label (default: lua).",
},
},
},
}
local function handler(input)
assert(
not (input.target_file and input.target_files),
"target_file and target_files are mutually exclusive"
)
assert(
input.target_file or input.target_files,
"target_file (string) or target_files (array) is required"
)
local multi_file
local files_list
if input.target_files then
multi_file = true
assert(type(input.target_files) == "table", "target_files must be an array")
assert(#input.target_files > 0, "target_files must not be empty")
for i, v in ipairs(input.target_files) do
assert(type(v) == "string", "target_files[" .. i .. "] must be a string")
end
files_list = {}
for _, p in ipairs(input.target_files) do
table.insert(files_list, to_abs(p))
end
else
multi_file = false
files_list = { to_abs(input.target_file) }
end
local effective_edit_mode = conf.edit_mode
assert(
not (multi_file and effective_edit_mode == "full"),
"multi-file mode requires edit_mode=diff"
)
local parent_ctx = agent._llm_ctx_top() or {}
local llm_conf = conf.llm or {}
local resolved_conf = {
runner = conf.runner,
lang = input.lang or conf.lang or "lua",
target_files = files_list, multi_file = multi_file,
spec = input.spec,
max_iters = conf.max_iters,
system = conf.system,
edit_mode = effective_edit_mode,
on_iter = conf.on_iter,
provider = llm_conf.provider or parent_ctx.provider,
base_url = llm_conf.base_url or parent_ctx.base_url,
api_key = llm_conf.api_key or parent_ctx.api_key,
api_key_env = llm_conf.api_key_env or parent_ctx.api_key_env,
model = llm_conf.model or parent_ctx.model,
max_tokens = llm_conf.max_tokens,
temperature = llm_conf.temperature,
disable_thinking = llm_conf.disable_thinking,
timeout = llm_conf.timeout,
}
local res = run_loop(resolved_conf)
local filtered = filter_for_tool_output(res)
local enc_ok, enc_str = pcall(std.json.encode, filtered)
if enc_ok then
return enc_str
end
return '{"ok":false,"failure_reason":"encode_failed","iters":0,"summary":"json encode failed"}'
end
tool.register(name, schema, handler)
return { name = name, schema = schema, handler = handler }
end
function M._test_set_llm_call(fn)
assert(type(fn) == "function", "_test_set_llm_call requires a function")
_llm_call_override = fn
end
function M._test_reset_llm_call()
_llm_call_override = nil
end
function M._test_make_mf_state()
return {
iter = 0,
last_err = nil,
sr_digest_prev = nil,
sr_history = {},
file_digest = {},
file_digest_refresh = "auto",
modified_set = {},
}
end
function M._test_set_distill_subloop(fn)
assert(type(fn) == "function", "_test_set_distill_subloop requires a function")
_distill_subloop_override = fn
end
function M._test_reset_distill_subloop()
_distill_subloop_override = nil
end
function M._test_helpers()
return {
should_use_cache = should_use_cache,
format_digest_response = format_digest_response,
truncate_with_warning = truncate_with_warning,
read_file_range_tool_handler = read_file_range_tool_handler,
read_file_tool_handler = read_file_tool_handler,
file_mtime = file_mtime,
split_lines = split_lines,
chunk_by_lines = chunk_by_lines,
extract_text = extract_text,
call_distill_llm = call_distill_llm,
binary_search_pack = binary_search_pack,
is_stagnant_v2 = is_stagnant_v2,
compute_sr_hash = compute_sr_hash,
collect_modified_paths = collect_modified_paths,
update_state = update_state,
build_line_index = build_line_index,
}
end
return M