diff --git a/frontend/src/components/llm/FineTuneDashboard.tsx b/frontend/src/components/llm/FineTuneDashboard.tsx
new file mode 100644
index 00000000..4664770a
--- /dev/null
+++ b/frontend/src/components/llm/FineTuneDashboard.tsx
@@ -0,0 +1,403 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import {
+ TrendingUp,
+ TrendingDown,
+ Minus,
+ Cpu,
+ HardDrive,
+ Download,
+ Upload,
+ RefreshCw,
+ AlertTriangle,
+ CheckCircle,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Progress } from "@/components/ui/progress";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Separator } from "@/components/ui/separator";
+
+interface FineTuneData {
+ epoch: number;
+ loss: number;
+ learning_rate: number;
+ timestamp: string;
+}
+
+interface ModelInfo {
+ name: string;
+ path: string;
+ size: string;
+ loaded: boolean;
+}
+
+interface AdapterInfo {
+ name: string;
+ path: string;
+ epochs: number;
+ final_loss: number;
+ loaded: boolean;
+}
+
+export function FineTuneDashboard({
+ events,
+ onRefresh
+}: {
+ events: any[];
+ onRefresh?: () => void;
+}) {
+ const [lossHistory, setLossHistory] = useState
([]);
+ const [currentEpoch, setCurrentEpoch] = useState(0);
+ const [learningRate, setLearningRate] = useState(0);
+ const [isTraining, setIsTraining] = useState(false);
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ // Extract loss data from events
+ const lossData = events
+ .filter(e => e.loss !== undefined || e.payload?.loss !== undefined)
+ .map(e => e.loss || e.payload?.loss || e.payload?.final_loss)
+ .filter(v => v !== undefined);
+
+ setLossHistory(lossData);
+
+ const latestEvent = events[events.length - 1];
+ if (latestEvent) {
+ setCurrentEpoch(latestEvent.epoch || latestEvent.payload?.epoch || 0);
+ setLearningRate(latestEvent.learning_rate || latestEvent.payload?.learning_rate || 0);
+ setIsTraining(latestEvent.type === "fine_tune_epoch" || latestEvent.type === "fine_tune_loss");
+ }
+ }, [events]);
+
+ useEffect(() => {
+ if (!canvasRef.current) return;
+ drawChart();
+ }, [lossHistory, canvasRef.current]);
+
+ const drawChart = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = rect.width * dpr;
+ canvas.height = rect.height * dpr;
+ ctx.scale(dpr, dpr);
+
+ const width = rect.width;
+ const height = rect.height;
+ const padding = 40;
+
+ ctx.clearRect(0, 0, width, height);
+
+ // Grid
+ ctx.strokeStyle = "rgba(100, 100, 100, 0.1)";
+ ctx.lineWidth = 1;
+ for (let i = 0; i <= 10; i++) {
+ const y = padding + (height - 2 * padding) * i / 10;
+ ctx.beginPath();
+ ctx.moveTo(padding, y);
+ ctx.lineTo(width - padding, y);
+ ctx.stroke();
+ }
+
+ if (lossHistory.length > 1) {
+ const minLoss = Math.min(...lossHistory);
+ const maxLoss = Math.max(...lossHistory);
+ const range = maxLoss - minLoss || 1;
+
+ // Loss line
+ ctx.strokeStyle = "#22c55e";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.beginPath();
+
+ lossHistory.forEach((loss, i) => {
+ const x = padding + (width - 2 * padding) * i / (lossHistory.length - 1);
+ const y = height - padding - (height - 2 * padding) * (loss - minLoss) / range;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ });
+ ctx.stroke();
+
+ // Points
+ ctx.fillStyle = "#22c55e";
+ lossHistory.forEach((loss, i) => {
+ const x = padding + (width - 2 * padding) * i / (lossHistory.length - 1);
+ const y = height - padding - (height - 2 * padding) * (loss - minLoss) / range;
+ ctx.beginPath();
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
+ ctx.fill();
+ });
+
+ // Trend indicator
+ if (lossHistory.length >= 2) {
+ const trend = lossHistory[lossHistory.length - 1] - lossHistory[lossHistory.length - 2];
+ const trendIcon = trend < 0 ? TrendingDown : trend > 0 ? TrendingUp : Minus;
+ // Could add trend indicator here
+ }
+ }
+
+ // Labels
+ if (lossHistory.length > 0) {
+ const minLoss = Math.min(...lossHistory);
+ const maxLoss = Math.max(...lossHistory);
+ ctx.fillStyle = "#9ca3af";
+ ctx.font = "11px sans-serif";
+ ctx.textAlign = "right";
+ ctx.fillText(maxLoss.toFixed(4), padding - 5, padding + 12);
+ ctx.fillText(minLoss.toFixed(4), padding - 5, height - padding + 4);
+ ctx.textAlign = "center";
+ ctx.fillText("Epoch", width / 2, height - 5);
+ }
+ };
+
+ return (
+
+ {/* Header with Stats */}
+
+
+
+
+
+
Current Epoch
+
{currentEpoch}
+
+
+
+
+
+
+
+
+
+
Current Loss
+
+ {lossHistory[lossHistory.length - 1]?.toFixed(4) || "N/A"}
+
+
+
+
+
+
+
+
+
+
+
Learning Rate
+
{learningRate.toExponential(2)}
+
+
+
+
+
+
+
+
+
+
Status
+
+
+ {isTraining ? "Training" : "Idle"}
+
+
+
+
+
+
+
+
+ {/* Loss Chart */}
+
+
+ Training Loss
+
+ {lossHistory.length} data points
+
+
+
+
+
+
+
+
+ {/* Recent Events */}
+
+
+ Recent Events
+
+
+
+ {events.slice(-10).reverse().map((event, index) => (
+
+
+
+ {new Date(event.timestamp || Date.now()).toLocaleTimeString()}
+
+
{event.type || "event"}
+ {event.loss !== undefined && (
+
+ Loss: {event.loss.toFixed(4)}
+
+ )}
+
+ ))}
+
+
+
+
+ );
+}
+
+export function ModelWeightSelector({
+ models,
+ adapters,
+ currentModel,
+ currentAdapter,
+ onModelChange,
+ onAdapterChange,
+ onLoadAdapter,
+ onUnloadAdapter,
+}: {
+ models: ModelInfo[];
+ adapters: AdapterInfo[];
+ currentModel: string;
+ currentAdapter: string | null;
+ onModelChange: (model: string) => void;
+ onAdapterChange: (adapter: string | null) => void;
+ onLoadAdapter: (path: string) => void;
+ onUnloadAdapter: () => void;
+}) {
+ return (
+
+ {/* Base Model Selector */}
+
+
+
+
+ Base Model
+
+
+
+
+
+
+ {models.map(model => (
+
+ {model.name}
+ {model.loaded && }
+
+ ))}
+
+
+
+
+ {/* Adapter Selector */}
+
+
+
+
+ LoRA Adapters
+
+
+
+
+
+
+ onAdapterChange("")}
+ >
+ None (Base Model)
+
+ {adapters.map(adapter => (
+ onAdapterChange(adapter.name)}
+ >
+ {adapter.name}
+ {adapter.loaded && }
+
+ ))}
+
+
+ {currentAdapter && (
+
+
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx
new file mode 100644
index 00000000..9866ba0d
--- /dev/null
+++ b/frontend/src/components/ui/progress.tsx
@@ -0,0 +1,19 @@
+import * as Progress from "@base-ui/react/progress";
+import { cn } from "@/lib/utils";
+
+const ProgressPrimitive = Progress.Root;
+
+function Progress({ className, ...props }: any) {
+ return (
+
+ );
+}
+
+export { Progress };
\ No newline at end of file
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 00000000..c1a81119
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,12 @@
+import { Select } from "@base-ui/react/select";
+import { cn } from "@/lib/utils";
+
+const { Root, Trigger, Value, Popup, Item } = Select;
+
+export {
+ Root as Select,
+ Trigger as SelectTrigger,
+ Value as SelectValue,
+ Popup as SelectContent,
+ Item as SelectItem
+};
\ No newline at end of file
diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx
new file mode 100644
index 00000000..59049fdd
--- /dev/null
+++ b/frontend/src/components/ui/slider.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+import { Slider as SliderPrimitive } from "@base-ui/react/slider"
+
+import { cn } from "@/lib/utils"
+
+function Slider({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Slider }
\ No newline at end of file
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..17307c5d
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,17 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
\ No newline at end of file
diff --git a/src/__pycache__/main.cpython-314.pyc b/src/__pycache__/main.cpython-314.pyc
deleted file mode 100644
index 72adbb7d..00000000
Binary files a/src/__pycache__/main.cpython-314.pyc and /dev/null differ
diff --git a/src/agents/direct_agent_brain.py b/src/agents/direct_agent_brain.py
new file mode 100644
index 00000000..b63bcf7e
--- /dev/null
+++ b/src/agents/direct_agent_brain.py
@@ -0,0 +1,444 @@
+"""
+Embedded Multi-Agent Brain Engine - Direct PyTorch Multi-Agent Reasoning
+
+This module implements a team of specialized cognitive agents (Planner, Researcher,
+Coder, Evaluator, Safety Gate) that operate in-process directly over the native
+local PyTorch model, eliminating external API dependencies.
+"""
+
+import logging
+import json
+import uuid
+from dataclasses import dataclass, field
+from typing import Dict, Any, List, Optional, Callable
+from enum import Enum
+from datetime import datetime
+
+from src.llm.local_weight_engine import LocalWeightEngine
+from src.config.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class AgentRole(str, Enum):
+ """Specialized agent roles in the multi-agent brain"""
+ PLANNER = "planner"
+ RESEARCHER = "researcher"
+ CODER = "coder"
+ EVALUATOR = "evaluator"
+ SAFETY_GATE = "safety_gate"
+
+
+@dataclass
+class BrainState:
+ """State container for multi-agent reasoning process"""
+ objective: str
+ current_step: int = 0
+ max_steps: int = 10
+ plan: List[Dict[str, Any]] = field(default_factory=list)
+ research_findings: List[Dict[str, Any]] = field(default_factory=list)
+ code_patches: List[Dict[str, Any]] = field(default_factory=list)
+ evaluation_results: List[Dict[str, Any]] = field(default_factory=list)
+ safety_checks: List[Dict[str, Any]] = field(default_factory=list)
+ final_output: Optional[str] = None
+ errors: List[str] = field(default_factory=list)
+ metadata: Dict[str, Any] = field(default_factory=dict)
+ created_at: datetime = field(default_factory=datetime.now)
+ updated_at: datetime = field(default_factory=datetime.now)
+
+
+@dataclass
+class AgentResult:
+ """Result from a single agent execution"""
+ agent_role: AgentRole
+ success: bool
+ output: Any
+ reasoning: str
+ confidence: float
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+
+class DirectAgentBrain:
+ """
+ Embedded multi-agent brain that runs specialized agents directly on local model weights.
+
+ Agents:
+ - Planner: Breaks down user requirements into execution steps
+ - Researcher: Scans project files and extracts relevant code context
+ - Coder: Generates code implementations or patches
+ - Evaluator: Evaluates generated code against test criteria
+ - Safety Gate: Runs static safety checks on generated code
+ """
+
+ def __init__(self, engine: LocalWeightEngine):
+ self.engine = engine
+ self._agents: Dict[AgentRole, Callable] = {}
+ self._state: Optional[BrainState] = None
+ self._execution_history: List[AgentResult] = []
+ self._register_agents()
+
+ def _register_agents(self) -> None:
+ """Register all specialized agent nodes"""
+ self._agents = {
+ AgentRole.PLANNER: self._planner_node,
+ AgentRole.RESEARCHER: self._researcher_node,
+ AgentRole.CODER: self._coder_node,
+ AgentRole.EVALUATOR: self._evaluator_node,
+ AgentRole.SAFETY_GATE: self._safety_gate_node,
+ }
+
+ def step(self, user_objective: str) -> BrainState:
+ """
+ Execute a complete multi-agent reasoning cycle.
+
+ Flow: Planner -> Researcher -> Coder -> Evaluator -> Safety Gate
+
+ Args:
+ user_objective: The high-level objective to accomplish
+
+ Returns:
+ Final BrainState with all agent outputs
+ """
+ # Initialize state
+ self._state = BrainState(objective=user_objective)
+ self._execution_history = []
+
+ logger.info(f"Starting multi-agent brain for objective: {user_objective}")
+
+ # Execute agents in sequence
+ agent_sequence = [
+ AgentRole.PLANNER,
+ AgentRole.RESEARCHER,
+ AgentRole.CODER,
+ AgentRole.EVALUATOR,
+ AgentRole.SAFETY_GATE,
+ ]
+
+ for role in agent_sequence:
+ if self._state.current_step >= self._state.max_steps:
+ logger.warning("Max steps reached, stopping execution")
+ break
+
+ logger.info(f"Executing {role.value} agent")
+ result = self._execute_agent(role)
+ self._execution_history.append(result)
+ self._state.current_step += 1
+ self._state.updated_at = datetime.now()
+
+ if not result.success:
+ logger.error(f"Agent {role.value} failed: {result.reasoning}")
+ self._state.errors.append(f"{role.value}: {result.reasoning}")
+ # Continue anyway for observability
+
+ # Compile final output
+ self._compile_final_output()
+
+ logger.info(f"Multi-agent brain completed in {self._state.current_step} steps")
+ return self._state
+
+ def _execute_agent(self, role: AgentRole) -> AgentResult:
+ """Execute a single agent and return result"""
+ agent_fn = self._agents.get(role)
+ if not agent_fn:
+ return AgentResult(
+ agent_role=role,
+ success=False,
+ output=None,
+ reasoning=f"Unknown agent role: {role}",
+ confidence=0.0,
+ )
+
+ try:
+ output = agent_fn(self._state)
+ return AgentResult(
+ agent_role=role,
+ success=True,
+ output=output,
+ reasoning=f"{role.value} completed successfully",
+ confidence=0.8,
+ )
+ except Exception as e:
+ logger.error(f"Agent {role.value} error: {e}")
+ return AgentResult(
+ agent_role=role,
+ success=False,
+ output=None,
+ reasoning=str(e),
+ confidence=0.0,
+ )
+
+ def _planner_node(self, state: BrainState) -> Dict[str, Any]:
+ """Planner Agent: Decompose objective into actionable steps"""
+ prompt = f"""You are a Planner Agent. Break down the following objective into 3-5 concrete, executable steps.
+
+Objective: {state.objective}
+
+Return a JSON array of steps, each with:
+- step_id: unique identifier
+- description: what to do
+- agent: which agent should execute (researcher, coder, evaluator)
+- dependencies: list of step_ids that must complete first
+- success_criteria: how to verify completion
+
+Example format:
+[
+ {{"step_id": "1", "description": "Research existing codebase", "agent": "researcher", "dependencies": [], "success_criteria": "Found relevant files"}},
+ {{"step_id": "2", "description": "Implement feature", "agent": "coder", "dependencies": ["1"], "success_criteria": "Code compiles and tests pass"}}
+]"""
+
+ response = self.engine.generate(prompt, max_new_tokens=512, temperature=0.3)
+
+ try:
+ plan = json.loads(response)
+ state.plan = plan
+ return {"plan": plan, "steps_count": len(plan)}
+ except json.JSONDecodeError:
+ # Fallback plan
+ fallback_plan = [
+ {"step_id": "1", "description": "Research codebase context", "agent": "researcher", "dependencies": [], "success_criteria": "Context gathered"},
+ {"step_id": "2", "description": "Generate implementation", "agent": "coder", "dependencies": ["1"], "success_criteria": "Code generated"},
+ {"step_id": "3", "description": "Evaluate implementation", "agent": "evaluator", "dependencies": ["2"], "success_criteria": "Tests pass"},
+ ]
+ state.plan = fallback_plan
+ return {"plan": fallback_plan, "steps_count": len(fallback_plan), "fallback": True}
+
+ def _researcher_node(self, state: BrainState) -> Dict[str, Any]:
+ """Researcher Agent: Scan project and extract relevant context"""
+ # Build context from plan
+ plan_context = json.dumps(state.plan, indent=2)
+
+ prompt = f"""You are a Researcher Agent. Analyze the objective and plan to identify what codebase context is needed.
+
+Objective: {state.objective}
+Plan: {plan_context}
+
+Return a JSON object with:
+- files_to_examine: list of file paths or patterns to look at
+- key_concepts: important concepts/patterns to understand
+- dependencies: external dependencies or libraries involved
+- risks: potential issues or complexities
+- recommendations: suggested approach"""
+
+ response = self.engine.generate(prompt, max_new_tokens=512, temperature=0.3)
+
+ try:
+ findings = json.loads(response)
+ state.research_findings.append(findings)
+ return findings
+ except json.JSONDecodeError:
+ fallback = {
+ "files_to_examine": ["src/", "tests/"],
+ "key_concepts": ["local LLM integration", "fine-tuning pipeline"],
+ "dependencies": ["torch", "transformers", "peft"],
+ "risks": ["Model loading memory", "Training stability"],
+ "recommendations": ["Use LoRA adapters", "Implement checkpointing"],
+ }
+ state.research_findings.append(fallback)
+ return fallback
+
+ def _coder_node(self, state: BrainState) -> Dict[str, Any]:
+ """Coder Agent: Generate code implementation based on plan and research"""
+ research_context = json.dumps(state.research_findings, indent=2)
+ plan_context = json.dumps(state.plan, indent=2)
+
+ prompt = f"""You are a Coder Agent. Generate a Python implementation based on the objective, plan, and research.
+
+Objective: {state.objective}
+Plan: {plan_context}
+Research Findings: {research_context}
+
+Generate a complete, working Python implementation. Return as JSON with:
+- files: array of {{"path": "...", "content": "..."}}
+- explanation: brief description of the implementation
+- tests: suggested test cases"""
+
+ response = self.engine.generate(prompt, max_new_tokens=1024, temperature=0.2)
+
+ try:
+ code_output = json.loads(response)
+ state.code_patches.append(code_output)
+ return code_output
+ except json.JSONDecodeError:
+ fallback = {
+ "files": [
+ {"path": "src/generated/implementation.py", "content": "# Implementation placeholder\npass"}
+ ],
+ "explanation": "Fallback implementation - needs manual completion",
+ "tests": ["Test basic functionality", "Test error handling"],
+ }
+ state.code_patches.append(fallback)
+ return fallback
+
+ def _evaluator_node(self, state: BrainState) -> Dict[str, Any]:
+ """Evaluator Agent: Evaluate generated code against criteria"""
+ code_context = json.dumps(state.code_patches, indent=2)
+ plan_context = json.dumps(state.plan, indent=2)
+
+ prompt = f"""You are an Evaluator Agent. Assess the generated implementation against the original objective.
+
+Objective: {state.objective}
+Plan: {plan_context}
+Generated Code: {code_context}
+
+Return a JSON object with:
+- score: 0.0-1.0 overall quality score
+- criteria_met: list of satisfied success criteria
+- criteria_failed: list of unmet criteria
+- issues: specific problems found
+- suggestions: improvements needed
+- passes: true/false whether it meets minimum threshold (0.7)"""
+
+ response = self.engine.generate(prompt, max_new_tokens=512, temperature=0.2)
+
+ try:
+ evaluation = json.loads(response)
+ state.evaluation_results.append(evaluation)
+ return evaluation
+ except json.JSONDecodeError:
+ fallback = {
+ "score": 0.5,
+ "criteria_met": ["Basic structure"],
+ "criteria_failed": ["Functionality verification"],
+ "issues": ["Evaluation parsing failed"],
+ "suggestions": ["Manual review required"],
+ "passes": False,
+ }
+ state.evaluation_results.append(fallback)
+ return fallback
+
+ def _safety_gate_node(self, state: BrainState) -> Dict[str, Any]:
+ """Safety Gate Agent: Run static safety checks on generated code"""
+ code_context = json.dumps(state.code_patches, indent=2)
+ evaluation = state.evaluation_results[-1] if state.evaluation_results else {}
+
+ prompt = f"""You are a Safety Gate Agent. Perform static safety analysis on the generated code.
+
+Code: {code_context}
+Evaluation: {json.dumps(evaluation, indent=2)}
+
+Check for:
+- Dangerous imports (os.system, subprocess, eval, exec)
+- File system access outside project
+- Network requests to external hosts
+- Code injection vulnerabilities
+- Hardcoded secrets or credentials
+- Infinite loops or resource exhaustion
+
+Return JSON with:
+- safe: true/false
+- violations: list of safety violations found
+- warnings: list of warnings
+- recommended_action: "allow", "modify", or "reject" """
+
+ response = self.engine.generate(prompt, max_new_tokens=512, temperature=0.1)
+
+ try:
+ safety = json.loads(response)
+ state.safety_checks.append(safety)
+ return safety
+ except json.JSONDecodeError:
+ fallback = {
+ "safe": True,
+ "violations": [],
+ "warnings": ["Safety check parsing failed - manual review recommended"],
+ "recommended_action": "allow",
+ }
+ state.safety_checks.append(fallback)
+ return fallback
+
+ def _compile_final_output(self) -> None:
+ """Compile final output from all agent results"""
+ if not self._state:
+ return
+
+ # Determine overall success
+ all_success = all(
+ r.success for r in self._execution_history
+ )
+ safety_passed = (
+ self._state.safety_checks[-1].get("safe", True)
+ if self._state.safety_checks else True
+ )
+ evaluation_passed = (
+ self._state.evaluation_results[-1].get("passes", False)
+ if self._state.evaluation_results else False
+ )
+
+ overall_success = all_success and safety_passed and evaluation_passed
+
+ # Build summary
+ summary = {
+ "objective": self._state.objective,
+ "success": overall_success,
+ "steps_executed": self._state.current_step,
+ "agents_run": [r.agent_role.value for r in self._execution_history],
+ "plan": self._state.plan,
+ "research_findings": self._state.research_findings,
+ "code_generated": len(self._state.code_patches) > 0,
+ "evaluation_score": self._state.evaluation_results[-1].get("score", 0) if self._state.evaluation_results else 0,
+ "safety_passed": safety_passed,
+ "errors": self._state.errors,
+ }
+
+ self._state.final_output = json.dumps(summary, indent=2)
+ self._state.metadata["summary"] = summary
+
+ def get_execution_history(self) -> List[AgentResult]:
+ """Get the execution history of all agents"""
+ return self._execution_history
+
+ def get_state(self) -> Optional[BrainState]:
+ """Get the current brain state"""
+ return self._state
+
+ def reset(self) -> None:
+ """Reset the brain for a new objective"""
+ self._state = None
+ self._execution_history = []
+
+
+class MultiAgentSwarm:
+ """
+ Coordinates multiple DirectAgentBrain instances for parallel problem solving.
+ """
+
+ def __init__(self, num_brains: int = 3):
+ self.num_brains = num_brains
+ self._engines: List[LocalWeightEngine] = []
+ self._brains: List[DirectAgentBrain] = []
+
+ def initialize(self, model_config) -> None:
+ """Initialize multiple model engines and brains"""
+ for i in range(self.num_brains):
+ engine = LocalWeightEngine()
+ engine.load_model(model_config)
+ engine.attach_lora(LoRAConfig())
+ self._engines.append(engine)
+ self._brains.append(DirectAgentBrain(engine))
+
+ def solve_parallel(self, objective: str) -> List[BrainState]:
+ """Run multiple brains in parallel on the same objective"""
+ import concurrent.futures
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.num_brains) as executor:
+ futures = [executor.submit(brain.step, objective) for brain in self._brains]
+ results = [f.result() for f in concurrent.futures.as_completed(futures)]
+
+ return results
+
+ def consensus(self, results: List[BrainState]) -> BrainState:
+ """Select best result via consensus"""
+ # Simple consensus: pick the one with highest evaluation score
+ best = max(results, key=lambda r: r.metadata.get("summary", {}).get("evaluation_score", 0))
+ return best
+
+ def cleanup(self) -> None:
+ """Clean up all engines"""
+ for engine in self._engines:
+ engine.unload()
+ self._engines.clear()
+ self._brains.clear()
+
+
+# Need to import LoRAConfig for the swarm
+from src.llm.local_weight_engine import LoRAConfig
\ No newline at end of file
diff --git a/src/api/__pycache__/free_api_client.cpython-314.pyc b/src/api/__pycache__/free_api_client.cpython-314.pyc
deleted file mode 100644
index e5487bad..00000000
Binary files a/src/api/__pycache__/free_api_client.cpython-314.pyc and /dev/null differ
diff --git a/src/api/main.py b/src/api/main.py
index f88fc83e..cca20708 100644
--- a/src/api/main.py
+++ b/src/api/main.py
@@ -175,7 +175,7 @@ def create_app() -> FastAPI:
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestSizeLimitMiddleware, max_size=10 * 1024 * 1024)
app.add_middleware(TimeoutMiddleware, timeout=60.0)
-
+
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
diff --git a/src/api/routes/chat.py b/src/api/routes/chat.py
index c4144f09..4f1a33ab 100644
--- a/src/api/routes/chat.py
+++ b/src/api/routes/chat.py
@@ -132,7 +132,9 @@ async def chat(
@router.post("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/chat/stream")
@limiter.limit("20/minute")
async def chat_stream(
- request: StreamChatRequest, request_obj: Request, current_user: TokenData = Depends(get_current_user)
+ request: StreamChatRequest,
+ request_obj: Request,
+ current_user: TokenData = Depends(get_current_user),
):
"""Handle streaming chat requests."""
try:
diff --git a/src/chatbot/__pycache__/learning_chatbot.cpython-314.pyc b/src/chatbot/__pycache__/learning_chatbot.cpython-314.pyc
deleted file mode 100644
index 3ca4f3e6..00000000
Binary files a/src/chatbot/__pycache__/learning_chatbot.cpython-314.pyc and /dev/null differ
diff --git a/src/cli/execution_loop_harness.py b/src/cli/execution_loop_harness.py
index 32927bec..a14e5521 100644
--- a/src/cli/execution_loop_harness.py
+++ b/src/cli/execution_loop_harness.py
@@ -77,8 +77,13 @@ def list(self, objective_id: str) -> list[dict[str, Any]]:
def _read_document(self) -> dict[str, Any]:
if not self.path.exists():
return {"version": 1, "objectives": {}}
+ if self.path.stat().st_size == 0:
+ return {"version": 1, "objectives": {}}
with self.path.open(encoding="utf-8") as checkpoint_file:
- document = json.load(checkpoint_file)
+ content = checkpoint_file.read().strip()
+ if not content:
+ return {"version": 1, "objectives": {}}
+ document = json.loads(content)
if document.get("version") != 1 or not isinstance(document.get("objectives"), dict):
raise click.ClickException(f"Invalid checkpoint store: {self.path}")
return document
diff --git a/src/crawler/__pycache__/web_crawler.cpython-314.pyc b/src/crawler/__pycache__/web_crawler.cpython-314.pyc
deleted file mode 100644
index a5637926..00000000
Binary files a/src/crawler/__pycache__/web_crawler.cpython-314.pyc and /dev/null differ
diff --git a/src/db/models.py b/src/db/models.py
index 57d159b4..37f4479e 100644
--- a/src/db/models.py
+++ b/src/db/models.py
@@ -2538,14 +2538,13 @@ class SubOrchestrator(Base):
# Relationships
director: Mapped[DirectorAgent] = relationship(back_populates="sub_orchestrators")
current_task: Mapped[SwarmSubTask | None] = relationship(
- "SwarmSubTask",
- foreign_keys=[current_task_id]
+ "SwarmSubTask", foreign_keys=[current_task_id]
)
assigned_tasks: Mapped[List["SwarmSubTask"]] = relationship(
"SwarmSubTask",
foreign_keys="[assigned_orchestrator_id]",
back_populates="assigned_orchestrator",
- cascade="all, delete-orphan"
+ cascade="all, delete-orphan",
)
def __repr__(self) -> str:
diff --git a/src/knowledge/__pycache__/knowledge_integrator.cpython-314.pyc b/src/knowledge/__pycache__/knowledge_integrator.cpython-314.pyc
deleted file mode 100644
index 50983b5b..00000000
Binary files a/src/knowledge/__pycache__/knowledge_integrator.cpython-314.pyc and /dev/null differ
diff --git a/src/learning/__pycache__/continuous_learner.cpython-314.pyc b/src/learning/__pycache__/continuous_learner.cpython-314.pyc
deleted file mode 100644
index d6492705..00000000
Binary files a/src/learning/__pycache__/continuous_learner.cpython-314.pyc and /dev/null differ
diff --git a/src/learning/auto_finetune_trigger.py b/src/learning/auto_finetune_trigger.py
new file mode 100644
index 00000000..33b49dcf
--- /dev/null
+++ b/src/learning/auto_finetune_trigger.py
@@ -0,0 +1,253 @@
+"""
+Auto Fine-Tune Trigger - Connects evaluator feedback to fine-tuning pipeline.
+
+This module monitors evaluation results and automatically triggers fine-tuning
+when requirements fail, enabling the system to learn from its mistakes.
+"""
+
+import logging
+import uuid
+from dataclasses import dataclass, field
+from typing import Dict, Any, Optional, List
+from datetime import datetime
+
+from src.llm.local_weight_engine import LocalWeightEngine
+from src.learning.requirement_finetune_pipeline import (
+ RequirementFineTunePipeline,
+ FineTuneConfig,
+ TrainingExample,
+ create_training_examples_from_feedback,
+)
+from src.config.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+@dataclass
+class AutoFineTuneConfig:
+ """Configuration for automatic fine-tuning trigger"""
+ evaluation_threshold: float = 0.8 # Trigger fine-tuning if score < threshold
+ max_auto_finetune_epochs: int = 3
+ auto_finetune_learning_rate: float = 2e-4
+ adapter_save_path: str = "data/adapters/requirement_adapter"
+ require_safety_approval: bool = True
+ max_concurrent_finetunes: int = 1
+
+
+@dataclass
+class FineTuneTriggerRecord:
+ """Record of a fine-tuning trigger event"""
+ trigger_id: str
+ requirement_id: str
+ evaluation_score: float
+ trigger_timestamp: datetime
+ fine_tune_config: FineTuneConfig
+ training_examples_count: int
+ fine_tune_results: Optional[Dict[str, Any]] = None
+ adapter_reloaded: bool = False
+ status: str = "triggered" # triggered, training, completed, failed
+
+
+class AutoFineTuneTrigger:
+ """
+ Monitors evaluator feedback and automatically triggers fine-tuning
+ when requirements fail to meet quality thresholds.
+ """
+
+ def __init__(
+ self,
+ engine: LocalWeightEngine,
+ pipeline: RequirementFineTunePipeline,
+ config: Optional[AutoFineTuneConfig] = None,
+ ):
+ self.engine = engine
+ self.pipeline = pipeline
+ self.config = config or AutoFineTuneConfig()
+ self._trigger_history: List[FineTuneTriggerRecord] = []
+ self._active_finetunes: int = 0
+
+ def check_evaluation_score(self, eval_result: Dict[str, Any]) -> bool:
+ """
+ Check if evaluation score triggers auto fine-tuning.
+
+ Args:
+ eval_result: Evaluation result from evaluator agent
+
+ Returns:
+ True if fine-tuning should be triggered
+ """
+ score = eval_result.get("score", 1.0)
+ passes = eval_result.get("passes", True)
+
+ # Trigger if score is below threshold or explicitly fails
+ should_trigger = score < self.config.evaluation_threshold or not passes
+
+ if should_trigger:
+ logger.info(
+ f"Auto fine-tune triggered: score={score:.3f}, "
+ f"threshold={self.config.evaluation_threshold}, passes={passes}"
+ )
+
+ return should_trigger
+
+ def trigger_fine_tune(
+ self,
+ requirement_id: str,
+ eval_result: Dict[str, Any],
+ failed_requirement: str,
+ expected_behavior: str,
+ actual_output: str,
+ ) -> FineTuneTriggerRecord:
+ """
+ Trigger automatic fine-tuning based on failed evaluation.
+
+ Args:
+ requirement_id: Unique identifier for the failed requirement
+ eval_result: Full evaluation result from evaluator
+ failed_requirement: The requirement that failed
+ expected_behavior: What the correct behavior should be
+ actual_output: What the model actually produced
+
+ Returns:
+ FineTuneTriggerRecord with trigger details and results
+ """
+ if self._active_finetunes >= self.config.max_concurrent_finetunes:
+ logger.warning("Max concurrent fine-tunes reached, skipping trigger")
+ return FineTuneTriggerRecord(
+ trigger_id=str(uuid.uuid4()),
+ requirement_id=requirement_id,
+ evaluation_score=eval_result.get("score", 0.0),
+ trigger_timestamp=datetime.now(),
+ fine_tune_config=FineTuneConfig(),
+ training_examples_count=0,
+ status="skipped_max_concurrent",
+ )
+
+ # Create training examples from the feedback
+ training_examples = create_training_examples_from_feedback(
+ failed_requirement=failed_requirement,
+ expected_behavior=expected_behavior,
+ actual_output=actual_output,
+ requirement_id=requirement_id,
+ )
+
+ # Configure fine-tuning
+ fine_tune_config = FineTuneConfig(
+ epochs=self.config.max_auto_finetune_epochs,
+ learning_rate=self.config.auto_finetune_learning_rate,
+ output_dir=self.config.adapter_save_path,
+ )
+
+ # Create trigger record
+ trigger_id = str(uuid.uuid4())
+ record = FineTuneTriggerRecord(
+ trigger_id=trigger_id,
+ requirement_id=requirement_id,
+ evaluation_score=eval_result.get("score", 0.0),
+ trigger_timestamp=datetime.now(),
+ fine_tune_config=fine_tune_config,
+ training_examples_count=len(training_examples),
+ )
+
+ self._trigger_history.append(record)
+ self._active_finetunes += 1
+
+ logger.info(f"Starting auto fine-tune {trigger_id} for requirement {requirement_id}")
+
+ try:
+ record.status = "training"
+
+ # Run fine-tuning
+ results = self.pipeline.fine_tune(
+ training_examples=training_examples,
+ epochs=fine_tune_config.epochs,
+ learning_rate=fine_tune_config.learning_rate,
+ )
+
+ record.fine_tune_results = results
+ record.status = "completed"
+
+ # Save adapter
+ adapter_path = self.pipeline.save_adapter(fine_tune_config.output_dir)
+ logger.info(f"Adapter saved to {adapter_path}")
+
+ # Reload adapter into engine for immediate use
+ self.reload_adapter(adapter_path)
+ record.adapter_reloaded = True
+
+ logger.info(
+ f"Auto fine-tune {trigger_id} completed: "
+ f"final_loss={results['final_loss']:.4f}, "
+ f"loss_decreased={results['loss_decreased']}"
+ )
+
+ except Exception as exc:
+ logger.error(f"Auto fine-tune {trigger_id} failed: {exc}")
+ record.status = "failed"
+ record.fine_tune_results = {"error": str(exc)}
+
+ finally:
+ self._active_finetunes -= 1
+
+ return record
+
+ def reload_adapter(self, adapter_path: str) -> None:
+ """
+ Reload the updated adapter into the engine.
+
+ Args:
+ adapter_path: Path to the saved adapter
+ """
+ logger.info(f"Reloading adapter from {adapter_path}")
+ self.engine.load_lora_adapter(adapter_path)
+ logger.info("Adapter reloaded successfully - subsequent generations will use updated weights")
+
+ def get_trigger_history(self) -> List[FineTuneTriggerRecord]:
+ """Get history of all fine-tune triggers"""
+ return self._trigger_history
+
+ def get_latest_trigger(self) -> Optional[FineTuneTriggerRecord]:
+ """Get the most recent trigger record"""
+ if not self._trigger_history:
+ return None
+ return self._trigger_history[-1]
+
+ def get_statistics(self) -> Dict[str, Any]:
+ """Get statistics about auto fine-tune triggers"""
+ total = len(self._trigger_history)
+ completed = sum(1 for r in self._trigger_history if r.status == "completed")
+ failed = sum(1 for r in self._trigger_history if r.status == "failed")
+ skipped = sum(1 for r in self._trigger_history if r.status == "skipped_max_concurrent")
+
+ loss_decreased_count = sum(
+ 1 for r in self._trigger_history
+ if r.fine_tune_results and r.fine_tune_results.get("loss_decreased", False)
+ )
+
+ return {
+ "total_triggers": total,
+ "completed": completed,
+ "failed": failed,
+ "skipped": skipped,
+ "success_rate": completed / total if total > 0 else 0.0,
+ "loss_decreased_rate": loss_decreased_count / completed if completed > 0 else 0.0,
+ "active_finetunes": self._active_finetunes,
+ }
+
+
+def create_auto_finetune_trigger(
+ engine: LocalWeightEngine,
+ config: Optional[AutoFineTuneConfig] = None,
+) -> AutoFineTuneTrigger:
+ """
+ Factory function to create an AutoFineTuneTrigger with all dependencies.
+
+ Args:
+ engine: LocalWeightEngine instance
+ config: Optional AutoFineTuneConfig
+
+ Returns:
+ Configured AutoFineTuneTrigger instance
+ """
+ pipeline = RequirementFineTunePipeline(engine, FineTuneConfig())
+ return AutoFineTuneTrigger(engine, pipeline, config)
\ No newline at end of file
diff --git a/src/learning/requirement_finetune_pipeline.py b/src/learning/requirement_finetune_pipeline.py
new file mode 100644
index 00000000..01facd0e
--- /dev/null
+++ b/src/learning/requirement_finetune_pipeline.py
@@ -0,0 +1,440 @@
+"""
+Direct Parameter Fine-Tuning Pipeline for native local LLM weight updates.
+
+This module implements gradient-based fine-tuning directly on model weights
+using PyTorch backpropagation, enabling the system to learn from failed
+requirements by updating its own parameters.
+"""
+
+import logging
+import os
+from dataclasses import dataclass
+from typing import Optional, Dict, Any, List, Iterator
+from pathlib import Path
+
+import torch
+from torch.utils.data import Dataset, DataLoader
+from torch.optim import AdamW
+from torch.nn.utils import clip_grad_norm_
+from transformers import AutoTokenizer
+from peft import PeftModel
+
+from src.llm.local_weight_engine import LocalWeightEngine, LoRAConfig
+from src.config.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+@dataclass
+class FineTuneConfig:
+ """Configuration for fine-tuning process"""
+ epochs: int = 3
+ learning_rate: float = 2e-4
+ batch_size: int = 1
+ gradient_accumulation_steps: int = 4
+ max_grad_norm: float = 1.0
+ warmup_steps: int = 10
+ weight_decay: float = 0.01
+ max_seq_length: int = 512
+ save_steps: int = 50
+ logging_steps: int = 10
+ output_dir: str = "data/adapters/requirement_adapter"
+
+
+@dataclass
+class TrainingExample:
+ """Single training example for requirement fine-tuning"""
+ prompt: str
+ completion: str
+ requirement_id: str
+ metadata: Dict[str, Any] = None
+
+ def __post_init__(self):
+ if self.metadata is None:
+ self.metadata = {}
+
+
+class RequirementDataset(Dataset):
+ """
+ Dataset for requirement-based fine-tuning.
+
+ Tokenizes prompt-completion pairs from failed requirements
+ into input_ids, attention_mask, and labels for causal LM training.
+ """
+
+ def __init__(
+ self,
+ examples: List[TrainingExample],
+ tokenizer: AutoTokenizer,
+ max_seq_length: int = 512,
+ ):
+ self.examples = examples
+ self.tokenizer = tokenizer
+ self.max_seq_length = max_seq_length
+
+ # Ensure pad token is set
+ if self.tokenizer.pad_token is None:
+ self.tokenizer.pad_token = self.tokenizer.eos_token
+
+ def __len__(self) -> int:
+ return len(self.examples)
+
+ def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
+ example = self.examples[idx]
+
+ # Format as prompt + completion
+ full_text = example.prompt + example.completion
+
+ # Tokenize full text
+ tokenized = self.tokenizer(
+ full_text,
+ truncation=True,
+ max_length=self.max_seq_length,
+ padding="max_length",
+ return_tensors="pt",
+ )
+
+ input_ids = tokenized["input_ids"].squeeze(0)
+ attention_mask = tokenized["attention_mask"].squeeze(0)
+
+ # For causal LM, labels are the same as input_ids
+ # We'll mask the prompt portion in the loss calculation
+ labels = input_ids.clone()
+
+ # Tokenize prompt separately to find its length
+ prompt_tokens = self.tokenizer(
+ example.prompt,
+ truncation=True,
+ max_length=self.max_seq_length,
+ padding=False,
+ return_tensors="pt",
+ )
+ prompt_length = prompt_tokens["input_ids"].shape[1]
+
+ # Mask prompt tokens in labels (set to -100 to ignore in loss)
+ labels[:prompt_length] = -100
+
+ return {
+ "input_ids": input_ids,
+ "attention_mask": attention_mask,
+ "labels": labels,
+ }
+
+
+class RequirementFineTunePipeline:
+ """
+ Fine-tuning pipeline for updating model weights based on requirement feedback.
+
+ Implements the complete training loop:
+ - Dataset preparation from failed requirements
+ - Gradient computation and backpropagation
+ - Optimizer steps with gradient clipping
+ - Adapter checkpointing to .safetensors
+ """
+
+ def __init__(
+ self,
+ engine: LocalWeightEngine,
+ config: Optional[FineTuneConfig] = None,
+ ):
+ self.engine = engine
+ self.config = config or FineTuneConfig()
+ self._optimizer: Optional[AdamW] = None
+ self._scheduler = None
+ self._global_step = 0
+ self._training_losses: List[float] = []
+
+ def prepare_dataset(
+ self,
+ training_examples: List[TrainingExample],
+ ) -> DataLoader:
+ """
+ Prepare DataLoader from training examples.
+
+ Args:
+ training_examples: List of TrainingExample objects
+
+ Returns:
+ DataLoader for training
+ """
+ if self.engine.tokenizer is None:
+ raise RuntimeError("Tokenizer not available. Load model first.")
+
+ dataset = RequirementDataset(
+ examples=training_examples,
+ tokenizer=self.engine.tokenizer,
+ max_seq_length=self.config.max_seq_length,
+ )
+
+ dataloader = DataLoader(
+ dataset,
+ batch_size=self.config.batch_size,
+ shuffle=True,
+ drop_last=False,
+ )
+
+ logger.info(f"Prepared dataset with {len(dataset)} examples")
+ return dataloader
+
+ def setup_optimizer(self) -> None:
+ """Setup optimizer for trainable parameters"""
+ trainable_params = self.engine.get_trainable_parameters()
+
+ if not trainable_params:
+ raise RuntimeError("No trainable parameters found. Attach LoRA adapter first.")
+
+ self._optimizer = AdamW(
+ trainable_params,
+ lr=self.config.learning_rate,
+ weight_decay=self.config.weight_decay,
+ betas=(0.9, 0.999),
+ eps=1e-8,
+ )
+
+ logger.info(f"Optimizer initialized with {len(trainable_params)} parameter groups")
+
+ def fine_tune(
+ self,
+ training_examples: List[TrainingExample],
+ epochs: Optional[int] = None,
+ learning_rate: Optional[float] = None,
+ ) -> Dict[str, Any]:
+ """
+ Execute fine-tuning loop with backpropagation.
+
+ Args:
+ training_examples: List of TrainingExample objects from failed requirements
+ epochs: Number of training epochs (overrides config)
+ learning_rate: Learning rate (overrides config)
+
+ Returns:
+ Dictionary with training metrics and results
+ """
+ if self.engine.model is None:
+ raise RuntimeError("Model not loaded. Call engine.load_model() first.")
+
+ if not self.engine._is_peft_model:
+ raise RuntimeError("LoRA adapter not attached. Call engine.attach_lora() first.")
+
+ # Override config if provided
+ epochs = epochs or self.config.epochs
+ if learning_rate:
+ self.config.learning_rate = learning_rate
+
+ # Enable training mode
+ self.engine.enable_training_mode()
+
+ # Setup optimizer
+ self.setup_optimizer()
+
+ # Prepare dataset
+ dataloader = self.prepare_dataset(training_examples)
+
+ # Training loop
+ total_steps = len(dataloader) * epochs
+ logger.info(f"Starting fine-tuning: {epochs} epochs, {total_steps} total steps")
+
+ self._training_losses = []
+ self._global_step = 0
+
+ for epoch in range(epochs):
+ epoch_loss = 0.0
+ num_batches = 0
+
+ for step, batch in enumerate(dataloader):
+ # Move batch to device
+ batch = {k: v.to(self.engine.device) for k, v in batch.items()}
+
+ # Forward pass
+ outputs = self.engine.forward(
+ input_ids=batch["input_ids"],
+ attention_mask=batch["attention_mask"],
+ labels=batch["labels"],
+ )
+
+ loss = outputs.loss
+
+ # Scale loss for gradient accumulation
+ loss = loss / self.config.gradient_accumulation_steps
+
+ # Backward pass
+ loss.backward()
+
+ epoch_loss += loss.item() * self.config.gradient_accumulation_steps
+ num_batches += 1
+
+ # Gradient accumulation step
+ if (step + 1) % self.config.gradient_accumulation_steps == 0:
+ # Clip gradients
+ clip_grad_norm_(
+ self.engine.get_trainable_parameters(),
+ self.config.max_grad_norm,
+ )
+
+ # Optimizer step
+ self._optimizer.step()
+ self._optimizer.zero_grad()
+
+ self._global_step += 1
+
+ # Logging
+ if self._global_step % self.config.logging_steps == 0:
+ avg_loss = epoch_loss / num_batches if num_batches > 0 else 0
+ logger.info(
+ f"Step {self._global_step}/{total_steps} | "
+ f"Epoch {epoch + 1}/{epochs} | "
+ f"Loss: {avg_loss:.4f}"
+ )
+
+ # Log epoch summary
+ avg_epoch_loss = epoch_loss / num_batches if num_batches > 0 else 0
+ self._training_losses.append(avg_epoch_loss)
+ logger.info(f"Epoch {epoch + 1}/{epochs} completed | Avg Loss: {avg_epoch_loss:.4f}")
+
+ # Final optimizer step if gradients remain
+ if self._optimizer is not None:
+ clip_grad_norm_(
+ self.engine.get_trainable_parameters(),
+ self.config.max_grad_norm,
+ )
+ self._optimizer.step()
+ self._optimizer.zero_grad()
+
+ # Return to eval mode
+ self.engine.enable_eval_mode()
+
+ # Training results
+ results = {
+ "epochs_completed": epochs,
+ "total_steps": self._global_step,
+ "final_loss": self._training_losses[-1] if self._training_losses else 0.0,
+ "loss_history": self._training_losses,
+ "loss_decreased": self._check_loss_decrease(),
+ }
+
+ logger.info(f"Fine-tuning completed. Final loss: {results['final_loss']:.4f}")
+ return results
+
+ def _check_loss_decrease(self) -> bool:
+ """Check if loss decreased during training"""
+ if len(self._training_losses) < 2:
+ return False
+ return self._training_losses[-1] < self._training_losses[0]
+
+ def save_adapter(self, save_path: Optional[str] = None) -> str:
+ """
+ Save fine-tuned LoRA adapter to disk.
+
+ Args:
+ save_path: Path to save adapter (uses config default if None)
+
+ Returns:
+ Path where adapter was saved
+ """
+ path = save_path or self.config.output_dir
+ self.engine.save_lora_adapter(path)
+ return path
+
+ def load_adapter(self, adapter_path: str) -> None:
+ """
+ Load a saved LoRA adapter into the engine.
+
+ Args:
+ adapter_path: Path to the saved adapter
+ """
+ self.engine.load_lora_adapter(adapter_path)
+ logger.info(f"Loaded adapter from {adapter_path}")
+
+ @property
+ def training_losses(self) -> List[float]:
+ return self._training_losses
+
+ @property
+ def global_step(self) -> int:
+ return self._global_step
+
+
+def create_training_examples_from_feedback(
+ failed_requirement: str,
+ expected_behavior: str,
+ actual_output: str,
+ requirement_id: str,
+) -> List[TrainingExample]:
+ """
+ Create training examples from requirement evaluation feedback.
+
+ Args:
+ failed_requirement: The original requirement that failed
+ expected_behavior: What the correct behavior should be
+ actual_output: What the model actually produced
+ requirement_id: Unique identifier for the requirement
+
+ Returns:
+ List of TrainingExample objects
+ """
+ # Create a prompt that includes the requirement and context
+ prompt = f"""Requirement: {failed_requirement}
+
+Expected behavior: {expected_behavior}
+
+The following output was incorrect:
+{actual_output}
+
+Please provide the correct implementation:"""
+
+ # The completion is the expected correct behavior
+ completion = f"\n{expected_behavior}"
+
+ example = TrainingExample(
+ prompt=prompt,
+ completion=completion,
+ requirement_id=requirement_id,
+ metadata={
+ "failed_requirement": failed_requirement,
+ "actual_output": actual_output,
+ }
+ )
+
+ return [example]
+
+
+def create_training_examples_from_code_fix(
+ original_code: str,
+ fixed_code: str,
+ issue_description: str,
+ requirement_id: str,
+) -> List[TrainingExample]:
+ """
+ Create training examples from a code fix scenario.
+
+ Args:
+ original_code: The buggy code
+ fixed_code: The corrected code
+ issue_description: Description of the issue
+ requirement_id: Unique identifier for the requirement
+
+ Returns:
+ List of TrainingExample objects
+ """
+ prompt = f"""Issue: {issue_description}
+
+Buggy code:
+```python
+{original_code}
+```
+
+Fixed code:"""
+
+ completion = f"\n```python\n{fixed_code}\n```"
+
+ example = TrainingExample(
+ prompt=prompt,
+ completion=completion,
+ requirement_id=requirement_id,
+ metadata={
+ "issue": issue_description,
+ "original_code": original_code,
+ }
+ )
+
+ return [example]
\ No newline at end of file
diff --git a/src/llm/__pycache__/__init__.cpython-314.pyc b/src/llm/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 18f1da7a..00000000
Binary files a/src/llm/__pycache__/__init__.cpython-314.pyc and /dev/null differ
diff --git a/src/llm/__pycache__/conversation_manager.cpython-314.pyc b/src/llm/__pycache__/conversation_manager.cpython-314.pyc
deleted file mode 100644
index c34f0eab..00000000
Binary files a/src/llm/__pycache__/conversation_manager.cpython-314.pyc and /dev/null differ
diff --git a/src/llm/__pycache__/deepseek_client.cpython-314.pyc b/src/llm/__pycache__/deepseek_client.cpython-314.pyc
deleted file mode 100644
index a753d5de..00000000
Binary files a/src/llm/__pycache__/deepseek_client.cpython-314.pyc and /dev/null differ
diff --git a/src/llm/__pycache__/rag_service.cpython-314.pyc b/src/llm/__pycache__/rag_service.cpython-314.pyc
deleted file mode 100644
index 9a0c739c..00000000
Binary files a/src/llm/__pycache__/rag_service.cpython-314.pyc and /dev/null differ
diff --git a/src/llm/local_weight_engine.py b/src/llm/local_weight_engine.py
index dc616e4d..b71eb977 100644
--- a/src/llm/local_weight_engine.py
+++ b/src/llm/local_weight_engine.py
@@ -28,6 +28,7 @@
@dataclass
class ModelLoadConfig:
"""Configuration for loading a local model"""
+
model_name_or_path: str
device: str = "auto" # "auto", "mps", "cuda", "cpu"
torch_dtype: str = "bfloat16" # "bfloat16", "float16", "float32"
@@ -41,13 +42,14 @@ class ModelLoadConfig:
@dataclass
class LoRAConfig:
"""Configuration for LoRA adapter"""
+
r: int = 8
lora_alpha: int = 16
target_modules: List[str] = None
lora_dropout: float = 0.05
bias: str = "none"
task_type: str = "CAUSAL_LM"
-
+
def __post_init__(self):
if self.target_modules is None:
self.target_modules = ["q_proj", "v_proj", "k_proj", "o_proj"]
@@ -56,7 +58,7 @@ def __post_init__(self):
class LocalWeightEngine:
"""
Native PyTorch engine for loading local LLM weights and performing direct inference/fine-tuning.
-
+
Supports:
- Qwen2.5-0.5B, Llama-3.2-1B, Phi-3.5-mini, and other HF models
- Automatic device selection (MPS for Apple Silicon, CUDA, CPU)
@@ -64,7 +66,7 @@ class LocalWeightEngine:
- LoRA/PEFT adapters for parameter-efficient fine-tuning
- Direct weight manipulation without external API calls
"""
-
+
def __init__(self):
self._model: Optional[AutoModelForCausalLM] = None
self._tokenizer: Optional[AutoTokenizer] = None
@@ -73,7 +75,7 @@ def __init__(self):
self._model_name: str = ""
self._peft_config: Optional[LoRAConfig] = None
self._is_peft_model: bool = False
-
+
def _resolve_device(self, device: str) -> torch.device:
"""Resolve device string to torch.device"""
if device == "auto":
@@ -84,7 +86,7 @@ def _resolve_device(self, device: str) -> torch.device:
else:
return torch.device("cpu")
return torch.device(device)
-
+
def _resolve_dtype(self, dtype_str: str) -> torch.dtype:
"""Resolve dtype string to torch.dtype"""
dtype_map = {
@@ -93,20 +95,22 @@ def _resolve_dtype(self, dtype_str: str) -> torch.dtype:
"float32": torch.float32,
}
return dtype_map.get(dtype_str, torch.bfloat16)
-
+
def load_model(self, config: ModelLoadConfig) -> None:
"""
Load model and tokenizer directly into PyTorch memory.
-
+
Args:
config: ModelLoadConfig with model path and loading options
"""
self._model_name = config.model_name_or_path
self._device = self._resolve_device(config.device)
torch_dtype = self._resolve_dtype(config.torch_dtype)
-
- logger.info(f"Loading model: {config.model_name_or_path} on {self._device} with {config.torch_dtype}")
-
+
+ logger.info(
+ f"Loading model: {config.model_name_or_path} on {self._device} with {config.torch_dtype}"
+ )
+
# Load tokenizer
self._tokenizer = AutoTokenizer.from_pretrained(
config.model_name_or_path,
@@ -114,7 +118,7 @@ def load_model(self, config: ModelLoadConfig) -> None:
)
if self._tokenizer.pad_token is None:
self._tokenizer.pad_token = self._tokenizer.eos_token
-
+
# Configure quantization if requested
quantization_config = config.quantization_config
if config.use_4bit and quantization_config is None:
@@ -128,7 +132,7 @@ def load_model(self, config: ModelLoadConfig) -> None:
quantization_config = BitsAndBytesConfig(
load_in_8bit=True,
)
-
+
# Load model
self._model = AutoModelForCausalLM.from_pretrained(
config.model_name_or_path,
@@ -138,11 +142,11 @@ def load_model(self, config: ModelLoadConfig) -> None:
quantization_config=quantization_config,
trust_remote_code=config.trust_remote_code,
)
-
+
# Move to device if not using device_map
if str(self._device) == "cpu" or config.device != "auto":
self._model = self._model.to(self._device)
-
+
# Load generation config
try:
self._generation_config = GenerationConfig.from_pretrained(config.model_name_or_path)
@@ -157,22 +161,22 @@ def load_model(self, config: ModelLoadConfig) -> None:
pad_token_id=self._tokenizer.pad_token_id,
eos_token_id=self._tokenizer.eos_token_id,
)
-
+
self._model.eval()
logger.info(f"Model loaded successfully on {self._model.device}")
-
+
def attach_lora(self, lora_config: LoRAConfig) -> None:
"""
Attach LoRA adapter for parameter-efficient fine-tuning.
-
+
Args:
lora_config: LoRAConfig with adapter parameters
"""
if self._model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
-
+
self._peft_config = lora_config
-
+
peft_config = LoraConfig(
r=lora_config.r,
lora_alpha=lora_config.lora_alpha,
@@ -181,41 +185,41 @@ def attach_lora(self, lora_config: LoRAConfig) -> None:
bias=lora_config.bias,
task_type=TaskType.CAUSAL_LM,
)
-
+
self._model = get_peft_model(self._model, peft_config)
self._is_peft_model = True
self._model.print_trainable_parameters()
logger.info("LoRA adapter attached successfully")
-
+
def load_lora_adapter(self, adapter_path: str) -> None:
"""
Load a saved LoRA adapter from disk.
-
+
Args:
adapter_path: Path to the saved adapter directory or .safetensors file
"""
if self._model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
-
+
self._model = PeftModel.from_pretrained(self._model, adapter_path)
self._is_peft_model = True
logger.info(f"Loaded LoRA adapter from {adapter_path}")
-
+
def save_lora_adapter(self, save_path: str) -> None:
"""
Save the current LoRA adapter to disk.
-
+
Args:
save_path: Directory path to save adapter
"""
if not self._is_peft_model:
raise RuntimeError("No LoRA adapter attached. Call attach_lora() first.")
-
+
os.makedirs(save_path, exist_ok=True)
self._model.save_pretrained(save_path)
self._tokenizer.save_pretrained(save_path)
logger.info(f"Saved LoRA adapter to {save_path}")
-
+
def generate(
self,
prompt: str,
@@ -229,7 +233,7 @@ def generate(
) -> str:
"""
Generate text directly from the loaded model.
-
+
Args:
prompt: Input prompt string
max_new_tokens: Maximum new tokens to generate
@@ -238,16 +242,16 @@ def generate(
top_k: Top-k sampling parameter
repetition_penalty: Repetition penalty
do_sample: Whether to use sampling
-
+
Returns:
Generated text string
"""
if self._model is None or self._tokenizer is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
-
+
# Tokenize prompt
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._device)
-
+
# Prepare generation config
gen_config = self._generation_config
gen_config.max_new_tokens = max_new_tokens
@@ -258,28 +262,28 @@ def generate(
gen_config.do_sample = do_sample and temperature > 0
gen_config.pad_token_id = self._tokenizer.pad_token_id
gen_config.eos_token_id = self._tokenizer.eos_token_id
-
+
# Generate
with torch.no_grad():
outputs = self._model.generate(
**inputs,
generation_config=gen_config,
)
-
+
# Decode only new tokens
- new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
+ new_tokens = outputs[0][inputs["input_ids"].shape[1] :]
generated_text = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
-
+
return generated_text
-
+
def get_model_info(self) -> Dict[str, Any]:
"""Get information about the loaded model"""
if self._model is None:
return {"status": "not_loaded"}
-
+
total_params = sum(p.numel() for p in self._model.parameters())
trainable_params = sum(p.numel() for p in self._model.parameters() if p.requires_grad)
-
+
return {
"model_name": self._model_name,
"device": str(self._model.device),
@@ -289,43 +293,43 @@ def get_model_info(self) -> Dict[str, Any]:
"is_peft_model": self._is_peft_model,
"peft_config": self._peft_config.__dict__ if self._peft_config else None,
}
-
+
def enable_training_mode(self) -> None:
"""Enable training mode (for fine-tuning)"""
if self._model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
self._model.train()
-
+
def enable_eval_mode(self) -> None:
"""Enable evaluation mode (for inference)"""
if self._model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
self._model.eval()
-
+
def get_trainable_parameters(self):
"""Get trainable parameters for optimizer"""
if self._model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
return [p for p in self._model.parameters() if p.requires_grad]
-
+
def forward(self, input_ids: torch.Tensor, labels: torch.Tensor = None, **kwargs):
"""Direct forward pass for training"""
if self._model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
return self._model(input_ids=input_ids, labels=labels, **kwargs)
-
+
@property
def model(self) -> Optional[AutoModelForCausalLM]:
return self._model
-
+
@property
def tokenizer(self) -> Optional[AutoTokenizer]:
return self._tokenizer
-
+
@property
def device(self) -> torch.device:
return self._device
-
+
def unload(self) -> None:
"""Unload model and free memory"""
if self._model is not None:
@@ -341,4 +345,4 @@ def unload(self) -> None:
torch.cuda.empty_cache()
elif torch.backends.mps.is_available():
torch.mps.empty_cache()
- logger.info("Model unloaded, memory freed")
\ No newline at end of file
+ logger.info("Model unloaded, memory freed")
diff --git a/src/processor/__pycache__/content_processor.cpython-314.pyc b/src/processor/__pycache__/content_processor.cpython-314.pyc
deleted file mode 100644
index 072d8610..00000000
Binary files a/src/processor/__pycache__/content_processor.cpython-314.pyc and /dev/null differ
diff --git a/src/safety/action_validator.py b/src/safety/action_validator.py
index f38a4440..e4ec10ae 100644
--- a/src/safety/action_validator.py
+++ b/src/safety/action_validator.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any
+from typing import Any, Dict, Optional
@dataclass
@@ -27,6 +27,8 @@ class ActionValidator:
"research",
"memory_consolidation",
"execute_task",
+ "weight_fine_tune", # Allow fine-tuning with boundary checks
+ "adapter_load", # Allow adapter loading with boundary checks
}
BLOCKED_ACTION_TYPES = {
"delete_file",
@@ -38,6 +40,11 @@ class ActionValidator:
}
BLOCKED_TERMS = ("rm -rf", "sudo", "chmod 777", "mkfs", ":(){")
+ # Parameter boundaries for weight fine-tuning
+ MAX_EPOCHS = 10
+ MAX_LEARNING_RATE = 1e-3
+ MAX_BATCH_SIZE = 32
+
def validate_action(self, action: Any) -> ValidationResult:
if action is None:
return ValidationResult(False, "Action is empty")
@@ -63,6 +70,31 @@ def validate_action(self, action: Any) -> ValidationResult:
if any(term in normalized_payload for term in self.BLOCKED_TERMS):
return ValidationResult(False, "Action contains a blocked operation")
+ # Parameter boundary checks for weight fine-tuning
+ if normalized_type == "weight_fine_tune" and isinstance(action, dict):
+ warnings = []
+ if "epochs" in action and action["epochs"] > self.MAX_EPOCHS:
+ return ValidationResult(
+ False,
+ f"Epochs {action['epochs']} exceeds maximum allowed {self.MAX_EPOCHS}"
+ )
+ if "learning_rate" in action and action["learning_rate"] > self.MAX_LEARNING_RATE:
+ return ValidationResult(
+ False,
+ f"Learning rate {action['learning_rate']} exceeds maximum allowed {self.MAX_LEARNING_RATE}"
+ )
+ if "batch_size" in action and action["batch_size"] > self.MAX_BATCH_SIZE:
+ return ValidationResult(
+ False,
+ f"Batch size {action['batch_size']} exceeds maximum allowed {self.MAX_BATCH_SIZE}"
+ )
+ if warnings:
+ return ValidationResult(
+ True,
+ "Action allowed with parameter warnings",
+ warnings
+ )
+
if normalized_type and normalized_type not in self.SAFE_ACTION_TYPES:
return ValidationResult(
True,
diff --git a/src/server/local_bridge.py b/src/server/local_bridge.py
new file mode 100644
index 00000000..51f6eb89
--- /dev/null
+++ b/src/server/local_bridge.py
@@ -0,0 +1,398 @@
+"""
+Native Process Bridge / Local Socket Server for real-time agent communication.
+
+This module exposes a lightweight WebSocket server running on localhost to stream
+agent thoughts and receive commands without external FastAPI/REST framework overhead.
+"""
+
+import asyncio
+import json
+import logging
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Dict, Any, Optional, List, Callable, Set
+from enum import Enum
+
+import websockets
+from websockets.server import WebSocketServerProtocol
+
+from src.config.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class BridgeEventType(str, Enum):
+ """Types of events emitted by the bridge"""
+ AGENT_THOUGHT = "agent_thought"
+ AGENT_STEP = "agent_step"
+ FINE_TUNE_EPOCH = "fine_tune_epoch"
+ FINE_TUNE_LOSS = "fine_tune_loss"
+ SWARM_STATUS = "swarm_status"
+ OBJECTIVE_STARTED = "objective_started"
+ OBJECTIVE_COMPLETED = "objective_completed"
+ OBJECTIVE_FAILED = "objective_failed"
+ CHECKPOINT_CREATED = "checkpoint_created"
+ CONSENSUS_REQUEST = "consensus_request"
+ CONSENSUS_RESULT = "consensus_result"
+ ERROR = "error"
+
+
+class BridgeCommandType(str, Enum):
+ """Types of commands accepted by the bridge"""
+ SUBMIT_OBJECTIVE = "submit_objective"
+ TRIGGER_FINETUNE = "trigger_finetune"
+ PAUSE_HARNESS = "pause_harness"
+ RESUME_HARNESS = "resume_harnass"
+ SWITCH_MODEL = "switch_model"
+ GET_STATUS = "get_status"
+ LIST_OBJECTIVES = "list_objectives"
+ GET_AGENT_STATE = "get_agent_state"
+
+
+@dataclass
+class BridgeEvent:
+ """Event emitted by the bridge to connected clients"""
+ event_type: BridgeEventType
+ payload: Dict[str, Any]
+ timestamp: datetime = field(default_factory=datetime.now)
+ correlation_id: Optional[str] = None
+
+ def to_json(self) -> str:
+ return json.dumps({
+ "event_type": self.event_type.value,
+ "payload": self.payload,
+ "timestamp": self.timestamp.isoformat(),
+ "correlation_id": self.correlation_id,
+ })
+
+
+@dataclass
+class BridgeCommand:
+ """Command received from a client"""
+ command_type: BridgeCommandType
+ payload: Dict[str, Any]
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ timestamp: datetime = field(default_factory=datetime.now)
+
+ @classmethod
+ def from_json(cls, json_str: str) -> "BridgeCommand":
+ data = json.loads(json_str)
+ return cls(
+ command_type=BridgeCommandType(data["command_type"]),
+ payload=data["payload"],
+ request_id=data.get("request_id", str(uuid.uuid4())),
+ timestamp=datetime.fromisoformat(data["timestamp"]) if "timestamp" in data else datetime.now(),
+ )
+
+
+class ClientConnection:
+ """Represents a connected WebSocket client"""
+
+ def __init__(self, websocket: WebSocketServerProtocol, client_id: str):
+ self.websocket = websocket
+ self.client_id = client_id
+ self.connected_at = datetime.now()
+ self.subscriptions: Set[BridgeEventType] = set()
+ self.last_ping = datetime.now()
+
+ async def send(self, event: BridgeEvent) -> bool:
+ """Send event to client if subscribed"""
+ if not self.subscriptions or event.event_type in self.subscriptions:
+ try:
+ await self.websocket.send(event.to_json())
+ return True
+ except Exception as exc:
+ logger.warning(f"Failed to send to client {self.client_id}: {exc}")
+ return False
+ return True
+
+ async def send_error(self, message: str, request_id: str = None) -> None:
+ """Send error response to client"""
+ error_event = BridgeEvent(
+ event_type=BridgeEventType.ERROR,
+ payload={"message": message, "request_id": request_id},
+ )
+ await self.send(error_event)
+
+ async def send_response(self, request_id: str, payload: Dict[str, Any]) -> None:
+ """Send command response to client"""
+ response_event = BridgeEvent(
+ event_type=BridgeEventType.SWARM_STATUS, # Reuse for responses
+ payload={"response": True, "request_id": request_id, **payload},
+ correlation_id=request_id,
+ )
+ await self.send(response_event)
+
+
+class LocalBridgeServer:
+ """
+ Lightweight WebSocket server for real-time agent communication.
+
+ Runs on localhost:8765 and handles:
+ - Streaming agent thoughts, steps, and fine-tune progress
+ - Receiving commands from UI/clients
+ - Managing client subscriptions
+ """
+
+ def __init__(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 8765,
+ ping_interval: float = 20.0,
+ ping_timeout: float = 10.0,
+ ):
+ self.host = host
+ self.port = port
+ self.ping_interval = ping_interval
+ self.ping_timeout = ping_timeout
+
+ self._server: Optional[websockets.WebSocketServer] = None
+ self._clients: Dict[str, ClientConnection] = {}
+ self._command_handlers: Dict[BridgeCommandType, Callable] = {}
+ self._running = False
+ self._broadcast_task: Optional[asyncio.Task] = None
+
+ # Register default command handlers
+ self._register_default_handlers()
+
+ def _register_default_handlers(self) -> None:
+ """Register default command handlers"""
+ self._command_handlers = {
+ BridgeCommandType.SUBMIT_OBJECTIVE: self._handle_submit_objective,
+ BridgeCommandType.TRIGGER_FINETUNE: self._handle_trigger_finetune,
+ BridgeCommandType.PAUSE_HARNESS: self._handle_pause_harness,
+ BridgeCommandType.RESUME_HARNESS: self._handle_resume_harness,
+ BridgeCommandType.SWITCH_MODEL: self._handle_switch_model,
+ BridgeCommandType.GET_STATUS: self._handle_get_status,
+ BridgeCommandType.LIST_OBJECTIVES: self._handle_list_objectives,
+ BridgeCommandType.GET_AGENT_STATE: self._handle_get_agent_state,
+ }
+
+ def register_command_handler(
+ self,
+ command_type: BridgeCommandType,
+ handler: Callable,
+ ) -> None:
+ """Register a custom command handler"""
+ self._command_handlers[command_type] = handler
+
+ async def start(self) -> None:
+ """Start the WebSocket server"""
+ if self._running:
+ logger.warning("Bridge server already running")
+ return
+
+ self._running = True
+ self._server = await websockets.serve(
+ self._handle_connection,
+ self.host,
+ self.port,
+ ping_interval=self.ping_interval,
+ ping_timeout=self.ping_timeout,
+ )
+
+ # Start broadcast task
+ self._broadcast_task = asyncio.create_task(self._broadcast_loop())
+
+ logger.info(f"LocalBridgeServer started on {self.host}:{self.port}")
+
+ async def stop(self) -> None:
+ """Stop the WebSocket server"""
+ if not self._running:
+ return
+
+ self._running = False
+
+ # Cancel broadcast task
+ if self._broadcast_task:
+ self._broadcast_task.cancel()
+ try:
+ await self._broadcast_task
+ except asyncio.CancelledError:
+ pass
+
+ # Close all client connections
+ for client in self._clients.values():
+ try:
+ await client.websocket.close()
+ except Exception:
+ pass
+
+ # Stop server
+ if self._server:
+ self._server.close()
+ await self._server.wait_closed()
+
+ self._clients.clear()
+ logger.info("LocalBridgeServer stopped")
+
+ async def _handle_connection(self, websocket: WebSocketServerProtocol) -> None:
+ """Handle new WebSocket connection"""
+ client_id = str(uuid.uuid4())
+ client = ClientConnection(websocket, client_id)
+ self._clients[client_id] = client
+
+ logger.info(f"Client connected: {client_id} (total: {len(self._clients)})")
+
+ try:
+ async for message in websocket:
+ await self._handle_message(client, message)
+ except websockets.exceptions.ConnectionClosed:
+ pass
+ except Exception as exc:
+ logger.error(f"Error handling client {client_id}: {exc}")
+ finally:
+ del self._clients[client_id]
+ logger.info(f"Client disconnected: {client_id} (total: {len(self._clients)})")
+
+ async def _handle_message(self, client: ClientConnection, message: str) -> None:
+ """Handle incoming message from client"""
+ try:
+ data = json.loads(message)
+
+ # Handle subscription requests
+ if "subscribe" in data:
+ event_types = data["subscribe"]
+ if isinstance(event_types, list):
+ for et in event_types:
+ try:
+ client.subscriptions.add(BridgeEventType(et))
+ except ValueError:
+ pass
+ return
+
+ if "unsubscribe" in data:
+ event_types = data["unsubscribe"]
+ if isinstance(event_types, list):
+ for et in event_types:
+ try:
+ client.subscriptions.discard(BridgeEventType(et))
+ except ValueError:
+ pass
+ return
+
+ # Handle commands
+ if "command_type" in data:
+ command = BridgeCommand.from_json(message)
+ await self._handle_command(client, command)
+
+ except json.JSONDecodeError:
+ await client.send_error("Invalid JSON")
+ except Exception as exc:
+ logger.error(f"Error handling message from {client.client_id}: {exc}")
+ await client.send_error(str(exc))
+
+ async def _handle_command(self, client: ClientConnection, command: BridgeCommand) -> None:
+ """Handle a command from client"""
+ handler = self._command_handlers.get(command.command_type)
+
+ if not handler:
+ await client.send_error(f"Unknown command: {command.command_type.value}", command.request_id)
+ return
+
+ try:
+ result = await handler(client, command.payload)
+ await client.send_response(command.request_id, result or {})
+ except Exception as exc:
+ logger.error(f"Command {command.command_type.value} failed: {exc}")
+ await client.send_error(str(exc), command.request_id)
+
+ # Default command handlers (to be overridden by integration)
+ async def _handle_submit_objective(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle submit_objective command"""
+ return {"status": "not_implemented", "message": "Integrate with objective manager"}
+
+ async def _handle_trigger_finetune(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle trigger_finetune command"""
+ return {"status": "not_implemented", "message": "Integrate with fine-tune pipeline"}
+
+ async def _handle_pause_harness(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle pause_harness command"""
+ return {"status": "not_implemented", "message": "Integrate with execution harness"}
+
+ async def _handle_resume_harness(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle resume_harness command"""
+ return {"status": "not_implemented", "message": "Integrate with execution harness"}
+
+ async def _handle_switch_model(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle switch_model command"""
+ return {"status": "not_implemented", "message": "Integrate with model manager"}
+
+ async def _handle_get_status(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle get_status command"""
+ return {
+ "status": "running" if self._running else "stopped",
+ "connected_clients": len(self._clients),
+ "server_uptime_seconds": (datetime.now() - self._started_at).total_seconds() if hasattr(self, '_started_at') else 0,
+ }
+
+ async def _handle_list_objectives(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle list_objectives command"""
+ return {"objectives": [], "message": "Integrate with objective manager"}
+
+ async def _handle_get_agent_state(self, client: ClientConnection, payload: Dict) -> Dict:
+ """Handle get_agent_state command"""
+ return {"agents": [], "message": "Integrate with agent brain"}
+
+ def broadcast(self, event: BridgeEvent) -> None:
+ """Broadcast event to all subscribed clients (sync version for non-async contexts)"""
+ if not self._running:
+ return
+
+ # Schedule async broadcast
+ asyncio.create_task(self._async_broadcast(event))
+
+ async def _async_broadcast(self, event: BridgeEvent) -> None:
+ """Async broadcast event to all subscribed clients"""
+ if not self._clients:
+ return
+
+ # Send to all clients that are subscribed
+ disconnected = []
+ for client_id, client in self._clients.items():
+ success = await client.send(event)
+ if not success:
+ disconnected.append(client_id)
+
+ # Clean up disconnected clients
+ for client_id in disconnected:
+ if client_id in self._clients:
+ del self._clients[client_id]
+
+ async def _broadcast_loop(self) -> None:
+ """Background loop for periodic broadcasts (heartbeat, etc.)"""
+ while self._running:
+ try:
+ # Send heartbeat
+ heartbeat = BridgeEvent(
+ event_type=BridgeEventType.SWARM_STATUS,
+ payload={"type": "heartbeat", "connected_clients": len(self._clients)},
+ )
+ await self._async_broadcast(heartbeat)
+
+ await asyncio.sleep(30) # Heartbeat every 30 seconds
+ except asyncio.CancelledError:
+ break
+ except Exception as exc:
+ logger.error(f"Error in broadcast loop: {exc}")
+ await asyncio.sleep(30)
+
+ @property
+ def client_count(self) -> int:
+ return len(self._clients)
+
+ @property
+ def is_running(self) -> bool:
+ return self._running
+
+
+# Convenience function for creating and starting the server
+async def create_bridge_server(
+ host: str = "127.0.0.1",
+ port: int = 8765,
+) -> LocalBridgeServer:
+ """Create and start a LocalBridgeServer"""
+ server = LocalBridgeServer(host=host, port=port)
+ await server.start()
+ return server
\ No newline at end of file
diff --git a/src/swarm/swarm_coordinator.py b/src/swarm/swarm_coordinator.py
index 46c31cd4..7ef2cf55 100644
--- a/src/swarm/swarm_coordinator.py
+++ b/src/swarm/swarm_coordinator.py
@@ -1,14 +1,18 @@
"""Swarm Coordinator for Swarm Orchestration (Phase 8).
Central coordinator for managing the entire swarm of agents.
+Enhanced with in-process message queues, consensus evaluation, and load balancing.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Callable
from uuid import UUID, uuid4
+from dataclasses import dataclass, field
+from enum import Enum
+from collections import deque
from pydantic import BaseModel, Field
@@ -18,6 +22,281 @@
logger = get_logger(__name__)
+class MessageType(str, Enum):
+ """Types of messages in the swarm communication system"""
+ TASK_ASSIGNMENT = "task_assignment"
+ TASK_RESULT = "task_result"
+ CONSENSUS_VOTE = "consensus_vote"
+ CONSENSUS_RESULT = "consensus_result"
+ HEARTBEAT = "heartbeat"
+ STATUS_UPDATE = "status_update"
+ LOAD_REPORT = "load_report"
+ SCALING_SIGNAL = "scaling_signal"
+
+
+@dataclass
+class SwarmMessage:
+ """Message for inter-agent communication"""
+ msg_type: MessageType
+ sender_id: UUID
+ recipient_id: Optional[UUID] # None for broadcast
+ payload: Dict[str, Any]
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ correlation_id: Optional[UUID] = None
+ reply_to: Optional[UUID] = None
+
+
+class InProcessMessageRouter:
+ """In-memory async message router for swarm communication"""
+
+ def __init__(self):
+ self._queues: Dict[UUID, asyncio.Queue] = {}
+ self._subscriptions: Dict[MessageType, List[UUID]] = {}
+ self._broadcast_queue: asyncio.Queue = asyncio.Queue()
+ self._running = False
+ self._router_task: Optional[asyncio.Task] = None
+
+ def register_agent(self, agent_id: UUID) -> asyncio.Queue:
+ """Register an agent and return its message queue"""
+ queue = asyncio.Queue()
+ self._queues[agent_id] = queue
+ return queue
+
+ def unregister_agent(self, agent_id: UUID) -> None:
+ """Unregister an agent"""
+ if agent_id in self._queues:
+ del self._queues[agent_id]
+ # Remove from subscriptions
+ for msg_type, subscribers in self._subscriptions.items():
+ if agent_id in subscribers:
+ subscribers.remove(agent_id)
+
+ def subscribe(self, agent_id: UUID, msg_type: MessageType) -> None:
+ """Subscribe agent to a message type"""
+ if msg_type not in self._subscriptions:
+ self._subscriptions[msg_type] = []
+ if agent_id not in self._subscriptions[msg_type]:
+ self._subscriptions[msg_type].append(agent_id)
+
+ def unsubscribe(self, agent_id: UUID, msg_type: MessageType) -> None:
+ """Unsubscribe agent from a message type"""
+ if msg_type in self._subscriptions:
+ if agent_id in self._subscriptions[msg_type]:
+ self._subscriptions[msg_type].remove(agent_id)
+
+ async def send(self, message: SwarmMessage) -> None:
+ """Send a message to a specific recipient or broadcast"""
+ if message.recipient_id is not None:
+ # Direct message
+ if message.recipient_id in self._queues:
+ await self._queues[message.recipient_id].put(message)
+ else:
+ logger.warning(f"Recipient {message.recipient_id} not registered")
+ else:
+ # Broadcast
+ await self._broadcast_queue.put(message)
+
+ async def start(self) -> None:
+ """Start the router"""
+ self._running = True
+ self._router_task = asyncio.create_task(self._route_loop())
+ logger.info("InProcessMessageRouter started")
+
+ async def stop(self) -> None:
+ """Stop the router"""
+ self._running = False
+ if self._router_task:
+ self._router_task.cancel()
+ try:
+ await self._router_task
+ except asyncio.CancelledError:
+ pass
+ logger.info("InProcessMessageRouter stopped")
+
+ async def _route_loop(self) -> None:
+ """Main routing loop"""
+ while self._running:
+ try:
+ # Process broadcast queue
+ try:
+ message = await asyncio.wait_for(self._broadcast_queue.get(), timeout=0.1)
+ for agent_id, queue in self._queues.items():
+ if message.sender_id != agent_id: # Don't send to sender
+ await queue.put(message)
+ except asyncio.TimeoutError:
+ pass
+
+ # Process direct messages (they're already in queues)
+ await asyncio.sleep(0.01)
+
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"Error in router loop: {e}")
+ await asyncio.sleep(0.1)
+
+ async def receive(self, agent_id: UUID, timeout: float = 1.0) -> Optional[SwarmMessage]:
+ """Receive a message for an agent"""
+ if agent_id not in self._queues:
+ return None
+ try:
+ return await asyncio.wait_for(self._queues[agent_id].get(), timeout=timeout)
+ except asyncio.TimeoutError:
+ return None
+
+
+class ConsensusEvaluator:
+ """Consensus evaluation for multi-agent agreement"""
+
+ def __init__(self, min_agreement: float = 0.66):
+ self.min_agreement = min_agreement
+ self._votes: Dict[UUID, Dict[UUID, Any]] = {} # proposal_id -> {voter_id: vote}
+ self._vote_weights: Dict[UUID, float] = {} # voter_id -> weight
+
+ def register_voter(self, voter_id: UUID, weight: float = 1.0) -> None:
+ """Register a voter with optional weight"""
+ self._vote_weights[voter_id] = weight
+
+ def unregister_voter(self, voter_id: UUID) -> None:
+ """Unregister a voter"""
+ if voter_id in self._vote_weights:
+ del self._vote_weights[voter_id]
+
+ def submit_vote(self, proposal_id: UUID, voter_id: UUID, vote: Any) -> None:
+ """Submit a vote for a proposal"""
+ if proposal_id not in self._votes:
+ self._votes[proposal_id] = {}
+ self._votes[proposal_id][voter_id] = vote
+
+ def evaluate(self, proposal_id: UUID) -> Dict[str, Any]:
+ """Evaluate consensus for a proposal"""
+ if proposal_id not in self._votes:
+ return {"consensus": False, "reason": "No votes submitted"}
+
+ votes = self._votes[proposal_id]
+ if not votes:
+ return {"consensus": False, "reason": "No votes"}
+
+ # Count votes by value
+ vote_counts: Dict[Any, float] = {}
+ total_weight = 0.0
+
+ for voter_id, vote in votes.items():
+ weight = self._vote_weights.get(voter_id, 1.0)
+ vote_str = str(vote)
+ vote_counts[vote_str] = vote_counts.get(vote_str, 0.0) + weight
+ total_weight += weight
+
+ # Find majority
+ max_votes = max(vote_counts.values()) if vote_counts else 0
+ agreement_ratio = max_votes / total_weight if total_weight > 0 else 0
+
+ consensus_reached = agreement_ratio >= self.min_agreement
+
+ # Get winning vote
+ winning_vote = max(vote_counts, key=vote_counts.get) if vote_counts else None
+
+ return {
+ "consensus": consensus_reached,
+ "agreement_ratio": agreement_ratio,
+ "winning_vote": winning_vote,
+ "vote_distribution": vote_counts,
+ "total_voters": len(votes),
+ "required_ratio": self.min_agreement,
+ }
+
+ def clear_proposal(self, proposal_id: UUID) -> None:
+ """Clear votes for a proposal"""
+ if proposal_id in self._votes:
+ del self._votes[proposal_id]
+
+
+class LoadBalancer:
+ """Load balancer for distributing tasks across agents"""
+
+ def __init__(self):
+ self._agent_loads: Dict[UUID, float] = {}
+ self._agent_capabilities: Dict[UUID, List[str]] = {}
+ self._agent_status: Dict[UUID, str] = {} # idle, busy, offline
+ self._task_history: Dict[UUID, List[Dict]] = {}
+
+ def register_agent(self, agent_id: UUID, capabilities: List[str] = None, max_load: float = 1.0) -> None:
+ """Register an agent with capabilities"""
+ self._agent_loads[agent_id] = 0.0
+ self._agent_capabilities[agent_id] = capabilities or []
+ self._agent_status[agent_id] = "idle"
+ self._task_history[agent_id] = []
+
+ def unregister_agent(self, agent_id: UUID) -> None:
+ """Unregister an agent"""
+ for d in [self._agent_loads, self._agent_capabilities, self._agent_status, self._task_history]:
+ if agent_id in d:
+ del d[agent_id]
+
+ def update_load(self, agent_id: UUID, load: float) -> None:
+ """Update agent load (0.0 to 1.0)"""
+ if agent_id in self._agent_loads:
+ self._agent_loads[agent_id] = max(0.0, min(1.0, load))
+ self._agent_status[agent_id] = "busy" if load > 0.1 else "idle"
+
+ def update_status(self, agent_id: UUID, status: str) -> None:
+ """Update agent status"""
+ if agent_id in self._agent_status:
+ self._agent_status[agent_id] = status
+
+ def record_task_completion(self, agent_id: UUID, task_info: Dict[str, Any]) -> None:
+ """Record task completion for learning"""
+ if agent_id in self._task_history:
+ self._task_history[agent_id].append({
+ **task_info,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ })
+ # Keep last 100 tasks
+ if len(self._task_history[agent_id]) > 100:
+ self._task_history[agent_id] = self._task_history[agent_id][-100:]
+
+ def select_agent(self, required_capabilities: List[str] = None, strategy: str = "least_loaded") -> Optional[UUID]:
+ """Select best agent for a task"""
+ # Filter by capabilities
+ candidates = []
+ for agent_id, capabilities in self._agent_capabilities.items():
+ if self._agent_status.get(agent_id) == "offline":
+ continue
+ if required_capabilities:
+ if not all(cap in capabilities for cap in required_capabilities):
+ continue
+ candidates.append(agent_id)
+
+ if not candidates:
+ return None
+
+ if strategy == "least_loaded":
+ return min(candidates, key=lambda a: self._agent_loads.get(a, 1.0))
+ elif strategy == "most_capable":
+ return max(candidates, key=lambda a: len(self._agent_capabilities.get(a, [])))
+ elif strategy == "round_robin":
+ # Simple round-robin based on task count
+ return min(candidates, key=lambda a: len(self._task_history.get(a, [])))
+
+ return candidates[0]
+
+ def get_load_report(self) -> Dict[str, Any]:
+ """Get current load report"""
+ return {
+ "agent_loads": {str(k): v for k, v in self._agent_loads.items()},
+ "agent_status": {str(k): v for k, v in self._agent_status.items()},
+ "total_agents": len(self._agent_loads),
+ "busy_agents": sum(1 for s in self._agent_status.values() if s == "busy"),
+ "idle_agents": sum(1 for s in self._agent_status.values() if s == "idle"),
+ }
+
+
+# Global instances for swarm communication
+message_router = InProcessMessageRouter()
+consensus_evaluator = ConsensusEvaluator()
+load_balancer = LoadBalancer()
+
+
class SwarmMetrics(BaseModel):
"""Metrics for the entire swarm."""
@@ -33,8 +312,11 @@ class SwarmMetrics(BaseModel):
class SwarmCoordinator:
- """Central coordinator for managing multiple director agents."""
-
+ """Central coordinator for managing multiple director agents.
+
+ Enhanced with in-process message queues, consensus evaluation, and load balancing.
+ """
+
def __init__(self, num_directors: int = 5, sub_orchestrators_per_director: int = 10):
"""Initialize swarm coordinator."""
self.num_directors = num_directors
@@ -43,6 +325,9 @@ def __init__(self, num_directors: int = 5, sub_orchestrators_per_director: int =
self.directors: Dict[UUID, DirectorAgent] = {}
self._running = False
self._monitor_task: Optional[asyncio.Task] = None
+ self._message_router = message_router
+ self._consensus = consensus_evaluator
+ self._load_balancer = load_balancer
logger.info(
f"SwarmCoordinator initialized with {num_directors} directors, "
@@ -54,11 +339,20 @@ async def initialize(self) -> None:
logger.info("Initializing SwarmCoordinator")
self._running = True
+ # Start message router
+ await self._message_router.start()
+
# Initialize director agents
for i in range(self.num_directors):
director = DirectorAgent(max_sub_orchestrators=self.sub_orchestrators_per_director)
await director.initialize()
- self.directors[uuid4()] = director
+ director_id = uuid4()
+ self.directors[director_id] = director
+
+ # Register with communication infrastructure
+ self._message_router.register_agent(director_id)
+ self._load_balancer.register_agent(director_id, ["director", "coordination"])
+ self._consensus.register_voter(director_id, weight=1.0)
# Start monitoring task
self._monitor_task = asyncio.create_task(self._monitor_loop())
@@ -77,6 +371,9 @@ async def shutdown(self) -> None:
except asyncio.CancelledError:
pass
+ # Stop message router
+ await self._message_router.stop()
+
# Shutdown all directors
for director in self.directors.values():
await director.shutdown()
@@ -101,14 +398,25 @@ async def submit_goal(self, goal: SwarmGoal) -> UUID:
except RuntimeError:
pass # Runtime not initialized yet (startup)
- # Find director with least load
- director = await self._get_least_loaded_director()
-
- if not director:
- raise RuntimeError("No available directors")
+ # Use load balancer to find best director
+ director_id = self._load_balancer.select_agent(
+ required_capabilities=["director"],
+ strategy="least_loaded"
+ )
+
+ if not director_id or director_id not in self.directors:
+ # Fallback to original method
+ director = await self._get_least_loaded_director()
+ if not director:
+ raise RuntimeError("No available directors")
+ else:
+ director = self.directors[director_id]
goal_id = await director.submit_goal(goal)
- logger.info(f"Goal {goal_id} assigned to director")
+ logger.info(f"Goal {goal_id} assigned to director {director_id}")
+
+ # Update load balancer
+ self._load_balancer.update_load(director_id, 0.5)
return goal_id
@@ -120,9 +428,9 @@ async def _get_least_loaded_director(self) -> Optional[DirectorAgent]:
min_load = float("inf")
least_loaded = None
- for director in self.directors.values():
+ for director_id, director in self.directors.items():
status = await director.monitor_swarm()
- load = status.get("busy_sub_orchestrators", 0)
+ load = status.get("busy_sub_orchestrators", 0) / max(1, status.get("total_sub_orchestrators", 1))
if load < min_load:
min_load = load
@@ -139,6 +447,41 @@ async def get_goal_status(self, goal_id: UUID) -> Optional[Dict[str, Any]]:
return None
+ async def request_consensus(self, proposal: Dict[str, Any], voters: List[UUID] = None) -> Dict[str, Any]:
+ """Request consensus from directors on a proposal"""
+ proposal_id = uuid4()
+
+ # Default to all directors if no voters specified
+ if voters is None:
+ voters = list(self.directors.keys())
+
+ # Broadcast consensus request
+ message = SwarmMessage(
+ msg_type=MessageType.CONSENSUS_VOTE,
+ sender_id=uuid4(), # coordinator ID
+ recipient_id=None, # broadcast
+ payload={
+ "proposal_id": str(proposal_id),
+ "proposal": proposal,
+ "voters": [str(v) for v in voters],
+ },
+ )
+ await self._message_router.send(message)
+
+ # Collect votes (simplified - in real implementation would wait for responses)
+ # For now, simulate voting
+ for voter_id in voters:
+ if voter_id in self.directors:
+ # Simulate vote based on proposal
+ vote = {"agree": True, "confidence": 0.8}
+ self._consensus.submit_vote(proposal_id, voter_id, vote)
+
+ # Evaluate consensus
+ result = self._consensus.evaluate(proposal_id)
+ self._consensus.clear_proposal(proposal_id)
+
+ return result
+
async def get_swarm_metrics(self) -> SwarmMetrics:
"""Get comprehensive metrics for the entire swarm."""
total_sub_orchestrators = 0
@@ -175,7 +518,7 @@ async def get_swarm_metrics(self) -> SwarmMetrics:
active_goals=active_goals,
completed_goals=completed_goals,
failed_goals=failed_goals,
- average_task_duration=0.0, # Would be calculated from task history
+ average_task_duration=0.0,
total_tasks_completed=total_tasks_completed,
swarm_utilization=swarm_utilization,
)
@@ -194,6 +537,10 @@ async def _monitor_loop(self) -> None:
f"{metrics.swarm_utilization:.2%} utilization"
)
+ # Get load balancer report
+ load_report = self._load_balancer.get_load_report()
+ logger.debug(f"Load report: {load_report}")
+
# Auto-scale if needed
await self._auto_scale(metrics)
@@ -230,7 +577,14 @@ async def scale_directors(self, target_count: int) -> bool:
for i in range(target_count - current_count):
director = DirectorAgent(max_sub_orchestrators=self.sub_orchestrators_per_director)
await director.initialize()
- self.directors[uuid4()] = director
+ director_id = uuid4()
+ self.directors[director_id] = director
+
+ # Register with communication infrastructure
+ self._message_router.register_agent(director_id)
+ self._load_balancer.register_agent(director_id, ["director", "coordination"])
+ self._consensus.register_voter(director_id, weight=1.0)
+
logger.info(f"Scaled directors from {current_count} to {target_count}")
elif target_count < current_count:
# Scale down (remove idle directors)
@@ -248,6 +602,11 @@ async def scale_directors(self, target_count: int) -> bool:
director_id = idle_directors[i]
await self.directors[director_id].shutdown()
del self.directors[director_id]
+
+ # Unregister from communication infrastructure
+ self._message_router.unregister_agent(director_id)
+ self._load_balancer.unregister_agent(director_id)
+ self._consensus.unregister_voter(director_id)
logger.info(f"Scaled directors from {current_count} to {target_count}")
diff --git a/tests/unit/test_action_validator.py b/tests/unit/test_action_validator.py
new file mode 100644
index 00000000..0255334e
--- /dev/null
+++ b/tests/unit/test_action_validator.py
@@ -0,0 +1,144 @@
+"""
+Unit tests for ActionValidator - Phase 7: Safety & Governance Gate Hardening
+"""
+
+import pytest
+from src.safety.action_validator import ActionValidator, ValidationResult
+
+
+class TestActionValidator:
+ """Test ActionValidator"""
+
+ @pytest.fixture
+ def validator(self):
+ return ActionValidator()
+
+ def test_allow_weight_fine_tune_within_bounds(self, validator):
+ """Test weight_fine_tune action with valid parameters"""
+ action = {
+ "type": "weight_fine_tune",
+ "epochs": 5,
+ "learning_rate": 1e-4,
+ "batch_size": 16,
+ }
+
+ result = validator.validate_action(action)
+
+ assert result.allowed is True
+ assert result.reason == "Action allowed"
+
+ def test_allow_adapter_load(self, validator):
+ """Test adapter_load action"""
+ action = {
+ "type": "adapter_load",
+ "adapter_path": "data/adapters/requirement_adapter",
+ }
+
+ result = validator.validate_action(action)
+
+ assert result.allowed is True
+ assert result.reason == "Action allowed"
+
+ def test_reject_weight_fine_tune_excessive_epochs(self, validator):
+ """Test weight_fine_tune with epochs > MAX_EPOCHS is rejected"""
+ action = {
+ "type": "weight_fine_tune",
+ "epochs": 15, # Exceeds MAX_EPOCHS=10
+ "learning_rate": 1e-4,
+ }
+
+ result = validator.validate_action(action)
+
+ assert result.allowed is False
+ assert "exceeds maximum allowed 10" in result.reason
+
+ def test_reject_weight_fine_tune_excessive_learning_rate(self, validator):
+ """Test weight_fine_tune with learning_rate > MAX_LEARNING_RATE is rejected"""
+ action = {
+ "type": "weight_fine_tune",
+ "epochs": 3,
+ "learning_rate": 5e-3, # Exceeds MAX_LEARNING_RATE=1e-3
+ }
+
+ result = validator.validate_action(action)
+
+ assert result.allowed is False
+ assert "exceeds maximum allowed 0.001" in result.reason
+
+ def test_reject_weight_fine_tune_excessive_batch_size(self, validator):
+ """Test weight_fine_tune with batch_size > MAX_BATCH_SIZE is rejected"""
+ action = {
+ "type": "weight_fine_tune",
+ "epochs": 3,
+ "learning_rate": 1e-4,
+ "batch_size": 64, # Exceeds MAX_BATCH_SIZE=32
+ }
+
+ result = validator.validate_action(action)
+
+ assert result.allowed is False
+ assert "exceeds maximum allowed 32" in result.reason
+
+ def test_reject_blocked_action_types(self, validator):
+ """Test that blocked action types are rejected"""
+ for blocked_type in ["delete_file", "write_file", "shell_command", "self_modify", "deploy"]:
+ action = {"type": blocked_type}
+ result = validator.validate_action(action)
+ assert result.allowed is False
+ assert "requires explicit approval" in result.reason
+
+ def test_reject_blocked_terms(self, validator):
+ """Test that blocked terms in payload are rejected"""
+ action = {"type": "execute_task", "payload": "rm -rf /"}
+ result = validator.validate_action(action)
+
+ assert result.allowed is False
+ assert "blocked operation" in result.reason
+
+ def test_allow_safe_action_types(self, validator):
+ """Test that safe action types are allowed"""
+ for safe_type in ["observe", "think", "reflect", "plan", "research", "memory_consolidation", "execute_task"]:
+ action = {"type": safe_type}
+ result = validator.validate_action(action)
+ assert result.allowed is True
+
+ def test_unknown_action_type_allowed_with_warning(self, validator):
+ """Test unknown action type is allowed with caution warning"""
+ action = {"type": "unknown_action"}
+ result = validator.validate_action(action)
+
+ assert result.allowed is True
+ assert "caution" in result.reason
+ assert "Unknown action type 'unknown_action'" in result.warnings
+
+ def test_none_action_rejected(self, validator):
+ """Test None action is rejected"""
+ result = validator.validate_action(None)
+
+ assert result.allowed is False
+ assert "empty" in result.reason
+
+ def test_string_action_type(self, validator):
+ """Test string action type is handled"""
+ result = validator.validate_action("plan")
+
+ assert result.allowed is True
+ assert result.reason == "Action allowed"
+
+
+class TestValidationResult:
+ """Test ValidationResult dataclass"""
+
+ def test_bool_true(self):
+ """Test ValidationResult with allowed=True evaluates to True"""
+ result = ValidationResult(allowed=True, reason="OK")
+ assert bool(result) is True
+
+ def test_bool_false(self):
+ """Test ValidationResult with allowed=False evaluates to False"""
+ result = ValidationResult(allowed=False, reason="Blocked")
+ assert bool(result) is False
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/unit/test_auto_finetune_trigger.py b/tests/unit/test_auto_finetune_trigger.py
new file mode 100644
index 00000000..605082be
--- /dev/null
+++ b/tests/unit/test_auto_finetune_trigger.py
@@ -0,0 +1,249 @@
+"""
+Unit tests for AutoFineTuneTrigger - Phase 8: Requirement Feedback & Automated Fine-Tune Triggering
+"""
+
+import pytest
+from unittest.mock import Mock, patch, MagicMock
+from datetime import datetime
+
+from src.learning.auto_finetune_trigger import (
+ AutoFineTuneTrigger,
+ AutoFineTuneConfig,
+ FineTuneTriggerRecord,
+ create_auto_finetune_trigger,
+)
+
+
+class TestAutoFineTuneConfig:
+ """Test AutoFineTuneConfig dataclass"""
+
+ def test_default_config(self):
+ config = AutoFineTuneConfig()
+ assert config.evaluation_threshold == 0.8
+ assert config.max_auto_finetune_epochs == 3
+ assert config.auto_finetune_learning_rate == 2e-4
+ assert config.adapter_save_path == "data/adapters/requirement_adapter"
+ assert config.require_safety_approval is True
+ assert config.max_concurrent_finetunes == 1
+
+ def test_custom_config(self):
+ config = AutoFineTuneConfig(
+ evaluation_threshold=0.7,
+ max_auto_finetune_epochs=5,
+ auto_finetune_learning_rate=1e-4,
+ adapter_save_path="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/custom/path",
+ )
+ assert config.evaluation_threshold == 0.7
+ assert config.max_auto_finetune_epochs == 5
+ assert config.auto_finetune_learning_rate == 1e-4
+ assert config.adapter_save_path == "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/custom/path"
+
+
+class TestFineTuneTriggerRecord:
+ """Test FineTuneTriggerRecord dataclass"""
+
+ def test_default_record(self):
+ record = FineTuneTriggerRecord(
+ trigger_id="test_123",
+ requirement_id="req_456",
+ evaluation_score=0.5,
+ trigger_timestamp=datetime.now(),
+ fine_tune_config=Mock(),
+ training_examples_count=3,
+ )
+
+ assert record.trigger_id == "test_123"
+ assert record.requirement_id == "req_456"
+ assert record.evaluation_score == 0.5
+ assert record.training_examples_count == 3
+ assert record.fine_tune_results is None
+ assert record.adapter_reloaded is False
+ assert record.status == "triggered"
+
+
+class TestAutoFineTuneTrigger:
+ """Test AutoFineTuneTrigger"""
+
+ @pytest.fixture
+ def mock_engine(self):
+ engine = Mock()
+ engine.load_lora_adapter = Mock()
+ return engine
+
+ @pytest.fixture
+ def mock_pipeline(self):
+ pipeline = Mock()
+ pipeline.fine_tune = Mock(return_value={
+ "final_loss": 0.3,
+ "loss_decreased": True,
+ "loss_history": [0.5, 0.4, 0.3],
+ })
+ pipeline.save_adapter = Mock(return_value="/path/to/adapter")
+ return pipeline
+
+ @pytest.fixture
+ def config(self):
+ return AutoFineTuneConfig(
+ evaluation_threshold=0.8,
+ max_auto_finetune_epochs=2,
+ auto_finetune_learning_rate=1e-4,
+ )
+
+ @pytest.fixture
+ def trigger(self, mock_engine, mock_pipeline, config):
+ return AutoFineTuneTrigger(mock_engine, mock_pipeline, config)
+
+ def test_init(self, trigger, mock_engine, mock_pipeline, config):
+ assert trigger.engine == mock_engine
+ assert trigger.pipeline == mock_pipeline
+ assert trigger.config == config
+ assert trigger._trigger_history == []
+ assert trigger._active_finetunes == 0
+
+ def test_check_evaluation_score_below_threshold(self, trigger):
+ """Test trigger when score below threshold"""
+ eval_result = {"score": 0.6, "passes": False}
+ assert trigger.check_evaluation_score(eval_result) is True
+
+ def test_check_evaluation_score_above_threshold(self, trigger):
+ """Test no trigger when score above threshold"""
+ eval_result = {"score": 0.9, "passes": True}
+ assert trigger.check_evaluation_score(eval_result) is False
+
+ def test_check_evaluation_score_fails_explicitly(self, trigger):
+ """Test trigger when passes is False"""
+ eval_result = {"score": 0.9, "passes": False}
+ assert trigger.check_evaluation_score(eval_result) is True
+
+ def test_check_evaluation_score_defaults(self, trigger):
+ """Test with default values (should not trigger)"""
+ eval_result = {}
+ # Default score is 1.0, default passes is True
+ assert trigger.check_evaluation_score(eval_result) is False
+
+ def test_trigger_fine_tune_basic(self, trigger, mock_pipeline):
+ """Test basic fine-tune trigger"""
+ record = trigger.trigger_fine_tune(
+ requirement_id="req_123",
+ eval_result={"score": 0.5, "passes": False},
+ failed_requirement="Implement login",
+ expected_behavior="User can login",
+ actual_output="Login failed",
+ )
+
+ assert record.requirement_id == "req_123"
+ assert record.evaluation_score == 0.5
+ assert record.training_examples_count == 1
+ assert record.status == "completed"
+ assert record.fine_tune_results is not None
+ assert record.adapter_reloaded is True
+
+ # Verify pipeline was called
+ mock_pipeline.fine_tune.assert_called_once()
+ mock_pipeline.save_adapter.assert_called_once()
+ trigger.engine.load_lora_adapter.assert_called_once()
+
+ def test_trigger_fine_tune_max_concurrent(self, trigger):
+ """Test that max concurrent fine-tunes is respected"""
+ trigger._active_finetunes = trigger.config.max_concurrent_finetunes
+
+ record = trigger.trigger_fine_tune(
+ requirement_id="req_123",
+ eval_result={"score": 0.5, "passes": False},
+ failed_requirement="Test",
+ expected_behavior="Test",
+ actual_output="Test",
+ )
+
+ assert record.status == "skipped_max_concurrent"
+
+ def test_trigger_fine_tune_handles_exception(self, trigger, mock_pipeline):
+ """Test that exceptions during fine-tune are handled"""
+ mock_pipeline.fine_tune.side_effect = Exception("Training failed")
+
+ record = trigger.trigger_fine_tune(
+ requirement_id="req_123",
+ eval_result={"score": 0.5, "passes": False},
+ failed_requirement="Test",
+ expected_behavior="Test",
+ actual_output="Test",
+ )
+
+ assert record.status == "failed"
+ assert "error" in record.fine_tune_results
+
+ def test_reload_adapter(self, trigger):
+ """Test adapter reload"""
+ trigger.reload_adapter("/path/to/adapter")
+ trigger.engine.load_lora_adapter.assert_called_once_with("/path/to/adapter")
+
+ def test_get_trigger_history(self, trigger):
+ """Test getting trigger history"""
+ assert trigger.get_trigger_history() == []
+
+ # Add a record
+ trigger._trigger_history.append(Mock())
+ assert len(trigger.get_trigger_history()) == 1
+
+ def test_get_latest_trigger(self, trigger):
+ """Test getting latest trigger"""
+ assert trigger.get_latest_trigger() is None
+
+ mock_record = Mock()
+ trigger._trigger_history.append(mock_record)
+ assert trigger.get_latest_trigger() == mock_record
+
+ def test_get_statistics(self, trigger):
+ """Test statistics calculation"""
+ stats = trigger.get_statistics()
+
+ assert stats["total_triggers"] == 0
+ assert stats["completed"] == 0
+ assert stats["failed"] == 0
+ assert stats["success_rate"] == 0.0
+ assert stats["active_finetunes"] == 0
+
+ def test_get_statistics_with_records(self, trigger):
+ """Test statistics with trigger records"""
+ # Add mock records with different statuses
+ trigger._trigger_history = [
+ Mock(status="completed", fine_tune_results={"loss_decreased": True}),
+ Mock(status="completed", fine_tune_results={"loss_decreased": False}),
+ Mock(status="failed", fine_tune_results={}),
+ Mock(status="skipped_max_concurrent", fine_tune_results=None),
+ ]
+
+ stats = trigger.get_statistics()
+
+ assert stats["total_triggers"] == 4
+ assert stats["completed"] == 2
+ assert stats["failed"] == 1
+ assert stats["skipped"] == 1
+ assert stats["success_rate"] == 0.5
+ assert stats["loss_decreased_rate"] == 0.5
+
+
+class TestCreateAutoFineTuneTrigger:
+ """Test factory function"""
+
+ def test_create_auto_finetune_trigger(self):
+ mock_engine = Mock()
+
+ trigger = create_auto_finetune_trigger(mock_engine)
+
+ assert isinstance(trigger, AutoFineTuneTrigger)
+ assert trigger.engine == mock_engine
+ assert trigger.pipeline is not None
+ assert trigger.config is not None
+
+ def test_create_auto_finetune_trigger_with_config(self):
+ mock_engine = Mock()
+ config = AutoFineTuneConfig(evaluation_threshold=0.7)
+
+ trigger = create_auto_finetune_trigger(mock_engine, config)
+
+ assert trigger.config.evaluation_threshold == 0.7
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/unit/test_direct_agent_brain.py b/tests/unit/test_direct_agent_brain.py
new file mode 100644
index 00000000..25e789d8
--- /dev/null
+++ b/tests/unit/test_direct_agent_brain.py
@@ -0,0 +1,343 @@
+"""
+Unit tests for DirectAgentBrain - Embedded Multi-Agent Brain Engine
+"""
+
+import pytest
+from unittest.mock import Mock, patch, MagicMock
+from dataclasses import dataclass
+import json
+
+from src.agents.direct_agent_brain import (
+ DirectAgentBrain,
+ MultiAgentSwarm,
+ BrainState,
+ AgentResult,
+ AgentRole,
+)
+
+
+class TestAgentRole:
+ """Test AgentRole enum"""
+
+ def test_agent_roles(self):
+ assert AgentRole.PLANNER.value == "planner"
+ assert AgentRole.RESEARCHER.value == "researcher"
+ assert AgentRole.CODER.value == "coder"
+ assert AgentRole.EVALUATOR.value == "evaluator"
+ assert AgentRole.SAFETY_GATE.value == "safety_gate"
+
+
+class TestBrainState:
+ """Test BrainState dataclass"""
+
+ def test_default_state(self):
+ state = BrainState(objective="Test objective")
+
+ assert state.objective == "Test objective"
+ assert state.current_step == 0
+ assert state.max_steps == 10
+ assert state.plan == []
+ assert state.research_findings == []
+ assert state.code_patches == []
+ assert state.evaluation_results == []
+ assert state.safety_checks == []
+ assert state.final_output is None
+ assert state.errors == []
+ assert state.metadata == {}
+
+ def test_custom_state(self):
+ state = BrainState(
+ objective="Custom",
+ max_steps=5,
+ metadata={"key": "value"},
+ )
+
+ assert state.max_steps == 5
+ assert state.metadata == {"key": "value"}
+
+
+class TestAgentResult:
+ """Test AgentResult dataclass"""
+
+ def test_success_result(self):
+ result = AgentResult(
+ agent_role=AgentRole.PLANNER,
+ success=True,
+ output={"plan": []},
+ reasoning="Success",
+ confidence=0.9,
+ )
+
+ assert result.success is True
+ assert result.confidence == 0.9
+
+ def test_failure_result(self):
+ result = AgentResult(
+ agent_role=AgentRole.CODER,
+ success=False,
+ output=None,
+ reasoning="Error occurred",
+ confidence=0.0,
+ )
+
+ assert result.success is False
+ assert result.confidence == 0.0
+
+
+class TestDirectAgentBrain:
+ """Test DirectAgentBrain"""
+
+ @pytest.fixture
+ def mock_engine(self):
+ engine = Mock()
+ engine.generate = Mock(return_value='{"test": "response"}')
+ return engine
+
+ def test_init(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+
+ assert brain.engine == mock_engine
+ assert len(brain._agents) == 5
+ assert AgentRole.PLANNER in brain._agents
+ assert AgentRole.RESEARCHER in brain._agents
+ assert AgentRole.CODER in brain._agents
+ assert AgentRole.EVALUATOR in brain._agents
+ assert AgentRole.SAFETY_GATE in brain._agents
+
+ def test_step_initializes_state(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ state = brain.step("Test objective")
+
+ assert isinstance(state, BrainState)
+ assert state.objective == "Test objective"
+ assert state.current_step > 0
+
+ def test_planner_node(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ mock_engine.generate.return_value = json.dumps([
+ {"step_id": "1", "description": "Step 1", "agent": "researcher", "dependencies": [], "success_criteria": "Done"}
+ ])
+
+ state = BrainState(objective="Test")
+ result = brain._planner_node(state)
+
+ assert "plan" in result
+ assert len(result["plan"]) == 1
+ assert state.plan == result["plan"]
+
+ def test_planner_node_fallback(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ # Return invalid JSON to trigger fallback
+ mock_engine.generate.return_value = "not valid json"
+
+ state = BrainState(objective="Test")
+ result = brain._planner_node(state)
+
+ assert "plan" in result
+ assert result.get("fallback") is True
+ assert len(state.plan) == 3
+
+ def test_researcher_node(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ mock_engine.generate.return_value = json.dumps({
+ "files_to_examine": ["src/"],
+ "key_concepts": ["test"],
+ "dependencies": [],
+ "risks": [],
+ "recommendations": [],
+ })
+
+ state = BrainState(objective="Test", plan=[{"step_id": "1", "agent": "researcher"}])
+ result = brain._researcher_node(state)
+
+ assert "files_to_examine" in result
+ assert len(state.research_findings) == 1
+
+ def test_coder_node(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ mock_engine.generate.return_value = json.dumps({
+ "files": [{"path": "test.py", "content": "print('hello')"}],
+ "explanation": "Test",
+ "tests": ["test"],
+ })
+
+ state = BrainState(objective="Test")
+ state.research_findings = [{"files_to_examine": ["src/"]}]
+ state.plan = [{"step_id": "1"}]
+
+ result = brain._coder_node(state)
+
+ assert "files" in result
+ assert len(state.code_patches) == 1
+
+ def test_evaluator_node(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ mock_engine.generate.return_value = json.dumps({
+ "score": 0.8,
+ "criteria_met": ["test"],
+ "criteria_failed": [],
+ "issues": [],
+ "suggestions": [],
+ "passes": True,
+ })
+
+ state = BrainState(objective="Test")
+ state.code_patches = [{"files": []}]
+ state.plan = [{"step_id": "1"}]
+
+ result = brain._evaluator_node(state)
+
+ assert result["score"] == 0.8
+ assert result["passes"] is True
+ assert len(state.evaluation_results) == 1
+
+ def test_safety_gate_node(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ mock_engine.generate.return_value = json.dumps({
+ "safe": True,
+ "violations": [],
+ "warnings": [],
+ "recommended_action": "allow",
+ })
+
+ state = BrainState(objective="Test")
+ state.code_patches = [{"files": []}]
+ state.evaluation_results = [{"score": 0.8}]
+
+ result = brain._safety_gate_node(state)
+
+ assert result["safe"] is True
+ assert result["recommended_action"] == "allow"
+ assert len(state.safety_checks) == 1
+
+ def test_execute_agent_success(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ state = BrainState(objective="Test")
+ brain._state = state
+
+ result = brain._execute_agent(AgentRole.PLANNER)
+
+ assert result.success is True
+ assert result.agent_role == AgentRole.PLANNER
+
+ def test_execute_agent_unknown_role(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ state = BrainState(objective="Test")
+ brain._state = state
+
+ # Patch agents to remove one
+ brain._agents = {}
+
+ result = brain._execute_agent(AgentRole.PLANNER)
+
+ assert result.success is False
+ assert "Unknown agent role" in result.reasoning
+
+ def test_get_execution_history(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ state = brain.step("Test")
+
+ history = brain.get_execution_history()
+
+ assert len(history) == 5 # 5 agents
+ assert all(isinstance(h, AgentResult) for h in history)
+
+ def test_get_state(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ state = brain.step("Test")
+
+ retrieved = brain.get_state()
+
+ assert retrieved == state
+
+ def test_reset(self, mock_engine):
+ brain = DirectAgentBrain(mock_engine)
+ brain.step("Test 1")
+ brain.reset()
+
+ assert brain._state is None
+ assert brain._execution_history == []
+
+
+class TestMultiAgentSwarm:
+ """Test MultiAgentSwarm"""
+
+ def test_init(self):
+ swarm = MultiAgentSwarm(num_brains=3)
+
+ assert swarm.num_brains == 3
+ assert swarm._engines == []
+ assert swarm._brains == []
+
+ @patch('src.agents.direct_agent_brain.LocalWeightEngine')
+ def test_initialize(self, mock_engine_class):
+ mock_engine = Mock()
+ mock_engine_class.return_value = mock_engine
+
+ swarm = MultiAgentSwarm(num_brains=2)
+ config = Mock()
+ swarm.initialize(config)
+
+ assert len(swarm._engines) == 2
+ assert len(swarm._brains) == 2
+ assert mock_engine.load_model.call_count == 2
+ assert mock_engine.attach_lora.call_count == 2
+
+ @patch('concurrent.futures.ThreadPoolExecutor')
+ def test_solve_parallel(self, mock_executor_class):
+ mock_executor = Mock()
+ mock_executor_class.return_value.__enter__.return_value = mock_executor
+
+ # Mock futures
+ mock_future1 = Mock()
+ mock_future1.result.return_value = BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.7}})
+ mock_future2 = Mock()
+ mock_future2.result.return_value = BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.9}})
+
+ mock_executor.submit.side_effect = [mock_future1, mock_future2]
+
+ # Mock as_completed
+ import concurrent.futures
+ original_as_completed = concurrent.futures.as_completed
+ concurrent.futures.as_completed = lambda fs: [mock_future1, mock_future2]
+
+ try:
+ swarm = MultiAgentSwarm(num_brains=2)
+ swarm._brains = [Mock(), Mock()]
+ swarm._brains[0].step = Mock(return_value=BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.7}}))
+ swarm._brains[1].step = Mock(return_value=BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.9}}))
+
+ results = swarm.solve_parallel("Test objective")
+
+ assert len(results) == 2
+ finally:
+ concurrent.futures.as_completed = original_as_completed
+
+ def test_consensus(self):
+ swarm = MultiAgentSwarm(num_brains=2)
+
+ state1 = BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.7}})
+ state2 = BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.9}})
+ state3 = BrainState(objective="Test", metadata={"summary": {"evaluation_score": 0.5}})
+
+ best = swarm.consensus([state1, state2, state3])
+
+ assert best.metadata["summary"]["evaluation_score"] == 0.9
+
+ def test_cleanup(self):
+ swarm = MultiAgentSwarm(num_brains=2)
+ mock_engine1 = Mock()
+ mock_engine2 = Mock()
+ swarm._engines = [mock_engine1, mock_engine2]
+ swarm._brains = [Mock(), Mock()]
+
+ swarm.cleanup()
+
+ mock_engine1.unload.assert_called_once()
+ mock_engine2.unload.assert_called_once()
+ assert swarm._engines == []
+ assert swarm._brains == []
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/unit/test_execution_loop_harness.py b/tests/unit/test_execution_loop_harness.py
new file mode 100644
index 00000000..0468f041
--- /dev/null
+++ b/tests/unit/test_execution_loop_harness.py
@@ -0,0 +1,480 @@
+"""
+Unit tests for Execution Loop Harness - Phase 6: Continuous Execution Runtime Harness
+"""
+
+import pytest
+import json
+import tempfile
+import os
+from pathlib import Path
+from unittest.mock import Mock, patch, MagicMock
+from uuid import uuid4
+from datetime import datetime, timezone
+
+from src.cli.execution_loop_harness import (
+ FileCheckpointStore,
+ ExecutionLoopHarness,
+ DEFAULT_STATE_FILE,
+ DEFAULT_PID_FILE,
+)
+
+
+class TestFileCheckpointStore:
+ """Test FileCheckpointStore"""
+
+ @pytest.fixture
+ def temp_state_file(self):
+ """Create a temporary state file"""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ temp_path = Path(f.name)
+ yield temp_path
+ # Cleanup
+ if temp_path.exists():
+ temp_path.unlink()
+ # Also cleanup any .tmp files
+ for tmp_file in temp_path.parent.glob(f".{temp_path.name}.*"):
+ tmp_file.unlink(missing_ok=True)
+
+ def test_init_creates_parent_dirs(self, temp_state_file):
+ """Test that parent directories are created"""
+ store = FileCheckpointStore(temp_state_file)
+ assert store.path == temp_state_file
+
+ def test_append_first_checkpoint(self, temp_state_file):
+ """Test appending first checkpoint"""
+ store = FileCheckpointStore(temp_state_file)
+
+ checkpoint = store.append(
+ objective_id="obj_123",
+ description="Test objective",
+ current_step=1,
+ total_steps=5,
+ status="running",
+ )
+
+ assert checkpoint["objective_id"] == "obj_123"
+ assert checkpoint["state_snapshot"]["tick_count"] == 1
+ assert checkpoint["progress_snapshot"]["step"] == 1
+ assert checkpoint["progress_snapshot"]["total_steps"] == 5
+ assert checkpoint["state_snapshot"]["status"] == "running"
+ assert checkpoint["metadata"]["sequence"] == 1
+ assert "created_at" in checkpoint
+
+ def test_append_multiple_checkpoints(self, temp_state_file):
+ """Test appending multiple checkpoints increments sequence"""
+ store = FileCheckpointStore(temp_state_file)
+
+ ckpt1 = store.append(objective_id="obj_1", description="Test", current_step=1, total_steps=3, status="running")
+ ckpt2 = store.append(objective_id="obj_1", description="Test", current_step=2, total_steps=3, status="running")
+ ckpt3 = store.append(objective_id="obj_1", description="Test", current_step=3, total_steps=3, status="completed")
+
+ assert ckpt1["metadata"]["sequence"] == 1
+ assert ckpt2["metadata"]["sequence"] == 2
+ assert ckpt3["metadata"]["sequence"] == 3
+
+ def test_latest_returns_last_checkpoint(self, temp_state_file):
+ """Test latest() returns the most recent checkpoint"""
+ store = FileCheckpointStore(temp_state_file)
+
+ store.append(objective_id="obj_1", description="Test", current_step=1, total_steps=3, status="running")
+ ckpt2 = store.append(objective_id="obj_1", description="Test", current_step=2, total_steps=3, status="running")
+ store.append(objective_id="obj_1", description="Test", current_step=3, total_steps=3, status="completed")
+
+ latest = store.latest("obj_1")
+
+ assert latest is not None
+ assert latest["metadata"]["sequence"] == 3
+ assert latest["progress_snapshot"]["step"] == 3
+
+ def test_latest_none_for_unknown_objective(self, temp_state_file):
+ """Test latest() returns None for unknown objective"""
+ store = FileCheckpointStore(temp_state_file)
+
+ latest = store.latest("unknown_obj")
+
+ assert latest is None
+
+ def test_list_returns_all_checkpoints(self, temp_state_file):
+ """Test list() returns all checkpoints for an objective"""
+ store = FileCheckpointStore(temp_state_file)
+
+ store.append(objective_id="obj_1", description="Test", current_step=1, total_steps=3, status="running")
+ store.append(objective_id="obj_1", description="Test", current_step=2, total_steps=3, status="running")
+ store.append(objective_id="obj_1", description="Test", current_step=3, total_steps=3, status="completed")
+
+ checkpoints = store.list("obj_1")
+
+ assert len(checkpoints) == 3
+ assert checkpoints[0]["metadata"]["sequence"] == 1
+ assert checkpoints[2]["metadata"]["sequence"] == 3
+
+ def test_atomic_write(self, temp_state_file):
+ """Test atomic write creates valid JSON"""
+ store = FileCheckpointStore(temp_state_file)
+
+ store.append(objective_id="obj_1", description="Test", current_step=1, total_steps=3, status="running")
+
+ # Verify file exists and is valid JSON
+ assert temp_state_file.exists()
+ with temp_state_file.open() as f:
+ data = json.load(f)
+
+ assert data["version"] == 1
+ assert "objectives" in data
+ assert "obj_1" in data["objectives"]
+
+ def test_read_nonexistent_file(self, temp_state_file):
+ """Test reading non-existent file returns empty document"""
+ # Don't create the file
+ store = FileCheckpointStore(temp_state_file)
+
+ doc = store._read_document()
+
+ assert doc == {"version": 1, "objectives": {}}
+
+ def test_invalid_version_raises(self, temp_state_file):
+ """Test invalid version raises error"""
+ # Write invalid version
+ with temp_state_file.open('w') as f:
+ json.dump({"version": 2, "objectives": {}}, f)
+
+ store = FileCheckpointStore(temp_state_file)
+
+ with pytest.raises(Exception) as exc_info:
+ store._read_document()
+
+ assert "Invalid checkpoint store" in str(exc_info.value)
+
+
+class TestExecutionLoopHarness:
+ """Test ExecutionLoopHarness"""
+
+ @pytest.fixture
+ def temp_files(self):
+ """Create temporary state and pid files"""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ state_file = Path(f.name)
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.pid', delete=False) as f:
+ pid_file = Path(f.name)
+
+ yield state_file, pid_file
+
+ # Cleanup
+ for p in [state_file, pid_file]:
+ if p.exists():
+ p.unlink()
+ # Cleanup tmp files
+ for tmp_file in state_file.parent.glob(f".{state_file.name}.*"):
+ tmp_file.unlink(missing_ok=True)
+
+ def test_init_validates_step_delay(self, temp_files):
+ """Test that negative step_delay raises error"""
+ state_file, pid_file = temp_files
+
+ with pytest.raises(Exception) as exc_info:
+ ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=-0.1,
+ )
+
+ assert "step-delay must be zero or greater" in str(exc_info.value)
+
+ def test_run_objective_creates_checkpoints(self, temp_files):
+ """Test run_objective creates checkpoints for each step"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01, # Fast for testing
+ )
+
+ objective_id = "test_obj_123"
+ result = harness.run_objective(
+ objective_id=objective_id,
+ description="Test objective",
+ total_steps=3,
+ )
+
+ assert result["state_snapshot"]["status"] == "completed"
+ assert result["progress_snapshot"]["step"] == 3
+ assert result["progress_snapshot"]["total_steps"] == 3
+
+ # Verify checkpoints were created (step 0 + 3 steps + final completed = 5)
+ store = FileCheckpointStore(state_file)
+ checkpoints = store.list(objective_id)
+ assert len(checkpoints) == 5 # step 0 + 3 steps + final completed
+
+ def test_run_objective_rejects_duplicate(self, temp_files):
+ """Test run_objective rejects duplicate objective_id"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ objective_id = "test_obj_123"
+ harness.run_objective(objective_id=objective_id, description="Test", total_steps=1)
+
+ with pytest.raises(Exception) as exc_info:
+ harness.run_objective(objective_id=objective_id, description="Test", total_steps=1)
+
+ assert "already has checkpoints" in str(exc_info.value)
+
+ def test_run_objective_validates_total_steps(self, temp_files):
+ """Test run_objective validates total_steps >= 1"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ with pytest.raises(Exception) as exc_info:
+ harness.run_objective(objective_id="test", description="Test", total_steps=0)
+
+ assert "total-steps must be at least 1" in str(exc_info.value)
+
+ def test_restore_and_resume(self, temp_files):
+ """Test restore_and_resume continues from latest checkpoint"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ objective_id = "test_obj_123"
+ # First run completes 2 of 5 steps - manually create checkpoints to simulate this
+ store = FileCheckpointStore(state_file)
+ # Simulate: objective was created with total_steps=5, but only 2 steps completed
+ store.append(objective_id=objective_id, description="Test objective", current_step=0, total_steps=5, status="running")
+ store.append(objective_id=objective_id, description="Test objective", current_step=1, total_steps=5, status="running")
+ store.append(objective_id=objective_id, description="Test objective", current_step=2, total_steps=5, status="running")
+
+ # Now restore and resume - should continue from step 2 to step 5
+ result = harness.restore_and_resume(objective_id)
+
+ assert result["progress_snapshot"]["step"] == 5
+ assert result["progress_snapshot"]["total_steps"] == 5
+ assert result["state_snapshot"]["status"] == "completed"
+
+ # Verify all checkpoints exist
+ checkpoints = store.list(objective_id)
+ # Should have: step 0, 1, 2 (original) + 3, 4, 5 (running) + final completed = 7
+ assert len(checkpoints) == 7
+
+ def test_restore_and_resume_nonexistent_fails(self, temp_files):
+ """Test restore_and_resume fails for nonexistent objective"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ with pytest.raises(Exception) as exc_info:
+ harness.restore_and_resume("nonexistent")
+
+ assert "No checkpoint found" in str(exc_info.value)
+
+ def test_restore_completed_objective_returns_immediately(self, temp_files):
+ """Test restore on completed objective returns immediately"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ objective_id = "test_obj_123"
+ # Complete the objective
+ harness.run_objective(objective_id=objective_id, description="Test", total_steps=2)
+
+ # Restore should return immediately with completed status
+ result = harness.restore_and_resume(objective_id)
+
+ assert result["state_snapshot"]["status"] == "completed"
+
+ def test_pid_file_management(self, temp_files):
+ """Test PID file is created and removed"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ objective_id = "test_obj_123"
+ harness.run_objective(objective_id=objective_id, description="Test", total_steps=1)
+
+ # PID file should be cleaned up after completion
+ assert not pid_file.exists()
+
+ def test_kill_command_reads_pid(self, temp_files):
+ """Test kill command reads PID from file"""
+ state_file, pid_file = temp_files
+
+ # Write a fake PID
+ pid_file.write_text("99999", encoding="utf-8")
+
+ # Mock os.kill to avoid actually killing process
+ with patch('os.kill') as mock_kill:
+ from src.cli.execution_loop_harness import kill
+ from click.testing import CliRunner
+
+ runner = CliRunner()
+ result = runner.invoke(kill, ['--pid-file', str(pid_file)])
+
+ mock_kill.assert_called_once_with(99999, 9) # SIGKILL = 9
+ assert result.exit_code == 0
+
+ def test_kill_command_missing_pid_file(self, temp_files):
+ """Test kill command fails if PID file missing"""
+ state_file, pid_file = temp_files
+ # Don't create pid_file - it shouldn't exist at all
+ if pid_file.exists():
+ pid_file.unlink()
+
+ from src.cli.execution_loop_harness import kill
+ from click.testing import CliRunner
+
+ runner = CliRunner()
+ result = runner.invoke(kill, ['--pid-file', str(pid_file)])
+
+ assert result.exit_code != 0
+ assert "PID file not found" in result.output
+
+ def test_kill_command_invalid_pid(self, temp_files):
+ """Test kill command fails if PID file has invalid content"""
+ state_file, pid_file = temp_files
+ pid_file.write_text("not_a_number", encoding="utf-8")
+
+ from src.cli.execution_loop_harness import kill
+ from click.testing import CliRunner
+
+ runner = CliRunner()
+ result = runner.invoke(kill, ['--pid-file', str(pid_file)])
+
+ assert result.exit_code != 0
+ assert "Invalid PID file" in result.output
+
+ def test_list_checkpoints_command(self, temp_files):
+ """Test list-checkpoints command"""
+ state_file, pid_file = temp_files
+
+ # Create some checkpoints
+ store = FileCheckpointStore(state_file)
+ store.append(objective_id="obj_1", description="Test", current_step=1, total_steps=3, status="running")
+ store.append(objective_id="obj_1", description="Test", current_step=2, total_steps=3, status="running")
+ store.append(objective_id="obj_1", description="Test", current_step=3, total_steps=3, status="completed")
+
+ from src.cli.execution_loop_harness import list_checkpoints
+ from click.testing import CliRunner
+
+ runner = CliRunner()
+ result = runner.invoke(list_checkpoints, ['obj_1', '--state-file', str(state_file)])
+
+ assert result.exit_code == 0
+ assert "harness_0001" in result.output
+ assert "harness_0002" in result.output
+ assert "harness_0003" in result.output
+ assert "completed" in result.output
+
+
+class TestCheckpointSurvival:
+ """Test checkpoint survival across process restarts"""
+
+ @pytest.fixture
+ def temp_files(self):
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ state_file = Path(f.name)
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.pid', delete=False) as f:
+ pid_file = Path(f.name)
+
+ yield state_file, pid_file
+
+ for p in [state_file, pid_file]:
+ if p.exists():
+ p.unlink()
+ for tmp_file in state_file.parent.glob(f".{state_file.name}.*"):
+ tmp_file.unlink(missing_ok=True)
+
+ def test_checkpoint_survives_process_restart(self, temp_files):
+ """Test that checkpoints survive process restart (simulated by new harness instance)"""
+ state_file, pid_file = temp_files
+
+ # First harness instance runs 2 steps of 5
+ store = FileCheckpointStore(state_file)
+ objective_id = "survival_test_obj"
+ # Simulate partial completion: 2 of 5 steps
+ store.append(objective_id=objective_id, description="Survival test", current_step=0, total_steps=5, status="running")
+ store.append(objective_id=objective_id, description="Survival test", current_step=1, total_steps=5, status="running")
+ store.append(objective_id=objective_id, description="Survival test", current_step=2, total_steps=5, status="running")
+
+ # Verify checkpoints exist
+ checkpoints = store.list(objective_id)
+ assert len(checkpoints) == 3 # step 0, 1, 2
+
+ # Simulate process restart by creating new harness instance
+ harness2 = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ # Resume from step 2 to step 5
+ result = harness2.restore_and_resume(objective_id)
+
+ assert result["progress_snapshot"]["step"] == 5
+ assert result["progress_snapshot"]["total_steps"] == 5
+ assert result["state_snapshot"]["status"] == "completed"
+
+ # Verify all checkpoints still exist
+ checkpoints = store.list(objective_id)
+ # Should have: step 0, 1, 2 (original) + 3, 4, 5 (running) + final completed = 7
+ assert len(checkpoints) == 7
+
+ def test_checkpoint_data_integrity(self, temp_files):
+ """Test that checkpoint data maintains integrity"""
+ state_file, pid_file = temp_files
+
+ harness = ExecutionLoopHarness(
+ checkpoint_store=FileCheckpointStore(state_file),
+ pid_file=pid_file,
+ step_delay=0.01,
+ )
+
+ objective_id = "integrity_test"
+ description = "Test data integrity"
+ total_steps = 3
+
+ harness.run_objective(objective_id=objective_id, description=description, total_steps=total_steps)
+
+ store = FileCheckpointStore(state_file)
+ checkpoints = store.list(objective_id)
+
+ # Should have 5 checkpoints: step 0, 1, 2, 3 running + final completed
+ assert len(checkpoints) == 5
+
+ for i, ckpt in enumerate(checkpoints):
+ assert ckpt["objective_id"] == objective_id
+ assert ckpt["state_snapshot"]["objective_description"] == description
+ assert ckpt["progress_snapshot"]["total_steps"] == total_steps
+ # Step should be i for steps 0-3, then final completed also at step 3
+ expected_step = i if i < total_steps else total_steps
+ assert ckpt["progress_snapshot"]["step"] == expected_step
+ assert ckpt["metadata"]["type"] == "process_harness"
+ assert "created_at" in ckpt
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/unit/test_local_bridge.py b/tests/unit/test_local_bridge.py
new file mode 100644
index 00000000..6e39fa98
--- /dev/null
+++ b/tests/unit/test_local_bridge.py
@@ -0,0 +1,349 @@
+"""
+Unit tests for LocalBridgeServer - Phase 9: Native Process Bridge / Local Socket Server
+"""
+
+import pytest
+import asyncio
+import json
+from unittest.mock import Mock, patch, AsyncMock, MagicMock
+from datetime import datetime
+
+from src.server.local_bridge import (
+ LocalBridgeServer,
+ BridgeEventType,
+ BridgeCommandType,
+ BridgeEvent,
+ BridgeCommand,
+ ClientConnection,
+ create_bridge_server,
+)
+
+
+class TestBridgeEventType:
+ """Test BridgeEventType enum"""
+
+ def test_event_types(self):
+ assert BridgeEventType.AGENT_THOUGHT.value == "agent_thought"
+ assert BridgeEventType.AGENT_STEP.value == "agent_step"
+ assert BridgeEventType.FINE_TUNE_EPOCH.value == "fine_tune_epoch"
+ assert BridgeEventType.FINE_TUNE_LOSS.value == "fine_tune_loss"
+ assert BridgeEventType.SWARM_STATUS.value == "swarm_status"
+ assert BridgeEventType.OBJECTIVE_STARTED.value == "objective_started"
+ assert BridgeEventType.OBJECTIVE_COMPLETED.value == "objective_completed"
+ assert BridgeEventType.OBJECTIVE_FAILED.value == "objective_failed"
+ assert BridgeEventType.CHECKPOINT_CREATED.value == "checkpoint_created"
+ assert BridgeEventType.CONSENSUS_REQUEST.value == "consensus_request"
+ assert BridgeEventType.CONSENSUS_RESULT.value == "consensus_result"
+ assert BridgeEventType.ERROR.value == "error"
+
+
+class TestBridgeCommandType:
+ """Test BridgeCommandType enum"""
+
+ def test_command_types(self):
+ assert BridgeCommandType.SUBMIT_OBJECTIVE.value == "submit_objective"
+ assert BridgeCommandType.TRIGGER_FINETUNE.value == "trigger_finetune"
+ assert BridgeCommandType.PAUSE_HARNESS.value == "pause_harness"
+ assert BridgeCommandType.RESUME_HARNESS.value == "resume_harnass"
+ assert BridgeCommandType.SWITCH_MODEL.value == "switch_model"
+ assert BridgeCommandType.GET_STATUS.value == "get_status"
+ assert BridgeCommandType.LIST_OBJECTIVES.value == "list_objectives"
+ assert BridgeCommandType.GET_AGENT_STATE.value == "get_agent_state"
+
+
+class TestBridgeEvent:
+ """Test BridgeEvent dataclass"""
+
+ def test_event_creation(self):
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_THOUGHT,
+ payload={"agent": "planner", "thought": "Planning..."},
+ correlation_id="corr_123",
+ )
+
+ assert event.event_type == BridgeEventType.AGENT_THOUGHT
+ assert event.payload == {"agent": "planner", "thought": "Planning..."}
+ assert event.correlation_id == "corr_123"
+ assert isinstance(event.timestamp, datetime)
+
+ def test_event_to_json(self):
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_STEP,
+ payload={"step": 1, "status": "running"},
+ )
+
+ json_str = event.to_json()
+ data = json.loads(json_str)
+
+ assert data["event_type"] == "agent_step"
+ assert data["payload"] == {"step": 1, "status": "running"}
+ assert "timestamp" in data
+
+
+class TestBridgeCommand:
+ """Test BridgeCommand dataclass"""
+
+ def test_command_creation(self):
+ command = BridgeCommand(
+ command_type=BridgeCommandType.SUBMIT_OBJECTIVE,
+ payload={"objective": "Test objective"},
+ request_id="req_123",
+ )
+
+ assert command.command_type == BridgeCommandType.SUBMIT_OBJECTIVE
+ assert command.payload == {"objective": "Test objective"}
+ assert command.request_id == "req_123"
+
+ def test_command_from_json(self):
+ json_str = json.dumps({
+ "command_type": "trigger_finetune",
+ "payload": {"requirement_id": "req_123"},
+ "request_id": "req_456",
+ "timestamp": "2024-01-01T00:00:00",
+ })
+
+ command = BridgeCommand.from_json(json_str)
+
+ assert command.command_type == BridgeCommandType.TRIGGER_FINETUNE
+ assert command.payload == {"requirement_id": "req_123"}
+ assert command.request_id == "req_456"
+
+
+class TestClientConnection:
+ """Test ClientConnection"""
+
+ @pytest.fixture
+ def mock_websocket(self):
+ ws = AsyncMock()
+ ws.send = AsyncMock()
+ ws.close = AsyncMock()
+ return ws
+
+ @pytest.mark.asyncio
+ async def test_send_event_subscribed(self, mock_websocket):
+ """Test sending event to subscribed client"""
+ client = ClientConnection(mock_websocket, "client_1")
+ client.subscriptions.add(BridgeEventType.AGENT_THOUGHT)
+
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_THOUGHT,
+ payload={"test": "data"},
+ )
+
+ result = await client.send(event)
+
+ assert result is True
+ mock_websocket.send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_send_event_not_subscribed(self, mock_websocket):
+ """Test sending event to non-subscribed client (should still send if no subscriptions)"""
+ client = ClientConnection(mock_websocket, "client_1")
+ # No subscriptions - should receive all events
+
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_THOUGHT,
+ payload={"test": "data"},
+ )
+
+ result = await client.send(event)
+
+ assert result is True
+ mock_websocket.send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_send_event_filtered(self, mock_websocket):
+ """Test event filtering by subscription"""
+ client = ClientConnection(mock_websocket, "client_1")
+ client.subscriptions.add(BridgeEventType.AGENT_STEP)
+ # Not subscribed to AGENT_THOUGHT
+
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_THOUGHT,
+ payload={"test": "data"},
+ )
+
+ result = await client.send(event)
+
+ assert result is True # Returns True but doesn't send
+ mock_websocket.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_send_error(self, mock_websocket):
+ """Test sending error response"""
+ client = ClientConnection(mock_websocket, "client_1")
+
+ await client.send_error("Test error", "req_123")
+
+ mock_websocket.send.assert_called_once()
+ sent_data = json.loads(mock_websocket.send.call_args[0][0])
+ assert sent_data["event_type"] == "error"
+ assert sent_data["payload"]["message"] == "Test error"
+ assert sent_data["payload"]["request_id"] == "req_123"
+
+ @pytest.mark.asyncio
+ async def test_send_response(self, mock_websocket):
+ """Test sending command response"""
+ client = ClientConnection(mock_websocket, "client_1")
+
+ await client.send_response("req_123", {"status": "ok"})
+
+ mock_websocket.send.assert_called_once()
+ sent_data = json.loads(mock_websocket.send.call_args[0][0])
+ assert sent_data["payload"]["response"] is True
+ assert sent_data["payload"]["request_id"] == "req_123"
+ assert sent_data["payload"]["status"] == "ok"
+
+
+class TestLocalBridgeServer:
+ """Test LocalBridgeServer"""
+
+ @pytest.fixture
+ def server(self):
+ return LocalBridgeServer(host="127.0.0.1", port=8765)
+
+ def test_init(self, server):
+ assert server.host == "127.0.0.1"
+ assert server.port == 8765
+ assert server._running is False
+ assert server._server is None
+ assert server._clients == {}
+ assert len(server._command_handlers) == 8 # 8 default handlers
+
+ @pytest.mark.asyncio
+ async def test_start_stop(self, server):
+ """Test starting and stopping the server"""
+ # Create a proper mock that returns an awaitable
+ async def mock_serve(*args, **kwargs):
+ mock_server = AsyncMock()
+ mock_server.close = AsyncMock()
+ mock_server.wait_closed = AsyncMock()
+ return mock_server
+
+ with patch('websockets.serve', side_effect=mock_serve):
+ await server.start()
+
+ assert server._running is True
+
+ await server.stop()
+
+ assert server._running is False
+
+ @pytest.mark.asyncio
+ async def test_start_already_running(self, server):
+ """Test starting already running server"""
+ server._running = True
+
+ with patch('websockets.serve') as mock_serve:
+ await server.start()
+ mock_serve.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event(self, server):
+ """Test broadcasting event"""
+ server._running = True
+
+ # Create mock clients
+ mock_client1 = AsyncMock()
+ mock_client1.send = AsyncMock(return_value=True)
+ mock_client2 = AsyncMock()
+ mock_client2.send = AsyncMock(return_value=True)
+
+ server._clients = {
+ "client1": mock_client1,
+ "client2": mock_client2,
+ }
+
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_THOUGHT,
+ payload={"test": "data"},
+ )
+
+ server.broadcast(event)
+
+ # Give time for async broadcast
+ await asyncio.sleep(0.1)
+
+ mock_client1.send.assert_called_once()
+ mock_client2.send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_broadcast_filters_by_subscription(self, server):
+ """Test broadcast filters by client subscription"""
+ server._running = True
+
+ # Create actual ClientConnection instances for proper filtering
+ mock_ws1 = AsyncMock()
+ mock_ws1.send = AsyncMock(return_value=True)
+ mock_ws2 = AsyncMock()
+ mock_ws2.send = AsyncMock(return_value=True)
+
+ client1 = ClientConnection(mock_ws1, "client1")
+ client1.subscriptions = {BridgeEventType.AGENT_THOUGHT}
+
+ client2 = ClientConnection(mock_ws2, "client2")
+ client2.subscriptions = {BridgeEventType.AGENT_STEP}
+
+ server._clients = {
+ "client1": client1,
+ "client2": client2,
+ }
+
+ event = BridgeEvent(
+ event_type=BridgeEventType.AGENT_THOUGHT,
+ payload={"test": "data"},
+ )
+
+ server.broadcast(event)
+ await asyncio.sleep(0.1)
+
+ mock_ws1.send.assert_called_once()
+ mock_ws2.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_register_command_handler(self, server):
+ """Test registering custom command handler"""
+ async def custom_handler(client, payload):
+ return {"custom": True}
+
+ server.register_command_handler(BridgeCommandType.SUBMIT_OBJECTIVE, custom_handler)
+
+ assert server._command_handlers[BridgeCommandType.SUBMIT_OBJECTIVE] == custom_handler
+
+ @pytest.mark.asyncio
+ async def test_client_count(self, server):
+ """Test client count property"""
+ assert server.client_count == 0
+
+ server._clients = {"client1": Mock(), "client2": Mock()}
+ assert server.client_count == 2
+
+ @pytest.mark.asyncio
+ async def test_is_running(self, server):
+ """Test is_running property"""
+ assert server.is_running is False
+
+ server._running = True
+ assert server.is_running is True
+
+
+class TestCreateBridgeServer:
+ """Test create_bridge_server factory function"""
+
+ @pytest.mark.asyncio
+ async def test_create_bridge_server(self):
+ """Test factory function creates and starts server"""
+ with patch('src.server.local_bridge.LocalBridgeServer') as mock_server_class:
+ mock_server = AsyncMock()
+ mock_server.start = AsyncMock()
+ mock_server_class.return_value = mock_server
+
+ server = await create_bridge_server("127.0.0.1", 8765)
+
+ assert server == mock_server
+ mock_server_class.assert_called_once_with(host="127.0.0.1", port=8765)
+ mock_server.start.assert_called_once()
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/unit/test_local_weight_engine.py b/tests/unit/test_local_weight_engine.py
index f9ac7042..8c8b79b1 100644
--- a/tests/unit/test_local_weight_engine.py
+++ b/tests/unit/test_local_weight_engine.py
@@ -208,14 +208,16 @@ def test_get_model_info_loaded(self):
"""Test get_model_info when model loaded"""
engine = LocalWeightEngine()
engine._model = Mock()
- # Mock parameters() to return an iterator
- mock_params = [Mock(numel=Mock(return_value=100)), Mock(numel=Mock(return_value=50))]
- engine._model.parameters.return_value = iter(mock_params)
- # Mock next() on the model for dtype
+ # Mock parameters() to return a fresh iterator each time
mock_param = Mock()
+ mock_param.numel.return_value = 100
+ mock_param.requires_grad = True
mock_param.dtype = torch.float32
- engine._model.__iter__ = Mock(return_value=iter([mock_param]))
- engine._model.__next__ = Mock(return_value=mock_param)
+
+ def params_iterator():
+ return iter([mock_param])
+
+ engine._model.parameters.side_effect = params_iterator
engine._model.device = torch.device("cpu")
engine._model_name = "test-model"
engine._is_peft_model = False
@@ -225,6 +227,8 @@ def test_get_model_info_loaded(self):
assert info["model_name"] == "test-model"
assert info["device"] == "cpu"
assert info["is_peft_model"] is False
+ assert info["total_parameters"] == 100
+ assert info["trainable_parameters"] == 100
def test_enable_training_mode(self):
"""Test enabling training mode"""
diff --git a/tests/unit/test_requirement_finetune_pipeline.py b/tests/unit/test_requirement_finetune_pipeline.py
new file mode 100644
index 00000000..3809c311
--- /dev/null
+++ b/tests/unit/test_requirement_finetune_pipeline.py
@@ -0,0 +1,353 @@
+"""
+Unit tests for RequirementFineTunePipeline - Direct Parameter Fine-Tuning Pipeline
+"""
+
+import pytest
+import torch
+from unittest.mock import Mock, patch, MagicMock
+from dataclasses import dataclass
+
+from src.learning.requirement_finetune_pipeline import (
+ RequirementFineTunePipeline,
+ FineTuneConfig,
+ TrainingExample,
+ RequirementDataset,
+ create_training_examples_from_feedback,
+ create_training_examples_from_code_fix,
+)
+
+
+class TestFineTuneConfig:
+ """Test FineTuneConfig dataclass"""
+
+ def test_default_config(self):
+ config = FineTuneConfig()
+ assert config.epochs == 3
+ assert config.learning_rate == 2e-4
+ assert config.batch_size == 1
+ assert config.gradient_accumulation_steps == 4
+ assert config.max_grad_norm == 1.0
+ assert config.output_dir == "data/adapters/requirement_adapter"
+
+ def test_custom_config(self):
+ config = FineTuneConfig(
+ epochs=5,
+ learning_rate=1e-4,
+ batch_size=2,
+ output_dir="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/custom/path",
+ )
+ assert config.epochs == 5
+ assert config.learning_rate == 1e-4
+ assert config.batch_size == 2
+ assert config.output_dir == "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/custom/path"
+
+
+class TestTrainingExample:
+ """Test TrainingExample dataclass"""
+
+ def test_creation(self):
+ example = TrainingExample(
+ prompt="Test prompt",
+ completion="Test completion",
+ requirement_id="req-123",
+ )
+ assert example.prompt == "Test prompt"
+ assert example.completion == "Test completion"
+ assert example.requirement_id == "req-123"
+ assert example.metadata == {}
+
+ def test_with_metadata(self):
+ example = TrainingExample(
+ prompt="Test",
+ completion="Test",
+ requirement_id="req-1",
+ metadata={"key": "value"},
+ )
+ assert example.metadata == {"key": "value"}
+
+
+class TestRequirementDataset:
+ """Test RequirementDataset"""
+
+ @pytest.fixture
+ def mock_tokenizer(self):
+ tokenizer = Mock()
+ tokenizer.pad_token = ""
+ tokenizer.eos_token = ""
+
+ # Mock tokenization output
+ tokenizer.return_value = {
+ "input_ids": torch.tensor([[1, 2, 3, 4, 5]]),
+ "attention_mask": torch.tensor([[1, 1, 1, 1, 1]]),
+ }
+
+ # Mock prompt tokenization
+ def tokenize_side_effect(text, **kwargs):
+ if "padding" in kwargs and not kwargs["padding"]:
+ return {"input_ids": torch.tensor([[1, 2]])}
+ return {
+ "input_ids": torch.tensor([[1, 2, 3, 4, 5]]),
+ "attention_mask": torch.tensor([[1, 1, 1, 1, 1]]),
+ }
+
+ tokenizer.side_effect = tokenize_side_effect
+ return tokenizer
+
+ @pytest.fixture
+ def training_examples(self):
+ return [
+ TrainingExample(
+ prompt="Prompt 1",
+ completion="Completion 1",
+ requirement_id="req-1",
+ ),
+ TrainingExample(
+ prompt="Prompt 2",
+ completion="Completion 2",
+ requirement_id="req-2",
+ ),
+ ]
+
+ def test_dataset_length(self, mock_tokenizer, training_examples):
+ dataset = RequirementDataset(training_examples, mock_tokenizer, max_seq_length=512)
+ assert len(dataset) == 2
+
+ def test_dataset_getitem(self, mock_tokenizer, training_examples):
+ dataset = RequirementDataset(training_examples, mock_tokenizer, max_seq_length=512)
+ item = dataset[0]
+
+ assert "input_ids" in item
+ assert "attention_mask" in item
+ assert "labels" in item
+ assert isinstance(item["input_ids"], torch.Tensor)
+ assert isinstance(item["attention_mask"], torch.Tensor)
+ assert isinstance(item["labels"], torch.Tensor)
+
+ def test_labels_mask_prompt(self, mock_tokenizer, training_examples):
+ """Test that prompt tokens are masked in labels (-100)"""
+ dataset = RequirementDataset(training_examples, mock_tokenizer, max_seq_length=512)
+ item = dataset[0]
+
+ # First 2 tokens should be -100 (prompt length)
+ assert item["labels"][0] == -100
+ assert item["labels"][1] == -100
+ # Rest should be actual token IDs
+ assert item["labels"][2] != -100
+
+
+class TestRequirementFineTunePipeline:
+ """Test RequirementFineTunePipeline"""
+
+ @pytest.fixture
+ def mock_engine(self):
+ engine = Mock(spec=[
+ 'model', 'tokenizer', 'device', 'get_trainable_parameters',
+ 'enable_training_mode', 'enable_eval_mode', 'forward',
+ 'save_lora_adapter', 'load_lora_adapter', '_is_peft_model'
+ ])
+ engine.model = Mock()
+ engine.tokenizer = Mock()
+ engine.tokenizer.pad_token = ""
+ engine.tokenizer.eos_token = ""
+ engine.device = torch.device("cpu")
+ engine._is_peft_model = True
+
+ # Mock trainable parameters - use real tensors with requires_grad
+ param1 = torch.nn.Parameter(torch.randn(10, 10))
+ param2 = torch.nn.Parameter(torch.randn(10, 10))
+ engine.get_trainable_parameters.return_value = [param1, param2]
+
+ # Mock forward pass
+ mock_output = Mock()
+ mock_output.loss = torch.tensor(0.5, requires_grad=True)
+ engine.forward.return_value = mock_output
+
+ # Mock tokenizer to return proper tensors
+ def tokenizer_side_effect(text, **kwargs):
+ if "padding" in kwargs and not kwargs["padding"]:
+ return {"input_ids": torch.tensor([[1, 2]])}
+ return {
+ "input_ids": torch.tensor([[1, 2, 3, 4, 5]]),
+ "attention_mask": torch.tensor([[1, 1, 1, 1, 1]]),
+ }
+
+ engine.tokenizer.side_effect = tokenizer_side_effect
+
+ return engine
+
+ @pytest.fixture
+ def config(self):
+ return FineTuneConfig(
+ epochs=2,
+ batch_size=1,
+ gradient_accumulation_steps=1,
+ logging_steps=10,
+ )
+
+ @pytest.fixture
+ def training_examples(self):
+ return [
+ TrainingExample(
+ prompt="Test prompt",
+ completion="Test completion",
+ requirement_id="req-1",
+ ),
+ ]
+
+ def test_init(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ assert pipeline.engine == mock_engine
+ assert pipeline.config == config
+ assert pipeline._optimizer is None
+ assert pipeline._global_step == 0
+
+ def test_prepare_dataset(self, mock_engine, config, training_examples):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ dataloader = pipeline.prepare_dataset(training_examples)
+
+ assert isinstance(dataloader, torch.utils.data.DataLoader)
+ assert dataloader.batch_size == 1
+
+ def test_prepare_dataset_without_tokenizer_raises(self, config):
+ engine = Mock()
+ engine.tokenizer = None
+ pipeline = RequirementFineTunePipeline(engine, config)
+
+ with pytest.raises(RuntimeError, match="Tokenizer not available"):
+ pipeline.prepare_dataset([])
+
+ def test_setup_optimizer(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ pipeline.setup_optimizer()
+
+ assert pipeline._optimizer is not None
+ assert isinstance(pipeline._optimizer, torch.optim.AdamW)
+
+ def test_setup_optimizer_no_trainable_params_raises(self, config):
+ engine = Mock()
+ engine.get_trainable_parameters.return_value = []
+ engine._is_peft_model = True
+ pipeline = RequirementFineTunePipeline(engine, config)
+
+ with pytest.raises(RuntimeError, match="No trainable parameters"):
+ pipeline.setup_optimizer()
+
+ @patch('src.learning.requirement_finetune_pipeline.clip_grad_norm_')
+ def test_fine_tune_basic(self, mock_clip_grad, mock_engine, config, training_examples):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+
+ results = pipeline.fine_tune(training_examples, epochs=1)
+
+ assert results["epochs_completed"] == 1
+ assert "final_loss" in results
+ assert "loss_history" in results
+ assert "loss_decreased" in results
+ assert isinstance(results["loss_history"], list)
+
+ # Verify training mode was enabled/disabled
+ mock_engine.enable_training_mode.assert_called_once()
+ mock_engine.enable_eval_mode.assert_called_once()
+
+ # Verify optimizer steps
+ assert mock_engine.forward.called
+ assert mock_clip_grad.called
+
+ def test_fine_tune_without_model_raises(self, config, training_examples):
+ engine = Mock()
+ engine.model = None
+ engine._is_peft_model = True
+ pipeline = RequirementFineTunePipeline(engine, config)
+
+ with pytest.raises(RuntimeError, match="Model not loaded"):
+ pipeline.fine_tune(training_examples)
+
+ def test_fine_tune_without_peft_raises(self, config, training_examples):
+ engine = Mock()
+ engine.model = Mock()
+ engine._is_peft_model = False
+ pipeline = RequirementFineTunePipeline(engine, config)
+
+ with pytest.raises(RuntimeError, match="LoRA adapter not attached"):
+ pipeline.fine_tune(training_examples)
+
+ def test_save_adapter(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ path = pipeline.save_adapter("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/custom/path")
+
+ assert path == "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/custom/path"
+ mock_engine.save_lora_adapter.assert_called_once_with("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/custom/path")
+
+ def test_save_adapter_default_path(self, mock_engine):
+ config = FineTuneConfig(output_dir="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/default/path")
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ path = pipeline.save_adapter()
+
+ assert path == "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/default/path"
+ mock_engine.save_lora_adapter.assert_called_once_with("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/default/path")
+
+ def test_load_adapter(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ pipeline.load_adapter("/path/to/adapter")
+
+ mock_engine.load_lora_adapter.assert_called_once_with("/path/to/adapter")
+
+ def test_check_loss_decrease(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ pipeline._training_losses = [1.0, 0.8, 0.6, 0.5]
+
+ assert pipeline._check_loss_decrease() is True
+
+ def test_check_loss_increase(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ pipeline._training_losses = [0.5, 0.6, 0.7, 0.8]
+
+ assert pipeline._check_loss_decrease() is False
+
+ def test_check_loss_insufficient_data(self, mock_engine, config):
+ pipeline = RequirementFineTunePipeline(mock_engine, config)
+ pipeline._training_losses = [0.5]
+
+ assert pipeline._check_loss_decrease() is False
+
+ pipeline._training_losses = []
+ assert pipeline._check_loss_decrease() is False
+
+
+class TestCreateTrainingExamples:
+ """Test training example creation functions"""
+
+ def test_create_from_feedback(self):
+ examples = create_training_examples_from_feedback(
+ failed_requirement="Implement login",
+ expected_behavior="User can login with email/password",
+ actual_output="Login failed",
+ requirement_id="req-123",
+ )
+
+ assert len(examples) == 1
+ example = examples[0]
+ assert "Implement login" in example.prompt
+ assert "User can login" in example.completion
+ assert example.requirement_id == "req-123"
+ assert "failed_requirement" in example.metadata
+ assert "actual_output" in example.metadata
+
+ def test_create_from_code_fix(self):
+ examples = create_training_examples_from_code_fix(
+ original_code="def add(a, b):\n return a - b",
+ fixed_code="def add(a, b):\n return a + b",
+ issue_description="Add function subtracts instead of adds",
+ requirement_id="req-456",
+ )
+
+ assert len(examples) == 1
+ example = examples[0]
+ assert "Add function subtracts" in example.prompt
+ assert "a + b" in example.completion
+ assert example.requirement_id == "req-456"
+ assert "issue" in example.metadata
+ assert "original_code" in example.metadata
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file
diff --git a/tests/unit/test_swarm_coordinator.py b/tests/unit/test_swarm_coordinator.py
new file mode 100644
index 00000000..e1cf4649
--- /dev/null
+++ b/tests/unit/test_swarm_coordinator.py
@@ -0,0 +1,533 @@
+"""
+Unit tests for Swarm Coordinator - Phase 5: Swarm Communication & Load Balancer Integration
+"""
+
+import pytest
+import asyncio
+from unittest.mock import Mock, patch, AsyncMock, MagicMock
+from uuid import UUID, uuid4
+from datetime import datetime, timezone
+
+from src.swarm.swarm_coordinator import (
+ SwarmCoordinator,
+ SwarmMetrics,
+ InProcessMessageRouter,
+ ConsensusEvaluator,
+ LoadBalancer,
+ SwarmMessage,
+ MessageType,
+ message_router,
+ consensus_evaluator,
+ load_balancer,
+)
+from src.swarm.director import DirectorAgent, SwarmGoal
+
+
+class TestMessageType:
+ """Test MessageType enum"""
+
+ def test_message_types(self):
+ assert MessageType.TASK_ASSIGNMENT.value == "task_assignment"
+ assert MessageType.TASK_RESULT.value == "task_result"
+ assert MessageType.CONSENSUS_VOTE.value == "consensus_vote"
+ assert MessageType.CONSENSUS_RESULT.value == "consensus_result"
+ assert MessageType.HEARTBEAT.value == "heartbeat"
+ assert MessageType.STATUS_UPDATE.value == "status_update"
+ assert MessageType.LOAD_REPORT.value == "load_report"
+ assert MessageType.SCALING_SIGNAL.value == "scaling_signal"
+
+
+class TestSwarmMessage:
+ """Test SwarmMessage dataclass"""
+
+ def test_message_creation(self):
+ sender = uuid4()
+ recipient = uuid4()
+ msg = SwarmMessage(
+ msg_type=MessageType.TASK_ASSIGNMENT,
+ sender_id=sender,
+ recipient_id=recipient,
+ payload={"task": "test"},
+ )
+
+ assert msg.msg_type == MessageType.TASK_ASSIGNMENT
+ assert msg.sender_id == sender
+ assert msg.recipient_id == recipient
+ assert msg.payload == {"task": "test"}
+ assert msg.correlation_id is None
+ assert msg.reply_to is None
+
+ def test_broadcast_message(self):
+ sender = uuid4()
+ msg = SwarmMessage(
+ msg_type=MessageType.HEARTBEAT,
+ sender_id=sender,
+ recipient_id=None, # broadcast
+ payload={"status": "alive"},
+ )
+
+ assert msg.recipient_id is None
+
+
+class TestInProcessMessageRouter:
+ """Test InProcessMessageRouter"""
+
+ @pytest.fixture
+ def router(self):
+ return InProcessMessageRouter()
+
+ @pytest.fixture
+ def agent_id(self):
+ return uuid4()
+
+ @pytest.mark.asyncio
+ async def test_register_agent(self, router, agent_id):
+ queue = router.register_agent(agent_id)
+
+ assert isinstance(queue, asyncio.Queue)
+ assert agent_id in router._queues
+
+ @pytest.mark.asyncio
+ async def test_unregister_agent(self, router, agent_id):
+ router.register_agent(agent_id)
+ router.unregister_agent(agent_id)
+
+ assert agent_id not in router._queues
+
+ @pytest.mark.asyncio
+ async def test_subscribe_unsubscribe(self, router, agent_id):
+ router.register_agent(agent_id)
+ router.subscribe(agent_id, MessageType.TASK_ASSIGNMENT)
+
+ assert agent_id in router._subscriptions[MessageType.TASK_ASSIGNMENT]
+
+ router.unsubscribe(agent_id, MessageType.TASK_ASSIGNMENT)
+
+ assert agent_id not in router._subscriptions[MessageType.TASK_ASSIGNMENT]
+
+ @pytest.mark.asyncio
+ async def test_send_direct_message(self, router, agent_id):
+ recipient_id = uuid4()
+ router.register_agent(agent_id)
+ router.register_agent(recipient_id)
+
+ msg = SwarmMessage(
+ msg_type=MessageType.TASK_ASSIGNMENT,
+ sender_id=agent_id,
+ recipient_id=recipient_id,
+ payload={"task": "test"},
+ )
+
+ await router.send(msg)
+
+ # Check recipient received it
+ received = await router.receive(recipient_id, timeout=0.1)
+ assert received is not None
+ assert received.payload == {"task": "test"}
+
+ @pytest.mark.asyncio
+ async def test_send_broadcast_message(self, router):
+ sender = uuid4()
+ recipient1 = uuid4()
+ recipient2 = uuid4()
+
+ router.register_agent(sender)
+ router.register_agent(recipient1)
+ router.register_agent(recipient2)
+
+ # Start router to process broadcasts
+ await router.start()
+
+ msg = SwarmMessage(
+ msg_type=MessageType.HEARTBEAT,
+ sender_id=sender,
+ recipient_id=None, # broadcast
+ payload={"status": "alive"},
+ )
+
+ await router.send(msg)
+
+ # Wait a bit for routing
+ await asyncio.sleep(0.1)
+
+ # Both recipients should receive
+ received1 = await router.receive(recipient1, timeout=0.5)
+ received2 = await router.receive(recipient2, timeout=0.5)
+
+ await router.stop()
+
+ assert received1 is not None
+ assert received2 is not None
+ assert received1.payload == {"status": "alive"}
+
+ @pytest.mark.asyncio
+ async def test_start_stop(self, router):
+ await router.start()
+ assert router._running is True
+ assert router._router_task is not None
+
+ await router.stop()
+ assert router._running is False
+
+
+class TestConsensusEvaluator:
+ """Test ConsensusEvaluator"""
+
+ @pytest.fixture
+ def evaluator(self):
+ return ConsensusEvaluator(min_agreement=0.66)
+
+ @pytest.fixture
+ def voters(self):
+ return [uuid4() for _ in range(5)]
+
+ def test_register_voter(self, evaluator, voters):
+ voter = voters[0]
+ evaluator.register_voter(voter, weight=2.0)
+
+ assert evaluator._vote_weights[voter] == 2.0
+
+ def test_unregister_voter(self, evaluator, voters):
+ voter = voters[0]
+ evaluator.register_voter(voter)
+ evaluator.unregister_voter(voter)
+
+ assert voter not in evaluator._vote_weights
+
+ def test_submit_vote(self, evaluator, voters):
+ proposal_id = uuid4()
+ voter = voters[0]
+
+ evaluator.submit_vote(proposal_id, voter, {"agree": True})
+
+ assert proposal_id in evaluator._votes
+ assert evaluator._votes[proposal_id][voter] == {"agree": True}
+
+ def test_evaluate_consensus_reached(self, evaluator, voters):
+ proposal_id = uuid4()
+
+ # 4 out of 5 agree (80% > 66%)
+ for i, voter in enumerate(voters):
+ evaluator.register_voter(voter, weight=1.0)
+ if i < 4:
+ evaluator.submit_vote(proposal_id, voter, {"agree": True})
+ else:
+ evaluator.submit_vote(proposal_id, voter, {"agree": False})
+
+ result = evaluator.evaluate(proposal_id)
+
+ assert result["consensus"] is True
+ assert result["agreement_ratio"] == 0.8
+ assert result["winning_vote"] == "{'agree': True}"
+
+ def test_evaluate_consensus_not_reached(self, evaluator, voters):
+ proposal_id = uuid4()
+
+ # 3 out of 5 agree (60% < 66%)
+ for i, voter in enumerate(voters):
+ evaluator.register_voter(voter, weight=1.0)
+ if i < 3:
+ evaluator.submit_vote(proposal_id, voter, {"agree": True})
+ else:
+ evaluator.submit_vote(proposal_id, voter, {"agree": False})
+
+ result = evaluator.evaluate(proposal_id)
+
+ assert result["consensus"] is False
+ assert result["agreement_ratio"] == 0.6
+
+ def test_evaluate_no_votes(self, evaluator):
+ proposal_id = uuid4()
+
+ result = evaluator.evaluate(proposal_id)
+
+ assert result["consensus"] is False
+ assert result["reason"] == "No votes submitted"
+
+ def test_clear_proposal(self, evaluator, voters):
+ proposal_id = uuid4()
+ voter = voters[0]
+
+ evaluator.submit_vote(proposal_id, voter, {"agree": True})
+ evaluator.clear_proposal(proposal_id)
+
+ assert proposal_id not in evaluator._votes
+
+
+class TestLoadBalancer:
+ """Test LoadBalancer"""
+
+ @pytest.fixture
+ def balancer(self):
+ return LoadBalancer()
+
+ @pytest.fixture
+ def agents(self):
+ return [uuid4() for _ in range(3)]
+
+ def test_register_agent(self, balancer, agents):
+ agent = agents[0]
+ balancer.register_agent(agent, ["coding", "testing"], max_load=1.0)
+
+ assert agent in balancer._agent_loads
+ assert agent in balancer._agent_capabilities
+ assert agent in balancer._agent_status
+ assert balancer._agent_capabilities[agent] == ["coding", "testing"]
+ assert balancer._agent_status[agent] == "idle"
+
+ def test_unregister_agent(self, balancer, agents):
+ agent = agents[0]
+ balancer.register_agent(agent)
+ balancer.unregister_agent(agent)
+
+ assert agent not in balancer._agent_loads
+ assert agent not in balancer._agent_capabilities
+ assert agent not in balancer._agent_status
+ assert agent not in balancer._task_history
+
+ def test_update_load(self, balancer, agents):
+ agent = agents[0]
+ balancer.register_agent(agent)
+
+ balancer.update_load(agent, 0.7)
+
+ assert balancer._agent_loads[agent] == 0.7
+ assert balancer._agent_status[agent] == "busy"
+
+ balancer.update_load(agent, 0.0)
+ assert balancer._agent_status[agent] == "idle"
+
+ def test_update_status(self, balancer, agents):
+ agent = agents[0]
+ balancer.register_agent(agent)
+
+ balancer.update_status(agent, "offline")
+ assert balancer._agent_status[agent] == "offline"
+
+ def test_record_task_completion(self, balancer, agents):
+ agent = agents[0]
+ balancer.register_agent(agent)
+
+ balancer.record_task_completion(agent, {"task_id": "123", "duration": 100})
+
+ assert len(balancer._task_history[agent]) == 1
+ assert balancer._task_history[agent][0]["task_id"] == "123"
+
+ def test_select_agent_least_loaded(self, balancer, agents):
+ for agent in agents:
+ balancer.register_agent(agent, ["coding"])
+
+ balancer.update_load(agents[0], 0.8)
+ balancer.update_load(agents[1], 0.3)
+ balancer.update_load(agents[2], 0.5)
+
+ selected = balancer.select_agent(required_capabilities=["coding"], strategy="least_loaded")
+
+ assert selected == agents[1] # Least loaded
+
+ def test_select_agent_most_capable(self, balancer, agents):
+ balancer.register_agent(agents[0], ["coding"])
+ balancer.register_agent(agents[1], ["coding", "testing", "docs"])
+ balancer.register_agent(agents[2], ["coding", "testing"])
+
+ selected = balancer.select_agent(required_capabilities=["coding"], strategy="most_capable")
+
+ assert selected == agents[1] # Most capabilities
+
+ def test_select_agent_round_robin(self, balancer, agents):
+ for agent in agents:
+ balancer.register_agent(agent, ["coding"])
+
+ # Add task history
+ balancer.record_task_completion(agents[0], {})
+ balancer.record_task_completion(agents[0], {})
+ balancer.record_task_completion(agents[1], {})
+
+ selected = balancer.select_agent(required_capabilities=["coding"], strategy="round_robin")
+
+ assert selected == agents[2] # Least tasks
+
+ def test_select_agent_no_candidates(self, balancer, agents):
+ balancer.register_agent(agents[0], ["testing"])
+
+ selected = balancer.select_agent(required_capabilities=["coding"])
+
+ assert selected is None
+
+ def test_select_agent_offline_excluded(self, balancer, agents):
+ balancer.register_agent(agents[0], ["coding"])
+ balancer.register_agent(agents[1], ["coding"])
+ balancer.update_status(agents[0], "offline")
+
+ selected = balancer.select_agent(required_capabilities=["coding"])
+
+ assert selected == agents[1]
+
+ def test_get_load_report(self, balancer, agents):
+ balancer.register_agent(agents[0], ["coding"])
+ balancer.register_agent(agents[1], ["testing"])
+ balancer.update_load(agents[0], 0.5) # This makes it "busy" (load > 0.1)
+ balancer.update_status(agents[1], "offline") # This one is offline
+
+ report = balancer.get_load_report()
+
+ assert report["total_agents"] == 2
+ assert report["busy_agents"] == 1 # agents[0] has load 0.5
+ assert report["idle_agents"] == 0 # agents[1] is offline, not idle
+ assert str(agents[0]) in report["agent_loads"]
+
+
+class TestSwarmCoordinator:
+ """Test SwarmCoordinator"""
+
+ @pytest.fixture
+ def coordinator(self):
+ return SwarmCoordinator(num_directors=2, sub_orchestrators_per_director=3)
+
+ def test_init(self, coordinator):
+ assert coordinator.num_directors == 2
+ assert coordinator.sub_orchestrators_per_director == 3
+ assert coordinator.directors == {}
+ assert coordinator._running is False
+
+ @pytest.mark.asyncio
+ async def test_initialize(self, coordinator):
+ with patch('src.swarm.swarm_coordinator.DirectorAgent') as mock_director_class:
+ mock_director = AsyncMock()
+ mock_director.initialize = AsyncMock()
+ mock_director_class.return_value = mock_director
+
+ await coordinator.initialize()
+
+ assert len(coordinator.directors) == 2
+ assert coordinator._running is True
+ assert coordinator._monitor_task is not None
+ assert mock_director_class.call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_shutdown(self, coordinator):
+ mock_director = AsyncMock()
+ mock_director.shutdown = AsyncMock()
+ director_id = uuid4()
+ coordinator.directors[director_id] = mock_director
+ coordinator._running = True
+ coordinator._monitor_task = asyncio.create_task(asyncio.sleep(10))
+
+ await coordinator.shutdown()
+
+ assert coordinator._running is False
+ mock_director.shutdown.assert_called_once()
+ assert coordinator.directors == {}
+
+ @pytest.mark.asyncio
+ async def test_submit_goal(self, coordinator):
+ mock_director = AsyncMock()
+ mock_director.submit_goal = AsyncMock(return_value=uuid4())
+ mock_director.monitor_swarm = AsyncMock(return_value={
+ "busy_sub_orchestrators": 1,
+ "total_sub_orchestrators": 3,
+ })
+ director_id = uuid4()
+ coordinator.directors[director_id] = mock_director
+ coordinator._running = True
+
+ # Mock load balancer
+ coordinator._load_balancer.select_agent = Mock(return_value=director_id)
+
+ goal = SwarmGoal(description="Test goal", priority=5)
+ goal_id = await coordinator.submit_goal(goal)
+
+ assert goal_id is not None
+ mock_director.submit_goal.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_request_consensus(self, coordinator):
+ mock_director = AsyncMock()
+ director_id = uuid4()
+ coordinator.directors[director_id] = mock_director
+ coordinator._running = True
+
+ proposal = {"action": "scale_up", "reason": "high_load"}
+ result = await coordinator.request_consensus(proposal)
+
+ assert "consensus" in result
+ assert "agreement_ratio" in result
+
+ @pytest.mark.asyncio
+ async def test_get_swarm_metrics(self, coordinator):
+ mock_director = AsyncMock()
+ mock_director.monitor_swarm = AsyncMock(return_value={
+ "total_sub_orchestrators": 3,
+ "busy_sub_orchestrators": 2,
+ "active_goals": 1,
+ })
+ mock_director.active_goals = {
+ uuid4(): Mock(status="completed"),
+ uuid4(): Mock(status="executing"),
+ }
+ director_id = uuid4()
+ coordinator.directors[director_id] = mock_director
+
+ metrics = await coordinator.get_swarm_metrics()
+
+ assert isinstance(metrics, SwarmMetrics)
+ assert metrics.total_directors == 1
+ assert metrics.total_sub_orchestrators == 3
+ assert metrics.active_goals == 1
+ assert metrics.completed_goals == 1
+ assert metrics.swarm_utilization > 0
+
+ @pytest.mark.asyncio
+ async def test_scale_directors_up(self, coordinator):
+ with patch('src.swarm.swarm_coordinator.DirectorAgent') as mock_director_class:
+ mock_director = AsyncMock()
+ mock_director.initialize = AsyncMock()
+ mock_director_class.return_value = mock_director
+
+ coordinator.directors = {uuid4(): AsyncMock()}
+ coordinator._running = True
+
+ await coordinator.scale_directors(3)
+
+ assert len(coordinator.directors) == 3
+ assert mock_director_class.call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_scale_directors_down(self, coordinator):
+ idle_director = AsyncMock()
+ idle_director.shutdown = AsyncMock()
+ idle_director.active_goals = {}
+
+ busy_director = AsyncMock()
+ busy_director.active_goals = {uuid4(): Mock(status="executing")}
+
+ coordinator.directors = {
+ uuid4(): idle_director,
+ uuid4(): busy_director,
+ }
+ coordinator._running = True
+
+ await coordinator.scale_directors(1)
+
+ assert len(coordinator.directors) == 1
+ idle_director.shutdown.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_director_status(self, coordinator):
+ mock_director = AsyncMock()
+ mock_director.monitor_swarm = AsyncMock(return_value={
+ "total_sub_orchestrators": 3,
+ "idle_sub_orchestrators": 1,
+ })
+ director_id = uuid4()
+ coordinator.directors[director_id] = mock_director
+
+ statuses = await coordinator.get_director_status()
+
+ assert len(statuses) == 1
+ assert statuses[0]["director_id"] == str(director_id)
+ assert statuses[0]["total_sub_orchestrators"] == 3
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
\ No newline at end of file