From 91a38ed6e552afda87408e11bc462aac45c621b8 Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Sun, 25 Oct 2026 15:05:13 +0100 Subject: [PATCH 01/10] common, grammar: add recursive depth limit, make it a requirement --- common/common.cpp | 82 ++++++++++++++------------- common/common.h | 2 + common/jinja/runtime.cpp | 8 +++ common/jinja/runtime.h | 3 + common/jinja/utils.h | 2 + common/jinja/value.cpp | 6 +- common/json-schema-to-grammar.cpp | 4 +- common/json-schema.cpp | 8 +-- common/peg-parser.cpp | 26 ++++++++- common/peg-parser.h | 6 ++ skills/code-review/SKILL.md | 1 + src/llama-grammar.cpp | 51 ++++++++++------- src/llama-grammar.h | 2 + tests/peg-parser/test-json-parser.cpp | 19 +++++++ tests/test-grammar-integration.cpp | 31 ++++++++++ tests/test-jinja.cpp | 21 +++++++ tests/test-server-component.cpp | 27 +++++++++ 17 files changed, 227 insertions(+), 72 deletions(-) diff --git a/common/common.cpp b/common/common.cpp index 3237c0fa7766..a4b4feaee3c0 100644 --- a/common/common.cpp +++ b/common/common.cpp @@ -742,50 +742,56 @@ static inline bool glob_class_match(const char c, const char * pattern, const ch } // simple glob: * matches non-/ chars, ** matches anything including /, [] matches character class -static inline bool glob_match(const char * pattern, const char * str) { - if (*pattern == '\0') { - return *str == '\0'; - } - if (pattern[0] == '*' && pattern[1] == '*') { - const char * p = pattern + 2; - if (glob_match(p, str)) return true; - if (*str != '\0') return glob_match(pattern, str + 1); +// only wildcards recurse, too many wildcards: no match +static bool glob_match(const char * pattern, const char * str, int depth = 0) { + if (depth >= COMMON_RECURSIVE_LIMIT) { return false; } - if (*pattern == '*') { - const char * p = pattern + 1; - for (; *str != '\0' && *str != '/'; str++) { - if (glob_match(p, str)) return true; - } - return glob_match(p, str); - } - if (*pattern == '?' && *str != '\0' && *str != '/') { - return glob_match(pattern + 1, str + 1); - } - if (*pattern == '[') { - const char * class_end = pattern + 1; - // If first character after '[' is ']' or '-', treat it as literal - if (*class_end == ']' || *class_end == '-') { - class_end++; - } - while (*class_end != '\0' && *class_end != ']') { - class_end++; + while (*pattern != '\0') { + if (pattern[0] == '*' && pattern[1] == '*') { + const char * p = pattern + 2; + for (;; str++) { + if (glob_match(p, str, depth + 1)) return true; + if (*str == '\0') return false; + } } - if (*class_end == ']') { - if (*str == '\0') return false; - bool matched = glob_class_match(*str, pattern + 1, class_end); - return matched && glob_match(class_end + 1, str + 1); - } else { - if (*str == '[') { - return glob_match(pattern + 1, str + 1); + if (*pattern == '*') { + const char * p = pattern + 1; + for (; *str != '\0' && *str != '/'; str++) { + if (glob_match(p, str, depth + 1)) return true; } - return false; + return glob_match(p, str, depth + 1); + } + if (*pattern == '?' && *str != '\0' && *str != '/') { + pattern++; + str++; + continue; + } + if (*pattern == '[') { + const char * class_end = pattern + 1; + // If first character after '[' is ']' or '-', treat it as literal + if (*class_end == ']' || *class_end == '-') { + class_end++; + } + while (*class_end != '\0' && *class_end != ']') { + class_end++; + } + if (*class_end == ']') { + if (*str == '\0' || !glob_class_match(*str, pattern + 1, class_end)) return false; + pattern = class_end + 1; + str++; + continue; + } + if (*str != '[') return false; + pattern++; + str++; + continue; } + if (*pattern != *str) return false; + pattern++; + str++; } - if (*pattern == *str) { - return glob_match(pattern + 1, str + 1); - } - return false; + return *str == '\0'; } bool glob_match(const std::string & pattern, const std::string & str) { diff --git a/common/common.h b/common/common.h index 70d6a671f512..8e3fe2c8971b 100644 --- a/common/common.h +++ b/common/common.h @@ -31,6 +31,8 @@ #define DIRECTORY_SEPARATOR '/' #endif // _WIN32 +#define COMMON_RECURSIVE_LIMIT 128 + #define COM_DBG(fmt, ...) LOG_DBG("cmn %12.*s: " fmt, 12, __func__, __VA_ARGS__) #define COM_TRC(fmt, ...) LOG_TRC("cmn %12.*s: " fmt, 12, __func__, __VA_ARGS__) #define COM_INF(fmt, ...) LOG_INF("cmn %12.*s: " fmt, 12, __func__, __VA_ARGS__) diff --git a/common/jinja/runtime.cpp b/common/jinja/runtime.cpp index a53acf84c4de..c2c5385d0a22 100644 --- a/common/jinja/runtime.cpp +++ b/common/jinja/runtime.cpp @@ -747,7 +747,11 @@ value macro_statement::execute_impl(context & ctx) const { std::string name = cast_stmt(this->name)->val; const func_handler func = [this, name](const func_args & args) -> value { + if (args.ctx.macro_depth >= JINJA_RECURSIVE_LIMIT) { + throw std::runtime_error("macro calls are nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep"); + } context macro_ctx(args.ctx); // new scope for macro execution + macro_ctx.macro_depth = args.ctx.macro_depth + 1; bind_parameters(name, this->args, args, macro_ctx); @@ -778,7 +782,11 @@ value call_statement::execute_impl(context & ctx) const { context caller_ctx(ctx); // new scope for caller execution const func_handler func = [this, caller_ctx = std::move(caller_ctx)](const func_args & args) -> value { + if (args.ctx.macro_depth >= JINJA_RECURSIVE_LIMIT) { + throw std::runtime_error("macro calls are nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep"); + } context block_ctx(caller_ctx); // new scope for block execution + block_ctx.macro_depth = args.ctx.macro_depth + 1; bind_parameters("caller", this->caller_args, args, block_ctx); diff --git a/common/jinja/runtime.h b/common/jinja/runtime.h index c01902a1a038..4d47da026791 100644 --- a/common/jinja/runtime.h +++ b/common/jinja/runtime.h @@ -58,6 +58,8 @@ struct context { bool is_get_stats = false; // whether to collect stats + int macro_depth = 0; + visitor_fn visitor; // src is optional, used for error reporting @@ -78,6 +80,7 @@ struct context { src(parent.src), current_time(parent.current_time), is_get_stats(parent.is_get_stats), + macro_depth(parent.macro_depth), // inherit variables (for example, when entering a new scope) env(mk_val(*parent.env)) {} diff --git a/common/jinja/utils.h b/common/jinja/utils.h index de6947fc28f3..da3757a4aae6 100644 --- a/common/jinja/utils.h +++ b/common/jinja/utils.h @@ -6,6 +6,8 @@ #include #include +#define JINJA_RECURSIVE_LIMIT 128 + namespace jinja { static void string_replace_all(std::string & s, const std::string & search, const std::string & replace) { diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp index e65c20a45987..ca6746304497 100644 --- a/common/jinja/value.cpp +++ b/common/jinja/value.cpp @@ -1441,8 +1441,6 @@ const func_builtins & value_undefined_t::get_builtins() const { // the runtime walks values with recursion, a deeper input overflows the stack -static constexpr int FROM_JSON_MAX_DEPTH = 128; - static value from_json(const common_json & j, bool mark_input, int depth = 0) { if (j.is_null()) { return mk_val(); @@ -1458,8 +1456,8 @@ static value from_json(const common_json & j, bool mark_input, int depth = 0) { str->mark_input(); } return str; - } else if ((j.is_array() || j.is_object()) && depth >= FROM_JSON_MAX_DEPTH) { - throw std::invalid_argument("JSON is nested more than " + std::to_string(FROM_JSON_MAX_DEPTH) + " levels deep"); + } else if ((j.is_array() || j.is_object()) && depth >= JINJA_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON is nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep"); } else if (j.is_array()) { auto arr = mk_val(); for (const auto & item : j) { diff --git a/common/json-schema-to-grammar.cpp b/common/json-schema-to-grammar.cpp index 3349ae4fe0cc..f483c1d9b21b 100644 --- a/common/json-schema-to-grammar.cpp +++ b/common/json-schema-to-grammar.cpp @@ -279,8 +279,6 @@ static std::unordered_map GRAMMAR_LITERAL_ESCAPES = { {'\r', "\\r"}, {'\n', "\\n"}, {'"', "\\\""}, {'-', "\\-"}, {']', "\\]"}, {'\\', "\\\\"} }; -static const int MAX_PATTERN_DEPTH = 100; - static std::unordered_set NON_LITERAL_SET = {'|', '.', '(', ')', '[', ']', '{', '}', '*', '+', '?', '^', '$'}; static std::unordered_set ESCAPED_IN_REGEXPS_BUT_NOT_IN_LITERALS = {'^', '$', '.', '[', ']', '(', ')', '|', '{', '}', '*', '+', '?'}; @@ -482,7 +480,7 @@ class common_chat_schema_converter { } } paren_depth++; - if (paren_depth > MAX_PATTERN_DEPTH) { + if (paren_depth > COMMON_RECURSIVE_LIMIT) { throw unsupported_pattern("pattern nesting too deep"); } seq.emplace_back("(" + to_rule(transform()) + ")", false); diff --git a/common/json-schema.cpp b/common/json-schema.cpp index e1bfa79dfdc7..6177bbf92db3 100644 --- a/common/json-schema.cpp +++ b/common/json-schema.cpp @@ -224,13 +224,11 @@ class common_chat_schema_builder { return node; } - // the builder and the code that walks the built schema recurse once per level, a deeper schema overflows the stack - static constexpr int max_depth = 128; - int depth_ = 0; + int depth_ = 0; common_chat_schema_ptr build_node(const common_json & schema, const std::string & path) { - if (depth_ >= max_depth) { - throw std::invalid_argument("JSON schema is nested more than " + std::to_string(max_depth) + " levels deep"); + if (depth_ >= COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); } depth_++; auto node = build_node_impl(schema, path); diff --git a/common/peg-parser.cpp b/common/peg-parser.cpp index 8033f7e81530..4f6476aa76bd 100644 --- a/common/peg-parser.cpp +++ b/common/peg-parser.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -736,8 +735,18 @@ struct parser_executor { } common_peg_parse_result operator()(const common_peg_rule_parser & p) { + // only rules recurse, a recursive rule is entered once per input level + if (ctx.rule_depth.size() <= p.child) { + ctx.rule_depth.resize(p.child + 1, 0); + } + if (ctx.rule_depth[p.child] >= COMMON_RECURSIVE_LIMIT) { + throw common_peg_recursion_error("input is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } + // Parse the child + ctx.rule_depth[p.child]++; auto result = arena.parse(p.child, ctx, start_pos); + ctx.rule_depth[p.child]--; if (!result.fail()) { std::string_view text; @@ -1069,9 +1078,20 @@ common_peg_parser operator|(const std::string & str, const common_peg_parser & p return operator|(str.c_str(), p); } +// same as std::regex_replace with [^a-zA-Z0-9-]+ -> "-", no std::regex: libstdc++ recurses per char static std::string rule_name(const std::string & name) { - static const std::regex invalid_rule_chars_re("[^a-zA-Z0-9-]+"); - return std::regex_replace(name, invalid_rule_chars_re, "-"); + std::string out; + bool in_invalid = false; + for (char c : name) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + out += c; + in_invalid = false; + } else if (!in_invalid) { + out += '-'; + in_invalid = true; + } + } + return out; } common_peg_parser_builder::common_peg_parser_builder() {} diff --git a/common/peg-parser.h b/common/peg-parser.h index 9255d6eb975f..6b348fab0132 100644 --- a/common/peg-parser.h +++ b/common/peg-parser.h @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -182,6 +183,10 @@ inline common_peg_parse_flags operator~(common_peg_parse_flags a) { return static_cast(~int(a)); } +struct common_peg_recursion_error : std::invalid_argument { + using std::invalid_argument::invalid_argument; +}; + struct common_peg_parse_context { std::string input; // [h, e, l, l, o, _, w, o, r, l, d] std::vector tokens; // [id, -1, -1, -1, -1, id, -1, -1, -1, -1, -1] @@ -189,6 +194,7 @@ struct common_peg_parse_context { common_peg_ast_arena ast; int parse_depth; + std::vector rule_depth; // index: rule child id common_peg_parse_context(common_peg_parse_flags flags = COMMON_PEG_PARSE_FLAG_NONE) : flags(flags), parse_depth(0) {} diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md index 2b15d18c4e33..b282a916462b 100644 --- a/skills/code-review/SKILL.md +++ b/skills/code-review/SKILL.md @@ -58,6 +58,7 @@ Mandatory on every review; any finding here is **blocking**. Rule of thumb: GGUF - **Reused/reserved buffers:** recheck bounds after a buffer is shrunk or reused; watch `reserve()` then index-by-assumed-size, and header fields read before their length is checked. - **Server JSON ints:** clamp client-supplied integers (token/discard counts, offsets) to non-negative and an upper bound before they reach index/pointer arithmetic. - **RPC-deserialized fields:** treat every field (type/buffer/data/ne/nb/op_params) as hostile - validate before use. Null/zero buffers skipping validation, attacker data pointers, out-of-range type indices, and negative strides sign-extending past a corner-only assert all give arbitrary read/write. +- **Recursion on untrusted input:** a function that recurses once per level (or per char) of request JSON, model output, a grammar, a template or a regex pattern overflows the stack - the HTTP threads have 512 KB on macOS, so a few hundred levels are enough. Every such function needs its own depth bound, checked in the function itself, not a pre-walk at a caller (the next caller skips it): libcommon uses `COMMON_RECURSIVE_LIMIT` (`common/common.h`), jinja `JINJA_RECURSIVE_LIMIT` (`common/jinja/utils.h`), the grammar `GRAMMAR_RECURSIVE_LIMIT` (`src/llama-grammar.h`). Count the levels of the input, not the frames, and throw `std::invalid_argument` for request input so the server answers 400. Look for recursion the code does not spell out: mutual recursion, recursive `std::function` lambdas, visitor/virtual dispatch, `std::regex` (libstdc++ recurses per char of the input), and per-char helpers that recurse where a loop would do. - **Lifetime/UAF:** flag stored raw pointers to caller/temporary storage, cached pointers to buffers a later free releases, async ops whose source may drop before completion, and structures not invalidated on free/realloc. Null-check conditionally-built or "not required" tensors before dereferencing. ## Approach and design (when a new component/infra is introduced) diff --git a/src/llama-grammar.cpp b/src/llama-grammar.cpp index ffc7cf27ce55..df1b4bd6f7df 100644 --- a/src/llama-grammar.cpp +++ b/src/llama-grammar.cpp @@ -11,7 +11,6 @@ #include #define MAX_REPETITION_THRESHOLD 2000 -#define MAX_GRAMMAR_RECURSION_DEPTH 256 // // helpers // @@ -598,8 +597,8 @@ const char * llama_grammar_parser::parse_sequence( n_prev_rules = 1; rule.push_back({LLAMA_GRETYPE_RULE_REF, ref_rule_id}); } else if (*pos == '(') { // grouping - if (depth >= MAX_GRAMMAR_RECURSION_DEPTH) { - throw std::runtime_error("grammar recursion depth limit exceeded (max " + std::to_string(MAX_GRAMMAR_RECURSION_DEPTH) + "), please reduce nesting depth"); + if (depth >= GRAMMAR_RECURSIVE_LIMIT) { + throw std::runtime_error("grammar recursion depth limit exceeded (max " + std::to_string(GRAMMAR_RECURSIVE_LIMIT) + "), please reduce nesting depth"); } // parse nested alternates into synthesized rule pos = parse_space(pos + 1, true); @@ -971,10 +970,14 @@ static bool llama_grammar_detect_left_recursion( size_t rule_index, std::vector * rules_visited, std::vector * rules_in_progress, - std::vector * rules_may_be_empty) { + std::vector * rules_may_be_empty, + uint32_t depth = 0) { if ((*rules_in_progress)[rule_index]) { return true; } + if (depth > GRAMMAR_RECURSIVE_LIMIT) { + throw std::runtime_error("grammar rules are chained more than " + std::to_string(GRAMMAR_RECURSIVE_LIMIT) + " levels deep"); + } (*rules_in_progress)[rule_index] = true; @@ -1000,7 +1003,7 @@ static bool llama_grammar_detect_left_recursion( bool recurse_into_nonterminal = true; for (size_t i = 0; i < rule.size(); i++) { if (rule[i].type == LLAMA_GRETYPE_RULE_REF && recurse_into_nonterminal) { - if (llama_grammar_detect_left_recursion(rules, (size_t)rule[i].value, rules_visited, rules_in_progress, rules_may_be_empty)) { + if (llama_grammar_detect_left_recursion(rules, (size_t)rule[i].value, rules_visited, rules_in_progress, rules_may_be_empty, depth + 1)) { return true; } if (!((*rules_may_be_empty)[(size_t)rule[i].value])) { @@ -1169,14 +1172,19 @@ struct llama_grammar * llama_grammar_init_impl( std::vector rules_visited(n_rules); std::vector rules_in_progress(n_rules); std::vector rules_may_be_empty(n_rules); - for (size_t i = 0; i < n_rules; i++) { - if (rules_visited[i]) { - continue; - } - if (llama_grammar_detect_left_recursion(vec_rules, i, &rules_visited, &rules_in_progress, &rules_may_be_empty)) { - LLAMA_LOG_ERROR("unsupported grammar, left recursion detected for nonterminal at index %zu", i); - return nullptr; + try { + for (size_t i = 0; i < n_rules; i++) { + if (rules_visited[i]) { + continue; + } + if (llama_grammar_detect_left_recursion(vec_rules, i, &rules_visited, &rules_in_progress, &rules_may_be_empty)) { + LLAMA_LOG_ERROR("unsupported grammar, left recursion detected for nonterminal at index %zu", i); + return nullptr; + } } + } catch (const std::exception & err) { + LLAMA_LOG_ERROR("unsupported grammar: %s\n", err.what()); + return nullptr; } // loop over alternates of start rule to build initial stacks @@ -1262,14 +1270,19 @@ struct llama_grammar * llama_grammar_init_impl( std::vector rules_visited(n_rules); std::vector rules_in_progress(n_rules); std::vector rules_may_be_empty(n_rules); - for (size_t i = 0; i < n_rules; i++) { - if (rules_visited[i]) { - continue; - } - if (llama_grammar_detect_left_recursion(vec_rules, i, &rules_visited, &rules_in_progress, &rules_may_be_empty)) { - LLAMA_LOG_ERROR("unsupported grammar, left recursion detected for nonterminal at index %zu\n", i); - return nullptr; + try { + for (size_t i = 0; i < n_rules; i++) { + if (rules_visited[i]) { + continue; + } + if (llama_grammar_detect_left_recursion(vec_rules, i, &rules_visited, &rules_in_progress, &rules_may_be_empty)) { + LLAMA_LOG_ERROR("unsupported grammar, left recursion detected for nonterminal at index %zu\n", i); + return nullptr; + } } + } catch (const std::exception & err) { + LLAMA_LOG_ERROR("unsupported grammar: %s\n", err.what()); + return nullptr; } // loop over alternates of start rule to build initial stacks diff --git a/src/llama-grammar.h b/src/llama-grammar.h index 2885f770597f..0364379fed90 100644 --- a/src/llama-grammar.h +++ b/src/llama-grammar.h @@ -7,6 +7,8 @@ #include #include +#define GRAMMAR_RECURSIVE_LIMIT 256 + struct llama_vocab; // grammar element type diff --git a/tests/peg-parser/test-json-parser.cpp b/tests/peg-parser/test-json-parser.cpp index ec7c2e668ff1..53735f7a5e3d 100644 --- a/tests/peg-parser/test-json-parser.cpp +++ b/tests/peg-parser/test-json-parser.cpp @@ -130,4 +130,23 @@ void test_json_parser(testing &t) { t.assert_true("fail", result.fail()); }); }); + + // json-value is entered once per level + t.test("deep JSON", [](testing &t) { + auto json = build_peg_parser([](common_peg_parser_builder & p) { return p.json(); }); + + const auto parse = [&](size_t depth) { + std::string input = std::string(depth, '[') + std::string(depth, ']'); + common_peg_parse_context ctx(input); + try { + return json.parse(ctx).success() ? std::string() : std::string("fail"); + } catch (const common_peg_recursion_error & e) { + return std::string(e.what()); + } + }; + + t.assert_equal("128 levels", std::string(), parse(128)); + t.assert_true("129 levels", parse(129).find("nested more than 128 levels deep") != std::string::npos); + t.assert_true("100000 levels", parse(100000).find("nested more than 128 levels deep") != std::string::npos); + }); } diff --git a/tests/test-grammar-integration.cpp b/tests/test-grammar-integration.cpp index 2af96f8b4797..75bfca645f00 100644 --- a/tests/test-grammar-integration.cpp +++ b/tests/test-grammar-integration.cpp @@ -879,6 +879,18 @@ static void test_failure_left_recursion() { empty ::= "blah" | )"""; assert(test_build_grammar_fails(hardest_str)); + const auto chain = [](int n) { + std::string s = "root ::= r0\n"; + for (int i = 0; i < n; i++) { + s += "r" + std::to_string(i) + " ::= r" + std::to_string(i + 1) + "\n"; + } + return s + "r" + std::to_string(n) + " ::= \"a\"\n"; + }; + llama_grammar * grammar = build_grammar(chain(100)); + assert(grammar != nullptr); + llama_grammar_free_impl(grammar); + assert(test_build_grammar_fails(chain(1000))); + fprintf(stderr, " ✅︎ Passed\n"); } @@ -1476,6 +1488,25 @@ static void test_json_schema() { // R"""({"productId": 1, "productName": "A green door", "price": 12.50, "tags": ["home", "green", "home"]})""", } ); + + // the deepest schema of json_schema_to_grammar must give a valid grammar + std::string deep_schema; + for (int i = 0; i < 127; i++) { + deep_schema += R"({"type": "array", "items": )"; + } + deep_schema += R"({"type": "string"})" + std::string(127, '}'); + test_schema( + "127 nested arrays", + deep_schema, + // Passing strings + { + std::string(127, '[') + R"("a")" + std::string(127, ']'), + }, + // Failing strings + { + std::string(126, '[') + R"("a")" + std::string(126, ']'), + } + ); } int main() { diff --git a/tests/test-jinja.cpp b/tests/test-jinja.cpp index eb05f878069e..08758b0aaf80 100644 --- a/tests/test-jinja.cpp +++ b/tests/test-jinja.cpp @@ -1291,6 +1291,27 @@ static void test_macros(testing & t) { json::object(), "Dict {\n root1: 1\n root2: {\n nest1: 1\n nest2: {\n nest3: 2\n }\n }\n}" ); + + test_template(t, "macro recursion", + "{% macro f(n) %}{% if n < 100 %}{{ f(n + 1) }}{% else %}{{ n }}{% endif %}{% endmacro %}{{ f(0) }}", + json::object(), + "100" + ); + + t.test("endless macro recursion", [](testing & t) { + const std::string tmpl = "{% macro f(n) %}{{ f(n + 1) }}{% endmacro %}{{ f(0) }}"; + jinja::lexer lexer; + jinja::program ast = jinja::parse_from_tokens(lexer.tokenize(tmpl)); + jinja::context ctx(tmpl); + jinja::runtime runtime(ctx); + std::string error; + try { + runtime.execute(ast); + } catch (const std::exception & e) { + error = e.what(); + } + t.assert_true("rejected: " + error, error.find("macro calls are nested more than 128 levels deep") != std::string::npos); + }); } static void test_namespace(testing & t) { diff --git a/tests/test-server-component.cpp b/tests/test-server-component.cpp index 0d2329cf7399..642d3f9250a8 100644 --- a/tests/test-server-component.cpp +++ b/tests/test-server-component.cpp @@ -527,6 +527,32 @@ static void test_dry_sequence_breakers(testing & t) { } } +// +// glob_match +// + +static void test_glob_match(testing & t) { + t.test("patterns", [](testing & t) { + t.assert_true("literal", glob_match("src/main.cpp", "src/main.cpp")); + t.assert_true("star", glob_match("src/*.cpp", "src/main.cpp")); + t.assert_true("star stops at /", !glob_match("*.cpp", "src/main.cpp")); + t.assert_true("double star", glob_match("**/*.cpp", "a/b/c/main.cpp")); + t.assert_true("double star at end", glob_match("src/**", "src/a/b")); + t.assert_true("question mark", glob_match("ma?n.cpp", "main.cpp")); + t.assert_true("question mark not /", !glob_match("a?b", "a/b")); + t.assert_true("class", glob_match("[a-c]x", "bx")); + t.assert_true("negated class", !glob_match("[!a-c]x", "bx")); + t.assert_true("unclosed class", glob_match("[ab", "[ab")); + t.assert_true("mismatch", !glob_match("main.cpp", "main.c")); + }); + t.test("long input", [](testing & t) { + const std::string path(100000, 'a'); + t.assert_true("long literal", glob_match(path, path)); + t.assert_true("long path", glob_match("**", path)); + t.assert_true("too many wildcards", !glob_match(std::string(1000, '*'), "a")); + }); +} + int main(int argc, char ** argv) { testing t; @@ -548,6 +574,7 @@ int main(int argc, char ** argv) { t.test("decision_escape", test_decision_escape); t.test("rerank_template", test_rerank_template); t.test("decision_depth", test_decision_depth); + t.test("glob_match", test_glob_match); t.test("dry_sequence_breakers", test_dry_sequence_breakers); return t.summary(); From 34f57790d47d82c2ebaaff861e763b95622baa49 Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Sun, 25 Oct 2026 15:52:23 +0100 Subject: [PATCH 02/10] catch more recursive functions --- common/chat-peg-parser.cpp | 68 +++++++++++++++-------- common/chat-peg-parser.h | 4 +- common/jinja/parser.cpp | 24 +++++++-- common/jinja/runtime.h | 6 ++- common/jinja/value.cpp | 13 +++-- common/jinja/value.h | 2 +- common/json-schema-to-grammar.cpp | 56 +++++++++++++++---- common/json-schema-to-grammar.h | 1 + common/json-schema.cpp | 23 +++++--- common/peg-parser.cpp | 38 +++++-------- src/llama-grammar.cpp | 18 ++++--- src/llama-grammar.h | 3 +- src/llama-vocab.cpp | 77 ++++++++++++++------------- tests/test-jinja.cpp | 51 +++++++++++++----- tests/test-json-schema-to-grammar.cpp | 17 ++++++ 15 files changed, 263 insertions(+), 138 deletions(-) diff --git a/common/chat-peg-parser.cpp b/common/chat-peg-parser.cpp index 74b81dcddb27..ca45ee65b9f2 100644 --- a/common/chat-peg-parser.cpp +++ b/common/chat-peg-parser.cpp @@ -966,7 +966,8 @@ void common_chat_peg_gemma4_mapper::from_ast(const common_peg_ast_arena & arena, } } -static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id) { +// depth counts the arrays and the dicts +static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth = 0) { const auto & node = arena.get(id); if (node.text.empty()) { @@ -984,7 +985,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-string") { std::string result = "\""; if (!node.children.empty()) { - result += gemma4_to_json(arena, node.children[0]); + result += gemma4_to_json(arena, node.children[0], depth); if (!node.is_partial) { result += "\""; } @@ -993,6 +994,9 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg } if (node.rule == "gemma4-array") { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } std::string result = "["; bool add_comma = false; @@ -1001,7 +1005,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg result += ','; } add_comma = true; - result += gemma4_to_json(arena, child_id); + result += gemma4_to_json(arena, child_id, depth + 1); } if (!node.is_partial) { @@ -1017,7 +1021,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-dict-key") { std::string result = "\""; if (!node.children.empty()) { - result += escape_json_string_inner(gemma4_to_json(arena, node.children[0])); + result += escape_json_string_inner(gemma4_to_json(arena, node.children[0], depth)); } if (!node.is_partial) { result += "\":"; @@ -1028,12 +1032,15 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-dict-kv") { std::string result; for (auto child_id : node.children) { - result += gemma4_to_json(arena, child_id); + result += gemma4_to_json(arena, child_id, depth); } return result; } if (node.rule == "gemma4-dict") { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } std::string result = "{"; bool add_comma = false; @@ -1042,7 +1049,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg result += ','; } add_comma = true; - result += gemma4_to_json(arena, child_id); + result += gemma4_to_json(arena, child_id, depth + 1); } if (!node.is_partial) { @@ -1053,7 +1060,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-value") { if (!node.children.empty()) { - return gemma4_to_json(arena, node.children[0]); + return gemma4_to_json(arena, node.children[0], depth); } return ""; } @@ -1061,7 +1068,10 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg return ""; } -void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id) { +void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } const auto & node = arena.get(id); if (node.tag == "reasoning") { @@ -1096,20 +1106,24 @@ void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, co } for (auto child_id : node.children) { - visit(arena, child_id); + visit(arena, child_id, depth + 1); } } static void minimax_m3_collect(const common_peg_ast_arena & arena, const common_peg_ast_node & node, const std::string & tag, - std::vector & out) { + std::vector & out, + int depth = 0) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } for (auto child_id : node.children) { const auto & child = arena.get(child_id); if (child.tag == tag) { out.push_back(child_id); } else { - minimax_m3_collect(arena, child, tag, out); + minimax_m3_collect(arena, child, tag, out, depth + 1); } } } @@ -1127,22 +1141,27 @@ static common_peg_ast_id minimax_m3_value_of(const common_peg_ast_arena & arena, return COMMON_PEG_INVALID_AST_ID; } -static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed); +// depth counts the objects and the arrays +static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed, int depth); -static std::string minimax_m3_member_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node) { +static std::string minimax_m3_member_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node, int depth) { auto name_id = arena.find_by_tag(node, common_chat_peg_builder::TOOL_ARG_NAME); if (name_id == COMMON_PEG_INVALID_AST_ID) { return ""; } return ordered_json(arena.get(name_id).text).dump() + ":" + - minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, node), !node.is_partial); + minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, node), !node.is_partial, depth); } static std::string minimax_m3_container_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node, bool is_object, - bool closed) { + bool closed, + int depth) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } const std::string tag = is_object ? common_chat_peg_builder::TOOL_ARG : common_chat_peg_minimax_m3_mapper::TOOL_ARG_ITEM; @@ -1157,9 +1176,9 @@ static std::string minimax_m3_container_to_json(const common_peg_ast_arena & are std::string text; if (is_object) { - text = minimax_m3_member_to_json(arena, entry); + text = minimax_m3_member_to_json(arena, entry, depth + 1); } else { - text = minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, entry), !entry.is_partial); + text = minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, entry), !entry.is_partial, depth + 1); } if (text.empty()) { @@ -1179,7 +1198,7 @@ static std::string minimax_m3_container_to_json(const common_peg_ast_arena & are return result; } -static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed) { +static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed, int depth) { if (id == COMMON_PEG_INVALID_AST_ID) { return ""; } @@ -1187,11 +1206,11 @@ static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, const auto & node = arena.get(id); if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_OBJECT) { - return minimax_m3_container_to_json(arena, node, /* is_object = */ true, closed); + return minimax_m3_container_to_json(arena, node, /* is_object = */ true, closed, depth); } if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_ARRAY) { - return minimax_m3_container_to_json(arena, node, /* is_object = */ false, closed); + return minimax_m3_container_to_json(arena, node, /* is_object = */ false, closed, depth); } if (node.tag == common_chat_peg_builder::TOOL_ARG_STRING_VALUE) { @@ -1209,7 +1228,10 @@ void common_chat_peg_minimax_m3_mapper::from_ast(const common_peg_ast_arena & } } -void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id) { +void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } const auto & node = arena.get(id); if (node.tag == common_chat_peg_builder::REASONING) { @@ -1227,13 +1249,13 @@ void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena if (name_id != COMMON_PEG_INVALID_AST_ID) { common_chat_tool_call call; call.name = std::string(arena.get(name_id).text); - call.arguments = minimax_m3_container_to_json(arena, node, /* is_object = */ true, !node.is_partial); + call.arguments = minimax_m3_container_to_json(arena, node, /* is_object = */ true, !node.is_partial, 0); result.tool_calls.push_back(call); } return; } for (auto child_id : node.children) { - visit(arena, child_id); + visit(arena, child_id, depth + 1); } } diff --git a/common/chat-peg-parser.h b/common/chat-peg-parser.h index 114fa049fa74..973a2bfca7b5 100644 --- a/common/chat-peg-parser.h +++ b/common/chat-peg-parser.h @@ -37,7 +37,7 @@ class common_chat_peg_gemma4_mapper : public common_chat_peg_mapper { common_chat_peg_gemma4_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {} virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result); private: - void visit(const common_peg_ast_arena & arena, common_peg_ast_id id); + void visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth = 0); }; class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper { @@ -49,7 +49,7 @@ class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper { common_chat_peg_minimax_m3_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {} virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result); private: - void visit(const common_peg_ast_arena & arena, common_peg_ast_id id); + void visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth = 0); }; struct content_structure; diff --git a/common/jinja/parser.cpp b/common/jinja/parser.cpp index 67dcafe17fed..7e99a1ac8fd0 100644 --- a/common/jinja/parser.cpp +++ b/common/jinja/parser.cpp @@ -21,6 +21,17 @@ static bool is_type(const statement_ptr & ptr) { class parser { const std::vector & tokens; size_t current = 0; + int depth = 0; + + struct depth_guard { + parser & p; + depth_guard(parser & p) : p(p) { + if (++p.depth > JINJA_RECURSIVE_LIMIT) { + throw parser_exception("Parser Error: template is nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep", p.source, p.peek().pos); + } + } + ~depth_guard() { p.depth--; } + }; std::string source; // for error reporting @@ -94,6 +105,7 @@ class parser { } statement_ptr parse_any() { + depth_guard guard(*this); size_t start_pos = current; switch (peek().t) { case token::comment: @@ -254,6 +266,7 @@ class parser { size_t pos0 = current; ++current; // consume {% ++current; // consume 'elif' + depth_guard guard(*this); alternate.push_back(parse_if_statement(pos0)); // nested If } else if (is_statement({"else"})) { ++current; // consume {% @@ -330,6 +343,7 @@ class parser { } statement_ptr parse_if_expression() { + depth_guard guard(*this); auto a = parse_logical_or_expression(); if (is_identifier("if")) { // Ternary expression @@ -373,6 +387,7 @@ class parser { statement_ptr parse_logical_negation_expression() { // Try parse unary operators if (is_identifier("not")) { + depth_guard guard(*this); size_t start_pos = current; auto op = next(); return mk_stmt(start_pos, op, parse_logical_negation_expression()); @@ -458,6 +473,7 @@ class parser { statement_ptr parse_unary_expression() { if (is(token::unary_operator)) { + depth_guard guard(*this); size_t start_pos = current; auto op = next(); return mk_stmt(start_pos, op, parse_unary_expression()); @@ -477,9 +493,11 @@ class parser { size_t start_pos = current; auto expr = mk_stmt(start_pos, std::move(callee), parse_args()); auto member = parse_member_expression(std::move(expr)); // foo.x().y - return is(token::open_paren) - ? parse_call_expression(std::move(member)) // foo.x()() - : std::move(member); + if (!is(token::open_paren)) { + return member; + } + depth_guard guard(*this); + return parse_call_expression(std::move(member)); // foo.x()() } statements parse_args() { diff --git a/common/jinja/runtime.h b/common/jinja/runtime.h index 4d47da026791..101e927c4b7f 100644 --- a/common/jinja/runtime.h +++ b/common/jinja/runtime.h @@ -713,7 +713,7 @@ struct rethrown_exception : public std::exception { ////////////////////// -static void gather_string_parts_recursive(const value & val, value_string & parts) { +static void gather_string_parts_recursive(const value & val, value_string & parts, int depth = 0) { // TODO: probably allow print value_none as "None" string? currently this breaks some templates if (is_val(val)) { const auto & str_val = cast_val(val)->val_str; @@ -721,10 +721,12 @@ static void gather_string_parts_recursive(const value & val, value_string & part } else if (is_val(val) || is_val(val) || is_val(val)) { std::string str_val = val->as_string().str(); parts->val_str.append(str_val); + } else if (is_val(val) && depth >= JINJA_RECURSIVE_LIMIT) { + throw std::runtime_error("value is nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep"); } else if (is_val(val)) { auto items = cast_val(val)->as_array(); for (const auto & item : items) { - gather_string_parts_recursive(item, parts); + gather_string_parts_recursive(item, parts, depth + 1); } } } diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp index ca6746304497..3aceca1e0177 100644 --- a/common/jinja/value.cpp +++ b/common/jinja/value.cpp @@ -1662,6 +1662,8 @@ static void value_to_json_internal(std::ostringstream & oss, const value & val, } } oss << "\""; + } else if ((is_val(val) || is_val(val)) && curr_lvl >= JINJA_RECURSIVE_LIMIT) { + throw std::runtime_error("value is nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep"); } else if (is_val(val)) { const auto & arr = val->as_array(); oss << "["; @@ -1727,17 +1729,20 @@ std::string value_to_string_repr(const value & val) { } // stats utility -void value_t::stats_t::mark_used(value & val, bool deep) { +void value_t::stats_t::mark_used(value & val, bool deep, int depth) { val->stats.used = true; + if (deep && (is_val(val) || is_val(val)) && depth >= JINJA_RECURSIVE_LIMIT) { + throw std::runtime_error("value is nested more than " + std::to_string(JINJA_RECURSIVE_LIMIT) + " levels deep"); + } if (deep) { if (is_val(val)) { for (auto & item : val->val_arr) { - mark_used(item, deep); + mark_used(item, deep, depth + 1); } } else if (is_val(val)) { for (auto & pair : val->val_obj) { - mark_used(pair.first, deep); - mark_used(pair.second, deep); + mark_used(pair.first, deep, depth + 1); + mark_used(pair.second, deep, depth + 1); } } } diff --git a/common/jinja/value.h b/common/jinja/value.h index 142943dfd79f..9bb1a87bd018 100644 --- a/common/jinja/value.h +++ b/common/jinja/value.h @@ -119,7 +119,7 @@ struct value_t { // ops can be builtin calls or operators: "array_access", "object_access" std::set ops; // utility to recursively mark value and its children as used - static void mark_used(value & val, bool deep = false); + static void mark_used(value & val, bool deep = false, int depth = 0); } stats; value_t() = default; diff --git a/common/json-schema-to-grammar.cpp b/common/json-schema-to-grammar.cpp index f483c1d9b21b..6f3ecc992113 100644 --- a/common/json-schema-to-grammar.cpp +++ b/common/json-schema-to-grammar.cpp @@ -272,7 +272,6 @@ static bool is_reserved_name(const std::string & name) { return RESERVED_NAMES.find(name) != RESERVED_NAMES.end(); } -static std::regex INVALID_RULE_CHARS_RE("[^a-zA-Z0-9-]+"); static std::regex GRAMMAR_LITERAL_ESCAPE_RE("[\r\n\"\\\\]"); static std::regex GRAMMAR_RANGE_LITERAL_ESCAPE_RE("[\r\n\"\\]\\-\\\\]"); static std::unordered_map GRAMMAR_LITERAL_ESCAPES = { @@ -310,6 +309,22 @@ static std::string format_literal(const std::string & literal) { std::string gbnf_format_literal(const std::string & literal) { return format_literal(literal); } +// same as std::regex_replace with [^a-zA-Z0-9-]+ -> "-", no std::regex: libstdc++ recurses per char +std::string gbnf_format_rule_name(const std::string & name) { + std::string out; + bool in_invalid = false; + for (char c : name) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + out += c; + in_invalid = false; + } else if (!in_invalid) { + out += '-'; + in_invalid = true; + } + } + return out; +} + static size_t gbnf_escape_length(const std::string & pattern, size_t pos) { if (pos + 1 >= pattern.length() || pattern[pos] != '\\') { return 0; @@ -345,6 +360,17 @@ class common_chat_schema_converter { std::unordered_set _refs_being_resolved; std::vector _errors; std::vector _warnings; + int _depth = 0; + + struct depth_guard { + int & depth; + depth_guard(int & depth) : depth(depth) { + if (++depth > COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } + } + ~depth_guard() { depth--; } + }; template static const T & as(const common_chat_schema & node) { @@ -352,7 +378,7 @@ class common_chat_schema_converter { } std::string _add_rule(const std::string & name, const std::string & rule) { - std::string esc_name = regex_replace(name, INVALID_RULE_CHARS_RE, "-"); + std::string esc_name = gbnf_format_rule_name(name); if (_rules.find(esc_name) == _rules.end() || _rules[esc_name] == rule) { _rules[esc_name] = rule; return esc_name; @@ -642,7 +668,11 @@ class common_chat_schema_converter { std::string char_rule = _add_primitive("char", PRIMITIVE_RULES.at("char")); std::ostringstream out; out << "[\"] ( "; - std::function visit = [&](size_t idx) { + // one level per char of the longest name + std::function visit = [&](size_t idx, int depth) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema property name is longer than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " chars"); + } const auto & node = trie.nodes[idx]; std::string rejects; auto first = true; @@ -657,7 +687,7 @@ class common_chat_schema_converter { out << "[" << c << "]"; if (!trie.nodes[child].children.empty()) { out << " ("; - visit(child); + visit(child, depth + 1); out << ")"; } else { out << " " << char_rule << "+"; @@ -667,7 +697,7 @@ class common_chat_schema_converter { out << " | [^\"" << rejects << "] " << char_rule << "*"; } }; - visit(0); + visit(0, 0); out << " )"; if (trie.nodes[0].pattern < 0) { @@ -680,8 +710,7 @@ class common_chat_schema_converter { std::string _resolve_ref(const common_chat_schema_ref & schema) { auto it = schema.ref.find('#'); std::string ref_fragment = it != std::string::npos ? schema.ref.substr(it + 1) : schema.ref; - static const std::regex nonalphanumeric_regex(R"([^a-zA-Z0-9-]+)"); - std::string ref_name = "ref" + std::regex_replace(ref_fragment, nonalphanumeric_regex, "-"); + std::string ref_name = "ref" + gbnf_format_rule_name(ref_fragment); if (_rules.find(ref_name) == _rules.end() && _refs_being_resolved.find(schema.ref) == _refs_being_resolved.end()) { if (!schema.target) { _errors.push_back("Unresolved $ref " + schema.ref); @@ -831,10 +860,13 @@ class common_chat_schema_converter { std::unordered_set required; std::vector> properties; std::map enum_values; - std::function add_component = [&](const common_chat_schema & comp, bool is_required) { + std::function add_component = [&](const common_chat_schema & comp, bool is_required, int depth) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } if (comp.kind() == common_chat_schema::KIND_REF) { if (const auto * target = as(comp).target) { - add_component(*target, is_required); + add_component(*target, is_required, depth + 1); } } else if (comp.kind() == common_chat_schema::KIND_OBJECT) { for (const auto & prop : as(comp).properties) { @@ -852,10 +884,10 @@ class common_chat_schema_converter { for (const auto & child : schema.children) { if (child->kind() == common_chat_schema::KIND_ANY_OF) { for (const auto & alt : as(*child).children) { - add_component(*alt, false); + add_component(*alt, false, 0); } } else { - add_component(*child, true); + add_component(*child, true, 0); } } if (!enum_values.empty()) { @@ -873,6 +905,8 @@ class common_chat_schema_converter { } std::string visit(const common_chat_schema & schema, const std::string & name) { + // the $ref targets are visited too, a $ref chain is not bounded by the builder + depth_guard guard(_depth); std::string rule_name = is_reserved_name(name) ? name + "-" : name.empty() ? "root" : name; std::string sub_name = name + (name.empty() ? "" : "-"); diff --git a/common/json-schema-to-grammar.h b/common/json-schema-to-grammar.h index b928c250bbef..b948743997ff 100644 --- a/common/json-schema-to-grammar.h +++ b/common/json-schema-to-grammar.h @@ -19,5 +19,6 @@ struct common_grammar_options { }; std::string gbnf_format_literal(const std::string & literal); +std::string gbnf_format_rule_name(const std::string & name); std::string build_grammar(const std::function & cb, const common_grammar_options & options = {}); diff --git a/common/json-schema.cpp b/common/json-schema.cpp index 6177bbf92db3..2f316e07c5e5 100644 --- a/common/json-schema.cpp +++ b/common/json-schema.cpp @@ -377,7 +377,11 @@ static common_chat_schema::value_type json_type(const common_json & value) { return common_chat_schema::TYPE_OBJECT; } -static common_chat_schema::type_set value_types_impl(const common_chat_schema & s, std::unordered_set & visited) { +// depth counts the $ref, anyOf and allOf hops, a $ref chain is not bounded by the builder +static common_chat_schema::type_set value_types_impl(const common_chat_schema & s, std::unordered_set & visited, int depth = 0) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } switch (s.kind()) { case common_chat_schema::KIND_ANY: return common_chat_schema::type_set::all(); @@ -411,21 +415,21 @@ static common_chat_schema::type_set value_types_impl(const common_chat_schema & // a cycle contributes no type, to be safe return {}; } - auto types = value_types_impl(*target, visited); + auto types = value_types_impl(*target, visited, depth + 1); visited.erase(target); return types; } case common_chat_schema::KIND_ANY_OF: { common_chat_schema::type_set types; for (const auto & child : static_cast(s).children) { - types |= value_types_impl(*child, visited); + types |= value_types_impl(*child, visited, depth + 1); } return types; } case common_chat_schema::KIND_ALL_OF: { auto types = common_chat_schema::type_set::all(); for (const auto & child : static_cast(s).children) { - types &= value_types_impl(*child, visited); + types &= value_types_impl(*child, visited, depth + 1); } return types; } @@ -438,7 +442,10 @@ common_chat_schema::type_set common_chat_schema::value_types() const { return value_types_impl(*this, visited); } -static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set & visited) { +static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set & visited, int depth = 0) { + if (depth >= COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } switch (s.kind()) { case common_chat_schema::KIND_STRING: return true; @@ -457,13 +464,13 @@ static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set< if (!target || !visited.insert(target).second) { return false; } - bool result = may_be_string_impl(*target, visited); + bool result = may_be_string_impl(*target, visited, depth + 1); visited.erase(target); return result; } case common_chat_schema::KIND_ANY_OF: for (const auto & child : static_cast(s).children) { - if (may_be_string_impl(*child, visited)) { + if (may_be_string_impl(*child, visited, depth + 1)) { return true; } } @@ -475,7 +482,7 @@ static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set< if (child->kind() == common_chat_schema::KIND_ANY) { continue; } - if (!may_be_string_impl(*child, visited)) { + if (!may_be_string_impl(*child, visited, depth + 1)) { return false; } any_string = true; diff --git a/common/peg-parser.cpp b/common/peg-parser.cpp index 4f6476aa76bd..0b1c95d45d6d 100644 --- a/common/peg-parser.cpp +++ b/common/peg-parser.cpp @@ -185,13 +185,17 @@ std::string common_peg_ast_node::sanitized_text() const { } void common_peg_ast_arena::visit(common_peg_ast_id id, const common_peg_ast_visitor & visitor) const { - if (id == COMMON_PEG_INVALID_AST_ID) { - return; - } - const auto & node = get(id); - visitor(node); - for (const auto & child : node.children) { - visit(child, visitor); + // no recursion: the AST is several nodes deep per level of the input + std::vector todo = { id }; + while (!todo.empty()) { + const auto cur = todo.back(); + todo.pop_back(); + if (cur == COMMON_PEG_INVALID_AST_ID) { + continue; + } + const auto & node = get(cur); + visitor(node); + todo.insert(todo.end(), node.children.rbegin(), node.children.rend()); } } @@ -1078,22 +1082,6 @@ common_peg_parser operator|(const std::string & str, const common_peg_parser & p return operator|(str.c_str(), p); } -// same as std::regex_replace with [^a-zA-Z0-9-]+ -> "-", no std::regex: libstdc++ recurses per char -static std::string rule_name(const std::string & name) { - std::string out; - bool in_invalid = false; - for (char c : name) { - if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { - out += c; - in_invalid = false; - } else if (!in_invalid) { - out += '-'; - in_invalid = true; - } - } - return out; -} - common_peg_parser_builder::common_peg_parser_builder() {} common_peg_parser common_peg_parser_builder::sequence(const std::vector & parsers) { @@ -1175,14 +1163,14 @@ common_peg_parser common_peg_parser_builder::schema(const common_peg_parser & p, } common_peg_parser common_peg_parser_builder::rule(const std::string & name, const common_peg_parser & p, bool trigger) { - auto clean_name = rule_name(name); + auto clean_name = gbnf_format_rule_name(name); auto rule_id = arena_.add_parser(common_peg_rule_parser{clean_name, p.id(), trigger}); arena_.add_rule(clean_name, rule_id); return ref(clean_name); } common_peg_parser common_peg_parser_builder::rule(const std::string & name, const std::function & builder_fn, bool trigger) { - auto clean_name = rule_name(name); + auto clean_name = gbnf_format_rule_name(name); if (arena_.has_rule(clean_name)) { return ref(clean_name); } diff --git a/src/llama-grammar.cpp b/src/llama-grammar.cpp index df1b4bd6f7df..76a9296b65a1 100644 --- a/src/llama-grammar.cpp +++ b/src/llama-grammar.cpp @@ -949,17 +949,18 @@ static void llama_grammar_advance_stack( static llama_grammar_candidates llama_grammar_reject_candidates( const llama_grammar_rules & rules, const llama_grammar_stacks & stacks, - const llama_grammar_candidates & candidates) { + const llama_grammar_candidates & candidates, + uint32_t depth) { GGML_ASSERT(!stacks.empty()); // REVIEW if (candidates.empty()) { return {}; } - auto rejects = llama_grammar_reject_candidates_for_stack(rules, stacks.front(), candidates); + auto rejects = llama_grammar_reject_candidates_for_stack(rules, stacks.front(), candidates, depth); for (size_t i = 1, size = stacks.size(); i < size; ++i) { - rejects = llama_grammar_reject_candidates_for_stack(rules, stacks[i], rejects); + rejects = llama_grammar_reject_candidates_for_stack(rules, stacks[i], rejects, depth); } return rejects; @@ -1070,7 +1071,12 @@ void llama_grammar_accept(struct llama_grammar * grammar, uint32_t chr) { llama_grammar_candidates llama_grammar_reject_candidates_for_stack( const llama_grammar_rules & rules, const llama_grammar_stack & stack, - const llama_grammar_candidates & candidates) { + const llama_grammar_candidates & candidates, + uint32_t depth) { + // one level per code point of a token, a longer token is rejected + if (depth >= GRAMMAR_RECURSIVE_LIMIT) { + return candidates; + } llama_grammar_candidates rejects; rejects.reserve(candidates.size()); @@ -1130,7 +1136,7 @@ llama_grammar_candidates llama_grammar_reject_candidates_for_stack( llama_grammar_stacks next_stacks; llama_grammar_advance_stack(rules, stack_after, next_stacks); - auto next_rejects = llama_grammar_reject_candidates(rules, next_stacks, next_candidates); + auto next_rejects = llama_grammar_reject_candidates(rules, next_stacks, next_candidates, depth + 1); for (const auto & tok : next_rejects) { rejects.push_back({ tok.index, tok.code_points - 1, tok.partial_utf8, tok.id }); } @@ -1412,7 +1418,7 @@ void llama_grammar_apply_impl(const struct llama_grammar & grammar, llama_token_ } } - const auto rejects = llama_grammar_reject_candidates(grammar.rules, grammar.stacks, candidates_grammar); + const auto rejects = llama_grammar_reject_candidates(grammar.rules, grammar.stacks, candidates_grammar, 0); for (const auto & reject : rejects) { cur_p->data[reject.index].logit = -INFINITY; } diff --git a/src/llama-grammar.h b/src/llama-grammar.h index 0364379fed90..6cc576792b33 100644 --- a/src/llama-grammar.h +++ b/src/llama-grammar.h @@ -83,7 +83,8 @@ void llama_grammar_accept(struct llama_grammar * grammar, uint32_t chr); std::vector llama_grammar_reject_candidates_for_stack( const llama_grammar_rules & rules, const llama_grammar_stack & stack, - const llama_grammar_candidates & candidates); + const llama_grammar_candidates & candidates, + uint32_t depth = 0); struct llama_grammar_parser { const llama_vocab * vocab; diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp index 05334e1fcee8..65b569762a2a 100644 --- a/src/llama-vocab.cpp +++ b/src/llama-vocab.cpp @@ -29,31 +29,25 @@ struct naive_trie { naive_trie() : has_value(false), value(0) { } + // no recursion: a token from the GGUF can be long void insert(const char * key, size_t len, int32_t value = 0) { - if (len == 0) { - this->has_value = true; - this->value = value; - return; - } - char c = key[0]; - auto res = children.find(c); - if (res != children.end()) { - res->second.insert(key + 1, len - 1, value); - } else { - auto res = children.insert(std::make_pair(c, naive_trie())); - res.first->second.insert(key + 1, len - 1, value); + naive_trie * node = this; + for (size_t i = 0; i < len; i++) { + node = &node->children[key[i]]; } + node->has_value = true; + node->value = value; } std::pair get_longest_prefix(const char * key, size_t len, size_t offset = 0) const { - if (len == 0 || offset == len) { - return std::make_pair(key, offset); - } - char c = key[offset]; - auto res = children.find(c); - if (res != children.end()) { - return res->second.get_longest_prefix(key, len, offset + 1); + const naive_trie * node = this; + while (offset < len) { + auto res = node->children.find(key[offset]); + if (res == node->children.end()) { + break; + } + node = &res->second; + offset++; } - return std::make_pair(key, offset); } const struct naive_trie * traverse(const char c) const { @@ -174,30 +168,37 @@ struct llm_tokenizer_spm_session { } private: + // no recursion: the merges of a long token can be deep void resegment(llm_symbol & symbol, std::vector & output) { - auto text = std::string(symbol.text, symbol.n); - auto token = vocab.text_to_token(text); + std::vector todo = { &symbol }; + while (!todo.empty()) { + const llm_symbol & cur = *todo.back(); + todo.pop_back(); - // Do we need to support is_unused? - if (token != LLAMA_TOKEN_NULL) { - output.push_back(token); - return; - } + auto text = std::string(cur.text, cur.n); + auto token = vocab.text_to_token(text); + + // Do we need to support is_unused? + if (token != LLAMA_TOKEN_NULL) { + output.push_back(token); + continue; + } - const auto p = rev_merge.find(text); + const auto p = rev_merge.find(text); - if (p == rev_merge.end()) { - // output any symbols that did not form tokens as bytes. - output.reserve(output.size() + symbol.n); - for (int j = 0; j < (int)symbol.n; ++j) { - llama_token id = vocab.byte_to_token(symbol.text[j]); - output.push_back(id); + if (p == rev_merge.end()) { + // output any symbols that did not form tokens as bytes. + output.reserve(output.size() + cur.n); + for (int j = 0; j < (int)cur.n; ++j) { + llama_token id = vocab.byte_to_token(cur.text[j]); + output.push_back(id); + } + continue; } - return; - } - resegment(symbols[p->second.first], output); - resegment(symbols[p->second.second], output); + todo.push_back(&symbols[p->second.second]); + todo.push_back(&symbols[p->second.first]); + } } void try_add_bigram(int left, int right) { diff --git a/tests/test-jinja.cpp b/tests/test-jinja.cpp index 08758b0aaf80..2a0117284071 100644 --- a/tests/test-jinja.cpp +++ b/tests/test-jinja.cpp @@ -17,6 +17,7 @@ using json = common_json; static void test_template(testing & t, const std::string & name, const std::string & tmpl, const json & vars, const std::string & expect); +static void test_template_error(testing & t, const std::string & name, const std::string & tmpl, const std::string & expect); static void test_whitespace_control(testing & t); static void test_conditionals(testing & t); @@ -606,6 +607,17 @@ static void test_expressions(testing & t) { {{"cond", true}}, "yes" ); + + test_template(t, "nested parentheses", + "{{ " + std::string(30, '(') + "1" + std::string(30, ')') + " }}", + json::object(), + "1" + ); + + test_template_error(t, "too deeply nested parentheses", + "{{ " + std::string(1000, '(') + "1" + std::string(1000, ')') + " }}", + "template is nested more than 128 levels deep" + ); } static void test_set_statement(testing & t) { @@ -1029,6 +1041,11 @@ static void test_filters(testing & t) { {{"x", nullptr}}, "None" ); + + test_template_error(t, "tojson of a list that contains itself", + "{% set arr = [] %}{% set _ = arr.append(arr) %}{{ arr|tojson }}", + "value is nested more than 128 levels deep" + ); } static void test_literals(testing & t) { @@ -1298,20 +1315,10 @@ static void test_macros(testing & t) { "100" ); - t.test("endless macro recursion", [](testing & t) { - const std::string tmpl = "{% macro f(n) %}{{ f(n + 1) }}{% endmacro %}{{ f(0) }}"; - jinja::lexer lexer; - jinja::program ast = jinja::parse_from_tokens(lexer.tokenize(tmpl)); - jinja::context ctx(tmpl); - jinja::runtime runtime(ctx); - std::string error; - try { - runtime.execute(ast); - } catch (const std::exception & e) { - error = e.what(); - } - t.assert_true("rejected: " + error, error.find("macro calls are nested more than 128 levels deep") != std::string::npos); - }); + test_template_error(t, "endless macro recursion", + "{% macro f(n) %}{{ f(n + 1) }}{% endmacro %}{{ f(0) }}", + "macro calls are nested more than 128 levels deep" + ); } static void test_namespace(testing & t) { @@ -2575,6 +2582,22 @@ static void test_string_parts(testing & t) { } +static void test_template_error(testing & t, const std::string & name, const std::string & tmpl, const std::string & expect) { + t.test(name, [&tmpl, &expect](testing & t) { + std::string error; + try { + jinja::lexer lexer; + jinja::program ast = jinja::parse_from_tokens(lexer.tokenize(tmpl)); + jinja::context ctx(tmpl); + jinja::runtime runtime(ctx); + runtime.gather_string_parts(runtime.execute(ast)); + } catch (const std::exception & e) { + error = e.what(); + } + t.assert_true("expected error: " + expect + ", got: " + error, error.find(expect) != std::string::npos); + }); +} + static void test_template_cpp(testing & t, const std::string & name, const std::string & tmpl, const json & vars, const std::string & expect) { t.test(name, [&tmpl, &vars, &expect](testing & t) { jinja::lexer lexer; diff --git a/tests/test-json-schema-to-grammar.cpp b/tests/test-json-schema-to-grammar.cpp index d5873c19d03e..c7d971b29dd0 100755 --- a/tests/test-json-schema-to-grammar.cpp +++ b/tests/test-json-schema-to-grammar.cpp @@ -1513,6 +1513,23 @@ static void test_all(const std::string & title, std::function Date: Sun, 25 Oct 2026 11:14:06 -0500 Subject: [PATCH 03/10] common : tidy up peg parser changes --- common/peg-parser.cpp | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/common/peg-parser.cpp b/common/peg-parser.cpp index 0b1c95d45d6d..5bd45b8e4a50 100644 --- a/common/peg-parser.cpp +++ b/common/peg-parser.cpp @@ -186,16 +186,16 @@ std::string common_peg_ast_node::sanitized_text() const { void common_peg_ast_arena::visit(common_peg_ast_id id, const common_peg_ast_visitor & visitor) const { // no recursion: the AST is several nodes deep per level of the input - std::vector todo = { id }; - while (!todo.empty()) { - const auto cur = todo.back(); - todo.pop_back(); + std::vector stack = { id }; + while (!stack.empty()) { + const auto cur = stack.back(); + stack.pop_back(); if (cur == COMMON_PEG_INVALID_AST_ID) { continue; } const auto & node = get(cur); visitor(node); - todo.insert(todo.end(), node.children.rbegin(), node.children.rend()); + stack.insert(stack.end(), node.children.rbegin(), node.children.rend()); } } @@ -740,9 +740,6 @@ struct parser_executor { common_peg_parse_result operator()(const common_peg_rule_parser & p) { // only rules recurse, a recursive rule is entered once per input level - if (ctx.rule_depth.size() <= p.child) { - ctx.rule_depth.resize(p.child + 1, 0); - } if (ctx.rule_depth[p.child] >= COMMON_RECURSIVE_LIMIT) { throw common_peg_recursion_error("input is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); } @@ -832,6 +829,10 @@ common_peg_parse_result common_peg_arena::parse(common_peg_parse_context & ctx, if (root_ == COMMON_PEG_INVALID_PARSER_ID) { throw std::runtime_error("No root parser set"); } + + // Initialize rule depth for all parsers + ctx.rule_depth.assign(parsers_.size(), 0); + return parse(root_, ctx, start); } From 8fa4bd1c0579947aa5c43f5c0e55f73c945c58da Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 25 Oct 2026 18:22:35 +0100 Subject: [PATCH 04/10] grammar : reject candidates in a loop, without a depth limit The depth limit in llama_grammar_reject_candidates_for_stack counted the code points of a vocab token, so any token of 256 chars or more was always rejected under a grammar, such as the 512-space token of GLM-4.7-Flash or the long space tokens of Laguna-S-2.1. The rejection now walks the code points with its own stack of levels instead of recursing: there is no limit and no stack risk, whatever the length of a token from the GGUF, and the masks match master. --- src/llama-grammar.cpp | 111 +++++++++++++++++++++++++++++------------- src/llama-grammar.h | 3 +- 2 files changed, 77 insertions(+), 37 deletions(-) diff --git a/src/llama-grammar.cpp b/src/llama-grammar.cpp index 76a9296b65a1..f6528b740727 100644 --- a/src/llama-grammar.cpp +++ b/src/llama-grammar.cpp @@ -946,26 +946,6 @@ static void llama_grammar_advance_stack( } } -static llama_grammar_candidates llama_grammar_reject_candidates( - const llama_grammar_rules & rules, - const llama_grammar_stacks & stacks, - const llama_grammar_candidates & candidates, - uint32_t depth) { - GGML_ASSERT(!stacks.empty()); // REVIEW - - if (candidates.empty()) { - return {}; - } - - auto rejects = llama_grammar_reject_candidates_for_stack(rules, stacks.front(), candidates, depth); - - for (size_t i = 1, size = stacks.size(); i < size; ++i) { - rejects = llama_grammar_reject_candidates_for_stack(rules, stacks[i], rejects, depth); - } - - return rejects; -} - static bool llama_grammar_detect_left_recursion( const llama_grammar_rules & rules, size_t rule_index, @@ -1068,16 +1048,18 @@ void llama_grammar_accept(struct llama_grammar * grammar, uint32_t chr) { grammar->stacks = std::move(stacks_new); } -llama_grammar_candidates llama_grammar_reject_candidates_for_stack( +// one code point of the candidates against one stack +struct llama_grammar_reject_step { + llama_grammar_candidates rejects; // the candidates the stack decides at this code point + llama_grammar_candidates next_candidates; // the candidates that match the code point + llama_grammar_stacks next_stacks; // the stacks that check the next code point + bool more; // next_candidates and next_stacks are set +}; + +static llama_grammar_reject_step llama_grammar_reject_candidates_step( const llama_grammar_rules & rules, const llama_grammar_stack & stack, - const llama_grammar_candidates & candidates, - uint32_t depth) { - // one level per code point of a token, a longer token is rejected - if (depth >= GRAMMAR_RECURSIVE_LIMIT) { - return candidates; - } - + const llama_grammar_candidates & candidates) { llama_grammar_candidates rejects; rejects.reserve(candidates.size()); @@ -1087,7 +1069,7 @@ llama_grammar_candidates llama_grammar_reject_candidates_for_stack( rejects.push_back(tok); } } - return rejects; + return { std::move(rejects), {}, {}, false }; } const llama_grammar_element * stack_pos = stack.back(); @@ -1105,7 +1087,7 @@ llama_grammar_candidates llama_grammar_reject_candidates_for_stack( rejects.push_back(tok); } } - return rejects; + return { std::move(rejects), {}, {}, false }; } llama_grammar_candidates next_candidates; @@ -1136,12 +1118,71 @@ llama_grammar_candidates llama_grammar_reject_candidates_for_stack( llama_grammar_stacks next_stacks; llama_grammar_advance_stack(rules, stack_after, next_stacks); - auto next_rejects = llama_grammar_reject_candidates(rules, next_stacks, next_candidates, depth + 1); - for (const auto & tok : next_rejects) { - rejects.push_back({ tok.index, tok.code_points - 1, tok.partial_utf8, tok.id }); + return { std::move(rejects), std::move(next_candidates), std::move(next_stacks), true }; +} + +// the candidates that no stack accepts, each stack narrows the rejects of the previous one +// no recursion: one level per code point of the longest candidate, a token from the GGUF can be long +static llama_grammar_candidates llama_grammar_reject_candidates( + const llama_grammar_rules & rules, + const llama_grammar_stacks & stacks, + const llama_grammar_candidates & candidates) { + GGML_ASSERT(!stacks.empty()); // REVIEW + + // the first level reads stacks and candidates in place, the deeper levels own theirs + struct level { + llama_grammar_stacks stacks; + llama_grammar_candidates in; // the candidates that reach this code point + size_t i; // the stack in progress + llama_grammar_candidates cur; // the candidates rejected by stacks[0, i) + llama_grammar_candidates rejects; // the rejects of stacks[i] at this code point + }; + + std::vector levels(1); + levels.reserve(64); + + while (true) { + level & lv = levels.back(); + + const bool first = levels.size() == 1; + const llama_grammar_stacks & lv_stacks = first ? stacks : lv.stacks; + const llama_grammar_candidates & lv_cur = lv.i > 0 ? lv.cur : first ? candidates : lv.in; + + if (lv.i == lv_stacks.size() || lv_cur.empty()) { + if (first) { + return std::move(lv.cur); + } + // the rejects of the next code point belong to stacks[i] of the parent level + level & parent = levels[levels.size() - 2]; + for (const auto & tok : lv.cur) { + parent.rejects.push_back({ tok.index, tok.code_points - 1, tok.partial_utf8, tok.id }); + } + levels.pop_back(); + parent.cur = std::move(parent.rejects); + parent.rejects = {}; + parent.i++; + continue; + } + + auto step = llama_grammar_reject_candidates_step(rules, lv_stacks[lv.i], lv_cur); + + if (!step.more) { + lv.cur = std::move(step.rejects); + lv.i++; + continue; + } + + GGML_ASSERT(!step.next_stacks.empty()); + lv.rejects = std::move(step.rejects); + levels.push_back({ std::move(step.next_stacks), std::move(step.next_candidates), 0, {}, {} }); } +} - return rejects; +llama_grammar_candidates llama_grammar_reject_candidates_for_stack( + const llama_grammar_rules & rules, + const llama_grammar_stack & stack, + const llama_grammar_candidates & candidates) { + return llama_grammar_reject_candidates(rules, llama_grammar_stacks{ stack }, candidates); } //////////////////// @@ -1418,7 +1459,7 @@ void llama_grammar_apply_impl(const struct llama_grammar & grammar, llama_token_ } } - const auto rejects = llama_grammar_reject_candidates(grammar.rules, grammar.stacks, candidates_grammar, 0); + const auto rejects = llama_grammar_reject_candidates(grammar.rules, grammar.stacks, candidates_grammar); for (const auto & reject : rejects) { cur_p->data[reject.index].logit = -INFINITY; } diff --git a/src/llama-grammar.h b/src/llama-grammar.h index 6cc576792b33..0364379fed90 100644 --- a/src/llama-grammar.h +++ b/src/llama-grammar.h @@ -83,8 +83,7 @@ void llama_grammar_accept(struct llama_grammar * grammar, uint32_t chr); std::vector llama_grammar_reject_candidates_for_stack( const llama_grammar_rules & rules, const llama_grammar_stack & stack, - const llama_grammar_candidates & candidates, - uint32_t depth = 0); + const llama_grammar_candidates & candidates); struct llama_grammar_parser { const llama_vocab * vocab; From 47112ac544a4667b3f40f46616244a7d63869dd9 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 25 Oct 2026 19:03:13 +0100 Subject: [PATCH 05/10] grammar : track rejected candidates as indices into the input At a given code point a candidate only differs from its input by the position of its code point pointer, so the levels now carry indices into the input candidates instead of copies. The first level needs no special case and the rejects map back to the input directly. Grammar sampling gets faster than master on GLM-4.7, Laguna, Qwen3.6 and gemma-4 with the json, list and spaces grammars, same masks. --- src/llama-grammar.cpp | 121 ++++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/src/llama-grammar.cpp b/src/llama-grammar.cpp index f6528b740727..590c38ad1e41 100644 --- a/src/llama-grammar.cpp +++ b/src/llama-grammar.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #define MAX_REPETITION_THRESHOLD 2000 @@ -1048,63 +1049,65 @@ void llama_grammar_accept(struct llama_grammar * grammar, uint32_t chr) { grammar->stacks = std::move(stacks_new); } -// one code point of the candidates against one stack -struct llama_grammar_reject_step { - llama_grammar_candidates rejects; // the candidates the stack decides at this code point - llama_grammar_candidates next_candidates; // the candidates that match the code point - llama_grammar_stacks next_stacks; // the stacks that check the next code point - bool more; // next_candidates and next_stacks are set -}; - -static llama_grammar_reject_step llama_grammar_reject_candidates_step( +// one code point of the candidates against one stack, the candidates are indices into the input +// and depth is the code point they are at: the candidates the stack decides go to rejects, the ones +// that match the code point go to next, to be checked against next_stacks +// returns false when the stack decides every candidate at this code point +static bool llama_grammar_reject_step( const llama_grammar_rules & rules, const llama_grammar_stack & stack, - const llama_grammar_candidates & candidates) { - llama_grammar_candidates rejects; - rejects.reserve(candidates.size()); + const llama_grammar_candidates & candidates, + size_t depth, + const std::vector & cur, + std::vector & rejects, + std::vector & next, + llama_grammar_stacks & next_stacks) { + rejects.reserve(cur.size()); if (stack.empty()) { - for (const auto & tok : candidates) { - if (*tok.code_points != 0 || tok.partial_utf8.n_remain != 0) { - rejects.push_back(tok); + for (const uint32_t i : cur) { + const auto & tok = candidates[i]; + if (tok.code_points[depth] != 0 || tok.partial_utf8.n_remain != 0) { + rejects.push_back(i); } } - return { std::move(rejects), {}, {}, false }; + return false; } const llama_grammar_element * stack_pos = stack.back(); // if the top of the stack is a token rule, then we only need to check the token id if (stack_pos->type == LLAMA_GRETYPE_TOKEN || stack_pos->type == LLAMA_GRETYPE_TOKEN_NOT) { - for (const auto & tok : candidates) { - if (*tok.code_points == 0) { + for (const uint32_t i : cur) { + const auto & tok = candidates[i]; + if (tok.code_points[depth] == 0) { // reached the end of a token consumed by char rules, reject iff it ended // in a partial response if (tok.partial_utf8.n_remain != 0) { - rejects.push_back(tok); + rejects.push_back(i); } } else if (!llama_grammar_match_token(stack_pos, tok.id)) { - rejects.push_back(tok); + rejects.push_back(i); } } - return { std::move(rejects), {}, {}, false }; + return false; } - llama_grammar_candidates next_candidates; - next_candidates.reserve(candidates.size()); + next.reserve(cur.size()); - for (const auto & tok : candidates) { - if (*tok.code_points == 0) { + for (const uint32_t i : cur) { + const auto & tok = candidates[i]; + if (tok.code_points[depth] == 0) { // reached end of full codepoints in token, reject iff it ended in a partial sequence // that cannot satisfy this position in grammar if (tok.partial_utf8.n_remain != 0 && !llama_grammar_match_partial_char(stack_pos, tok.partial_utf8)) { - rejects.push_back(tok); + rejects.push_back(i); } - } else if (llama_grammar_match_char(stack_pos, *tok.code_points).first) { - next_candidates.push_back({ tok.index, tok.code_points + 1, tok.partial_utf8, tok.id }); + } else if (llama_grammar_match_char(stack_pos, tok.code_points[depth]).first) { + next.push_back(i); } else { - rejects.push_back(tok); + rejects.push_back(i); } } @@ -1115,48 +1118,43 @@ static llama_grammar_reject_step llama_grammar_reject_candidates_step( if (!llama_grammar_is_end_of_sequence(stack_pos_after)) { stack_after.push_back(stack_pos_after); } - llama_grammar_stacks next_stacks; llama_grammar_advance_stack(rules, stack_after, next_stacks); - return { std::move(rejects), std::move(next_candidates), std::move(next_stacks), true }; + return true; } // the candidates that no stack accepts, each stack narrows the rejects of the previous one -// no recursion: one level per code point of the longest candidate, a token from the GGUF can be long +// no recursion: a level per code point, a token from the GGUF can be long static llama_grammar_candidates llama_grammar_reject_candidates( const llama_grammar_rules & rules, const llama_grammar_stacks & stacks, const llama_grammar_candidates & candidates) { GGML_ASSERT(!stacks.empty()); // REVIEW - // the first level reads stacks and candidates in place, the deeper levels own theirs + // a level checks the candidates that reach its code point against its stacks in turn struct level { - llama_grammar_stacks stacks; - llama_grammar_candidates in; // the candidates that reach this code point - size_t i; // the stack in progress - llama_grammar_candidates cur; // the candidates rejected by stacks[0, i) - llama_grammar_candidates rejects; // the rejects of stacks[i] at this code point + llama_grammar_stacks stacks; + size_t i; // the stack in progress + std::vector cur; // the candidates rejected by stacks[0, i) + std::vector rejects; // the rejects of stacks[i] at this code point }; - std::vector levels(1); - levels.reserve(64); + std::vector all(candidates.size()); + std::iota(all.begin(), all.end(), 0u); + + std::vector levels; + levels.push_back({ stacks, 0, std::move(all), {} }); while (true) { level & lv = levels.back(); - const bool first = levels.size() == 1; - const llama_grammar_stacks & lv_stacks = first ? stacks : lv.stacks; - const llama_grammar_candidates & lv_cur = lv.i > 0 ? lv.cur : first ? candidates : lv.in; - - if (lv.i == lv_stacks.size() || lv_cur.empty()) { - if (first) { - return std::move(lv.cur); + if (lv.i == lv.stacks.size() || lv.cur.empty()) { + if (levels.size() == 1) { + break; } - // the rejects of the next code point belong to stacks[i] of the parent level + // the rejects at the next code point belong to stacks[i] of the parent level level & parent = levels[levels.size() - 2]; - for (const auto & tok : lv.cur) { - parent.rejects.push_back({ tok.index, tok.code_points - 1, tok.partial_utf8, tok.id }); - } + parent.rejects.insert(parent.rejects.end(), lv.cur.begin(), lv.cur.end()); levels.pop_back(); parent.cur = std::move(parent.rejects); parent.rejects = {}; @@ -1164,18 +1162,27 @@ static llama_grammar_candidates llama_grammar_reject_candidates( continue; } - auto step = llama_grammar_reject_candidates_step(rules, lv_stacks[lv.i], lv_cur); + std::vector rejects; + std::vector next; + llama_grammar_stacks next_stacks; - if (!step.more) { - lv.cur = std::move(step.rejects); + if (!llama_grammar_reject_step(rules, lv.stacks[lv.i], candidates, levels.size() - 1, lv.cur, rejects, next, next_stacks)) { + lv.cur = std::move(rejects); lv.i++; continue; } - GGML_ASSERT(!step.next_stacks.empty()); - lv.rejects = std::move(step.rejects); - levels.push_back({ std::move(step.next_stacks), std::move(step.next_candidates), 0, {}, {} }); + GGML_ASSERT(!next_stacks.empty()); + lv.rejects = std::move(rejects); + levels.push_back({ std::move(next_stacks), 0, std::move(next), {} }); + } + + llama_grammar_candidates rejects; + rejects.reserve(levels[0].cur.size()); + for (const uint32_t i : levels[0].cur) { + rejects.push_back(candidates[i]); } + return rejects; } llama_grammar_candidates llama_grammar_reject_candidates_for_stack( From f7c4801c2d67cd08072e6337b19f69912f69f49b Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 25 Oct 2026 19:06:54 +0100 Subject: [PATCH 06/10] nit : shorten the comment of llama_grammar_reject_step --- src/llama-grammar.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/llama-grammar.cpp b/src/llama-grammar.cpp index 590c38ad1e41..14ebfde7ae07 100644 --- a/src/llama-grammar.cpp +++ b/src/llama-grammar.cpp @@ -1049,10 +1049,8 @@ void llama_grammar_accept(struct llama_grammar * grammar, uint32_t chr) { grammar->stacks = std::move(stacks_new); } -// one code point of the candidates against one stack, the candidates are indices into the input -// and depth is the code point they are at: the candidates the stack decides go to rejects, the ones -// that match the code point go to next, to be checked against next_stacks -// returns false when the stack decides every candidate at this code point +// checks the code point at depth of each candidate (an index into the input) against the stack +// returns true when some match: they go to next, and the stacks that follow to next_stacks static bool llama_grammar_reject_step( const llama_grammar_rules & rules, const llama_grammar_stack & stack, From 1425b1bd8d66697ac9b96f9c9736593463e21934 Mon Sep 17 00:00:00 2001 From: Alde Rojas Date: Sun, 25 Oct 2026 13:51:08 -0500 Subject: [PATCH 07/10] common : add depth_guard for schema walks --- common/json-schema.cpp | 51 +++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/common/json-schema.cpp b/common/json-schema.cpp index 2f316e07c5e5..eb57a524a605 100644 --- a/common/json-schema.cpp +++ b/common/json-schema.cpp @@ -9,6 +9,18 @@ #include #include +namespace { +struct depth_guard { + int & depth; + depth_guard(int & depth) : depth(depth) { + if (++depth > COMMON_RECURSIVE_LIMIT) { + throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + } + } + ~depth_guard() { depth--; } +}; +} // namespace + class common_chat_schema_builder { const common_json & root_; common_chat_schema_document & doc_; @@ -227,13 +239,8 @@ class common_chat_schema_builder { int depth_ = 0; common_chat_schema_ptr build_node(const common_json & schema, const std::string & path) { - if (depth_ >= COMMON_RECURSIVE_LIMIT) { - throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } - depth_++; - auto node = build_node_impl(schema, path); - depth_--; - return node; + depth_guard guard(depth_); + return build_node_impl(schema, path); } common_chat_schema_ptr build_node_impl(const common_json & schema, const std::string & path) { @@ -378,10 +385,8 @@ static common_chat_schema::value_type json_type(const common_json & value) { } // depth counts the $ref, anyOf and allOf hops, a $ref chain is not bounded by the builder -static common_chat_schema::type_set value_types_impl(const common_chat_schema & s, std::unordered_set & visited, int depth = 0) { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } +static common_chat_schema::type_set value_types_impl(const common_chat_schema & s, std::unordered_set & visited, int & depth) { + depth_guard guard(depth); switch (s.kind()) { case common_chat_schema::KIND_ANY: return common_chat_schema::type_set::all(); @@ -415,21 +420,21 @@ static common_chat_schema::type_set value_types_impl(const common_chat_schema & // a cycle contributes no type, to be safe return {}; } - auto types = value_types_impl(*target, visited, depth + 1); + auto types = value_types_impl(*target, visited, depth); visited.erase(target); return types; } case common_chat_schema::KIND_ANY_OF: { common_chat_schema::type_set types; for (const auto & child : static_cast(s).children) { - types |= value_types_impl(*child, visited, depth + 1); + types |= value_types_impl(*child, visited, depth); } return types; } case common_chat_schema::KIND_ALL_OF: { auto types = common_chat_schema::type_set::all(); for (const auto & child : static_cast(s).children) { - types &= value_types_impl(*child, visited, depth + 1); + types &= value_types_impl(*child, visited, depth); } return types; } @@ -439,13 +444,12 @@ static common_chat_schema::type_set value_types_impl(const common_chat_schema & common_chat_schema::type_set common_chat_schema::value_types() const { std::unordered_set visited; - return value_types_impl(*this, visited); + int depth = 0; + return value_types_impl(*this, visited, depth); } -static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set & visited, int depth = 0) { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } +static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set & visited, int & depth) { + depth_guard guard(depth); switch (s.kind()) { case common_chat_schema::KIND_STRING: return true; @@ -464,13 +468,13 @@ static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set< if (!target || !visited.insert(target).second) { return false; } - bool result = may_be_string_impl(*target, visited, depth + 1); + bool result = may_be_string_impl(*target, visited, depth); visited.erase(target); return result; } case common_chat_schema::KIND_ANY_OF: for (const auto & child : static_cast(s).children) { - if (may_be_string_impl(*child, visited, depth + 1)) { + if (may_be_string_impl(*child, visited, depth)) { return true; } } @@ -482,7 +486,7 @@ static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set< if (child->kind() == common_chat_schema::KIND_ANY) { continue; } - if (!may_be_string_impl(*child, visited, depth + 1)) { + if (!may_be_string_impl(*child, visited, depth)) { return false; } any_string = true; @@ -496,7 +500,8 @@ static bool may_be_string_impl(const common_chat_schema & s, std::unordered_set< bool common_chat_schema::may_be_string() const { std::unordered_set visited; - return may_be_string_impl(*this, visited); + int depth = 0; + return may_be_string_impl(*this, visited, depth); } const char * common_chat_schema::kind_name(node_kind kind) { From 9e98dbeb905b01fdbe5958c07c8aaa1500b9fb07 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 25 Oct 2026 20:31:23 +0100 Subject: [PATCH 08/10] grammar : move GRAMMAR_RECURSIVE_LIMIT into the .cpp --- src/llama-grammar.cpp | 1 + src/llama-grammar.h | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/llama-grammar.cpp b/src/llama-grammar.cpp index 14ebfde7ae07..4e096a3684c6 100644 --- a/src/llama-grammar.cpp +++ b/src/llama-grammar.cpp @@ -12,6 +12,7 @@ #include #define MAX_REPETITION_THRESHOLD 2000 +#define GRAMMAR_RECURSIVE_LIMIT 256 // // helpers // diff --git a/src/llama-grammar.h b/src/llama-grammar.h index 0364379fed90..2885f770597f 100644 --- a/src/llama-grammar.h +++ b/src/llama-grammar.h @@ -7,8 +7,6 @@ #include #include -#define GRAMMAR_RECURSIVE_LIMIT 256 - struct llama_vocab; // grammar element type From 510bcb37c1b84b9e3558698bf258e399a8599ff9 Mon Sep 17 00:00:00 2001 From: Alde Rojas Date: Sun, 25 Oct 2026 14:45:52 -0500 Subject: [PATCH 09/10] common : reduce recursion limit for peg parser --- common/peg-parser.cpp | 7 +++++-- tests/peg-parser/test-json-parser.cpp | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/common/peg-parser.cpp b/common/peg-parser.cpp index 5bd45b8e4a50..167a889e3295 100644 --- a/common/peg-parser.cpp +++ b/common/peg-parser.cpp @@ -13,6 +13,9 @@ #include #include +// The parser can run with a lower limit than COMMON_RECURSIVE_LIMIT +static constexpr int RULE_RECURSIVE_LIMIT = 64; + // Trick to catch missing branches template inline constexpr bool is_always_false_v = false; @@ -740,8 +743,8 @@ struct parser_executor { common_peg_parse_result operator()(const common_peg_rule_parser & p) { // only rules recurse, a recursive rule is entered once per input level - if (ctx.rule_depth[p.child] >= COMMON_RECURSIVE_LIMIT) { - throw common_peg_recursion_error("input is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); + if (ctx.rule_depth[p.child] >= RULE_RECURSIVE_LIMIT) { + throw common_peg_recursion_error("input is nested more than " + std::to_string(RULE_RECURSIVE_LIMIT) + " levels deep"); } // Parse the child diff --git a/tests/peg-parser/test-json-parser.cpp b/tests/peg-parser/test-json-parser.cpp index 53735f7a5e3d..9752da461fbe 100644 --- a/tests/peg-parser/test-json-parser.cpp +++ b/tests/peg-parser/test-json-parser.cpp @@ -145,8 +145,8 @@ void test_json_parser(testing &t) { } }; - t.assert_equal("128 levels", std::string(), parse(128)); - t.assert_true("129 levels", parse(129).find("nested more than 128 levels deep") != std::string::npos); - t.assert_true("100000 levels", parse(100000).find("nested more than 128 levels deep") != std::string::npos); + t.assert_equal("64 levels", std::string(), parse(64)); + t.assert_true("65 levels", parse(65).find("nested more than 64 levels deep") != std::string::npos); + t.assert_true("100000 levels", parse(100000).find("nested more than 64 levels deep") != std::string::npos); }); } From b8addd2f24acf0707887ca1882479466938844d8 Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Sun, 25 Oct 2026 23:16:29 +0100 Subject: [PATCH 10/10] improve --- common/chat-peg-parser.cpp | 68 +++++++++++++------------------------- common/chat-peg-parser.h | 4 +-- common/jinja/runtime.cpp | 22 ++++++++---- 3 files changed, 40 insertions(+), 54 deletions(-) diff --git a/common/chat-peg-parser.cpp b/common/chat-peg-parser.cpp index ca45ee65b9f2..74b81dcddb27 100644 --- a/common/chat-peg-parser.cpp +++ b/common/chat-peg-parser.cpp @@ -966,8 +966,7 @@ void common_chat_peg_gemma4_mapper::from_ast(const common_peg_ast_arena & arena, } } -// depth counts the arrays and the dicts -static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth = 0) { +static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id) { const auto & node = arena.get(id); if (node.text.empty()) { @@ -985,7 +984,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-string") { std::string result = "\""; if (!node.children.empty()) { - result += gemma4_to_json(arena, node.children[0], depth); + result += gemma4_to_json(arena, node.children[0]); if (!node.is_partial) { result += "\""; } @@ -994,9 +993,6 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg } if (node.rule == "gemma4-array") { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } std::string result = "["; bool add_comma = false; @@ -1005,7 +1001,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg result += ','; } add_comma = true; - result += gemma4_to_json(arena, child_id, depth + 1); + result += gemma4_to_json(arena, child_id); } if (!node.is_partial) { @@ -1021,7 +1017,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-dict-key") { std::string result = "\""; if (!node.children.empty()) { - result += escape_json_string_inner(gemma4_to_json(arena, node.children[0], depth)); + result += escape_json_string_inner(gemma4_to_json(arena, node.children[0])); } if (!node.is_partial) { result += "\":"; @@ -1032,15 +1028,12 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-dict-kv") { std::string result; for (auto child_id : node.children) { - result += gemma4_to_json(arena, child_id, depth); + result += gemma4_to_json(arena, child_id); } return result; } if (node.rule == "gemma4-dict") { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } std::string result = "{"; bool add_comma = false; @@ -1049,7 +1042,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg result += ','; } add_comma = true; - result += gemma4_to_json(arena, child_id, depth + 1); + result += gemma4_to_json(arena, child_id); } if (!node.is_partial) { @@ -1060,7 +1053,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg if (node.rule == "gemma4-value") { if (!node.children.empty()) { - return gemma4_to_json(arena, node.children[0], depth); + return gemma4_to_json(arena, node.children[0]); } return ""; } @@ -1068,10 +1061,7 @@ static std::string gemma4_to_json(const common_peg_ast_arena & arena, common_peg return ""; } -void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth) { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } +void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id) { const auto & node = arena.get(id); if (node.tag == "reasoning") { @@ -1106,24 +1096,20 @@ void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, co } for (auto child_id : node.children) { - visit(arena, child_id, depth + 1); + visit(arena, child_id); } } static void minimax_m3_collect(const common_peg_ast_arena & arena, const common_peg_ast_node & node, const std::string & tag, - std::vector & out, - int depth = 0) { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } + std::vector & out) { for (auto child_id : node.children) { const auto & child = arena.get(child_id); if (child.tag == tag) { out.push_back(child_id); } else { - minimax_m3_collect(arena, child, tag, out, depth + 1); + minimax_m3_collect(arena, child, tag, out); } } } @@ -1141,27 +1127,22 @@ static common_peg_ast_id minimax_m3_value_of(const common_peg_ast_arena & arena, return COMMON_PEG_INVALID_AST_ID; } -// depth counts the objects and the arrays -static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed, int depth); +static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed); -static std::string minimax_m3_member_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node, int depth) { +static std::string minimax_m3_member_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node) { auto name_id = arena.find_by_tag(node, common_chat_peg_builder::TOOL_ARG_NAME); if (name_id == COMMON_PEG_INVALID_AST_ID) { return ""; } return ordered_json(arena.get(name_id).text).dump() + ":" + - minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, node), !node.is_partial, depth); + minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, node), !node.is_partial); } static std::string minimax_m3_container_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node, bool is_object, - bool closed, - int depth) { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } + bool closed) { const std::string tag = is_object ? common_chat_peg_builder::TOOL_ARG : common_chat_peg_minimax_m3_mapper::TOOL_ARG_ITEM; @@ -1176,9 +1157,9 @@ static std::string minimax_m3_container_to_json(const common_peg_ast_arena & are std::string text; if (is_object) { - text = minimax_m3_member_to_json(arena, entry, depth + 1); + text = minimax_m3_member_to_json(arena, entry); } else { - text = minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, entry), !entry.is_partial, depth + 1); + text = minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, entry), !entry.is_partial); } if (text.empty()) { @@ -1198,7 +1179,7 @@ static std::string minimax_m3_container_to_json(const common_peg_ast_arena & are return result; } -static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed, int depth) { +static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed) { if (id == COMMON_PEG_INVALID_AST_ID) { return ""; } @@ -1206,11 +1187,11 @@ static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, const auto & node = arena.get(id); if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_OBJECT) { - return minimax_m3_container_to_json(arena, node, /* is_object = */ true, closed, depth); + return minimax_m3_container_to_json(arena, node, /* is_object = */ true, closed); } if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_ARRAY) { - return minimax_m3_container_to_json(arena, node, /* is_object = */ false, closed, depth); + return minimax_m3_container_to_json(arena, node, /* is_object = */ false, closed); } if (node.tag == common_chat_peg_builder::TOOL_ARG_STRING_VALUE) { @@ -1228,10 +1209,7 @@ void common_chat_peg_minimax_m3_mapper::from_ast(const common_peg_ast_arena & } } -void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth) { - if (depth >= COMMON_RECURSIVE_LIMIT) { - throw std::runtime_error("AST is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep"); - } +void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id) { const auto & node = arena.get(id); if (node.tag == common_chat_peg_builder::REASONING) { @@ -1249,13 +1227,13 @@ void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena if (name_id != COMMON_PEG_INVALID_AST_ID) { common_chat_tool_call call; call.name = std::string(arena.get(name_id).text); - call.arguments = minimax_m3_container_to_json(arena, node, /* is_object = */ true, !node.is_partial, 0); + call.arguments = minimax_m3_container_to_json(arena, node, /* is_object = */ true, !node.is_partial); result.tool_calls.push_back(call); } return; } for (auto child_id : node.children) { - visit(arena, child_id, depth + 1); + visit(arena, child_id); } } diff --git a/common/chat-peg-parser.h b/common/chat-peg-parser.h index 973a2bfca7b5..114fa049fa74 100644 --- a/common/chat-peg-parser.h +++ b/common/chat-peg-parser.h @@ -37,7 +37,7 @@ class common_chat_peg_gemma4_mapper : public common_chat_peg_mapper { common_chat_peg_gemma4_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {} virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result); private: - void visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth = 0); + void visit(const common_peg_ast_arena & arena, common_peg_ast_id id); }; class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper { @@ -49,7 +49,7 @@ class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper { common_chat_peg_minimax_m3_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {} virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result); private: - void visit(const common_peg_ast_arena & arena, common_peg_ast_id id, int depth = 0); + void visit(const common_peg_ast_arena & arena, common_peg_ast_id id); }; struct content_structure; diff --git a/common/jinja/runtime.cpp b/common/jinja/runtime.cpp index c2c5385d0a22..7ec6606d7105 100644 --- a/common/jinja/runtime.cpp +++ b/common/jinja/runtime.cpp @@ -3,6 +3,7 @@ #include "value.h" #include "utils.h" +#include #include #include #include @@ -52,32 +53,39 @@ static void ensure_key_type_allowed(const value & val) { // execute with error handling value statement::execute(context & ctx) const { + std::exception_ptr pending; + std::string message; try { return execute_impl(ctx); } catch (const continue_statement::signal & /* ex */) { - throw; + pending = std::current_exception(); } catch (const break_statement::signal & /* ex */) { - throw; + pending = std::current_exception(); } catch (const rethrown_exception & /* ex */) { - throw; + pending = std::current_exception(); } catch (const not_implemented_exception & /* ex */) { - throw; + pending = std::current_exception(); } catch (const std::exception & e) { const std::string & source = *ctx.src; if (source.empty()) { std::ostringstream oss; oss << "\nError executing " << type() << " at position " << pos << ": " << e.what(); - throw rethrown_exception(oss.str()); + message = oss.str(); } else { std::ostringstream oss; oss << "\n------------\n"; oss << "While executing " << type() << " at " << get_line_col(source, pos) << " in source:\n"; oss << peak_source(source, pos) << "\n"; oss << "Error: " << e.what(); - // throw as another exception to avoid repeated formatting - throw rethrown_exception(oss.str()); + message = oss.str(); } } + // throw outside the catch: MSVC keeps the unwound frames on the stack while a catch block runs + if (pending) { + std::rethrow_exception(pending); + } + // throw as another exception to avoid repeated formatting + throw rethrown_exception(message); } value identifier::execute_impl(context & ctx) const {