Engine bug: parse_messages_json drops a message's content when the JSON key order is content before role
Component: cactus-engine — cactus::ffi::parse_messages_json
File/line: cactus-engine/src/utils.h:1029 (function begins at line 997)
Version observed: dfd3d8b4 (v1.14-90); confirmed still present at HEAD 3c4d9263 (2026-07-09)
Impact: Silent, data-dependent prompt corruption — no error is surfaced; the model just runs on a truncated prompt.
Summary
parse_messages_json is a hand-rolled string scanner. For each message object it locates "role"
first, then searches for "content" starting from the end of the role value. If a message object
serializes content before role (e.g. {"content":"…","role":"user"}), the content key is behind
the search start, is never found within the object, and msg.content is left empty. The message's
text silently never reaches the model.
Because JSON object key order is not semantically significant, any caller that builds the messages
payload from an unordered map can emit either order. This surfaced from a Swift Dictionary
serialized via JSONSerialization, whose key order is unspecified and was observed to vary
call-to-call — so the same request parses correctly or drops content depending on incidental key order.
Root cause
// cactus-engine/src/utils.h @ HEAD 3c4d9263 — parse_messages_json (abridged; obj_start L1013, content_pos L1029)
size_t obj_start = pos; // '{' of this message object
// … compute obj_end (matching '}') …
size_t role_pos = json.find("\"role\"", pos); // searched from obj_start — OK
// … parse role, role_end = closing quote of the role value …
size_t content_pos = json.find("\"content\"", role_end); // ← BUG: anchored to role_end
if (content_pos != std::string::npos && content_pos < obj_end) {
// parse content …
}
role is searched from obj_start, so role is order-independent. Only content is anchored to
role_end, making it the sole order-fragile field: content-before-role ⇒ content_pos lands in the
next object (or npos), fails the < obj_end bound, and content is dropped.
Reproduction (deterministic)
Send a single-message completion with content before role:
[{"content":"What is the capital of France?","role":"user"}]
Observed: the prompt tokenizes to only the chat-template scaffolding (content absent); the model
responds as if given an empty/near-empty user turn. Swapping the two keys to {"role":…,"content":…}
produces the correct full render. Nothing in between changes.
Compiled & run against the real function (no device/weights needed)
Verified directly against parse_messages_json in cactus-engine/src/utils.h — checkout 3c4d9263,
Apple clang 21.0.0, arm64-apple-darwin. utils.h → cactus_kernels.h → <arm_neon.h>, so an ARM
host (Apple Silicon) is required. Test:
#include "utils.h" // cactus-engine/src/utils.h
#include <iostream>
int main() {
std::vector<std::string> imgs;
auto a = cactus::ffi::parse_messages_json(R"([{"role":"user","content":"hello"}])", imgs);
auto b = cactus::ffi::parse_messages_json(R"([{"content":"hello","role":"user"}])", imgs);
std::cout << "role-first role=[" << a[0].role << "] content=[" << a[0].content << "]\n";
std::cout << "content-first role=[" << b[0].role << "] content=[" << b[0].content << "]\n";
bool ok = (a[0].content == "hello") && (b[0].content == "hello");
std::cout << (ok ? "PASS: both orderings parse content"
: "FAIL: content-first dropped content (bug present)") << "\n";
return ok ? 0 : 1;
}
Compile + run from the repo root. At this HEAD the include set is wider than a bare utils.h suggests:
utils.h → gemma_tools.h pulls in vendored libs/picojson.h, and cactus_kernels.h pulls in
src/threading.h, so -I cactus-engine/libs, -I cactus-kernels/src, and -I cactus-graph/src are all
required in addition to the obvious three:
$ clang++ -std=c++20 \
-I cactus-engine/src -I cactus-engine/libs \
-I cactus-kernels -I cactus-kernels/src \
-I cactus-graph -I cactus-graph/src \
parse_test.cpp -o parse_test && ./parse_test
role-first role=[user] content=[hello]
content-first role=[user] content=[]
FAIL: content-first dropped content (bug present)
Only key position differs between the two payloads; content-first drops the content to empty. Note the
function lives in namespace cactus::ffi (call it cactus::ffi::parse_messages_json, or add
using namespace cactus::ffi;). Confirmed on 3c4d9263 (function at utils.h:997, content anchor at
utils.h:1029).
Evidence (controlled A/B, Test app, iPhone 13, dfd3d8b)
Three input-ordering arms × two models × 4 submits, identical prompt. msg_sha = SHA-256 of the exact
bytes sent to the FFI:
| Key order |
msg_sha |
Qwen3-1.7B |
Gemma-4-2B |
role first (correct) |
a3772c56 (constant) |
full structured output, 4/4 |
full structured output, 4/4 |
content first (forced) |
70cb9d90 (constant) |
content dropped, garbage, 4/4 |
content dropped, wrong reply, 4/4 |
JSONSerialization (unordered) |
cycles 4c9ca478/2432f4e8/aefe369a |
output varies per submit |
output varies per submit |
msg_sha is model-independent (identical bytes → identical hash on both models), and each hash maps to
a fixed output regardless of model — i.e. the output is a pure function of the input bytes, and the only
thing changing is JSON key order. The content-first arm fails 100% of the time, deterministically.
Workaround for callers (until fixed)
Serialize messages with role before content explicitly (do not rely on map order, and note
sort_keys/.sortedKeys makes it worse — content sorts before role). The test app now
builds the messages JSON by hand (roleFirstMessagesJSON) for this reason.
Engine bug:
parse_messages_jsondrops a message'scontentwhen the JSON key order iscontentbeforeroleComponent:
cactus-engine—cactus::ffi::parse_messages_jsonFile/line:
cactus-engine/src/utils.h:1029(function begins at line 997)Version observed:
dfd3d8b4(v1.14-90); confirmed still present at HEAD3c4d9263(2026-07-09)Impact: Silent, data-dependent prompt corruption — no error is surfaced; the model just runs on a truncated prompt.
Summary
parse_messages_jsonis a hand-rolled string scanner. For each message object it locates"role"first, then searches for
"content"starting from the end of the role value. If a message objectserializes
contentbeforerole(e.g.{"content":"…","role":"user"}), the content key is behindthe search start, is never found within the object, and
msg.contentis left empty. The message'stext silently never reaches the model.
Because JSON object key order is not semantically significant, any caller that builds the messages
payload from an unordered map can emit either order. This surfaced from a Swift
Dictionaryserialized via
JSONSerialization, whose key order is unspecified and was observed to varycall-to-call — so the same request parses correctly or drops content depending on incidental key order.
Root cause
roleis searched fromobj_start, so role is order-independent. Onlycontentis anchored torole_end, making it the sole order-fragile field: content-before-role ⇒content_poslands in thenext object (or npos), fails the
< obj_endbound, and content is dropped.Reproduction (deterministic)
Send a single-message completion with content before role:
[{"content":"What is the capital of France?","role":"user"}]Observed: the prompt tokenizes to only the chat-template scaffolding (content absent); the model
responds as if given an empty/near-empty user turn. Swapping the two keys to
{"role":…,"content":…}produces the correct full render. Nothing in between changes.
Compiled & run against the real function (no device/weights needed)
Verified directly against
parse_messages_jsonincactus-engine/src/utils.h— checkout3c4d9263,Apple clang 21.0.0,
arm64-apple-darwin.utils.h→cactus_kernels.h→<arm_neon.h>, so an ARMhost (Apple Silicon) is required. Test:
Compile + run from the repo root. At this HEAD the include set is wider than a bare
utils.hsuggests:utils.h→gemma_tools.hpulls in vendoredlibs/picojson.h, andcactus_kernels.hpulls insrc/threading.h, so-I cactus-engine/libs,-I cactus-kernels/src, and-I cactus-graph/srcare allrequired in addition to the obvious three:
Only key position differs between the two payloads; content-first drops the content to empty. Note the
function lives in namespace
cactus::ffi(call itcactus::ffi::parse_messages_json, or addusing namespace cactus::ffi;). Confirmed on3c4d9263(function atutils.h:997, content anchor atutils.h:1029).Evidence (controlled A/B, Test app, iPhone 13, dfd3d8b)
Three input-ordering arms × two models × 4 submits, identical prompt.
msg_sha= SHA-256 of the exactbytes sent to the FFI:
rolefirst (correct)a3772c56(constant)contentfirst (forced)70cb9d90(constant)JSONSerialization(unordered)4c9ca478/2432f4e8/aefe369amsg_shais model-independent (identical bytes → identical hash on both models), and each hash maps toa fixed output regardless of model — i.e. the output is a pure function of the input bytes, and the only
thing changing is JSON key order. The
content-first arm fails 100% of the time, deterministically.Workaround for callers (until fixed)
Serialize messages with
rolebeforecontentexplicitly (do not rely on map order, and notesort_keys/.sortedKeysmakes it worse —contentsorts beforerole). The test app nowbuilds the messages JSON by hand (
roleFirstMessagesJSON) for this reason.