diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 50c7d19..c03f29d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2380,4 +2380,13 @@ target_link_libraries(step388_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step389_test tests/step389_test.cpp) +target_include_directories(step389_test PRIVATE src) +target_link_libraries(step389_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/RoutingRules.h b/editor/src/RoutingRules.h new file mode 100644 index 0000000..88d2515 --- /dev/null +++ b/editor/src/RoutingRules.h @@ -0,0 +1,351 @@ +#pragma once +// Step 389: RoutingRules — Configurable, composable routing rules engine +// +// Makes routing rules configurable rather than hardcoded in RoutingEngine. +// Rules have conditions (AND-combined) and actions. First matching rule wins. +// Default rules encode the same logic as RoutingEngine's hardcoded cascade. + +#include "WorkItem.h" +#include "RoutingEngine.h" +#include +#include +#include +#include + +// --- RuleCondition --- + +struct RuleCondition { + std::string type; // "annotation" | "complexity" | "contextWidth" | "nodeType" + // | "rejectionCount" | "language" | "pattern" + std::string property; // for annotation: annotation type/property name + std::string op; // "==" | "!=" | "<" | ">" | "<=" | ">=" + std::string value; // comparison target (string or numeric-as-string) + + json toJson() const { + return json{{"type", type}, {"property", property}, {"op", op}, {"value", value}}; + } + + static RuleCondition fromJson(const json& j) { + RuleCondition c; + if (j.contains("type")) c.type = j["type"].get(); + if (j.contains("property")) c.property = j["property"].get(); + if (j.contains("op")) c.op = j["op"].get(); + if (j.contains("value")) c.value = j["value"].get(); + return c; + } +}; + +// --- RoutingAction --- + +struct RoutingAction { + std::string workerType; // "deterministic"|"template"|"slm"|"llm"|"human" + bool reviewRequired = false; + std::string contextWidthOverride; // optional: override context width + float budgetMultiplier = 1.0f; // multiply token budget + + json toJson() const { + json j = {{"workerType", workerType}, {"reviewRequired", reviewRequired}, + {"budgetMultiplier", budgetMultiplier}}; + if (!contextWidthOverride.empty()) j["contextWidthOverride"] = contextWidthOverride; + return j; + } + + static RoutingAction fromJson(const json& j) { + RoutingAction a; + if (j.contains("workerType")) a.workerType = j["workerType"].get(); + if (j.contains("reviewRequired")) a.reviewRequired = j["reviewRequired"].get(); + if (j.contains("contextWidthOverride")) a.contextWidthOverride = j["contextWidthOverride"].get(); + if (j.contains("budgetMultiplier")) a.budgetMultiplier = j["budgetMultiplier"].get(); + return a; + } +}; + +// --- RoutingRule --- + +struct RoutingRule { + std::string name; + int priority = 100; // lower = higher priority + std::vector conditions; // all must match (AND) + RoutingAction action; + + json toJson() const { + json condArr = json::array(); + for (const auto& c : conditions) condArr.push_back(c.toJson()); + return json{{"name", name}, {"priority", priority}, + {"conditions", condArr}, {"action", action.toJson()}}; + } + + static RoutingRule fromJson(const json& j) { + RoutingRule r; + if (j.contains("name")) r.name = j["name"].get(); + if (j.contains("priority")) r.priority = j["priority"].get(); + if (j.contains("conditions")) { + for (const auto& c : j["conditions"]) r.conditions.push_back(RuleCondition::fromJson(c)); + } + if (j.contains("action")) r.action = RoutingAction::fromJson(j["action"]); + return r; + } +}; + +// --- Condition evaluation helpers --- + +namespace routing_rules_detail { + +inline bool compareNumeric(const std::string& op, float lhs, float rhs) { + if (op == "==" || op == "=") return lhs == rhs; + if (op == "!=") return lhs != rhs; + if (op == "<") return lhs < rhs; + if (op == ">") return lhs > rhs; + if (op == "<=") return lhs <= rhs; + if (op == ">=") return lhs >= rhs; + return false; +} + +inline float safeFloat(const std::string& s) { + try { return std::stof(s); } catch (...) { return 0.0f; } +} + +inline bool evaluateCondition(const RuleCondition& cond, const WorkItem& item) { + if (cond.type == "annotation") { + // Check workerType annotation (the most common case) + if (cond.property == "workerType" || cond.property == "Automatability") { + if (cond.op == "==" || cond.op == "=") return item.workerType == cond.value; + if (cond.op == "!=") return item.workerType != cond.value; + } + if (cond.property == "reviewRequired") { + bool val = (cond.value == "true"); + if (cond.op == "==" || cond.op == "=") return item.reviewRequired == val; + if (cond.op == "!=") return item.reviewRequired != val; + } + return false; + } + + if (cond.type == "complexity") { + // Complexity conditions compare against priority as a proxy + // (priority maps: critical=0, high=1, medium=2, low=3) + float itemVal = static_cast(priorityToInt(item.priority)); + float threshold = safeFloat(cond.value); + return compareNumeric(cond.op, itemVal, threshold); + } + + if (cond.type == "contextWidth") { + std::string width = item.contextWidth.empty() ? "local" : item.contextWidth; + if (cond.op == "==" || cond.op == "=") return width == cond.value; + if (cond.op == "!=") return width != cond.value; + return false; + } + + if (cond.type == "nodeType") { + if (cond.op == "==" || cond.op == "=") return item.nodeType == cond.value; + if (cond.op == "!=") return item.nodeType != cond.value; + return false; + } + + if (cond.type == "rejectionCount") { + float count = static_cast(item.rejectionHistory.size()); + float threshold = safeFloat(cond.value); + return compareNumeric(cond.op, count, threshold); + } + + if (cond.type == "language") { + // Language is not directly on WorkItem, use bufferId extension as hint + std::string ext; + auto dot = item.bufferId.rfind('.'); + if (dot != std::string::npos) ext = item.bufferId.substr(dot + 1); + if (cond.op == "==" || cond.op == "=") return ext == cond.value || item.bufferId == cond.value; + if (cond.op == "!=") return ext != cond.value && item.bufferId != cond.value; + return false; + } + + if (cond.type == "pattern") { + bool isGetter = isGetterSetterPattern(item.nodeName); + bool isConstructor = (item.nodeName == "__init__" || item.nodeName == "constructor" || + item.nodeName.find("Constructor") != std::string::npos); + if (cond.value == "getter-setter") return isGetter; + if (cond.value == "constructor") return isConstructor; + if (cond.value == "simple") return isGetter || isConstructor; + return false; + } + + return false; +} + +} // namespace routing_rules_detail + +// --- RulesEngine --- + +class RulesEngine { +public: + void addRule(const RoutingRule& rule) { + rules_.push_back(rule); + sortRules(); + } + + void clearRules() { rules_.clear(); } + + size_t ruleCount() const { return rules_.size(); } + + // Evaluate: first matching rule wins + RoutingDecision evaluate(const WorkItem& item) const { + for (const auto& rule : rules_) { + if (matchesAll(rule, item)) { + return applyAction(rule, item); + } + } + // Fall through to default routing + return defaultRoute(item); + } + + // Get the default rules (encoding the same logic as RoutingEngine's hardcoded cascade) + static std::vector getDefaultRules() { + std::vector rules; + + // Rule 1: Explicit worker type override (highest priority) + { + RoutingRule r; + r.name = "explicit-worker"; + r.priority = 10; + r.conditions.push_back({"annotation", "workerType", "!=", ""}); + r.action.workerType = ""; // Will be filled from item + rules.push_back(r); + } + + // Rule 2: Getter/setter pattern → template + { + RoutingRule r; + r.name = "getter-setter-template"; + r.priority = 20; + r.conditions.push_back({"pattern", "", "==", "getter-setter"}); + r.action.workerType = "template"; + r.action.reviewRequired = false; + rules.push_back(r); + } + + // Rule 3: Cross-project context → LLM + { + RoutingRule r; + r.name = "cross-project-llm"; + r.priority = 30; + r.conditions.push_back({"contextWidth", "", "==", "cross-project"}); + r.action.workerType = "llm"; + r.action.reviewRequired = false; + rules.push_back(r); + } + + // Rule 4: Project context → LLM + { + RoutingRule r; + r.name = "project-context-llm"; + r.priority = 40; + r.conditions.push_back({"contextWidth", "", "==", "project"}); + r.action.workerType = "llm"; + r.action.reviewRequired = false; + rules.push_back(r); + } + + // Rule 5: Rejection escalation (>=1 rejection → escalate) + { + RoutingRule r; + r.name = "rejection-escalation"; + r.priority = 50; + r.conditions.push_back({"rejectionCount", "", ">=", "1"}); + r.action.workerType = "llm"; // escalated + r.action.reviewRequired = true; + r.action.budgetMultiplier = 1.5f; + rules.push_back(r); + } + + // Rule 6: Local context default → SLM + { + RoutingRule r; + r.name = "local-default-slm"; + r.priority = 90; + r.conditions.push_back({"contextWidth", "", "==", "local"}); + r.action.workerType = "slm"; + r.action.reviewRequired = false; + rules.push_back(r); + } + + // Rule 7: File context default → SLM + { + RoutingRule r; + r.name = "file-default-slm"; + r.priority = 91; + r.conditions.push_back({"contextWidth", "", "==", "file"}); + r.action.workerType = "slm"; + r.action.reviewRequired = false; + rules.push_back(r); + } + + return rules; + } + + // Load/save rules from JSON + json saveRules() const { + json arr = json::array(); + for (const auto& r : rules_) arr.push_back(r.toJson()); + return arr; + } + + void loadRules(const json& j) { + rules_.clear(); + if (j.is_array()) { + for (const auto& elem : j) { + rules_.push_back(RoutingRule::fromJson(elem)); + } + sortRules(); + } + } + + const std::vector& getRules() const { return rules_; } + +private: + std::vector rules_; + + void sortRules() { + std::sort(rules_.begin(), rules_.end(), + [](const RoutingRule& a, const RoutingRule& b) { + return a.priority < b.priority; + }); + } + + static bool matchesAll(const RoutingRule& rule, const WorkItem& item) { + if (rule.conditions.empty()) return true; + for (const auto& cond : rule.conditions) { + if (!routing_rules_detail::evaluateCondition(cond, item)) return false; + } + return true; + } + + static RoutingDecision applyAction(const RoutingRule& rule, const WorkItem& item) { + RoutingDecision d; + // Special case: explicit-worker rule uses item's own worker type + if (rule.name == "explicit-worker" && !item.workerType.empty()) { + d.workerType = item.workerType; + } else { + d.workerType = rule.action.workerType; + } + d.reviewRequired = rule.action.reviewRequired; + d.contextWidth = rule.action.contextWidthOverride.empty() + ? (item.contextWidth.empty() ? "local" : item.contextWidth) + : rule.action.contextWidthOverride; + d.contextBudgetTokens = static_cast( + estimateContextBudget(d.contextWidth) * rule.action.budgetMultiplier); + d.confidence = 0.85f; + d.reasoning = "Matched rule: " + rule.name; + d.agentRole = agentRoleForWorker(d.workerType); + return d; + } + + static RoutingDecision defaultRoute(const WorkItem& item) { + RoutingDecision d; + d.contextWidth = item.contextWidth.empty() ? "local" : item.contextWidth; + d.contextBudgetTokens = estimateContextBudget(d.contextWidth); + d.workerType = "llm"; + d.reviewRequired = false; + d.confidence = 0.5f; + d.reasoning = "Default routing to LLM (no rule matched)"; + d.agentRole = "generator"; + return d; + } +}; diff --git a/editor/tests/step389_test.cpp b/editor/tests/step389_test.cpp new file mode 100644 index 0000000..0b6c639 --- /dev/null +++ b/editor/tests/step389_test.cpp @@ -0,0 +1,303 @@ +// Step 389: Routing Rules Engine (12 tests) + +#include +#include +#include +#include "RoutingRules.h" + +static WorkItem makeItem(const std::string& id, + const std::string& name = "doWork", + const std::string& workerType = "", + const std::string& contextWidth = "local", + const std::string& priority = "medium") { + WorkItem item; + item.id = id; + item.nodeId = id + "_node"; + item.nodeName = name; + item.nodeType = "Function"; + item.bufferId = "main.py"; + item.workerType = workerType; + item.contextWidth = contextWidth; + item.priority = priority; + item.status = WI_PENDING; + item.createdAt = workItemTimestamp(); + return item; +} + +int main() { + int passed = 0; + + // Test 1: First matching rule wins + { + RulesEngine engine; + RoutingRule r1; + r1.name = "rule-A"; + r1.priority = 10; + r1.conditions.push_back({"contextWidth", "", "==", "local"}); + r1.action.workerType = "template"; + + RoutingRule r2; + r2.name = "rule-B"; + r2.priority = 20; + r2.conditions.push_back({"contextWidth", "", "==", "local"}); + r2.action.workerType = "llm"; + + engine.addRule(r2); + engine.addRule(r1); // added second but lower priority number = higher priority + + auto item = makeItem("t1"); + auto decision = engine.evaluate(item); + assert(decision.workerType == "template"); + assert(decision.reasoning.find("rule-A") != std::string::npos); + std::cout << "PASS: test 1 — first matching rule wins\n"; + passed++; + } + + // Test 2: Conditions AND together + { + RulesEngine engine; + RoutingRule r; + r.name = "local-function"; + r.priority = 10; + r.conditions.push_back({"contextWidth", "", "==", "local"}); + r.conditions.push_back({"nodeType", "", "==", "Function"}); + r.action.workerType = "template"; + engine.addRule(r); + + // Matches both conditions + auto item1 = makeItem("t1"); + assert(engine.evaluate(item1).workerType == "template"); + + // Fails nodeType condition + auto item2 = makeItem("t2"); + item2.nodeType = "Class"; + auto dec2 = engine.evaluate(item2); + assert(dec2.workerType == "llm"); // falls through to default + std::cout << "PASS: test 2 — conditions AND together\n"; + passed++; + } + + // Test 3: Annotation condition matches + { + RulesEngine engine; + RoutingRule r; + r.name = "explicit-human"; + r.priority = 10; + r.conditions.push_back({"annotation", "workerType", "==", "human"}); + r.action.workerType = "human"; + r.action.reviewRequired = true; + engine.addRule(r); + + auto item = makeItem("t1", "doWork", "human"); + auto dec = engine.evaluate(item); + assert(dec.workerType == "human"); + assert(dec.reviewRequired == true); + std::cout << "PASS: test 3 — annotation condition matches\n"; + passed++; + } + + // Test 4: Complexity threshold (via priority proxy) + { + RulesEngine engine; + RoutingRule r; + r.name = "high-priority-llm"; + r.priority = 10; + // priority critical=0, high=1 → complexity < 2 means critical or high + r.conditions.push_back({"complexity", "", "<", "2"}); + r.action.workerType = "llm"; + r.action.budgetMultiplier = 2.0f; + engine.addRule(r); + + auto highItem = makeItem("t1", "doWork", "", "local", "high"); + auto dec1 = engine.evaluate(highItem); + assert(dec1.workerType == "llm"); + + auto lowItem = makeItem("t2", "doWork", "", "local", "low"); + auto dec2 = engine.evaluate(lowItem); + assert(dec2.workerType == "llm"); // falls through to default (also llm) + assert(dec2.reasoning.find("Default") != std::string::npos); // but via default + std::cout << "PASS: test 4 — complexity threshold\n"; + passed++; + } + + // Test 5: Pattern matching (getter-setter) + { + RulesEngine engine; + RoutingRule r; + r.name = "getter-template"; + r.priority = 10; + r.conditions.push_back({"pattern", "", "==", "getter-setter"}); + r.action.workerType = "template"; + engine.addRule(r); + + auto getter = makeItem("t1", "getName"); + assert(engine.evaluate(getter).workerType == "template"); + + auto setter = makeItem("t2", "setValue"); + assert(engine.evaluate(setter).workerType == "template"); + + auto normal = makeItem("t3", "processData"); + assert(engine.evaluate(normal).workerType != "template"); + std::cout << "PASS: test 5 — pattern matching\n"; + passed++; + } + + // Test 6: Default rules sensible + { + auto defaults = RulesEngine::getDefaultRules(); + assert(defaults.size() >= 5); + + // Verify known rules exist + bool hasGetterRule = false, hasProjectRule = false; + for (const auto& r : defaults) { + if (r.name == "getter-setter-template") hasGetterRule = true; + if (r.name == "project-context-llm") hasProjectRule = true; + } + assert(hasGetterRule); + assert(hasProjectRule); + std::cout << "PASS: test 6 — default rules sensible\n"; + passed++; + } + + // Test 7: Custom rules override defaults + { + RulesEngine engine; + // Load defaults first + auto defaults = RulesEngine::getDefaultRules(); + for (const auto& r : defaults) engine.addRule(r); + + // Add custom rule with highest priority + RoutingRule custom; + custom.name = "force-deterministic"; + custom.priority = 1; // highest + custom.conditions.push_back({"contextWidth", "", "==", "local"}); + custom.action.workerType = "deterministic"; + engine.addRule(custom); + + auto item = makeItem("t1", "doWork", "", "local"); + auto dec = engine.evaluate(item); + assert(dec.workerType == "deterministic"); + assert(dec.reasoning.find("force-deterministic") != std::string::npos); + std::cout << "PASS: test 7 — custom rules override defaults\n"; + passed++; + } + + // Test 8: No match falls through to default + { + RulesEngine engine; + // Add a rule that won't match + RoutingRule r; + r.name = "specific"; + r.priority = 10; + r.conditions.push_back({"contextWidth", "", "==", "cross-project"}); + r.action.workerType = "human"; + engine.addRule(r); + + auto item = makeItem("t1", "doWork", "", "local"); + auto dec = engine.evaluate(item); + assert(dec.workerType == "llm"); // default + assert(dec.reasoning.find("Default") != std::string::npos); + std::cout << "PASS: test 8 — no match falls through to default\n"; + passed++; + } + + // Test 9: Rule priority ordering + { + RulesEngine engine; + RoutingRule low; + low.name = "low-pri"; + low.priority = 100; + low.action.workerType = "slm"; + engine.addRule(low); + + RoutingRule high; + high.name = "high-pri"; + high.priority = 1; + high.action.workerType = "template"; + engine.addRule(high); + + // Both have empty conditions (match anything), high-pri should win + auto item = makeItem("t1"); + auto dec = engine.evaluate(item); + assert(dec.workerType == "template"); + assert(engine.getRules()[0].name == "high-pri"); + std::cout << "PASS: test 9 — rule priority ordering\n"; + passed++; + } + + // Test 10: Rules persist to JSON + { + RulesEngine engine; + RoutingRule r; + r.name = "test-rule"; + r.priority = 42; + r.conditions.push_back({"pattern", "", "==", "getter-setter"}); + r.action.workerType = "template"; + r.action.budgetMultiplier = 1.5f; + engine.addRule(r); + + json saved = engine.saveRules(); + assert(saved.is_array()); + assert(saved.size() == 1); + assert(saved[0]["name"] == "test-rule"); + assert(saved[0]["priority"] == 42); + + RulesEngine loaded; + loaded.loadRules(saved); + assert(loaded.ruleCount() == 1); + assert(loaded.getRules()[0].name == "test-rule"); + assert(loaded.getRules()[0].action.budgetMultiplier == 1.5f); + std::cout << "PASS: test 10 — rules persist to JSON\n"; + passed++; + } + + // Test 11: Budget multiplier applied + { + RulesEngine engine; + RoutingRule r; + r.name = "big-budget"; + r.priority = 10; + r.conditions.push_back({"contextWidth", "", "==", "local"}); + r.action.workerType = "llm"; + r.action.budgetMultiplier = 3.0f; + engine.addRule(r); + + auto item = makeItem("t1"); + auto dec = engine.evaluate(item); + assert(dec.contextBudgetTokens == 500 * 3); // local=500 * 3.0 + std::cout << "PASS: test 11 — budget multiplier applied\n"; + passed++; + } + + // Test 12: Rejection count escalation + { + RulesEngine engine; + RoutingRule r; + r.name = "escalate-on-reject"; + r.priority = 10; + r.conditions.push_back({"rejectionCount", "", ">=", "2"}); + r.action.workerType = "human"; + r.action.reviewRequired = true; + engine.addRule(r); + + auto item = makeItem("t1"); + // No rejections — rule doesn't match + auto dec1 = engine.evaluate(item); + assert(dec1.workerType != "human"); + + // Add 2 rejections + RejectionAttempt a1; a1.workerType = "slm"; a1.feedback = "bad"; + RejectionAttempt a2; a2.workerType = "llm"; a2.feedback = "still bad"; + item.rejectionHistory.push_back(a1); + item.rejectionHistory.push_back(a2); + + auto dec2 = engine.evaluate(item); + assert(dec2.workerType == "human"); + assert(dec2.reviewRequired == true); + std::cout << "PASS: test 12 — rejection count escalation\n"; + passed++; + } + + std::cout << "\nStep 389 result: " << passed << "/12 tests passed\n"; + return (passed == 12) ? 0 : 1; +}