Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
loader.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
11#include "yaml_util.h"
12
13#include <nlohmann/json.hpp>
14
15#include <algorithm>
16#include <fstream>
17#include <optional>
18#include <sstream>
19
20#include <dlfcn.h>
21
22static auto s_log = entropic::log::get("config");
23
24namespace entropic::config {
25
36 ryml::ConstNodeRef node, ModelConfig& config)
37{
38 extract(node, "cache_type_k", config.cache_type_k);
39 extract(node, "cache_type_v", config.cache_type_v);
40 extract(node, "n_batch", config.n_batch);
41 extract(node, "n_ubatch", config.n_ubatch); // gh#23 v2.3.17
42 extract(node, "n_threads", config.n_threads);
43 extract(node, "tensor_split", config.tensor_split);
44 extract(node, "split_mode", config.split_mode); // gh#23 v2.3.18
45 extract(node, "main_gpu", config.main_gpu); // gh#23 v2.3.19
46 extract(node, "offload_kqv", config.offload_kqv); // gh#23 v2.3.20
47 extract(node, "rope_freq_base", config.rope_freq_base); // gh#23 v2.3.21
48 extract(node, "rope_freq_scale", config.rope_freq_scale); // gh#23 v2.3.22
49 extract(node, "n_parallel", config.n_parallel); // gh#23 v2.3.23
50 extract(node, "max_sessions", config.max_sessions); // gh#144 v2.12.0
51 // gh#153 #42(iii) (v2.13.0, EXPERIMENTAL): expert-tensor offload.
52 // Absent leaves the struct default 0 — no overrides, byte-identical.
53 extract(node, "cpu_moe_layers", config.cpu_moe_layers);
54 extract(node, "flash_attn", config.flash_attn);
55}
56
72 ryml::ConstNodeRef node, TierConfig& config)
73{
74 float f = 0.0f;
75 int i = 0;
76 bool b = false;
77 std::string s;
78 if (extract(node, "temperature", f)) { config.temperature = f; }
79 if (extract(node, "top_p", f)) { config.top_p = f; }
80 if (extract(node, "min_p", f)) { config.min_p = f; }
81 if (extract(node, "presence_penalty", f)) { config.presence_penalty = f; }
82 if (extract(node, "frequency_penalty", f)) { config.frequency_penalty = f; }
83 if (extract(node, "repeat_penalty", f)) { config.repeat_penalty = f; }
84 if (extract(node, "top_k", i)) { config.top_k = i; }
85 if (extract(node, "max_output_tokens", i)) { config.max_output_tokens = i; }
86 if (extract(node, "enable_thinking", b)) { config.enable_thinking = b; }
87 if (extract(node, "tool_call_mode", s)) { config.tool_call_mode = s; } // gh#103
88}
89
104 ryml::ConstNodeRef node, TierConfig& config)
105{
106 if (!node.has_child("speculative")) { return; }
107 auto spec = node["speculative"];
108 bool mtp = false;
109 if (extract(spec, "mtp", mtp)) { config.speculative_mtp = mtp; }
110}
111
125static std::string resolve_model_path(
126 ryml::ConstNodeRef node,
127 const BundledModels& registry,
128 ModelConfig& config)
129{
130 std::string path_str;
131 if (extract(node, "path", path_str)) {
132 config.path = registry.resolve(path_str);
133 return "";
134 }
135
136 std::string family;
137 std::string size;
138 std::string quant;
139 const bool hf = extract(node, "family", family);
140 const bool hs = extract(node, "size", size);
141 const bool hq = extract(node, "quant", quant);
142 if (!hf && !hs && !hq) { return ""; } // no path, no selector
143
144 // Single-exit accumulator (knots returns gate ≤ 3).
145 std::string err;
146 if (!(hf && hs && hq)) {
147 err = "model selector requires all of family/size/quant "
148 "(or use path:)";
149 } else if (const std::string key = registry.find_by(family, size, quant);
150 key.empty()) {
151 err = "no bundled model matches family=" + family
152 + " size=" + size + " quant=" + quant;
153 } else {
154 config.path = registry.resolve(key);
155 }
156 return err;
157}
158
175static void parse_gpu_layers(ryml::ConstNodeRef node, ModelConfig& config) {
176 std::string raw;
177 if (extract(node, "gpu_layers", raw) && raw == "auto") {
178 config.gpu_layers_auto = true;
179 return;
180 }
181 extract(node, "gpu_layers", config.gpu_layers);
182}
183
195static std::string parse_model_config(
196 ryml::ConstNodeRef node,
197 const BundledModels& registry,
198 ModelConfig& config)
199{
200 const std::string err = resolve_model_path(node, registry, config);
201 if (!err.empty()) { return err; }
202
203 extract(node, "adapter", config.adapter);
204 extract(node, "context_length", config.context_length);
206 extract(node, "keep_warm", config.keep_warm);
207 extract(node, "use_mlock", config.use_mlock);
208 extract(node, "reasoning_budget", config.reasoning_budget);
210 extract_string_list_opt(node, "allowed_tools", config.allowed_tools);
211
212 /* v1.9.11 mmproj wiring at the loader (gh#42, gh#41): read mmproj
213 * here so the YAML key matches the bundled_models.yaml registry
214 * shape and the orchestrator can declare a tier vision-capable. */
215 std::string mmproj_str;
216 if (extract(node, "mmproj", mmproj_str)) {
217 config.mmproj_path = registry.resolve(mmproj_str);
218 }
219
220 return "";
221}
222
239static std::string parse_tier_config(
240 ryml::ConstNodeRef node,
241 const BundledModels& registry,
242 TierConfig& config)
243{
244 auto err = parse_model_config(node, registry, config);
245 if (!err.empty()) {
246 return err;
247 }
248
249 // gh#94 (v2.7.3): per-tier sampler overrides live on TierConfig.
251
252 // gh#108 (v2.9.4): per-tier speculative.mtp override.
254
255 extract_tri_state_path(node, "identity",
256 config.identity, config.identity_disabled);
257
258 std::string grammar_str;
259 if (extract(node, "grammar", grammar_str)) {
260 config.grammar = expand_home(std::filesystem::path(grammar_str));
261 }
262
263 // gh#134 (v2.10.4): opt a mandatory-tool tier into eager tool-call
264 // grammar enforcement. Optional so an unset tier inherits AUTO.
265 bool require_tc = false;
266 if (extract(node, "require_tool_call", require_tc)) {
267 config.require_tool_call = require_tc;
268 }
269
270 std::string auto_chain_str;
271 if (extract(node, "auto_chain", auto_chain_str)) {
272 config.auto_chain = auto_chain_str;
273 }
274
275 bool routable_val = false;
276 if (extract(node, "routable", routable_val)) {
277 config.routable = routable_val;
278 }
279
280 // gh#162 (v2.13.0): a tier may declare that a contextless delegation
281 // to it is refused at the tool boundary.
282 extract(node, "requires_context", config.requires_context);
283
284 /* gh#41 v2.1.8: tier capabilities. Missing key → ["text"] so
285 * every pre-v2.1.8 tier config remains valid. Configs that
286 * declare capabilities explicitly must include "text" themselves
287 * if the tier still serves text — we don't auto-inject. */
288 if (!extract_string_list(node, "capabilities", config.capabilities)) {
289 config.capabilities = {"text"};
290 }
291
292 return "";
293}
294
305static std::string parse_models_config(
306 ryml::ConstNodeRef node,
307 const BundledModels& registry,
308 ModelsConfig& config)
309{
310 extract(node, "default", config.default_tier);
311 // gh#157 (v2.13.0): opt-in lazy load of the default tier.
312 extract(node, "defer_load", config.defer_load);
313
314 if (node.has_child("router")) {
315 config.router.emplace();
316 auto err = parse_model_config(node["router"], registry,
317 *config.router);
318 if (!err.empty()) {
319 return "models.router: " + err;
320 }
321 }
322
323 for (auto child : node) {
324 std::string key = to_string(child.key());
325 if (key == "default" || key == "router" || key == "defer_load") {
326 continue;
327 }
328 if (!child.is_map()) {
329 continue;
330 }
331
333 if (config.tiers.count(key) > 0) {
334 tier = config.tiers[key];
335 }
336 auto err = parse_tier_config(child, registry, tier);
337 if (!err.empty()) {
338 return "models." + key + ": " + err;
339 }
340 config.tiers[key] = std::move(tier);
341 }
342
343 return "";
344}
345
354static std::string parse_routing_config(
355 ryml::ConstNodeRef node,
356 RoutingConfig& config)
357{
358 extract(node, "enabled", config.enabled);
359 extract(node, "fallback_tier", config.fallback_tier);
360
361 std::string class_prompt;
362 if (extract(node, "classification_prompt", class_prompt)) {
363 config.classification_prompt = class_prompt;
364 }
365
366 extract_string_map(node, "tier_map", config.tier_map);
367 extract_string_list_map(node, "handoff_rules", config.handoff_rules);
368
369 return "";
370}
371
380static std::string parse_compaction_config(
381 ryml::ConstNodeRef node,
382 CompactionConfig& config)
383{
384 extract(node, "enabled", config.enabled);
385 extract(node, "threshold_percent", config.threshold_percent);
386 extract(node, "preserve_recent_turns", config.preserve_recent_turns);
387 extract(node, "summary_max_tokens", config.summary_max_tokens);
388 extract(node, "notify_user", config.notify_user);
389 extract(node, "save_full_history", config.save_full_history);
390 extract(node, "tool_result_ttl", config.tool_result_ttl);
391 extract(node, "warning_threshold_percent",
392 config.warning_threshold_percent);
393 return "";
394}
395
410static std::string parse_delegation_config(
411 ryml::ConstNodeRef node,
412 DelegationConfig& config)
413{
414 std::string mode;
415 extract(node, "isolation", mode);
416 if (mode.empty() || mode == "none") {
418 } else if (mode == "sandbox") {
420 } else {
421 return "delegation.isolation: unknown mode '" + mode +
422 "' (expected 'none' or 'sandbox')";
423 }
424 return "";
425}
426
435static std::string parse_permissions_config(
436 ryml::ConstNodeRef node,
437 PermissionsConfig& config)
438{
439 extract_string_list(node, "allow", config.allow);
440 extract_string_list(node, "deny", config.deny);
441 extract(node, "auto_approve", config.auto_approve);
442 return "";
443}
444
461static std::string parse_outside_root_access(
462 ryml::ConstNodeRef node,
463 FilesystemConfig& config)
464{
465 std::string raw;
466 if (!extract(node, "allow_outside_root", raw)) { return ""; }
467 static const std::vector<std::string> yes = {
468 "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON", "1"};
469 static const std::vector<std::string> no = {
470 "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF",
471 "0"};
472 std::string err;
473 if (std::find(yes.begin(), yes.end(), raw) != yes.end()) {
474 config.allow_outside_root = OutsideRootAccess::allow;
475 } else if (std::find(no.begin(), no.end(), raw) != no.end()) {
476 config.allow_outside_root = OutsideRootAccess::refuse;
477 } else if (raw == "optional") {
478 config.allow_outside_root = OutsideRootAccess::optional;
479 } else {
480 err = "mcp.filesystem.allow_outside_root: unknown value '" + raw
481 + "' (expected true, false or optional)";
482 }
483 return err;
484}
485
502static std::string parse_outside_root_list(
503 ryml::ConstNodeRef node,
504 c4::csubstr key,
505 std::vector<std::filesystem::path>& out)
506{
507 std::vector<std::string> raw;
508 if (!extract_string_list(node, key, raw)) { return ""; }
509 std::vector<std::filesystem::path> paths;
510 std::string err;
511 for (const auto& entry : raw) {
512 auto p = expand_home(std::filesystem::path(entry));
513 if (!p.is_absolute() && err.empty()) {
514 err = "mcp.filesystem." + to_string(key) + ": entry '" + entry
515 + "' is not an absolute path";
516 }
517 paths.push_back(std::move(p));
518 }
519 if (err.empty()) { out = std::move(paths); }
520 return err;
521}
522
533static std::string parse_filesystem_config(
534 ryml::ConstNodeRef node,
535 FilesystemConfig& config)
536{
537 extract(node, "diagnostics_on_edit", config.diagnostics_on_edit);
538 extract(node, "fail_on_errors", config.fail_on_errors);
539 extract(node, "diagnostics_timeout", config.diagnostics_timeout);
540 extract(node, "max_read_context_pct", config.max_read_context_pct);
541 extract(node, "max_walk_entries", config.max_walk_entries);
542
543 int max_read = 0;
544 if (extract(node, "max_read_bytes", max_read)) {
545 config.max_read_bytes = max_read;
546 }
547
549 if (err.empty()) {
550 err = parse_outside_root_list(node, "outside_root_allow",
551 config.outside_root_allow);
552 }
553 if (err.empty()) {
554 err = parse_outside_root_list(node, "outside_root_deny",
555 config.outside_root_deny);
556 }
557 return err;
558}
559
575 ryml::ConstNodeRef node,
576 ExternalMCPConfig& config)
577{
578 if (!node.is_map()) {
579 return;
580 }
581 for (auto child : node) {
582 if (!child.has_key() || !child.has_val() || child.val_is_null()) {
583 continue;
584 }
585 config.tool_descriptions[to_string(child.key())] =
586 to_string(child.val());
587 }
588}
589
598static std::string parse_external_mcp_config(
599 ryml::ConstNodeRef node,
600 ExternalMCPConfig& config)
601{
602 extract(node, "enabled", config.enabled);
603 extract(node, "rate_limit", config.rate_limit);
604 extract(node, "ask_streaming", config.ask_streaming);
605 // gh#204 (v2.14.0): range-checked where it is used — the listener fails
606 // configure loudly on a bad port rather than this parse dropping it.
607 extract(node, "http_port", config.http_port);
608 // gh#145: consumer-owned identity. Both default to "entropic", so an
609 // unset config advertises exactly the pre-2.12.0 names.
610 extract(node, "tool_prefix", config.tool_prefix);
611 extract(node, "server_name", config.server_name);
612
613 if (node.is_map() && node.has_child("socket_path")
614 && !node["socket_path"].val_is_null()) {
615 std::filesystem::path tmp;
616 extract_path(node, "socket_path", tmp);
617 config.socket_path = tmp;
618 }
619
620 if (node.is_map() && node.has_child("tool_descriptions")) {
621 parse_tool_descriptions(node["tool_descriptions"], config);
622 }
623
624 return "";
625}
626
642static std::string parse_bash_config(
643 ryml::ConstNodeRef node,
644 BashConfig& config)
645{
646 int timeout = config.timeout_seconds;
647 std::string err;
648 if (extract(node, "timeout_seconds", timeout) && timeout < 1) {
649 err = "mcp.bash.timeout_seconds: must be a positive number of "
650 "seconds, got " + std::to_string(timeout);
651 } else {
652 config.timeout_seconds = timeout;
653 }
654 return err;
655}
656
666 extract(node, "command", entry.command);
667 extract(node, "url", entry.url);
668 extract_string_list(node, "args", entry.args);
669 if (node.has_child("env") && node["env"].is_map()) {
670 for (auto kv : node["env"]) {
671 if (kv.has_key() && kv.has_val()) {
672 entry.env[to_string(kv.key())] = to_string(kv.val());
673 }
674 }
675 }
676 return entry;
677}
678
692static std::string parse_external_servers(ryml::ConstNodeRef node,
693 MCPConfig& config) {
694 if (!node.has_child("external_servers")) { return ""; }
695 auto servers = node["external_servers"];
696 if (!servers.is_map()) {
697 return "mcp.external_servers: must be a map of name -> server";
698 }
699 std::string err;
700 for (auto child : servers) {
701 const std::string name = to_string(child.key());
703 if (entry.command.empty() && entry.url.empty()) {
704 err = "mcp.external_servers." + name + ": needs `command` or `url`";
705 break;
706 }
707 config.external_servers[name] = std::move(entry);
708 }
709 return err;
710}
711
719static void parse_mcpjson_enablement(ryml::ConstNodeRef node,
720 MCPConfig& config) {
721 extract_string_list(node, "enabled_mcpjson_servers",
722 config.enabled_mcpjson_servers);
723 extract_string_list(node, "disabled_mcpjson_servers",
724 config.disabled_mcpjson_servers);
725 extract(node, "enable_all_project_mcp_servers",
726 config.enable_all_project_mcp_servers);
727}
728
740static std::string validate_server_timeout(const MCPConfig& config) {
741 std::string err;
742 if (config.server_timeout_seconds < 1) {
743 err = "mcp.server_timeout_seconds: must be a positive number of "
744 "seconds, got " + std::to_string(config.server_timeout_seconds);
745 }
746 return err;
747}
748
762static std::string parse_mcp_sections(
763 ryml::ConstNodeRef node,
764 MCPConfig& config)
765{
766 std::string err;
767 if (node.has_child("filesystem")) {
768 err = parse_filesystem_config(node["filesystem"], config.filesystem);
769 }
770 if (err.empty() && node.has_child("bash")) {
771 err = parse_bash_config(node["bash"], config.bash);
772 }
773 if (node.has_child("external")) {
774 parse_external_mcp_config(node["external"], config.external);
775 }
777 if (err.empty()) { err = parse_external_servers(node, config); }
778 return err;
779}
780
791static std::string parse_mcp_config(
792 ryml::ConstNodeRef node,
793 MCPConfig& config)
794{
795 extract(node, "enable_entropic", config.enable_entropic);
796 extract(node, "enable_filesystem", config.enable_filesystem);
797 extract(node, "enable_bash", config.enable_bash);
798 extract(node, "enable_git", config.enable_git);
799 extract(node, "enable_diagnostics", config.enable_diagnostics);
800 extract(node, "enable_web", config.enable_web);
801 extract(node, "server_timeout_seconds", config.server_timeout_seconds);
802 extract(node, "working_dir", config.working_dir);
803
804 // gh#133 (v2.10.1): mcp.plugins — dlopen-loaded in-process server .so
805 // paths. expand_home so `~/...` works like every other path key.
806 std::vector<std::string> plugin_paths;
807 if (extract_string_list(node, "plugins", plugin_paths)) {
808 config.plugins.clear();
809 for (const auto& p : plugin_paths) {
810 config.plugins.push_back(expand_home(std::filesystem::path(p)));
811 }
812 }
814 return err.empty() ? validate_server_timeout(config) : err;
815}
816
825static std::string parse_generation_config(
826 ryml::ConstNodeRef node,
827 GenerationConfig& config)
828{
829 extract(node, "max_tokens", config.max_tokens);
830 extract(node, "default_temperature", config.default_temperature);
831 extract(node, "default_top_p", config.default_top_p);
832 // gh#80 (v2.5.0): thinking-budget knobs (validated in build_loop_config).
833 extract(node, "budget_mode", config.budget_mode);
834 extract(node, "budget_limit", config.budget_limit);
835 // gh#110 (v2.9.6): batch vs streaming agent-loop delivery mode.
836 extract(node, "stream_output", config.stream_output);
837 return "";
838}
839
848static std::string parse_lsp_config(
849 ryml::ConstNodeRef node,
850 LSPConfig& config)
851{
852 extract(node, "enabled", config.enabled);
853 extract(node, "python_enabled", config.python_enabled);
854 extract(node, "c_enabled", config.c_enabled);
855 return "";
856}
857
866static std::string parse_prompt_cache_config(
867 ryml::ConstNodeRef node,
868 PromptCacheConfig& config)
869{
870 extract(node, "enabled", config.enabled);
871 extract(node, "log_hits", config.log_hits);
872
873 int max_bytes_int = 0;
874 if (extract(node, "max_bytes", max_bytes_int)) {
875 config.max_bytes = static_cast<size_t>(max_bytes_int);
876 }
877
878 return "";
879}
880
889 ryml::ConstNodeRef node,
891{
892 extract(node, "enabled", config.enabled);
893 extract(node, "max_revisions", config.max_revisions);
894 extract(node, "max_critique_tokens", config.max_critique_tokens);
895 extract(node, "temperature", config.temperature);
896 extract(node, "enable_thinking", config.enable_thinking);
897 extract(node, "priority", config.priority);
898 extract(node, "grammar_key", config.grammar_key);
899 extract_string_list(node, "skip_tiers", config.skip_tiers);
900}
901
921 ryml::ConstNodeRef node,
922 const BundledModels& registry,
923 SpeculativeConfig& config)
924{
925 extract(node, "enabled", config.enabled);
926 extract(node, "n_draft", config.n_draft);
927 extract(node, "mtp", config.mtp); // gh#106: drive the MTP head path
928 // Nested ModelConfig — every llama.cpp knob is consumer-tunable
929 // via `inference.speculative.draft.<field>`. Defaults come from
930 // `make_default_draft_model_config()` (gpu_layers=0, flash_attn=
931 // false, context_length=8192, n_threads=4). (v2.1.11)
932 if (node.has_child("draft")) {
933 auto err = parse_model_config(
934 node["draft"], registry, config.draft);
935 if (!err.empty()) {
936 s_log->warn("inference.speculative.draft parse: {}", err);
937 }
938 }
939}
940
958 ryml::ConstNodeRef root,
959 const BundledModels& registry,
960 ParsedConfig& config)
961{
962 if (!root.has_child("inference")) { return; }
963 auto inf = root["inference"];
964 if (inf.has_child("prompt_cache"))
965 parse_prompt_cache_config(inf["prompt_cache"], config.prompt_cache);
966 if (inf.has_child("speculative"))
967 parse_speculative_config(inf["speculative"], registry,
968 config.inference.speculative);
969}
970
981static std::string parse_optional_subsections(
982 ryml::ConstNodeRef root,
983 const BundledModels& registry,
984 ParsedConfig& config)
985{
986 std::string err;
987 if (root.has_child("generation"))
988 parse_generation_config(root["generation"], config.generation);
989 if (root.has_child("permissions"))
990 parse_permissions_config(root["permissions"], config.permissions);
991 if (root.has_child("mcp"))
992 err = parse_mcp_config(root["mcp"], config.mcp);
993 if (root.has_child("compaction"))
994 parse_compaction_config(root["compaction"], config.compaction);
995 if (root.has_child("lsp"))
996 parse_lsp_config(root["lsp"], config.lsp);
997 parse_inference_subsections(root, registry, config);
998 if (root.has_child("constitutional_validation"))
1000 root["constitutional_validation"],
1001 config.constitutional_validation);
1002 return err;
1003}
1004
1012static void extract_scalar_fields(ryml::ConstNodeRef root,
1013 ParsedConfig& config)
1014{
1015 extract(root, "log_level", config.log_level);
1016 extract(root, "inject_model_context", config.inject_model_context);
1017 // gh#158 (v2.13.0): opt-in per-session-key run concurrency.
1018 extract(root, "concurrent_sessions", config.concurrent_sessions);
1019 extract(root, "vram_reserve_mb", config.vram_reserve_mb);
1020 extract_path(root, "config_dir", config.config_dir);
1021 extract_path(root, "log_dir", config.log_dir);
1022 extract(root, "ggml_logging", config.ggml_logging);
1023 extract_path(root, "llama_log_path", config.llama_log_path); // gh#23 v2.3.24
1024 extract(root, "console_logging", config.console_logging);
1025
1026 extract_tri_state_path(root, "constitution",
1027 config.constitution, config.constitution_disabled);
1028 /* gh#141 (v2.11.0): app_context accepts an object carrying the text
1029 * inline, for consumers that hold it in memory and cannot write it to
1030 * disk. Checked BEFORE the tri-state path parse, because the object form
1031 * has no meaning as a path and would otherwise be stringified into one. */
1032 if (!extract_inline_content(root, "app_context",
1033 config.app_context_content)) {
1034 extract_tri_state_path(root, "app_context",
1035 config.app_context, config.app_context_disabled);
1036 }
1037 // gh#179 (v2.14.0): per-workspace app_context file, opt-in.
1038 extract_path(root, "workspace_app_context", config.workspace_app_context);
1039}
1040
1053static std::string validate_workspace_app_context(const ParsedConfig& config) {
1054 const auto& p = config.workspace_app_context;
1055 std::string err;
1056 bool escapes = false;
1057 for (const auto& part : p) { escapes = escapes || part == ".."; }
1058 if (!p.empty() && (p.is_absolute() || escapes)) {
1059 err = "workspace_app_context must be a path relative to the workspace "
1060 "root with no '..' (got '" + p.string() + "')";
1061 }
1062 return err;
1063}
1064
1080static std::string parse_optional_sections(
1081 ryml::ConstNodeRef root,
1082 const BundledModels& registry,
1083 ParsedConfig& config)
1084{
1085 auto err = parse_optional_subsections(root, registry, config);
1087 if (err.empty()) { err = validate_workspace_app_context(config); } // gh#179
1088 if (err.empty() && root.has_child("delegation")) {
1089 err = parse_delegation_config(root["delegation"],
1090 config.delegation);
1091 }
1092 return err;
1093}
1094
1104static std::string parse_top_sections(
1105 ryml::ConstNodeRef root,
1106 const BundledModels& registry,
1107 ParsedConfig& config)
1108{
1109 std::string err;
1110 if (root.has_child("models")) {
1111 err = parse_models_config(root["models"], registry, config.models);
1112 }
1113 if (err.empty() && root.has_child("routing")) {
1114 err = parse_routing_config(root["routing"], config.routing);
1115 }
1116 if (err.empty()) {
1117 err = parse_optional_sections(root, registry, config);
1118 }
1119 return err;
1120}
1121
1139 const std::filesystem::path& path,
1140 const BundledModels& registry,
1141 ParsedConfig& config)
1142{
1143 auto content = read_file(path);
1144 if (content.empty()) {
1145 return "cannot read config file: " + path.string();
1146 }
1147
1148 ryml::Tree tree = ryml::parse_in_arena(
1149 ryml::to_csubstr(path.string()),
1150 ryml::to_csubstr(content));
1151 ryml::ConstNodeRef root = tree.rootref();
1152 if (!root.is_map()) {
1153 return "config file root is not a YAML mapping: " + path.string();
1154 }
1155
1156 return parse_top_sections(root, registry, config);
1157}
1158
1168static std::string load_bundled_default(
1169 const std::filesystem::path& global_path,
1170 const BundledModels& registry,
1171 ParsedConfig& config)
1172{
1174 auto bundled = data_dir / "default_config.yaml";
1175 if (!std::filesystem::exists(bundled)) {
1176 s_log->warn("No config found (checked global, project, bundled)");
1177 return "";
1178 }
1179 s_log->info("No user config — loading bundled default: {}",
1180 bundled.string());
1181 auto err = parse_config_file(bundled, registry, config);
1182 if (!err.empty()) { return "bundled default: " + err; }
1183
1184 // Auto-create global config so this only happens once. gh#109
1185 // follow-up: load_layered() intentionally passes an empty global_path
1186 // (it doesn't want the auto-create side effect) — exists("") is
1187 // always false, so without this guard every fresh-install call
1188 // through load_layered (no ~/.entropic/config.yaml yet) threw
1189 // filesystem_error trying to create_directories("").
1190 if (!global_path.empty() && !std::filesystem::exists(global_path)) {
1191 auto parent = global_path.parent_path();
1192 std::filesystem::create_directories(parent);
1193 std::filesystem::copy_file(bundled, global_path);
1194 s_log->info("Created {}", global_path.string());
1195 }
1196 return "";
1197}
1198
1210static std::string load_config_layers(
1211 const std::filesystem::path& global_path,
1212 const std::filesystem::path& project_path,
1213 const BundledModels& registry,
1214 ParsedConfig& config)
1215{
1216 bool have_config = false;
1217 std::string err;
1218
1219 if (std::filesystem::exists(global_path)) {
1220 s_log->info("Loading global config: {}", global_path.string());
1222 if (!err.empty()) { return "global config: " + err; }
1223 have_config = true;
1224 }
1225
1226 if (err.empty() && std::filesystem::exists(project_path)) {
1227 s_log->info("Loading project config: {}", project_path.string());
1228 err = parse_config_file(project_path, registry, config);
1229 if (!err.empty()) { return "project config: " + err; }
1230 have_config = true;
1231 }
1232
1233 return have_config ? ""
1235}
1236
1249std::string load_config(
1250 const std::filesystem::path& global_path,
1251 const std::filesystem::path& project_path,
1252 const BundledModels& registry,
1253 ParsedConfig& config)
1254{
1255 auto err = load_config_layers(global_path, project_path, registry, config);
1256 if (!err.empty()) { return err; }
1257
1259
1260 std::vector<std::string> warnings;
1262 for (const auto& w : warnings) { s_log->warn("{}", w); }
1263 return err;
1264}
1265
1277 const std::filesystem::path& path,
1278 const BundledModels& registry,
1279 ParsedConfig& config)
1280{
1281 auto err = parse_config_file(path, registry, config);
1282 if (!err.empty()) {
1283 return err;
1284 }
1285
1287
1288 std::vector<std::string> warnings;
1290 for (const auto& w : warnings) {
1291 s_log->warn("{}", w);
1292 }
1293 return err;
1294}
1295
1317static std::string load_global_layer(
1318 const BundledModels& registry, ParsedConfig& config)
1319{
1320 const char* home = getenv("HOME");
1321 std::string err;
1322 if (home) {
1323 auto path = std::filesystem::path(home) / ".entropic" / "config.yaml";
1324 if (std::filesystem::exists(path)) {
1325 s_log->info("Loading global config: {}", path.string());
1326 err = parse_config_file(path, registry, config);
1327 if (!err.empty()) {
1328 err = "global config: " + err;
1329 }
1330 }
1331 }
1332 return err;
1333}
1334
1353static std::filesystem::path resolve_consumer_defaults(
1354 const std::filesystem::path& consumer_defaults)
1355{
1356 // Input already usable (empty, absolute, or found at CWD) → passthrough.
1357 if (consumer_defaults.empty()
1358 || consumer_defaults.is_absolute()
1359 || std::filesystem::exists(consumer_defaults)) {
1360 return consumer_defaults;
1361 }
1362 // Fall back to <install-prefix>/share/entropic/<filename>. Uses
1363 // the same librentropic.so location trick — dladdr on any address
1364 // in this translation unit resolves to the .so's on-disk path.
1365 std::filesystem::path result = consumer_defaults;
1366 Dl_info info = {};
1367 if (dladdr(reinterpret_cast<void*>(&resolve_consumer_defaults), &info) != 0
1368 && info.dli_fname != nullptr) {
1369 std::error_code ec;
1370 auto lib_path = std::filesystem::absolute(info.dli_fname, ec);
1371 if (!ec) {
1372 auto candidate = lib_path.parent_path().parent_path()
1373 / "share" / "entropic" / consumer_defaults.filename();
1374 if (std::filesystem::exists(candidate)) {
1375 result = candidate;
1376 }
1377 }
1378 }
1379 return result;
1380}
1381
1395static bool yaml_has_key(const std::filesystem::path& path,
1396 const char* key) {
1397 auto content = read_file(path);
1398 if (content.empty()) { return false; }
1399 auto tree = ryml::parse_in_arena(
1400 ryml::to_csubstr(path.string()),
1401 ryml::to_csubstr(content));
1402 auto root = tree.rootref();
1403 return root.is_map() && root.has_child(ryml::to_csubstr(key));
1404}
1405
1423 const std::filesystem::path& consumer_defaults_in,
1424 const BundledModels& registry, ParsedConfig& config)
1425{
1427 if (consumer_defaults.empty() || !std::filesystem::exists(consumer_defaults)) {
1428 return;
1429 }
1430 s_log->info("Loading consumer defaults: {}", consumer_defaults.string());
1431
1432 // Replace semantics: if consumer defines models or routing,
1433 // clear existing tiers/routing so global ones don't leak through.
1434 if (yaml_has_key(consumer_defaults, "models")) {
1435 s_log->info("Consumer defines models: — replacing global tiers");
1436 config.models.tiers.clear();
1437 config.models.router.reset();
1438 config.models.default_tier.clear();
1439 }
1440 if (yaml_has_key(consumer_defaults, "routing")) {
1441 config.routing = RoutingConfig{};
1442 }
1443
1444 auto err = parse_config_file(consumer_defaults, registry, config);
1445 if (!err.empty()) {
1446 s_log->warn("Consumer defaults failed: {}", err);
1447 }
1448}
1449
1468static std::string load_project_layer(
1469 const std::filesystem::path& project_dir,
1470 const BundledModels& registry, ParsedConfig& config)
1471{
1472 std::string err;
1473 if (!project_dir.empty()) {
1474 auto path = project_dir / "config.local.yaml";
1475 if (std::filesystem::exists(path)) {
1476 s_log->info("Loading project config: {}", path.string());
1477 if (yaml_has_key(path, "models")) {
1478 s_log->info("Project defines models: — replacing prior tiers");
1479 config.models.tiers.clear();
1480 config.models.router.reset();
1481 config.models.default_tier.clear();
1482 }
1483 if (yaml_has_key(path, "routing")) {
1484 config.routing = RoutingConfig{};
1485 }
1486 err = parse_config_file(path, registry, config);
1487 if (!err.empty()) {
1488 err = "project config: " + err;
1489 }
1490 }
1491 }
1492 return err;
1493}
1494
1523std::string load_layered(
1524 const std::filesystem::path& project_dir,
1525 const std::filesystem::path& consumer_defaults,
1526 const BundledModels& registry,
1527 ParsedConfig& config)
1528{
1529 auto err = load_global_layer(registry, config);
1530 if (err.empty()) {
1532 if (!project_dir.empty() && config.log_dir.empty()) {
1533 config.log_dir = project_dir;
1534 }
1535 err = load_project_layer(project_dir, registry, config);
1536 }
1537 if (err.empty() && config.models.tiers.empty()) {
1538 // v2.11.1: parse into a SCRATCH config and transplant only the model
1539 // block. Previously this re-parsed the whole bundled default straight
1540 // over `config`, AFTER the project layer had already run — so a
1541 // fresh-install machine silently lost every setting an explicit layer
1542 // had established. data/default_config.yaml sets `mcp.enable_bash:
1543 // true`, so a project asking for false got true back, and REQ-CFG-001's
1544 // most-specific-layer-wins rule was violated by the layer that is
1545 // supposed to be LEAST specific.
1546 //
1547 // Invisible on any machine whose ~/.entropic/config.yaml declares tiers
1548 // (the fallback never fires there) and hit on every CI runner and every
1549 // fresh install. The condition that triggers it is a MISSING MODEL SET,
1550 // so the model set is the only thing it may supply.
1552 fallback.config_dir = config.config_dir;
1553 err = load_bundled_default(std::filesystem::path{}, registry, fallback);
1554 if (err.empty()) {
1555 config.models = std::move(fallback.models);
1556 }
1557 }
1558 if (err.empty()) {
1559 // v2.14.0: .mcp.json is no longer merged here. This path only ever
1560 // fed `external_servers`, which nothing connected; discovery now runs
1561 // once, in the MCP layer, behind the enablement gate (gh#199).
1563 }
1564 return err;
1565}
1566
1581static std::string parse_config_string(
1582 const std::string& content,
1583 const BundledModels& registry,
1584 ParsedConfig& config)
1585{
1586 if (content.empty()) {
1587 return "config string is empty";
1588 }
1589
1590 ryml::Tree tree = ryml::parse_in_arena(
1591 ryml::to_csubstr("<json>"),
1592 ryml::to_csubstr(content));
1593 auto root = tree.rootref();
1594 if (!root.is_map()) {
1595 return "config string root is not a mapping";
1596 }
1597
1598 std::string err;
1599 if (root.has_child("models")) {
1600 err = parse_models_config(root["models"], registry, config.models);
1601 }
1602 if (err.empty() && root.has_child("routing")) {
1603 err = parse_routing_config(root["routing"], config.routing);
1604 }
1605 if (err.empty()) {
1606 err = parse_optional_sections(root, registry, config);
1607 }
1608 return err;
1609}
1610
1622 const std::string& content,
1623 const BundledModels& registry,
1624 ParsedConfig& config)
1625{
1626 auto err = parse_config_string(content, registry, config);
1627 if (!err.empty()) {
1628 return err;
1629 }
1630
1632
1633 std::vector<std::string> warnings;
1635 for (const auto& w : warnings) {
1636 s_log->warn("{}", w);
1637 }
1638 return err;
1639}
1640
1641} // namespace entropic::config
Bundled model registry loaded from bundled_models.yaml.
std::filesystem::path resolve(const std::string &value) const
Resolve a model reference to a filesystem path.
std::string find_by(const std::string &family, const std::string &size_label, const std::string &quant) const
Look up a registry key by (family, size, quant) (gh#62).
std::string err
size_exceeded, when set
const ReadWindow & w
Requested window.
static std::string validate_server_timeout(const MCPConfig &config)
Refuse a server_timeout_seconds that cannot bound anything (gh#175).
Definition loader.cpp:740
static void extract_scalar_fields(ryml::ConstNodeRef root, ParsedConfig &config)
Extract the top-level scalar/path config fields.
Definition loader.cpp:1012
static std::string validate_workspace_app_context(const ParsedConfig &config)
Refuse a workspace_app_context that could leave a workspace root.
Definition loader.cpp:1053
static std::string parse_outside_root_access(ryml::ConstNodeRef node, FilesystemConfig &config)
Parse mcp.filesystem.allow_outside_root (v2.13.0 tri-state).
Definition loader.cpp:461
static std::string parse_config_string(const std::string &content, const BundledModels &registry, ParsedConfig &config)
Parse a config string (YAML or JSON) and overlay onto config.
Definition loader.cpp:1581
static std::string load_project_layer(const std::filesystem::path &project_dir, const BundledModels &registry, ParsedConfig &config)
Load the project-local layer if present.
Definition loader.cpp:1468
static ExternalServerEntry parse_external_server_entry(ryml::ConstNodeRef node)
Parse one declared external server (v2.14.0).
Definition loader.cpp:664
static std::string parse_external_servers(ryml::ConstNodeRef node, MCPConfig &config)
Parse mcp.external_servers (v2.14.0).
Definition loader.cpp:692
static std::string parse_external_mcp_config(ryml::ConstNodeRef node, ExternalMCPConfig &config)
Parse the external MCP section from a YAML node.
Definition loader.cpp:598
static void parse_mcpjson_enablement(ryml::ConstNodeRef node, MCPConfig &config)
Parse the .mcp.json enablement keys (v2.14.0, gh#199).
Definition loader.cpp:719
static bool yaml_has_key(const std::filesystem::path &path, const char *key)
Load the consumer/app defaults layer if present (non-fatal).
Definition loader.cpp:1395
static std::string load_global_layer(const BundledModels &registry, ParsedConfig &config)
Discover and parse .mcp.json from the project directory.
Definition loader.cpp:1317
static std::string parse_generation_config(ryml::ConstNodeRef node, GenerationConfig &config)
Parse the generation section from a YAML node.
Definition loader.cpp:825
static void parse_gpu_layers(ryml::ConstNodeRef node, ModelConfig &config)
Parse gpu_layers, which may be a count or the word auto (gh#148).
Definition loader.cpp:175
static std::string parse_permissions_config(ryml::ConstNodeRef node, PermissionsConfig &config)
Parse the permissions section from a YAML node.
Definition loader.cpp:435
static void parse_inference_subsections(ryml::ConstNodeRef root, const BundledModels &registry, ParsedConfig &config)
Parse the nested optional config sub-sections.
Definition loader.cpp:957
static void load_consumer_layer(const std::filesystem::path &consumer_defaults_in, const BundledModels &registry, ParsedConfig &config)
Load the consumer-defaults layer.
Definition loader.cpp:1422
static std::string parse_delegation_config(ryml::ConstNodeRef node, DelegationConfig &config)
Parse the delegation section from a YAML node (gh#160).
Definition loader.cpp:410
static std::filesystem::path resolve_consumer_defaults(const std::filesystem::path &consumer_defaults)
Resolve a relative consumer_defaults path against install prefix.
Definition loader.cpp:1353
static void parse_speculative_config(ryml::ConstNodeRef node, const BundledModels &registry, SpeculativeConfig &config)
Parse inference.speculative YAML node into SpeculativeConfig.
Definition loader.cpp:920
static void parse_tool_descriptions(ryml::ConstNodeRef node, ExternalMCPConfig &config)
Parse mcp.external.tool_descriptions into the override map.
Definition loader.cpp:574
static void parse_constitutional_validation_config(ryml::ConstNodeRef node, ConstitutionalValidationConfig &config)
Parse constitutional_validation section.
Definition loader.cpp:888
static std::string parse_mcp_config(ryml::ConstNodeRef node, MCPConfig &config)
Parse the MCP section from a YAML node.
Definition loader.cpp:791
static std::string load_bundled_default(const std::filesystem::path &global_path, const BundledModels &registry, ParsedConfig &config)
Load bundled default config when no user config exists.
Definition loader.cpp:1168
static std::string parse_optional_sections(ryml::ConstNodeRef root, const BundledModels &registry, ParsedConfig &config)
Parse the optional config sections.
Definition loader.cpp:1080
static void parse_tier_speculative_override(ryml::ConstNodeRef node, TierConfig &config)
Parse the per-tier speculative.mtp override (gh#108, v2.9.4).
Definition loader.cpp:103
static std::string parse_filesystem_config(ryml::ConstNodeRef node, FilesystemConfig &config)
Parse the filesystem section from a YAML node.
Definition loader.cpp:533
static std::string parse_compaction_config(ryml::ConstNodeRef node, CompactionConfig &config)
Parse the compaction section from a YAML node.
Definition loader.cpp:380
static std::string parse_mcp_sections(ryml::ConstNodeRef node, MCPConfig &config)
Parse the MCP section's per-server sub-sections (v2.13.0).
Definition loader.cpp:762
static std::string parse_bash_config(ryml::ConstNodeRef node, BashConfig &config)
Parse mcp.bash (v2.13.0).
Definition loader.cpp:642
static std::string parse_top_sections(ryml::ConstNodeRef root, const BundledModels &registry, ParsedConfig &config)
Parse the top-level models/routing/optional sections.
Definition loader.cpp:1104
static std::string parse_routing_config(ryml::ConstNodeRef node, RoutingConfig &config)
Parse the routing section from a YAML node.
Definition loader.cpp:354
static void parse_sampler_overrides(ryml::ConstNodeRef node, TierConfig &config)
Read per-tier sampler overrides from a YAML model/tier node.
Definition loader.cpp:71
static void parse_model_runtime_knobs(ryml::ConstNodeRef node, ModelConfig &config)
Read the model-runtime knobs (KV cache, batching, threads).
Definition loader.cpp:35
static std::string parse_prompt_cache_config(ryml::ConstNodeRef node, PromptCacheConfig &config)
Parse prompt_cache config from inference YAML section.
Definition loader.cpp:866
static std::string resolve_model_path(ryml::ConstNodeRef node, const BundledModels &registry, ModelConfig &config)
Resolve config.path from an explicit path: or a selector (gh#62).
Definition loader.cpp:125
static std::string parse_model_config(ryml::ConstNodeRef node, const BundledModels &registry, ModelConfig &config)
Parse a ModelConfig from a YAML node.
Definition loader.cpp:195
static std::string parse_outside_root_list(ryml::ConstNodeRef node, c4::csubstr key, std::vector< std::filesystem::path > &out)
Parse one outside-root path list (outside_root_allow/_deny).
Definition loader.cpp:502
static std::string load_config_layers(const std::filesystem::path &global_path, const std::filesystem::path &project_path, const BundledModels &registry, ParsedConfig &config)
Load global, project, and bundled config layers in order.
Definition loader.cpp:1210
static std::string parse_tier_config(ryml::ConstNodeRef node, const BundledModels &registry, TierConfig &config)
Parse a TierConfig from a YAML node.
Definition loader.cpp:239
static std::string parse_models_config(ryml::ConstNodeRef node, const BundledModels &registry, ModelsConfig &config)
Parse the models section from a YAML node.
Definition loader.cpp:305
static std::string parse_optional_subsections(ryml::ConstNodeRef root, const BundledModels &registry, ParsedConfig &config)
Parse the nested optional config sub-sections.
Definition loader.cpp:981
static std::string parse_lsp_config(ryml::ConstNodeRef node, LSPConfig &config)
Parse the LSP section from a YAML node.
Definition loader.cpp:848
Config loader — YAML to C++ structs with validation.
ENTROPIC_EXPORT std::string load_config(const std::filesystem::path &global_path, const std::filesystem::path &project_path, const BundledModels &registry, ParsedConfig &config)
Load config using layered resolution.
Definition loader.cpp:1249
ENTROPIC_EXPORT std::string load_config_from_string(const std::string &content, const BundledModels &registry, ParsedConfig &config)
Load config from a YAML/JSON string (no layering).
Definition loader.cpp:1621
ENTROPIC_EXPORT std::filesystem::path resolve_data_dir(const ParsedConfig &config)
Resolve the bundled data directory.
Definition data_dir.cpp:81
ENTROPIC_EXPORT std::string parse_config_file(const std::filesystem::path &path, const BundledModels &registry, ParsedConfig &config)
Parse a config YAML file and overlay onto existing config.
Definition loader.cpp:1138
ENTROPIC_EXPORT void apply_env_overrides(ParsedConfig &config)
Apply ENTROPIC_* environment variable overrides.
ENTROPIC_EXPORT std::string load_config_from_file(const std::filesystem::path &path, const BundledModels &registry, ParsedConfig &config)
Load config from a single YAML file (no layering).
Definition loader.cpp:1276
ENTROPIC_EXPORT std::string load_layered(const std::filesystem::path &project_dir, const std::filesystem::path &consumer_defaults, const BundledModels &registry, ParsedConfig &config)
Load config with consumer defaults + global + project layers.
Definition loader.cpp:1523
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
@ refuse
false: every escape refused ("Path escapes project root")
@ allow
true: every escape served, no prompt
@ optional
optional: every escape asks the host's path approver
@ tier
The TIER's identity frontmatter grammar: stem (gh#154).
@ none
Child tools share the session's working dir (default)
@ sandbox
Child tools run in a copy; output is a patch.
std::string config
–config FILE
Definition serve.cpp:38
Bash MCP server configuration (v2.13.0).
Definition config.h:923
Auto-compaction configuration.
Definition config.h:1059
Constitutional validation pipeline configuration.
Definition config.h:1177
Delegation behaviour knobs (gh#160, v2.13.0).
Definition config.h:1102
External MCP server configuration (Entropic-as-server).
Definition config.h:856
Configuration for a single external MCP server entry.
Definition config.h:942
std::string command
Stdio command (empty for SSE)
Definition config.h:943
std::vector< std::string > args
Stdio command arguments.
Definition config.h:944
std::string url
SSE endpoint URL (empty for stdio)
Definition config.h:946
std::unordered_map< std::string, std::string > env
Stdio environment variables.
Definition config.h:945
Filesystem MCP server configuration.
Definition config.h:781
Generation parameters configuration (top-level defaults).
Definition config.h:1110
LSP integration configuration.
Definition config.h:1155
MCP server configuration.
Definition config.h:953
Model configuration for a single tier.
Definition config.h:154
Configuration for all models (tiers + router).
Definition config.h:689
Full parsed configuration.
Definition config.h:1338
std::filesystem::path config_dir
Config dir — base for bundled data discovery.
Definition config.h:1412
Tool permission configuration.
Definition config.h:754
Prompt caching configuration.
Definition config.h:348
Configuration for model routing.
Definition config.h:742
Speculative-decoding configuration (inference.speculative.
Definition config.h:1285
Tier-specific model configuration.
Definition config.h:547
Config validation functions.
ENTROPIC_EXPORT std::string validate_config(const ParsedConfig &config, std::vector< std::string > &warnings)
Validate the full ParsedConfig.
Definition validate.cpp:482
std::filesystem::path expand_home(const std::filesystem::path &p)
Expand ~ to home directory in a path.
Definition yaml_util.cpp:61
bool extract_string_list_map(ryml::ConstNodeRef node, c4::csubstr key, std::unordered_map< std::string, std::vector< std::string > > &out)
Extract a map of string to list of strings from a YAML mapping.
bool extract_inline_content(ryml::ConstNodeRef node, c4::csubstr key, std::optional< std::string > &out)
Extract inline text from an object-form value (gh#141).
std::string read_file(const std::filesystem::path &path)
Read a file into a string.
Definition yaml_util.cpp:39
bool extract_path(ryml::ConstNodeRef node, c4::csubstr key, std::filesystem::path &out)
Extract a filesystem path with ~ expansion.
bool extract_string_list(ryml::ConstNodeRef node, c4::csubstr key, std::vector< std::string > &out)
Extract a vector of strings from a YAML sequence node.
bool extract_string_map(ryml::ConstNodeRef node, c4::csubstr key, std::unordered_map< std::string, std::string > &out)
Extract a map of string to string from a YAML mapping node.
bool extract_string_list_opt(ryml::ConstNodeRef node, c4::csubstr key, std::optional< std::vector< std::string > > &out)
Extract an optional vector of strings.
bool extract_tri_state_path(ryml::ConstNodeRef node, c4::csubstr key, std::optional< std::filesystem::path > &out, bool &disabled)
Extract a tri-state path (null = default, false = disabled, string = path).
std::string to_string(c4::csubstr s)
Convert ryml csubstr to std::string.
Definition yaml_util.cpp:27
bool extract(ryml::ConstNodeRef node, c4::csubstr key, std::string &out)
Extract a string value from a YAML node.
Definition yaml_util.cpp:85
ryml extraction helpers for config parsing.