Merge commit 'b7c153886fb1866a9b14bea0f500baa17535be08'

This commit is contained in:
Alexa Amundson
2025-11-25 13:44:19 -06:00
5 changed files with 819 additions and 0 deletions

View 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
View File

@@ -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
View 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
View 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
View 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;
});
});
});