Merge commit 'b7c153886fb1866a9b14bea0f500baa17535be08'
This commit is contained in:
71
.github/workflows/lucidia-spawn-runner.yml
vendored
Normal file
71
.github/workflows/lucidia-spawn-runner.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: 🧬 Lucidia Spawn Runner – Agent Auto-Spawn Monitor
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every 6 hours
|
||||||
|
- cron: "0 */6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
escalations_last_7_days:
|
||||||
|
description: "Number of escalations in last 7 days"
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
|
digest_count:
|
||||||
|
description: "Current digest count"
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
|
average_blocked_pct:
|
||||||
|
description: "Average blocked percentage"
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
|
created_repos_last_72h:
|
||||||
|
description: "Repos created in last 72 hours"
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
|
overdue_issues_count:
|
||||||
|
description: "Count of overdue issues"
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
|
emoji_threshold:
|
||||||
|
description: "Emoji reaction threshold"
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
|
emoji_type:
|
||||||
|
description: "Emoji type to check (e.g., 🛟)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
spawn-runner:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 🧬 Checkout Repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: 🧠 Set up Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: 🚦 Run Lucidia Spawn Runner
|
||||||
|
id: spawn
|
||||||
|
env:
|
||||||
|
ESCALATIONS_LAST_7_DAYS: ${{ github.event.inputs.escalations_last_7_days || '0' }}
|
||||||
|
DIGEST_COUNT: ${{ github.event.inputs.digest_count || '0' }}
|
||||||
|
AVERAGE_BLOCKED_PCT: ${{ github.event.inputs.average_blocked_pct || '0' }}
|
||||||
|
CREATED_REPOS_LAST_72H: ${{ github.event.inputs.created_repos_last_72h || '0' }}
|
||||||
|
OVERDUE_ISSUES_COUNT: ${{ github.event.inputs.overdue_issues_count || '0' }}
|
||||||
|
EMOJI_THRESHOLD: ${{ github.event.inputs.emoji_threshold || '0' }}
|
||||||
|
EMOJI_TYPE: ${{ github.event.inputs.emoji_type || '' }}
|
||||||
|
run: node bot/lucidia-spawn-runner.js
|
||||||
|
|
||||||
|
- name: 📤 Upload Spawned Agents Manifest
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: spawned-agents
|
||||||
|
path: spawned-agents.json
|
||||||
|
if-no-files-found: ignore
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -27,3 +27,10 @@ dist
|
|||||||
# Generated JS files (source is TypeScript)
|
# Generated JS files (source is TypeScript)
|
||||||
*.js
|
*.js
|
||||||
!vitest.config.js
|
!vitest.config.js
|
||||||
|
# TypeScript build output
|
||||||
|
*.js
|
||||||
|
!bot/*.js
|
||||||
|
!bot/**/*.js
|
||||||
|
|
||||||
|
# Generated output
|
||||||
|
spawned-agents.json
|
||||||
|
|||||||
291
bot/lucidia-spawn-runner.js
Normal file
291
bot/lucidia-spawn-runner.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
// bot/lucidia-spawn-runner.js
|
||||||
|
// 🧬 Lucidia Spawn Runner – Evaluates spawn rules and instantiates agents
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YAML-like spawn rules (simple parser for our DSL)
|
||||||
|
* @param {string} content - Raw YAML content
|
||||||
|
* @returns {object} Parsed spawn rules
|
||||||
|
*/
|
||||||
|
function parseSpawnRules(content) {
|
||||||
|
const rules = [];
|
||||||
|
let currentRule = null;
|
||||||
|
let inIf = false;
|
||||||
|
let inThen = false;
|
||||||
|
let inConfig = false;
|
||||||
|
let currentIndent = 0;
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
|
||||||
|
// Detect rule start
|
||||||
|
if (trimmed === "- if:") {
|
||||||
|
if (currentRule) {
|
||||||
|
rules.push(currentRule);
|
||||||
|
}
|
||||||
|
currentRule = { if: {}, then: {} };
|
||||||
|
inIf = true;
|
||||||
|
inThen = false;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "then:") {
|
||||||
|
inIf = false;
|
||||||
|
inThen = true;
|
||||||
|
inConfig = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "config:") {
|
||||||
|
inConfig = true;
|
||||||
|
if (!currentRule.then.config) {
|
||||||
|
currentRule.then.config = {};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key-value pairs (supports alphanumeric, underscore, and hyphen in keys)
|
||||||
|
const kvMatch = trimmed.match(/^([\w-]+):\s*(.*)$/);
|
||||||
|
if (kvMatch) {
|
||||||
|
const key = kvMatch[1];
|
||||||
|
let value = kvMatch[2];
|
||||||
|
|
||||||
|
// Handle quoted strings
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays
|
||||||
|
if (value.startsWith("[") && value.endsWith("]")) {
|
||||||
|
value = value
|
||||||
|
.slice(1, -1)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim().replace(/^["']|["']$/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inIf && currentRule) {
|
||||||
|
currentRule.if[key] = value;
|
||||||
|
} else if (inConfig && currentRule) {
|
||||||
|
currentRule.then.config[key] = value;
|
||||||
|
} else if (inThen && currentRule) {
|
||||||
|
currentRule.then[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the last rule
|
||||||
|
if (currentRule) {
|
||||||
|
rules.push(currentRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { spawn_rules: rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a condition against current metrics
|
||||||
|
* @param {string} condition - Condition string (e.g., "> 10", "> 20%")
|
||||||
|
* @param {number} value - Current metric value
|
||||||
|
* @returns {boolean} Whether condition is met
|
||||||
|
*/
|
||||||
|
function evaluateCondition(condition, value) {
|
||||||
|
if (typeof condition !== "string") return false;
|
||||||
|
|
||||||
|
// Handle percentage conditions
|
||||||
|
const isPct = condition.includes("%");
|
||||||
|
const condStr = condition.replace("%", "").trim();
|
||||||
|
|
||||||
|
const match = condStr.match(/^(>=|<=|!=|==|>|<|=)\s*(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match) return false;
|
||||||
|
|
||||||
|
const operator = match[1];
|
||||||
|
const threshold = parseFloat(match[2]);
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case ">":
|
||||||
|
return value > threshold;
|
||||||
|
case ">=":
|
||||||
|
return value >= threshold;
|
||||||
|
case "<":
|
||||||
|
return value < threshold;
|
||||||
|
case "<=":
|
||||||
|
return value <= threshold;
|
||||||
|
case "=":
|
||||||
|
case "==":
|
||||||
|
return value === threshold;
|
||||||
|
case "!=":
|
||||||
|
return value !== threshold;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate all conditions in a rule's "if" block
|
||||||
|
* @param {object} conditions - Object of condition key-value pairs
|
||||||
|
* @param {object} metrics - Current system metrics
|
||||||
|
* @returns {boolean} Whether all conditions are met
|
||||||
|
*/
|
||||||
|
function evaluateRule(conditions, metrics) {
|
||||||
|
for (const [key, condition] of Object.entries(conditions)) {
|
||||||
|
const value = metrics[key];
|
||||||
|
if (value === undefined) {
|
||||||
|
console.log(`⚠️ Metric "${key}" not found, skipping condition`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!evaluateCondition(condition, value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn an agent based on the rule configuration
|
||||||
|
* @param {object} spawnConfig - The "then" block from a spawn rule
|
||||||
|
* @param {object} context - Additional context for spawning
|
||||||
|
* @returns {object} Spawned agent info
|
||||||
|
*/
|
||||||
|
function spawnAgent(spawnConfig, context = {}) {
|
||||||
|
const agentId = spawnConfig.spawn;
|
||||||
|
const config = spawnConfig.config || {};
|
||||||
|
|
||||||
|
const agent = {
|
||||||
|
id: `${agentId}-${Date.now()}`,
|
||||||
|
type: agentId,
|
||||||
|
role: config.role || "worker",
|
||||||
|
traits: config.traits || [],
|
||||||
|
parent: config.parent || config.inherits_from || null,
|
||||||
|
ttl: parseTTL(config.ttl),
|
||||||
|
outputs: config.outputs || [],
|
||||||
|
log_channel: config.log_channel || "agent.log",
|
||||||
|
spawned_at: new Date().toISOString(),
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`🧬 Spawning agent: ${agent.id}`);
|
||||||
|
console.log(` Role: ${agent.role}`);
|
||||||
|
console.log(` Traits: ${agent.traits.join(", ") || "none"}`);
|
||||||
|
if (agent.parent) {
|
||||||
|
console.log(` Parent: ${agent.parent}`);
|
||||||
|
}
|
||||||
|
if (agent.ttl) {
|
||||||
|
console.log(` TTL: ${config.ttl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TTL string to milliseconds
|
||||||
|
* @param {string} ttl - TTL string (e.g., "14d", "24h", "7d")
|
||||||
|
* @returns {number|null} TTL in milliseconds or null
|
||||||
|
*/
|
||||||
|
function parseTTL(ttl) {
|
||||||
|
if (!ttl) return null;
|
||||||
|
|
||||||
|
const match = String(ttl).match(/^(\d+)([dhms])$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
const multipliers = {
|
||||||
|
s: 1000,
|
||||||
|
m: 60 * 1000,
|
||||||
|
h: 60 * 60 * 1000,
|
||||||
|
d: 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return value * (multipliers[unit] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the spawn rule evaluator
|
||||||
|
* @param {string} rulesPath - Path to spawn rules YAML file
|
||||||
|
* @param {object} metrics - Current system metrics
|
||||||
|
* @returns {object[]} Array of spawned agents
|
||||||
|
*/
|
||||||
|
function runSpawnEvaluator(rulesPath, metrics) {
|
||||||
|
console.log("🧠 Lucidia Spawn Runner Activated");
|
||||||
|
console.log(`📄 Loading rules from: ${rulesPath}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(rulesPath)) {
|
||||||
|
console.error(`🚫 Spawn rules file not found: ${rulesPath}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(rulesPath, "utf8");
|
||||||
|
const parsed = parseSpawnRules(content);
|
||||||
|
const rules = parsed.spawn_rules || [];
|
||||||
|
|
||||||
|
console.log(`📋 Found ${rules.length} spawn rules`);
|
||||||
|
console.log(`📊 Current metrics:`, JSON.stringify(metrics, null, 2));
|
||||||
|
|
||||||
|
const spawnedAgents = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rules.length; i++) {
|
||||||
|
const rule = rules[i];
|
||||||
|
console.log(`\n🔍 Evaluating rule ${i + 1}...`);
|
||||||
|
|
||||||
|
if (evaluateRule(rule.if, metrics)) {
|
||||||
|
console.log(`✅ Rule ${i + 1} conditions met!`);
|
||||||
|
const agent = spawnAgent(rule.then, { rule_index: i, metrics });
|
||||||
|
spawnedAgents.push(agent);
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ Rule ${i + 1} conditions not met, skipping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🧬 Total agents spawned: ${spawnedAgents.length}`);
|
||||||
|
return spawnedAgents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default metrics (can be overridden by environment or external sources)
|
||||||
|
* @returns {object} Default metrics object
|
||||||
|
*/
|
||||||
|
function getDefaultMetrics() {
|
||||||
|
return {
|
||||||
|
escalations_last_7_days: parseInt(process.env.ESCALATIONS_LAST_7_DAYS || "0", 10),
|
||||||
|
digest_count: parseInt(process.env.DIGEST_COUNT || "0", 10),
|
||||||
|
average_blocked_pct: parseFloat(process.env.AVERAGE_BLOCKED_PCT || "0"),
|
||||||
|
created_repos_last_72h: parseInt(process.env.CREATED_REPOS_LAST_72H || "0", 10),
|
||||||
|
overdue_issues_count: parseInt(process.env.OVERDUE_ISSUES_COUNT || "0", 10),
|
||||||
|
emoji_threshold: parseInt(process.env.EMOJI_THRESHOLD || "0", 10),
|
||||||
|
emoji_type: process.env.EMOJI_TYPE || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
if (require.main === module) {
|
||||||
|
const rulesPath = path.resolve(__dirname, "../lucidia.spawn-rules.yml");
|
||||||
|
const metrics = getDefaultMetrics();
|
||||||
|
|
||||||
|
const spawnedAgents = runSpawnEvaluator(rulesPath, metrics);
|
||||||
|
|
||||||
|
// Output spawned agents as JSON for downstream processing
|
||||||
|
if (spawnedAgents.length > 0) {
|
||||||
|
const outputPath = path.resolve(__dirname, "../spawned-agents.json");
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(spawnedAgents, null, 2));
|
||||||
|
console.log(`\n📤 Spawned agents written to: ${outputPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions for testing and external use
|
||||||
|
module.exports = {
|
||||||
|
parseSpawnRules,
|
||||||
|
evaluateCondition,
|
||||||
|
evaluateRule,
|
||||||
|
spawnAgent,
|
||||||
|
parseTTL,
|
||||||
|
runSpawnEvaluator,
|
||||||
|
getDefaultMetrics,
|
||||||
|
};
|
||||||
52
lucidia.spawn-rules.yml
Normal file
52
lucidia.spawn-rules.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 🧬 lucidia.spawn-rules.yml
|
||||||
|
# Auto-spawn rules for agent replication based on system conditions
|
||||||
|
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
escalations_last_7_days: "> 10"
|
||||||
|
then:
|
||||||
|
spawn: guardian-clone-agent
|
||||||
|
config:
|
||||||
|
role: sentinel
|
||||||
|
traits: ["incident-response", "human-routing"]
|
||||||
|
parent: guardian-agent
|
||||||
|
ttl: 14d
|
||||||
|
log_channel: escalations.log
|
||||||
|
|
||||||
|
- if:
|
||||||
|
digest_count: "> 4"
|
||||||
|
average_blocked_pct: "> 20%"
|
||||||
|
then:
|
||||||
|
spawn: digest-auditor-agent
|
||||||
|
config:
|
||||||
|
role: analyst
|
||||||
|
traits: ["summary-check", "anomaly-detector"]
|
||||||
|
inherits_from: codex-digest-agent
|
||||||
|
|
||||||
|
- if:
|
||||||
|
created_repos_last_72h: "> 5"
|
||||||
|
then:
|
||||||
|
spawn: repo-mapper-agent
|
||||||
|
config:
|
||||||
|
role: indexer
|
||||||
|
outputs: ["org-registry.json", "repo-trees.mdx"]
|
||||||
|
|
||||||
|
- if:
|
||||||
|
overdue_issues_count: "> 20"
|
||||||
|
then:
|
||||||
|
spawn: overdue-cleaner-agent
|
||||||
|
config:
|
||||||
|
role: sweeper
|
||||||
|
traits: ["issue-triage", "stale-detection"]
|
||||||
|
ttl: 7d
|
||||||
|
|
||||||
|
- if:
|
||||||
|
emoji_threshold: "> 50"
|
||||||
|
emoji_type: "🛟"
|
||||||
|
then:
|
||||||
|
spawn: escalation-swarm-agent
|
||||||
|
config:
|
||||||
|
role: sentinel
|
||||||
|
traits: ["batch-escalation", "priority-router"]
|
||||||
|
parent: guardian-agent
|
||||||
|
ttl: 24h
|
||||||
398
tests/spawnRunner.test.ts
Normal file
398
tests/spawnRunner.test.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
// Import the module functions
|
||||||
|
const spawnRunner = require("../bot/lucidia-spawn-runner.js");
|
||||||
|
|
||||||
|
describe("lucidia-spawn-runner", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseTTL", () => {
|
||||||
|
it("parses days correctly", () => {
|
||||||
|
expect(spawnRunner.parseTTL("14d")).toBe(14 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(spawnRunner.parseTTL("7d")).toBe(7 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses hours correctly", () => {
|
||||||
|
expect(spawnRunner.parseTTL("24h")).toBe(24 * 60 * 60 * 1000);
|
||||||
|
expect(spawnRunner.parseTTL("1h")).toBe(60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses minutes correctly", () => {
|
||||||
|
expect(spawnRunner.parseTTL("30m")).toBe(30 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses seconds correctly", () => {
|
||||||
|
expect(spawnRunner.parseTTL("60s")).toBe(60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid TTL", () => {
|
||||||
|
expect(spawnRunner.parseTTL(null)).toBeNull();
|
||||||
|
expect(spawnRunner.parseTTL("invalid")).toBeNull();
|
||||||
|
expect(spawnRunner.parseTTL("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evaluateCondition", () => {
|
||||||
|
it("evaluates > condition correctly", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("> 10", 15)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("> 10", 10)).toBe(false);
|
||||||
|
expect(spawnRunner.evaluateCondition("> 10", 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates >= condition correctly", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition(">= 10", 15)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition(">= 10", 10)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition(">= 10", 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates < condition correctly", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("< 10", 5)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("< 10", 10)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates <= condition correctly", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("<= 10", 5)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("<= 10", 10)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("<= 10", 15)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates = and == condition correctly", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("= 10", 10)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("== 10", 10)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("= 10", 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates != condition correctly", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("!= 10", 5)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("!= 10", 10)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles percentage conditions", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("> 20%", 25)).toBe(true);
|
||||||
|
expect(spawnRunner.evaluateCondition("> 20%", 15)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-string condition", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition(null, 10)).toBe(false);
|
||||||
|
expect(spawnRunner.evaluateCondition(123, 10)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid condition format", () => {
|
||||||
|
expect(spawnRunner.evaluateCondition("invalid", 10)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evaluateRule", () => {
|
||||||
|
it("returns true when all conditions are met", () => {
|
||||||
|
const conditions = {
|
||||||
|
escalations_last_7_days: "> 10",
|
||||||
|
};
|
||||||
|
const metrics = {
|
||||||
|
escalations_last_7_days: 15,
|
||||||
|
};
|
||||||
|
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when any condition is not met", () => {
|
||||||
|
const conditions = {
|
||||||
|
escalations_last_7_days: "> 10",
|
||||||
|
digest_count: "> 5",
|
||||||
|
};
|
||||||
|
const metrics = {
|
||||||
|
escalations_last_7_days: 15,
|
||||||
|
digest_count: 3,
|
||||||
|
};
|
||||||
|
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when multiple conditions are all met", () => {
|
||||||
|
const conditions = {
|
||||||
|
digest_count: "> 4",
|
||||||
|
average_blocked_pct: "> 20%",
|
||||||
|
};
|
||||||
|
const metrics = {
|
||||||
|
digest_count: 6,
|
||||||
|
average_blocked_pct: 25,
|
||||||
|
};
|
||||||
|
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues evaluation when metric is missing", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const conditions = {
|
||||||
|
unknown_metric: "> 10",
|
||||||
|
known_metric: "> 5",
|
||||||
|
};
|
||||||
|
const metrics = {
|
||||||
|
known_metric: 10,
|
||||||
|
};
|
||||||
|
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(true);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spawnAgent", () => {
|
||||||
|
it("creates an agent with correct properties", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const spawnConfig = {
|
||||||
|
spawn: "guardian-clone-agent",
|
||||||
|
config: {
|
||||||
|
role: "sentinel",
|
||||||
|
traits: ["incident-response", "human-routing"],
|
||||||
|
parent: "guardian-agent",
|
||||||
|
ttl: "14d",
|
||||||
|
log_channel: "escalations.log",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = spawnRunner.spawnAgent(spawnConfig);
|
||||||
|
|
||||||
|
expect(agent.type).toBe("guardian-clone-agent");
|
||||||
|
expect(agent.role).toBe("sentinel");
|
||||||
|
expect(agent.traits).toEqual(["incident-response", "human-routing"]);
|
||||||
|
expect(agent.parent).toBe("guardian-agent");
|
||||||
|
expect(agent.ttl).toBe(14 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(agent.log_channel).toBe("escalations.log");
|
||||||
|
expect(agent.spawned_at).toBeDefined();
|
||||||
|
expect(agent.id).toContain("guardian-clone-agent-");
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses inherits_from when parent is not specified", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const spawnConfig = {
|
||||||
|
spawn: "digest-auditor-agent",
|
||||||
|
config: {
|
||||||
|
role: "analyst",
|
||||||
|
inherits_from: "codex-digest-agent",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = spawnRunner.spawnAgent(spawnConfig);
|
||||||
|
expect(agent.parent).toBe("codex-digest-agent");
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing config gracefully", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const spawnConfig = {
|
||||||
|
spawn: "simple-agent",
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = spawnRunner.spawnAgent(spawnConfig);
|
||||||
|
expect(agent.type).toBe("simple-agent");
|
||||||
|
expect(agent.role).toBe("worker");
|
||||||
|
expect(agent.traits).toEqual([]);
|
||||||
|
expect(agent.parent).toBeNull();
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSpawnRules", () => {
|
||||||
|
it("parses a simple spawn rule", () => {
|
||||||
|
const yaml = `
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
escalations_last_7_days: "> 10"
|
||||||
|
then:
|
||||||
|
spawn: guardian-clone-agent
|
||||||
|
config:
|
||||||
|
role: sentinel
|
||||||
|
ttl: 14d
|
||||||
|
`;
|
||||||
|
const result = spawnRunner.parseSpawnRules(yaml);
|
||||||
|
|
||||||
|
expect(result.spawn_rules).toHaveLength(1);
|
||||||
|
expect(result.spawn_rules[0].if.escalations_last_7_days).toBe("> 10");
|
||||||
|
expect(result.spawn_rules[0].then.spawn).toBe("guardian-clone-agent");
|
||||||
|
expect(result.spawn_rules[0].then.config.role).toBe("sentinel");
|
||||||
|
expect(result.spawn_rules[0].then.config.ttl).toBe("14d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple spawn rules", () => {
|
||||||
|
const yaml = `
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
escalations_last_7_days: "> 10"
|
||||||
|
then:
|
||||||
|
spawn: guardian-clone-agent
|
||||||
|
- if:
|
||||||
|
digest_count: "> 4"
|
||||||
|
then:
|
||||||
|
spawn: digest-auditor-agent
|
||||||
|
`;
|
||||||
|
const result = spawnRunner.parseSpawnRules(yaml);
|
||||||
|
|
||||||
|
expect(result.spawn_rules).toHaveLength(2);
|
||||||
|
expect(result.spawn_rules[0].then.spawn).toBe("guardian-clone-agent");
|
||||||
|
expect(result.spawn_rules[1].then.spawn).toBe("digest-auditor-agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles array values in config", () => {
|
||||||
|
const yaml = `
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
created_repos_last_72h: "> 5"
|
||||||
|
then:
|
||||||
|
spawn: repo-mapper-agent
|
||||||
|
config:
|
||||||
|
outputs: ["org-registry.json", "repo-trees.mdx"]
|
||||||
|
`;
|
||||||
|
const result = spawnRunner.parseSpawnRules(yaml);
|
||||||
|
|
||||||
|
expect(result.spawn_rules[0].then.config.outputs).toEqual([
|
||||||
|
"org-registry.json",
|
||||||
|
"repo-trees.mdx",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips comments and empty lines", () => {
|
||||||
|
const yaml = `
|
||||||
|
# This is a comment
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
escalations_last_7_days: "> 10"
|
||||||
|
then:
|
||||||
|
spawn: guardian-clone-agent
|
||||||
|
`;
|
||||||
|
const result = spawnRunner.parseSpawnRules(yaml);
|
||||||
|
|
||||||
|
expect(result.spawn_rules).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runSpawnEvaluator", () => {
|
||||||
|
it("returns empty array when rules file does not exist", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = spawnRunner.runSpawnEvaluator("/nonexistent/path.yml", {});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"🚫 Spawn rules file not found: /nonexistent/path.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns agents when conditions are met", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Create a temp file with test spawn rules using cross-platform temp directory
|
||||||
|
const tempPath = path.join(os.tmpdir(), "test-spawn-rules.yml");
|
||||||
|
fs.writeFileSync(tempPath, `
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
escalations_last_7_days: "> 10"
|
||||||
|
then:
|
||||||
|
spawn: guardian-clone-agent
|
||||||
|
config:
|
||||||
|
role: sentinel
|
||||||
|
`);
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
escalations_last_7_days: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = spawnRunner.runSpawnEvaluator(tempPath, metrics);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].type).toBe("guardian-clone-agent");
|
||||||
|
expect(result[0].role).toBe("sentinel");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not spawn agents when conditions are not met", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Create a temp file with test spawn rules using cross-platform temp directory
|
||||||
|
const tempPath = path.join(os.tmpdir(), "test-spawn-rules-2.yml");
|
||||||
|
fs.writeFileSync(tempPath, `
|
||||||
|
spawn_rules:
|
||||||
|
- if:
|
||||||
|
escalations_last_7_days: "> 10"
|
||||||
|
then:
|
||||||
|
spawn: guardian-clone-agent
|
||||||
|
`);
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
escalations_last_7_days: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = spawnRunner.runSpawnEvaluator(tempPath, metrics);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultMetrics", () => {
|
||||||
|
it("returns metrics from environment variables", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
ESCALATIONS_LAST_7_DAYS: "12",
|
||||||
|
DIGEST_COUNT: "5",
|
||||||
|
AVERAGE_BLOCKED_PCT: "25.5",
|
||||||
|
CREATED_REPOS_LAST_72H: "8",
|
||||||
|
OVERDUE_ISSUES_COUNT: "30",
|
||||||
|
EMOJI_THRESHOLD: "60",
|
||||||
|
EMOJI_TYPE: "🛟",
|
||||||
|
};
|
||||||
|
|
||||||
|
const metrics = spawnRunner.getDefaultMetrics();
|
||||||
|
|
||||||
|
expect(metrics.escalations_last_7_days).toBe(12);
|
||||||
|
expect(metrics.digest_count).toBe(5);
|
||||||
|
expect(metrics.average_blocked_pct).toBe(25.5);
|
||||||
|
expect(metrics.created_repos_last_72h).toBe(8);
|
||||||
|
expect(metrics.overdue_issues_count).toBe(30);
|
||||||
|
expect(metrics.emoji_threshold).toBe(60);
|
||||||
|
expect(metrics.emoji_type).toBe("🛟");
|
||||||
|
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default zero values when env vars are not set", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.ESCALATIONS_LAST_7_DAYS;
|
||||||
|
delete process.env.DIGEST_COUNT;
|
||||||
|
delete process.env.AVERAGE_BLOCKED_PCT;
|
||||||
|
delete process.env.CREATED_REPOS_LAST_72H;
|
||||||
|
delete process.env.OVERDUE_ISSUES_COUNT;
|
||||||
|
delete process.env.EMOJI_THRESHOLD;
|
||||||
|
delete process.env.EMOJI_TYPE;
|
||||||
|
|
||||||
|
const metrics = spawnRunner.getDefaultMetrics();
|
||||||
|
|
||||||
|
expect(metrics.escalations_last_7_days).toBe(0);
|
||||||
|
expect(metrics.digest_count).toBe(0);
|
||||||
|
expect(metrics.average_blocked_pct).toBe(0);
|
||||||
|
expect(metrics.created_repos_last_72h).toBe(0);
|
||||||
|
expect(metrics.overdue_issues_count).toBe(0);
|
||||||
|
expect(metrics.emoji_threshold).toBe(0);
|
||||||
|
expect(metrics.emoji_type).toBe("");
|
||||||
|
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user