// Copyright (C) 2024-2026 Petr Mironychev // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE #include "AgentLoader.hpp" #include #include #include #include #include #include #include #include #include #include #include #include namespace QodeAssist::Agents { namespace { QJsonValue tomlToJson(const toml::node &node) { if (auto *table = node.as_table()) { QJsonObject obj; for (const auto &[key, value] : *table) { obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value)); } return obj; } if (auto *array = node.as_array()) { QJsonArray arr; for (const auto &item : *array) { arr.append(tomlToJson(item)); } return arr; } if (auto *str = node.as_string()) { return QString::fromStdString(str->get()); } if (auto *integer = node.as_integer()) { return static_cast(integer->get()); } if (auto *floating = node.as_floating_point()) { return floating->get(); } if (auto *boolean = node.as_boolean()) { return boolean->get(); } return QJsonValue::Null; } QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay) { QJsonObject result = base; for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) { const QJsonValue baseVal = result.value(it.key()); const QJsonValue overlayVal = it.value(); if (baseVal.isObject() && overlayVal.isObject()) { result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject()); } else { result[it.key()] = overlayVal; } } return result; } QString readUtf8(const QString &path, QString *error) { QFile f(path); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { if (error) *error = QStringLiteral("Cannot open: %1").arg(path); return {}; } return QString::fromUtf8(f.readAll()); } std::optional parseTomlFile(const QString &path, QString *error) { QString readErr; const QString contents = readUtf8(path, &readErr); if (!readErr.isEmpty()) { if (error) *error = readErr; return std::nullopt; } toml::table tbl; try { tbl = toml::parse(contents.toStdString(), path.toStdString()); } catch (const toml::parse_error &e) { std::ostringstream oss; oss << e; if (error) { *error = QStringLiteral("TOML parse error in %1: %2") .arg(path, QString::fromStdString(oss.str())); } return std::nullopt; } return tomlToJson(tbl).toObject(); } QStringList stringArray(const QJsonValue &v) { QStringList out; if (!v.isArray()) return out; for (const auto &elem : v.toArray()) { if (elem.isString()) out.append(elem.toString()); } return out; } AgentConfig configFromMerged(const QJsonObject &obj) { AgentConfig cfg; cfg.schemaVersion = obj.value("schema_version").toInt(1); cfg.name = obj.value("name").toString(); cfg.description = obj.value("description").toString(); cfg.providerInstance = obj.value("provider_instance").toString(); cfg.model = obj.value("model").toString(); cfg.endpoint = obj.value("endpoint").toString(); cfg.systemPrompt = obj.value("system_prompt").toString(); cfg.enableThinking = obj.value("enable_thinking").toBool(false); cfg.enableTools = obj.value("enable_tools").toBool(false); cfg.cachePrompt = obj.value("cache_prompt").toBool(false); cfg.cacheTtl = obj.value("cache_ttl").toString(); cfg.tags = stringArray(obj.value("tags")); const QJsonObject matchObj = obj.value("match").toObject(); cfg.match.filePatterns = stringArray(matchObj.value("file_patterns")); cfg.match.pathPatterns = stringArray(matchObj.value("path_patterns")); cfg.match.projectNames = stringArray(matchObj.value("project_names")); cfg.extendsName = obj.value("extends").toString(); cfg.abstract = obj.value("abstract").toBool(false); cfg.hidden = obj.value("hidden").toBool(false); cfg.body = obj.value("body").toObject(); return cfg; } struct RawEntry { QJsonObject obj; QString filePath; bool isUserLayer = false; }; constexpr int kMaxExtendsDepth = 32; void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringList &warnings) { static const QSet kTopLevelKeys = { QStringLiteral("schema_version"), QStringLiteral("name"), QStringLiteral("description"), QStringLiteral("provider_instance"), QStringLiteral("model"), QStringLiteral("endpoint"), QStringLiteral("system_prompt"), QStringLiteral("tags"), QStringLiteral("match"), QStringLiteral("enable_thinking"), QStringLiteral("enable_tools"), QStringLiteral("cache_prompt"), QStringLiteral("cache_ttl"), QStringLiteral("body"), QStringLiteral("extends"), QStringLiteral("abstract"), QStringLiteral("hidden")}; static const QSet kMatchKeys = { QStringLiteral("file_patterns"), QStringLiteral("path_patterns"), QStringLiteral("project_names")}; for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) { if (!kTopLevelKeys.contains(it.key())) { warnings.append(QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)") .arg(it.key(), filePath)); } } const QJsonObject matchObj = obj.value("match").toObject(); for (auto it = matchObj.constBegin(); it != matchObj.constEnd(); ++it) { if (!kMatchKeys.contains(it.key())) { warnings.append(QStringLiteral("Unknown key 'match.%1' in %2 — ignored (typo?)") .arg(it.key(), filePath)); } } } void scanDir( const QString &dir, bool isUserLayer, QHash &raw, QStringList &errors, QStringList *warnings) { if (dir.isEmpty()) return; QDir d(dir); if (!d.exists()) return; const QStringList files = d.entryList({"*.toml"}, QDir::Files); for (const QString &fname : files) { const QString fullPath = d.filePath(fname); QString err; auto objOpt = parseTomlFile(fullPath, &err); if (!objOpt) { errors.append(err); continue; } const QString name = objOpt->value("name").toString(); if (name.isEmpty()) { errors.append(QStringLiteral("Agent at %1 has no 'name'").arg(fullPath)); continue; } if (warnings) lintUnknownKeys(*objOpt, fullPath, *warnings); const auto existing = raw.constFind(name); if (existing != raw.constEnd() && existing->isUserLayer != isUserLayer) { errors.append( QStringLiteral("Agent '%1' at %2 has the same name as a bundled agent — " "bundled agents cannot be replaced; rename it and use " "'extends' to build on the bundled one") .arg(name, fullPath)); continue; } if (warnings && existing != raw.constEnd()) { warnings->append( QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins") .arg(name, existing->filePath, fullPath)); } raw.insert(name, {*objOpt, fullPath, isUserLayer}); } } QJsonObject mergeChild(const QJsonObject &parentMerged, const QJsonObject &self, const QString &name) { QJsonObject merged = deepMerge(parentMerged, self); merged["name"] = name; for (const QString &key : {QStringLiteral("abstract"), QStringLiteral("hidden")}) { if (self.contains(key)) merged[key] = self.value(key); else merged.remove(key); } return merged; } QJsonObject resolveExtends( const QString &name, const QHash &raw, QSet &visiting, QStringList &errors, int depth = 0) { if (depth > kMaxExtendsDepth) { errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'") .arg(kMaxExtendsDepth) .arg(name)); return {}; } if (visiting.contains(name)) { errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name)); return {}; } if (!raw.contains(name)) { errors.append(QStringLiteral("Unknown agent '%1'").arg(name)); return {}; } visiting.insert(name); QJsonObject self = raw.value(name).obj; const QString parent = self.value("extends").toString(); if (!parent.isEmpty()) { if (!raw.contains(parent)) { errors.append(QStringLiteral("Agent '%1' extends unknown agent '%2' (%3)") .arg(name, parent, raw.value(name).filePath)); visiting.remove(name); return {}; } const QJsonObject parentMerged = resolveExtends(parent, raw, visiting, errors, depth + 1); self = mergeChild(parentMerged, self, name); } visiting.remove(name); return self; } } // namespace std::optional AgentLoader::parseFile( const QString &path, const QString &qrcPrefix, QString *error, QStringList *warnings) { auto objOpt = parseTomlFile(path, error); if (!objOpt) return std::nullopt; const QString name = objOpt->value("name").toString(); if (name.isEmpty()) { if (error) *error = QStringLiteral("Agent at %1 has no 'name'").arg(path); return std::nullopt; } if (warnings) lintUnknownKeys(*objOpt, path, *warnings); QHash raw; QStringList scanErrors; scanDir(qrcPrefix, /*isUserLayer=*/false, raw, scanErrors, nullptr); scanDir(QFileInfo(path).absolutePath(), /*isUserLayer=*/true, raw, scanErrors, nullptr); raw.insert(name, {*objOpt, path, true}); QSet visiting; QStringList resolveErrors; const QJsonObject merged = resolveExtends(name, raw, visiting, resolveErrors); if (!resolveErrors.isEmpty() || merged.isEmpty()) { if (error) { *error = resolveErrors.isEmpty() ? QStringLiteral("Agent '%1' resolved to an empty config").arg(name) : resolveErrors.join(QStringLiteral("; ")); } return std::nullopt; } AgentConfig cfg = configFromMerged(merged); cfg.sourcePath = path; if (cfg.abstract) { if (error) { *error = QStringLiteral("Agent '%1' is abstract — extend it instead of " "loading it directly").arg(name); } return std::nullopt; } return cfg; } AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QString &userDir) { LoadResult result; QHash raw; scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors, &result.warnings); scanDir(userDir, /*isUserLayer=*/true, raw, result.errors, &result.warnings); for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) { const QString &name = it.key(); QSet visiting; const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors); if (merged.isEmpty()) continue; AgentConfig cfg = configFromMerged(merged); cfg.sourcePath = it.value().filePath; if (cfg.abstract) continue; const QString validation = AgentConfig::validate(cfg); if (!validation.isEmpty()) { result.errors.append(validation); continue; } result.configs.push_back(std::move(cfg)); } std::sort(result.configs.begin(), result.configs.end(), [](const AgentConfig &a, const AgentConfig &b) { return a.name < b.name; }); return result; } } // namespace QodeAssist::Agents