local ctx = _CTX
local function parse_pointer(pointer)
if pointer == nil or pointer == "" then return {} end
if pointer:sub(1, 1) ~= "/" then
error("invalid JSON Pointer (must start with '/' or be empty): " .. tostring(pointer))
end
local parts = {}
for part in (pointer .. "/"):sub(2):gmatch("([^/]*)/") do
part = part:gsub("~1", "/"):gsub("~0", "~")
table.insert(parts, part)
end
return parts
end
local function is_array(t)
if type(t) ~= "table" then return false end
local n = #t
if n == 0 then
return false
end
for k, _ in pairs(t) do
if type(k) ~= "number" or k < 1 or k > n or math.floor(k) ~= k then
return false
end
end
return true
end
local function navigate_parent(root, parts)
if #parts == 0 then return nil, nil end
local current = root
for i = 1, #parts - 1 do
local key = parts[i]
if type(current) ~= "table" then
error("path traverses non-table at part " .. i .. ": " .. tostring(key))
end
local num_key = tonumber(key)
if num_key ~= nil and is_array(current) then
current = current[num_key + 1]
else
current = current[key]
end
if current == nil then
error("path not found at part " .. i .. ": " .. tostring(key))
end
end
return current, parts[#parts]
end
local function apply_op(root, op)
local op_name = op.op
local parts = parse_pointer(op.path)
if op_name == "replace" then
if #parts == 0 then return op.value end
local parent, last_key = navigate_parent(root, parts)
local num_key = tonumber(last_key)
if num_key ~= nil and is_array(parent) then
parent[num_key + 1] = op.value
else
parent[last_key] = op.value
end
return root
elseif op_name == "add" then
if #parts == 0 then return op.value end
local parent, last_key = navigate_parent(root, parts)
if last_key == "-" then
table.insert(parent, op.value)
else
local num_key = tonumber(last_key)
if num_key ~= nil and is_array(parent) then
table.insert(parent, num_key + 1, op.value)
else
parent[last_key] = op.value
end
end
return root
elseif op_name == "remove" then
if #parts == 0 then return nil end
local parent, last_key = navigate_parent(root, parts)
local num_key = tonumber(last_key)
if num_key ~= nil and is_array(parent) then
table.remove(parent, num_key + 1)
else
parent[last_key] = nil
end
return root
elseif op_name == "move" or op_name == "copy" then
local from_parts = parse_pointer(op.from or "")
if #from_parts == 0 then
error(op_name .. ": 'from' required")
end
local from_parent, from_key = navigate_parent(root, from_parts)
local val
local num_from = tonumber(from_key)
if num_from ~= nil and is_array(from_parent) then
val = from_parent[num_from + 1]
else
val = from_parent[from_key]
end
if op_name == "move" then
if num_from ~= nil and is_array(from_parent) then
table.remove(from_parent, num_from + 1)
else
from_parent[from_key] = nil
end
end
return apply_op(root, { op = "add", path = op.path, value = val })
elseif op_name == "test" then
local parent, last_key = navigate_parent(root, parts)
local actual
local num_key = tonumber(last_key)
if num_key ~= nil and is_array(parent) then
actual = parent[num_key + 1]
else
actual = parent[last_key]
end
if actual ~= op.value then
error("test failed at " .. tostring(op.path))
end
return root
else
error("unsupported RFC 6902 op: " .. tostring(op_name))
end
end
local function apply_ops(root, ops)
for _, op in ipairs(ops or {}) do
root = apply_op(root, op)
end
return root
end
local function deep_equal(a, b)
if a == b then return true end
if type(a) ~= "table" or type(b) ~= "table" then return false end
for k, v in pairs(a) do
if not deep_equal(v, b[k]) then return false end
end
for k, _ in pairs(b) do
if a[k] == nil then return false end
end
return true
end
local function parse_semver(s)
if type(s) ~= "string" then return 0, 0, 0 end
local maj, min, pat = s:match("^(%d+)%.(%d+)%.(%d+)")
if maj == nil then return 0, 0, 0 end
return tonumber(maj), tonumber(min), tonumber(pat)
end
local function bump_semver(prev_label, bump)
local maj, min, pat = parse_semver(prev_label)
if bump == "major" then
return string.format("%d.0.0", maj + 1)
elseif bump == "minor" then
return string.format("%d.%d.0", maj, min + 1)
else
return string.format("%d.%d.%d", maj, min, pat + 1)
end
end
local prev_bp_yaml = ctx.prev_bp_yaml
if prev_bp_yaml == nil then error("patch_applier: ctx.prev_bp_yaml missing") end
local prev_bp_json = host.yaml_to_json(prev_bp_yaml)
if type(prev_bp_json) ~= "table" then
error("patch_applier: yaml_to_json returned non-table")
end
local before_json = host.yaml_to_json(prev_bp_yaml)
local patch = ctx.patch or {}
local ops = patch.ops or {}
local new_bp_json = apply_ops(prev_bp_json, ops)
local function extract_agent_index(path)
local idx = path:match("^/agents/(%d+)/profile/system_prompt$")
return idx and tonumber(idx) or nil
end
local touched_indices = {}
for _, op in ipairs(ops) do
if op.op == "replace" or op.op == "add" then
local idx = extract_agent_index(op.path or "")
if idx ~= nil then
touched_indices[idx] = true
end
end
end
if next(touched_indices) ~= nil then
local agents = new_bp_json.agents
if type(agents) == "table" then
for idx, _ in pairs(touched_indices) do
local agent = agents[idx + 1]
if type(agent) == "table" and type(agent.profile) == "table" then
local body = agent.profile.system_prompt
if type(body) == "string" then
agent.profile.version_hash = host.content_hash(body)
end
end
end
end
end
if not deep_equal(new_bp_json, before_json) then
local metadata = new_bp_json.metadata
if metadata == nil then
new_bp_json.metadata = {}
metadata = new_bp_json.metadata
end
local prev_label = metadata.version_label
local new_label = bump_semver(prev_label, patch.bump or "patch")
metadata.version_label = new_label
end
local new_bp_yaml = host.canonical_yaml(new_bp_json)
if type(new_bp_yaml) ~= "string" then
error("patch_applier: canonical_yaml returned non-string")
end
local new_hash = host.content_hash(new_bp_yaml)
return {
prev_bp_yaml = prev_bp_yaml,
prev_bp_json = before_json,
prev_hash = ctx.prev_hash,
patch = patch,
new_bp_yaml = new_bp_yaml,
new_bp_json = new_bp_json,
new_hash = new_hash,
epoch_id = ctx.epoch_id,
}