From e88fa2c52fdbae9c606ac0b3cee38aa78d73f744 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:18:45 -0400 Subject: [PATCH 01/14] feat(events): add event system for SDK observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventBus with exact/prefix/catch-all subscriptions, FelixEvent frozen dataclass, EventType enum (agent.*, workflow.*, task.*, message.*, stream.*, spawn.*), and EventEmitterMixin. Wired into FelixWorkflow (6 lifecycle events), LLMAgent (task start/complete), and CentralPost (lifecycle bridge). All event bus params are optional — zero overhead when not used. 35 new tests. 698 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/__init__.py | 5 + src/felix_agent_sdk/agents/llm_agent.py | 31 +- .../communication/central_post.py | 26 ++ src/felix_agent_sdk/events/__init__.py | 17 + src/felix_agent_sdk/events/bus.py | 166 +++++++++ src/felix_agent_sdk/events/mixins.py | 51 +++ src/felix_agent_sdk/events/types.py | 68 ++++ src/felix_agent_sdk/workflows/runner.py | 94 ++++- tests/unit/test_events.py | 320 ++++++++++++++++++ tests/unit/test_workflow_events.py | 172 ++++++++++ 10 files changed, 932 insertions(+), 18 deletions(-) create mode 100644 src/felix_agent_sdk/events/__init__.py create mode 100644 src/felix_agent_sdk/events/bus.py create mode 100644 src/felix_agent_sdk/events/mixins.py create mode 100644 src/felix_agent_sdk/events/types.py create mode 100644 tests/unit/test_events.py create mode 100644 tests/unit/test_workflow_events.py diff --git a/src/felix_agent_sdk/__init__.py b/src/felix_agent_sdk/__init__.py index 39f0886..ac580ae 100644 --- a/src/felix_agent_sdk/__init__.py +++ b/src/felix_agent_sdk/__init__.py @@ -31,6 +31,7 @@ auto_detect_provider, ) from felix_agent_sdk.communication import CentralPost, Message, MessageType, Spoke +from felix_agent_sdk.events import EventBus, EventType, FelixEvent from felix_agent_sdk.memory import ContextCompressor, KnowledgeStore, TaskMemory from felix_agent_sdk.tokens import TokenBudget from felix_agent_sdk.workflows import FelixWorkflow, WorkflowConfig, run_felix_workflow @@ -66,6 +67,10 @@ "FelixWorkflow", "run_felix_workflow", "WorkflowConfig", + # Events + "EventBus", + "EventType", + "FelixEvent", # Tokens "TokenBudget", ] diff --git a/src/felix_agent_sdk/agents/llm_agent.py b/src/felix_agent_sdk/agents/llm_agent.py index d3370fc..00c1168 100644 --- a/src/felix_agent_sdk/agents/llm_agent.py +++ b/src/felix_agent_sdk/agents/llm_agent.py @@ -19,6 +19,9 @@ from felix_agent_sdk.agents.base import Agent from felix_agent_sdk.core.helix import HelixGeometry +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.mixins import EventEmitterMixin +from felix_agent_sdk.events.types import EventType from felix_agent_sdk.providers.base import BaseProvider from felix_agent_sdk.providers.types import ChatMessage, CompletionResult, MessageRole from felix_agent_sdk.tokens.budget import TokenBudget @@ -76,7 +79,7 @@ class LLMResult: # --------------------------------------------------------------------------- -class LLMAgent(Agent): +class LLMAgent(Agent, EventEmitterMixin): """LLM-powered agent that processes tasks via a provider-agnostic interface. Extends :class:`Agent` with LLM capabilities: adaptive temperature, @@ -98,11 +101,14 @@ def __init__( temperature_range: Optional[Tuple[float, float]] = None, max_tokens: Optional[int] = None, token_budget: Optional[TokenBudget] = None, + event_bus: Optional[EventBus] = None, ) -> None: super().__init__(agent_id, helix, spawn_time=spawn_time, velocity=velocity) self.provider = provider self.agent_type = agent_type + if event_bus is not None: + self.set_event_bus(event_bus) self.temperature_range = temperature_range or _DEFAULT_TEMPERATURE_RANGES.get( agent_type, _DEFAULT_TEMP_RANGE @@ -308,12 +314,18 @@ def _call_provider( # ------------------------------------------------------------------ def process_task(self, task: LLMTask) -> LLMResult: - """Process a task: prompt ➜ provider call ➜ confidence ➜ result. + """Process a task: prompt -> provider call -> confidence -> result. This is the primary entry point for running an agent on a task. """ start = time.monotonic() + self.emit_event( + EventType.TASK_STARTED, + {"task_id": task.task_id, "agent_type": self.agent_type}, + source=f"agent:{self.agent_id}", + ) + temperature = self.get_adaptive_temperature() system_prompt, user_prompt = self.create_position_aware_prompt(task) max_tokens = self.max_tokens @@ -339,6 +351,21 @@ def process_task(self, task: LLMTask) -> LLMResult: token_budget_used=completion.total_tokens, ) self.processing_results.append(result) + + self.emit_event( + EventType.TASK_COMPLETED, + { + "task_id": task.task_id, + "agent_type": self.agent_type, + "confidence": round(confidence, 4), + "temperature": round(temperature, 4), + "tokens": completion.total_tokens, + "processing_time": round(elapsed, 4), + "phase": result.position_info.get("phase", ""), + }, + source=f"agent:{self.agent_id}", + ) + return result # ------------------------------------------------------------------ diff --git a/src/felix_agent_sdk/communication/central_post.py b/src/felix_agent_sdk/communication/central_post.py index 7f2a87b..9b44199 100644 --- a/src/felix_agent_sdk/communication/central_post.py +++ b/src/felix_agent_sdk/communication/central_post.py @@ -16,6 +16,8 @@ from felix_agent_sdk.communication.messages import Message, MessageType from felix_agent_sdk.communication.registry import AgentRegistry +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.types import EventType, FelixEvent if TYPE_CHECKING: from felix_agent_sdk.agents.base import Agent @@ -37,6 +39,14 @@ class AgentLifecycleEvent(Enum): FAILED = "failed" +# Map legacy lifecycle events to the new EventType system. +_LIFECYCLE_TO_EVENT_TYPE: Dict[AgentLifecycleEvent, str] = { + AgentLifecycleEvent.SPAWNED: EventType.AGENT_SPAWNED, + AgentLifecycleEvent.COMPLETED: EventType.AGENT_COMPLETED, + AgentLifecycleEvent.FAILED: EventType.AGENT_FAILED, +} + + # --------------------------------------------------------------------------- # CentralPost # --------------------------------------------------------------------------- @@ -65,10 +75,12 @@ def __init__( max_agents: int = 25, enable_metrics: bool = False, provider: Optional[BaseProvider] = None, + event_bus: Optional[EventBus] = None, ) -> None: self._max_agents = max_agents self._enable_metrics = enable_metrics self._provider = provider + self._event_bus = event_bus # Agent tracking self._registered_agents: Dict[str, Any] = {} @@ -545,6 +557,8 @@ def remove_lifecycle_callback( def emit_lifecycle_event(self, event: AgentLifecycleEvent, agent_id: str) -> None: """Invoke all callbacks registered for *event*. + Also emits onto the attached :class:`EventBus` if present. + Args: event: The lifecycle event that occurred. agent_id: The agent ID to pass to each callback. @@ -559,6 +573,18 @@ def emit_lifecycle_event(self, event: AgentLifecycleEvent, agent_id: str) -> Non agent_id, ) + # Bridge to EventBus + if self._event_bus is not None: + event_type = _LIFECYCLE_TO_EVENT_TYPE.get(event) + if event_type is not None: + self._event_bus.emit( + FelixEvent( + event_type=event_type, + source=f"agent:{agent_id}", + data={"agent_id": agent_id}, + ) + ) + # ------------------------------------------------------------------ # Shutdown # ------------------------------------------------------------------ diff --git a/src/felix_agent_sdk/events/__init__.py b/src/felix_agent_sdk/events/__init__.py new file mode 100644 index 0000000..a9c7d21 --- /dev/null +++ b/src/felix_agent_sdk/events/__init__.py @@ -0,0 +1,17 @@ +"""Felix event system for SDK observability. + +Provides a synchronous pub/sub event bus, typed events, and a mixin +for emitting events from any component. +""" + +from felix_agent_sdk.events.bus import EventBus, EventCallback +from felix_agent_sdk.events.mixins import EventEmitterMixin +from felix_agent_sdk.events.types import EventType, FelixEvent + +__all__ = [ + "EventBus", + "EventCallback", + "EventEmitterMixin", + "EventType", + "FelixEvent", +] diff --git a/src/felix_agent_sdk/events/bus.py b/src/felix_agent_sdk/events/bus.py new file mode 100644 index 0000000..3f36fc3 --- /dev/null +++ b/src/felix_agent_sdk/events/bus.py @@ -0,0 +1,166 @@ +"""Synchronous publish/subscribe event bus. + +The ``EventBus`` is the central nervous system of Felix observability. +Components emit events; observers subscribe by exact type or prefix pattern. +""" + +from __future__ import annotations + +import logging +import threading +from collections import defaultdict +from typing import Callable, Dict, List + +from felix_agent_sdk.events.types import FelixEvent + +logger = logging.getLogger(__name__) + +# Type alias for event callbacks +EventCallback = Callable[[FelixEvent], None] + + +class EventBus: + """Synchronous event bus with prefix-pattern subscriptions. + + Thread-safe. Callbacks are invoked inline during :meth:`emit`. + If a callback raises, the exception is logged and the remaining + callbacks still execute (same isolation pattern as + ``CentralPost.emit_lifecycle_event``). + + Examples:: + + bus = EventBus() + + # Exact match + bus.subscribe("workflow.started", my_handler) + + # Prefix match — catches all agent.* events + bus.subscribe("agent.*", my_agent_handler) + + # Catch-all + bus.subscribe_all(my_logger) + """ + + def __init__(self) -> None: + self._exact: Dict[str, List[EventCallback]] = defaultdict(list) + self._prefix: Dict[str, List[EventCallback]] = defaultdict(list) + self._catch_all: List[EventCallback] = [] + self._lock = threading.Lock() + self._history: List[FelixEvent] = [] + self._record_history = False + + # ------------------------------------------------------------------ + # Configuration + # ------------------------------------------------------------------ + + def enable_history(self, enabled: bool = True) -> None: + """Toggle event recording. When enabled, all emitted events are + stored in :attr:`history` for later inspection.""" + with self._lock: + self._record_history = enabled + if not enabled: + self._history.clear() + + @property + def history(self) -> List[FelixEvent]: + """Read-only copy of recorded events (empty if history is disabled).""" + with self._lock: + return list(self._history) + + # ------------------------------------------------------------------ + # Subscribe / unsubscribe + # ------------------------------------------------------------------ + + def subscribe(self, event_type: str, callback: EventCallback) -> None: + """Register *callback* for events matching *event_type*. + + If *event_type* ends with ``".*"``, the callback receives every + event whose type starts with the prefix (e.g. ``"agent.*"`` + matches ``"agent.spawned"``, ``"agent.completed"``, etc.). + """ + with self._lock: + if event_type.endswith(".*"): + prefix = event_type[:-1] # "agent.*" → "agent." + self._prefix[prefix].append(callback) + else: + self._exact[event_type].append(callback) + + def subscribe_all(self, callback: EventCallback) -> None: + """Register *callback* for **every** event.""" + with self._lock: + self._catch_all.append(callback) + + def unsubscribe(self, event_type: str, callback: EventCallback) -> None: + """Remove *callback* from *event_type* subscriptions.""" + with self._lock: + if event_type.endswith(".*"): + prefix = event_type[:-1] + cbs = self._prefix.get(prefix, []) + else: + cbs = self._exact.get(event_type, []) + if callback in cbs: + cbs.remove(callback) + + def unsubscribe_all(self, callback: EventCallback) -> None: + """Remove *callback* from the catch-all list.""" + with self._lock: + if callback in self._catch_all: + self._catch_all.remove(callback) + + # ------------------------------------------------------------------ + # Emit + # ------------------------------------------------------------------ + + def emit(self, event: FelixEvent) -> None: + """Dispatch *event* to all matching subscribers. + + Matching order: exact → prefix → catch-all. + Exceptions in callbacks are logged and swallowed. + """ + with self._lock: + if self._record_history: + self._history.append(event) + + targets: List[EventCallback] = [] + + # Exact match + targets.extend(self._exact.get(event.event_type, [])) + + # Prefix match + for prefix, cbs in self._prefix.items(): + if event.event_type.startswith(prefix): + targets.extend(cbs) + + # Catch-all + targets.extend(self._catch_all) + + # Invoke outside the lock to avoid deadlocks in callbacks + for cb in targets: + try: + cb(event) + except Exception: + logger.exception( + "EventBus callback error for %s from %s", + event.event_type, + event.source, + ) + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def clear(self) -> None: + """Remove all subscriptions and recorded history.""" + with self._lock: + self._exact.clear() + self._prefix.clear() + self._catch_all.clear() + self._history.clear() + + @property + def subscriber_count(self) -> int: + """Total number of registered callbacks (exact + prefix + catch-all).""" + with self._lock: + exact_count = sum(len(cbs) for cbs in self._exact.values()) + prefix_count = sum(len(cbs) for cbs in self._prefix.values()) + return exact_count + prefix_count + len(self._catch_all) diff --git a/src/felix_agent_sdk/events/mixins.py b/src/felix_agent_sdk/events/mixins.py new file mode 100644 index 0000000..59daee4 --- /dev/null +++ b/src/felix_agent_sdk/events/mixins.py @@ -0,0 +1,51 @@ +"""Mixin for classes that emit events onto an EventBus.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.types import FelixEvent + + +class EventEmitterMixin: + """Opt-in mixin that gives any class event-emission capability. + + When an :class:`EventBus` is attached via :meth:`set_event_bus`, calls + to :meth:`emit_event` dispatch a :class:`FelixEvent` onto the bus. + When no bus is attached, :meth:`emit_event` is a silent no-op — + zero overhead for users who don't enable observability. + """ + + _event_bus: Optional[EventBus] = None + + def set_event_bus(self, bus: Optional[EventBus]) -> None: + """Attach or detach an event bus.""" + self._event_bus = bus + + def emit_event( + self, + event_type: str, + data: Optional[Dict[str, Any]] = None, + *, + source: Optional[str] = None, + ) -> None: + """Emit a :class:`FelixEvent` if a bus is attached. + + Args: + event_type: Event category (e.g. ``EventType.TASK_STARTED``). + data: Arbitrary payload dict. + source: Override the default source identifier. + """ + if self._event_bus is None: + return + event = FelixEvent( + event_type=event_type, + source=source or self._default_event_source(), + data=data or {}, + ) + self._event_bus.emit(event) + + def _default_event_source(self) -> str: + """Return a default source string. Override for richer identification.""" + return type(self).__name__ diff --git a/src/felix_agent_sdk/events/types.py b/src/felix_agent_sdk/events/types.py new file mode 100644 index 0000000..334e086 --- /dev/null +++ b/src/felix_agent_sdk/events/types.py @@ -0,0 +1,68 @@ +"""Event type definitions for the Felix observability system. + +Provides a unified event model that all SDK components can emit into. +Events are frozen dataclasses — immutable records of things that happened. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict + + +class EventType(str, Enum): + """All observable events in the Felix SDK. + + Naming convention: ``component.action`` with dotted hierarchy + so subscribers can match by prefix (e.g. ``"agent.*"``). + """ + + # Agent lifecycle + AGENT_SPAWNED = "agent.spawned" + AGENT_COMPLETED = "agent.completed" + AGENT_FAILED = "agent.failed" + AGENT_POSITION_UPDATED = "agent.position_updated" + AGENT_CHECKPOINT = "agent.checkpoint" + + # Task processing + TASK_STARTED = "task.started" + TASK_COMPLETED = "task.completed" + + # Workflow lifecycle + WORKFLOW_STARTED = "workflow.started" + WORKFLOW_ROUND_STARTED = "workflow.round.started" + WORKFLOW_ROUND_COMPLETED = "workflow.round.completed" + WORKFLOW_CONVERGED = "workflow.converged" + WORKFLOW_SYNTHESIS_STARTED = "workflow.synthesis.started" + WORKFLOW_COMPLETED = "workflow.completed" + + # Communication + MESSAGE_QUEUED = "message.queued" + MESSAGE_PROCESSED = "message.processed" + + # Streaming (wired in PR #3) + STREAM_TOKEN = "stream.token" + STREAM_COMPLETED = "stream.completed" + + # Dynamic spawning (wired in PR #4) + SPAWN_TRIGGERED = "spawn.triggered" + SPAWN_COMPLETED = "spawn.completed" + + +@dataclass(frozen=True) +class FelixEvent: + """An immutable record of something that happened in the SDK. + + Attributes: + event_type: The event category (from :class:`EventType` or a custom string). + source: Identifies the emitter, e.g. ``"workflow"`` or ``"agent:research-001"``. + data: Arbitrary payload. Contents vary by event type. + timestamp: Monotonic timestamp (seconds) when the event was created. + """ + + event_type: str + source: str + data: Dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=time.monotonic) diff --git a/src/felix_agent_sdk/workflows/runner.py b/src/felix_agent_sdk/workflows/runner.py index 9ac556e..cd3287d 100644 --- a/src/felix_agent_sdk/workflows/runner.py +++ b/src/felix_agent_sdk/workflows/runner.py @@ -13,12 +13,17 @@ import logging import time +from typing import Optional + from felix_agent_sdk.agents.base import AgentState from felix_agent_sdk.agents.factory import AgentFactory from felix_agent_sdk.agents.llm_agent import LLMAgent, LLMResult, LLMTask from felix_agent_sdk.communication.central_post import CentralPost from felix_agent_sdk.communication.messages import MessageType from felix_agent_sdk.communication.spoke import SpokeManager +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.mixins import EventEmitterMixin +from felix_agent_sdk.events.types import EventType from felix_agent_sdk.providers.base import BaseProvider from felix_agent_sdk.workflows.config import WorkflowConfig, WorkflowResult from felix_agent_sdk.workflows.context_builder import CollaborativeContextBuilder @@ -27,7 +32,7 @@ logger = logging.getLogger(__name__) -class FelixWorkflow: +class FelixWorkflow(EventEmitterMixin): """Multi-agent workflow runner using helical agent progression. Orchestrates agent team creation, discrete processing rounds with @@ -37,12 +42,20 @@ class FelixWorkflow: Args: config: Workflow configuration (team composition, thresholds, …). provider: LLM provider shared by all agents. + event_bus: Optional event bus for observability. """ - def __init__(self, config: WorkflowConfig, provider: BaseProvider) -> None: + def __init__( + self, + config: WorkflowConfig, + provider: BaseProvider, + event_bus: Optional[EventBus] = None, + ) -> None: self._config = config self._provider = provider self._factory = AgentFactory(provider, helix_config=config.helix_config) + if event_bus is not None: + self.set_event_bus(event_bus) # ------------------------------------------------------------------ # Public API @@ -66,12 +79,22 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: start = time.monotonic() # --- Setup --- - hub = CentralPost(max_agents=self._config.max_agents) + hub = CentralPost(max_agents=self._config.max_agents, event_bus=self._event_bus) spoke_mgr = SpokeManager(hub=hub) agents = self._create_team(spoke_mgr) builder = CollaborativeContextBuilder() all_results: list[LLMResult] = [] + self.emit_event( + EventType.WORKFLOW_STARTED, + { + "task": task_description, + "max_rounds": self._config.max_rounds, + "agents_count": len(agents), + "team_composition": [(a.agent_type, a.agent_id) for a in agents], + }, + ) + try: # --- Processing rounds --- rounds_completed = 0 @@ -80,6 +103,13 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: for round_num in range(self._config.max_rounds): current_time = (round_num + 1) * time_step + rounds_completed = round_num + 1 + + self.emit_event( + EventType.WORKFLOW_ROUND_STARTED, + {"round": rounds_completed, "current_time": round(current_time, 4)}, + ) + round_results = self._run_round( agents=agents, spoke_mgr=spoke_mgr, @@ -89,24 +119,39 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: current_time=current_time, ) all_results.extend(round_results) - rounds_completed = round_num + 1 + + avg_confidence = 0.0 + if round_results: + avg_confidence = sum(r.confidence for r in round_results) / len(round_results) + + self.emit_event( + EventType.WORKFLOW_ROUND_COMPLETED, + { + "round": rounds_completed, + "results_count": len(round_results), + "avg_confidence": round(avg_confidence, 4), + }, + ) # Dynamic spawning hook (no-op for now) self._check_dynamic_spawning(agents, round_results) # Convergence check - if round_results: - avg_confidence = sum(r.confidence for r in round_results) / len(round_results) - if avg_confidence >= self._config.confidence_threshold: - logger.info( - "Workflow converged at round %d (confidence=%.3f)", - rounds_completed, - avg_confidence, - ) - converged = True - break + if round_results and avg_confidence >= self._config.confidence_threshold: + logger.info( + "Workflow converged at round %d (confidence=%.3f)", + rounds_completed, + avg_confidence, + ) + self.emit_event( + EventType.WORKFLOW_CONVERGED, + {"round": rounds_completed, "confidence": round(avg_confidence, 4)}, + ) + converged = True + break # --- Synthesis --- + self.emit_event(EventType.WORKFLOW_SYNTHESIS_STARTED, {}) synthesizer = WorkflowSynthesizer(self._provider, self._config) synthesis = synthesizer.synthesize(all_results, task_description) @@ -118,7 +163,7 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: elapsed = time.monotonic() - start total_tokens = sum(r.token_budget_used for r in all_results) - return WorkflowResult( + result = WorkflowResult( synthesis=synthesis, agent_results=all_results, total_rounds=rounds_completed, @@ -131,6 +176,19 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: "team_composition": [(a.agent_type, a.agent_id) for a in agents], }, ) + + self.emit_event( + EventType.WORKFLOW_COMPLETED, + { + "rounds": rounds_completed, + "converged": converged, + "final_confidence": round(final_confidence, 4), + "total_tokens": total_tokens, + "elapsed_seconds": round(elapsed, 3), + }, + ) + + return result finally: spoke_mgr.shutdown_all() hub.shutdown() @@ -152,6 +210,9 @@ def _create_team(self, spoke_mgr: SpokeManager) -> list[LLMAgent]: spawn_time=spawn_time, **kwargs, ) + # Propagate event bus to each agent + if self._event_bus is not None: + agent.set_event_bus(self._event_bus) spoke_mgr.create_spoke(agent.agent_id, agent=agent) agents.append(agent) @@ -246,10 +307,11 @@ def run_felix_workflow( provider: BaseProvider, task_description: str, context: str = "", + event_bus: Optional[EventBus] = None, ) -> WorkflowResult: """Run a Felix workflow in one call. Thin wrapper around :class:`FelixWorkflow` for simple use cases. """ - workflow = FelixWorkflow(config, provider) + workflow = FelixWorkflow(config, provider, event_bus=event_bus) return workflow.run(task_description, context=context) diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py new file mode 100644 index 0000000..6d5a719 --- /dev/null +++ b/tests/unit/test_events.py @@ -0,0 +1,320 @@ +"""Tests for the Felix event system — bus, types, and mixins.""" + +from __future__ import annotations + +import threading +import time + +import pytest + +from felix_agent_sdk.events import EventBus, EventType, FelixEvent +from felix_agent_sdk.events.mixins import EventEmitterMixin + + +# --------------------------------------------------------------------------- +# FelixEvent +# --------------------------------------------------------------------------- + + +class TestFelixEvent: + def test_construction(self): + evt = FelixEvent(event_type="test.event", source="test") + assert evt.event_type == "test.event" + assert evt.source == "test" + assert evt.data == {} + assert isinstance(evt.timestamp, float) + + def test_frozen(self): + evt = FelixEvent(event_type="x", source="y") + with pytest.raises(AttributeError): + evt.event_type = "z" # type: ignore[misc] + + def test_with_data(self): + evt = FelixEvent(event_type="x", source="y", data={"key": "val"}) + assert evt.data == {"key": "val"} + + +# --------------------------------------------------------------------------- +# EventType enum +# --------------------------------------------------------------------------- + + +class TestEventType: + def test_agent_events(self): + assert EventType.AGENT_SPAWNED.value == "agent.spawned" + assert EventType.AGENT_COMPLETED.value == "agent.completed" + assert EventType.AGENT_FAILED.value == "agent.failed" + + def test_workflow_events(self): + assert EventType.WORKFLOW_STARTED.value == "workflow.started" + assert EventType.WORKFLOW_ROUND_STARTED.value == "workflow.round.started" + assert EventType.WORKFLOW_COMPLETED.value == "workflow.completed" + + def test_task_events(self): + assert EventType.TASK_STARTED.value == "task.started" + assert EventType.TASK_COMPLETED.value == "task.completed" + + def test_string_enum(self): + # EventType values can be used as plain strings + assert EventType.WORKFLOW_STARTED == "workflow.started" + + +# --------------------------------------------------------------------------- +# EventBus — subscription and dispatch +# --------------------------------------------------------------------------- + + +class TestEventBusSubscription: + def test_exact_match(self): + bus = EventBus() + received = [] + bus.subscribe("workflow.started", received.append) + + bus.emit(FelixEvent(event_type="workflow.started", source="test")) + bus.emit(FelixEvent(event_type="workflow.completed", source="test")) + + assert len(received) == 1 + assert received[0].event_type == "workflow.started" + + def test_prefix_match(self): + bus = EventBus() + received = [] + bus.subscribe("agent.*", received.append) + + bus.emit(FelixEvent(event_type="agent.spawned", source="test")) + bus.emit(FelixEvent(event_type="agent.completed", source="test")) + bus.emit(FelixEvent(event_type="workflow.started", source="test")) + + assert len(received) == 2 + assert received[0].event_type == "agent.spawned" + assert received[1].event_type == "agent.completed" + + def test_catch_all(self): + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + + bus.emit(FelixEvent(event_type="agent.spawned", source="a")) + bus.emit(FelixEvent(event_type="workflow.started", source="b")) + + assert len(received) == 2 + + def test_multiple_subscribers(self): + bus = EventBus() + r1, r2 = [], [] + bus.subscribe("test.event", r1.append) + bus.subscribe("test.event", r2.append) + + bus.emit(FelixEvent(event_type="test.event", source="test")) + + assert len(r1) == 1 + assert len(r2) == 1 + + def test_unsubscribe_exact(self): + bus = EventBus() + received = [] + bus.subscribe("test.event", received.append) + bus.unsubscribe("test.event", received.append) + + bus.emit(FelixEvent(event_type="test.event", source="test")) + assert len(received) == 0 + + def test_unsubscribe_prefix(self): + bus = EventBus() + received = [] + bus.subscribe("agent.*", received.append) + bus.unsubscribe("agent.*", received.append) + + bus.emit(FelixEvent(event_type="agent.spawned", source="test")) + assert len(received) == 0 + + def test_unsubscribe_all(self): + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + bus.unsubscribe_all(received.append) + + bus.emit(FelixEvent(event_type="test.event", source="test")) + assert len(received) == 0 + + +# --------------------------------------------------------------------------- +# EventBus — exception isolation +# --------------------------------------------------------------------------- + + +class TestEventBusExceptionIsolation: + def test_bad_callback_does_not_block_others(self): + bus = EventBus() + received = [] + + def bad_callback(event: FelixEvent) -> None: + raise RuntimeError("boom") + + bus.subscribe("test.event", bad_callback) + bus.subscribe("test.event", received.append) + + # Should not raise + bus.emit(FelixEvent(event_type="test.event", source="test")) + + # Good callback still ran + assert len(received) == 1 + + +# --------------------------------------------------------------------------- +# EventBus — history +# --------------------------------------------------------------------------- + + +class TestEventBusHistory: + def test_history_disabled_by_default(self): + bus = EventBus() + bus.emit(FelixEvent(event_type="test", source="test")) + assert len(bus.history) == 0 + + def test_history_enabled(self): + bus = EventBus() + bus.enable_history() + + bus.emit(FelixEvent(event_type="a", source="test")) + bus.emit(FelixEvent(event_type="b", source="test")) + + assert len(bus.history) == 2 + assert bus.history[0].event_type == "a" + + def test_history_returns_copy(self): + bus = EventBus() + bus.enable_history() + bus.emit(FelixEvent(event_type="x", source="test")) + + h1 = bus.history + h2 = bus.history + assert h1 is not h2 + + def test_disable_clears_history(self): + bus = EventBus() + bus.enable_history() + bus.emit(FelixEvent(event_type="x", source="test")) + bus.enable_history(False) + assert len(bus.history) == 0 + + +# --------------------------------------------------------------------------- +# EventBus — utilities +# --------------------------------------------------------------------------- + + +class TestEventBusUtilities: + def test_clear(self): + bus = EventBus() + bus.subscribe("test", lambda e: None) + bus.subscribe_all(lambda e: None) + bus.enable_history() + bus.emit(FelixEvent(event_type="test", source="test")) + + bus.clear() + assert bus.subscriber_count == 0 + assert len(bus.history) == 0 + + def test_subscriber_count(self): + bus = EventBus() + assert bus.subscriber_count == 0 + + bus.subscribe("a", lambda e: None) + bus.subscribe("b.*", lambda e: None) + bus.subscribe_all(lambda e: None) + + assert bus.subscriber_count == 3 + + def test_thread_safety(self): + """Concurrent emit/subscribe should not crash.""" + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + + def emitter(): + for _ in range(50): + bus.emit(FelixEvent(event_type="test", source="thread")) + + threads = [threading.Thread(target=emitter) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(received) == 200 + + +# --------------------------------------------------------------------------- +# EventEmitterMixin +# --------------------------------------------------------------------------- + + +class TestEventEmitterMixin: + def test_no_bus_is_silent(self): + class MyComponent(EventEmitterMixin): + pass + + comp = MyComponent() + # Should not raise even with no bus + comp.emit_event("test.event", {"key": "val"}) + + def test_emit_with_bus(self): + class MyComponent(EventEmitterMixin): + pass + + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + + comp = MyComponent() + comp.set_event_bus(bus) + comp.emit_event("test.event", {"key": "val"}) + + assert len(received) == 1 + assert received[0].event_type == "test.event" + assert received[0].data == {"key": "val"} + + def test_default_source(self): + class MyComponent(EventEmitterMixin): + pass + + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + + comp = MyComponent() + comp.set_event_bus(bus) + comp.emit_event("test.event") + + assert received[0].source == "MyComponent" + + def test_custom_source(self): + class MyComponent(EventEmitterMixin): + pass + + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + + comp = MyComponent() + comp.set_event_bus(bus) + comp.emit_event("test.event", source="custom:src") + + assert received[0].source == "custom:src" + + def test_detach_bus(self): + class MyComponent(EventEmitterMixin): + pass + + bus = EventBus() + received = [] + bus.subscribe_all(received.append) + + comp = MyComponent() + comp.set_event_bus(bus) + comp.emit_event("a") + comp.set_event_bus(None) + comp.emit_event("b") + + assert len(received) == 1 diff --git a/tests/unit/test_workflow_events.py b/tests/unit/test_workflow_events.py new file mode 100644 index 0000000..aa2df41 --- /dev/null +++ b/tests/unit/test_workflow_events.py @@ -0,0 +1,172 @@ +"""Tests verifying event emission from FelixWorkflow.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from felix_agent_sdk import WorkflowConfig, run_felix_workflow +from felix_agent_sdk.events import EventBus, EventType, FelixEvent +from felix_agent_sdk.providers.base import BaseProvider +from felix_agent_sdk.providers.types import CompletionResult + + +def _make_mock_provider() -> BaseProvider: + """Provider that returns canned responses.""" + provider = MagicMock(spec=BaseProvider) + counter = [0] + responses = [ + "Research finding about renewable energy.", + "Analysis of market trends and data.", + "Critique: findings are sound but need more evidence.", + "Synthesis of all findings into a coherent report.", + ] + + def _complete(messages, **kwargs): + idx = counter[0] % len(responses) + counter[0] += 1 + return CompletionResult( + content=responses[idx], + model="mock", + usage={"prompt_tokens": 40, "completion_tokens": 30, "total_tokens": 70}, + ) + + provider.complete.side_effect = _complete + provider.count_tokens.return_value = 40 + return provider + + +class TestWorkflowEventSequence: + """Verify the exact sequence of events emitted during a workflow run.""" + + def test_full_event_sequence(self): + bus = EventBus() + bus.enable_history() + + provider = _make_mock_provider() + config = WorkflowConfig(max_rounds=2) + + run_felix_workflow( + config, provider, "Test task", event_bus=bus + ) + + events = bus.history + types = [e.event_type for e in events] + + # Must start with workflow.started + assert types[0] == EventType.WORKFLOW_STARTED + + # Must end with workflow.completed + assert types[-1] == EventType.WORKFLOW_COMPLETED + + # Must contain round starts and completions + assert EventType.WORKFLOW_ROUND_STARTED in types + assert EventType.WORKFLOW_ROUND_COMPLETED in types + + # Must contain task processing events + assert EventType.TASK_STARTED in types + assert EventType.TASK_COMPLETED in types + + # Must contain synthesis + assert EventType.WORKFLOW_SYNTHESIS_STARTED in types + + def test_round_events_bracket_tasks(self): + """Round start/end should bracket the task events within that round.""" + bus = EventBus() + bus.enable_history() + + provider = _make_mock_provider() + config = WorkflowConfig(max_rounds=1) + + run_felix_workflow(config, provider, "Test", event_bus=bus) + + events = bus.history + types = [e.event_type for e in events] + + round_start_idx = types.index(EventType.WORKFLOW_ROUND_STARTED) + round_end_idx = types.index(EventType.WORKFLOW_ROUND_COMPLETED) + task_start_idx = types.index(EventType.TASK_STARTED) + task_end_idx = types.index(EventType.TASK_COMPLETED) + + assert round_start_idx < task_start_idx + assert task_end_idx < round_end_idx + + def test_workflow_started_has_metadata(self): + bus = EventBus() + bus.enable_history() + + config = WorkflowConfig(max_rounds=1) + run_felix_workflow(config, _make_mock_provider(), "My task", event_bus=bus) + + started = [e for e in bus.history if e.event_type == EventType.WORKFLOW_STARTED] + assert len(started) == 1 + assert started[0].data["task"] == "My task" + assert started[0].data["max_rounds"] == 1 + assert started[0].data["agents_count"] == 3 # default team + + def test_workflow_completed_has_metadata(self): + bus = EventBus() + bus.enable_history() + + config = WorkflowConfig(max_rounds=1) + run_felix_workflow(config, _make_mock_provider(), "Task", event_bus=bus) + + completed = [e for e in bus.history if e.event_type == EventType.WORKFLOW_COMPLETED] + assert len(completed) == 1 + assert "rounds" in completed[0].data + assert "final_confidence" in completed[0].data + assert "total_tokens" in completed[0].data + assert "elapsed_seconds" in completed[0].data + + def test_task_completed_has_agent_details(self): + bus = EventBus() + bus.enable_history() + + config = WorkflowConfig(max_rounds=1) + run_felix_workflow(config, _make_mock_provider(), "Task", event_bus=bus) + + task_events = [e for e in bus.history if e.event_type == EventType.TASK_COMPLETED] + assert len(task_events) > 0 + + first = task_events[0] + assert "task_id" in first.data + assert "agent_type" in first.data + assert "confidence" in first.data + assert "temperature" in first.data + assert "tokens" in first.data + + def test_no_bus_no_crash(self): + """Workflow works fine without an event bus.""" + config = WorkflowConfig(max_rounds=1) + result = run_felix_workflow(config, _make_mock_provider(), "Task") + assert result.synthesis is not None + + def test_subscriber_receives_events_live(self): + """Verify that subscribers are called during the workflow, not after.""" + bus = EventBus() + received_during = [] + + def on_round_start(event: FelixEvent): + received_during.append(event.event_type) + + bus.subscribe(EventType.WORKFLOW_ROUND_STARTED, on_round_start) + + config = WorkflowConfig(max_rounds=2) + run_felix_workflow(config, _make_mock_provider(), "Task", event_bus=bus) + + assert len(received_during) == 2 + + def test_event_count_scales_with_rounds(self): + """More rounds = more events.""" + bus1 = EventBus() + bus1.enable_history() + bus2 = EventBus() + bus2.enable_history() + + run_felix_workflow( + WorkflowConfig(max_rounds=1), _make_mock_provider(), "T", event_bus=bus1 + ) + run_felix_workflow( + WorkflowConfig(max_rounds=3), _make_mock_provider(), "T", event_bus=bus2 + ) + + assert len(bus2.history) > len(bus1.history) From a91b57fb98649b7384684cfe03092ae343f4d1ce Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:23:39 -0400 Subject: [PATCH 02/14] fix(events): defensive _event_bus instance attribute + TODO annotations - EventEmitterMixin now writes _event_bus to instance dict explicitly via __dict__ to prevent class-level attribute leakage between instances - Always call set_event_bus() in FelixWorkflow/LLMAgent __init__ (even when None) so the attribute exists for direct access in runner.py - Added TODO comments on placeholder EventType members not yet emitted Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/agents/llm_agent.py | 3 +-- src/felix_agent_sdk/events/mixins.py | 11 +++++++---- src/felix_agent_sdk/events/types.py | 12 +++++++----- src/felix_agent_sdk/workflows/runner.py | 3 +-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/felix_agent_sdk/agents/llm_agent.py b/src/felix_agent_sdk/agents/llm_agent.py index 00c1168..66d7890 100644 --- a/src/felix_agent_sdk/agents/llm_agent.py +++ b/src/felix_agent_sdk/agents/llm_agent.py @@ -107,8 +107,7 @@ def __init__( self.provider = provider self.agent_type = agent_type - if event_bus is not None: - self.set_event_bus(event_bus) + self.set_event_bus(event_bus) self.temperature_range = temperature_range or _DEFAULT_TEMPERATURE_RANGES.get( agent_type, _DEFAULT_TEMP_RANGE diff --git a/src/felix_agent_sdk/events/mixins.py b/src/felix_agent_sdk/events/mixins.py index 59daee4..cc9c275 100644 --- a/src/felix_agent_sdk/events/mixins.py +++ b/src/felix_agent_sdk/events/mixins.py @@ -15,13 +15,16 @@ class EventEmitterMixin: to :meth:`emit_event` dispatch a :class:`FelixEvent` onto the bus. When no bus is attached, :meth:`emit_event` is a silent no-op — zero overhead for users who don't enable observability. + + Note: ``_event_bus`` is declared as a class-level annotation for type + checkers but always written to the *instance* dict via ``set_event_bus``. """ - _event_bus: Optional[EventBus] = None + _event_bus: Optional[EventBus] def set_event_bus(self, bus: Optional[EventBus]) -> None: - """Attach or detach an event bus.""" - self._event_bus = bus + """Attach or detach an event bus (writes to instance, not class).""" + self.__dict__["_event_bus"] = bus def emit_event( self, @@ -37,7 +40,7 @@ def emit_event( data: Arbitrary payload dict. source: Override the default source identifier. """ - if self._event_bus is None: + if getattr(self, "_event_bus", None) is None: return event = FelixEvent( event_type=event_type, diff --git a/src/felix_agent_sdk/events/types.py b/src/felix_agent_sdk/events/types.py index 334e086..6efee65 100644 --- a/src/felix_agent_sdk/events/types.py +++ b/src/felix_agent_sdk/events/types.py @@ -19,18 +19,19 @@ class EventType(str, Enum): so subscribers can match by prefix (e.g. ``"agent.*"``). """ - # Agent lifecycle + # Agent lifecycle — SPAWNED/COMPLETED/FAILED bridged from CentralPost AGENT_SPAWNED = "agent.spawned" AGENT_COMPLETED = "agent.completed" AGENT_FAILED = "agent.failed" + # TODO: emit from Agent.update_position() and should_process_at_checkpoint() AGENT_POSITION_UPDATED = "agent.position_updated" AGENT_CHECKPOINT = "agent.checkpoint" - # Task processing + # Task processing — emitted from LLMAgent.process_task() TASK_STARTED = "task.started" TASK_COMPLETED = "task.completed" - # Workflow lifecycle + # Workflow lifecycle — emitted from FelixWorkflow.run() WORKFLOW_STARTED = "workflow.started" WORKFLOW_ROUND_STARTED = "workflow.round.started" WORKFLOW_ROUND_COMPLETED = "workflow.round.completed" @@ -39,14 +40,15 @@ class EventType(str, Enum): WORKFLOW_COMPLETED = "workflow.completed" # Communication + # TODO: emit from CentralPost.queue_message() and _handle_message() MESSAGE_QUEUED = "message.queued" MESSAGE_PROCESSED = "message.processed" - # Streaming (wired in PR #3) + # Streaming — wired in feat/streaming PR STREAM_TOKEN = "stream.token" STREAM_COMPLETED = "stream.completed" - # Dynamic spawning (wired in PR #4) + # Dynamic spawning — wired in feat/dynamic-spawning PR SPAWN_TRIGGERED = "spawn.triggered" SPAWN_COMPLETED = "spawn.completed" diff --git a/src/felix_agent_sdk/workflows/runner.py b/src/felix_agent_sdk/workflows/runner.py index cd3287d..42a5e5a 100644 --- a/src/felix_agent_sdk/workflows/runner.py +++ b/src/felix_agent_sdk/workflows/runner.py @@ -54,8 +54,7 @@ def __init__( self._config = config self._provider = provider self._factory = AgentFactory(provider, helix_config=config.helix_config) - if event_bus is not None: - self.set_event_bus(event_bus) + self.set_event_bus(event_bus) # ------------------------------------------------------------------ # Public API From c5ee91948edd5bb4477b745251942fb922e4d374 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:34:50 -0400 Subject: [PATCH 03/14] feat(logging): add structured logging and EventLogBridge - configure_logging() one-call setup for all felix_agent_sdk.* loggers - FelixLogConfig: level, format (text/json), per-subsystem overrides - JSONFormatter: structured JSON output with event metadata fields - EventLogBridge: subscribes to EventBus, auto-logs events at configurable levels (workflow events at INFO, task/agent at DEBUG) - FelixEvent.__post_init__ normalises EventType enum members to plain strings for consistent str()/logging across Python versions 16 new tests. 714 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/__init__.py | 3 + src/felix_agent_sdk/events/types.py | 12 +- src/felix_agent_sdk/utils/__init__.py | 18 +- src/felix_agent_sdk/utils/logging.py | 216 ++++++++++++++++++++++ tests/unit/test_logging.py | 246 ++++++++++++++++++++++++++ 5 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 src/felix_agent_sdk/utils/logging.py create mode 100644 tests/unit/test_logging.py diff --git a/src/felix_agent_sdk/__init__.py b/src/felix_agent_sdk/__init__.py index ac580ae..22e0f3c 100644 --- a/src/felix_agent_sdk/__init__.py +++ b/src/felix_agent_sdk/__init__.py @@ -34,6 +34,7 @@ from felix_agent_sdk.events import EventBus, EventType, FelixEvent from felix_agent_sdk.memory import ContextCompressor, KnowledgeStore, TaskMemory from felix_agent_sdk.tokens import TokenBudget +from felix_agent_sdk.utils import configure_logging from felix_agent_sdk.workflows import FelixWorkflow, WorkflowConfig, run_felix_workflow __all__ = [ @@ -71,6 +72,8 @@ "EventBus", "EventType", "FelixEvent", + # Logging + "configure_logging", # Tokens "TokenBudget", ] diff --git a/src/felix_agent_sdk/events/types.py b/src/felix_agent_sdk/events/types.py index 6efee65..4b71155 100644 --- a/src/felix_agent_sdk/events/types.py +++ b/src/felix_agent_sdk/events/types.py @@ -58,7 +58,9 @@ class FelixEvent: """An immutable record of something that happened in the SDK. Attributes: - event_type: The event category (from :class:`EventType` or a custom string). + event_type: The event category as a plain string. Accepts + :class:`EventType` enum members — they are normalised to + their ``.value`` on construction. source: Identifies the emitter, e.g. ``"workflow"`` or ``"agent:research-001"``. data: Arbitrary payload. Contents vary by event type. timestamp: Monotonic timestamp (seconds) when the event was created. @@ -68,3 +70,11 @@ class FelixEvent: source: str data: Dict[str, Any] = field(default_factory=dict) timestamp: float = field(default_factory=time.monotonic) + + def __post_init__(self) -> None: + # Normalise EventType enum members to plain strings so that + # str(), logging %-formatting, and == comparisons all behave + # consistently across Python versions. + et = self.event_type + if hasattr(et, "value"): + object.__setattr__(self, "event_type", et.value) diff --git a/src/felix_agent_sdk/utils/__init__.py b/src/felix_agent_sdk/utils/__init__.py index 9bb34d6..f7f5aee 100644 --- a/src/felix_agent_sdk/utils/__init__.py +++ b/src/felix_agent_sdk/utils/__init__.py @@ -1,8 +1,18 @@ """Felix shared utilities. -Logging, configuration loading, and common type definitions. - -Status: Stub — populated as needed during implementation. +Logging configuration, event-log bridging, and common helpers. """ -__all__: list[str] = [] +from felix_agent_sdk.utils.logging import ( + EventLogBridge, + FelixLogConfig, + JSONFormatter, + configure_logging, +) + +__all__ = [ + "configure_logging", + "FelixLogConfig", + "JSONFormatter", + "EventLogBridge", +] diff --git a/src/felix_agent_sdk/utils/logging.py b/src/felix_agent_sdk/utils/logging.py new file mode 100644 index 0000000..59530dc --- /dev/null +++ b/src/felix_agent_sdk/utils/logging.py @@ -0,0 +1,216 @@ +"""Structured logging configuration for the Felix SDK. + +Provides a one-call ``configure_logging()`` helper that sets up sensible +defaults for all ``felix_agent_sdk.*`` loggers, plus an ``EventLogBridge`` +that subscribes to an :class:`EventBus` and logs every event automatically. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, Literal, Optional + +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.types import FelixEvent + +_ROOT_LOGGER_NAME = "felix_agent_sdk" + +# Default log level mapping for events (event_type prefix → level name) +_DEFAULT_EVENT_LEVELS: Dict[str, str] = { + "workflow.started": "INFO", + "workflow.completed": "INFO", + "workflow.converged": "INFO", + "workflow.round": "DEBUG", + "workflow.synthesis": "DEBUG", + "task.": "DEBUG", + "agent.": "DEBUG", + "message.": "DEBUG", + "stream.": "DEBUG", + "spawn.": "INFO", +} + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@dataclass +class FelixLogConfig: + """Configuration for :func:`configure_logging`. + + Attributes: + level: Root log level for all ``felix_agent_sdk.*`` loggers. + format: ``"text"`` for human-readable, ``"json"`` for structured output. + subsystem_levels: Override levels for specific loggers, e.g. + ``{"felix_agent_sdk.providers": "DEBUG"}``. + include_timestamps: Prefix lines with ISO-8601 timestamps (text mode). + output_handler: Pre-built handler to use. When ``None`` a + :class:`logging.StreamHandler` writing to stderr is created. + """ + + level: str = "INFO" + format: Literal["text", "json"] = "text" + subsystem_levels: Dict[str, str] = field(default_factory=dict) + include_timestamps: bool = True + output_handler: Optional[logging.Handler] = field(default=None, repr=False) + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- + + +class JSONFormatter(logging.Formatter): + """Emit each log record as a single JSON object per line. + + Fields: ``timestamp``, ``level``, ``logger``, ``message``, and any + ``extra`` keys set on the record. + """ + + def format(self, record: logging.LogRecord) -> str: + obj: Dict[str, Any] = { + "timestamp": self.formatTime(record, self.datefmt), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + # Capture extra fields attached by EventLogBridge or user code + for key in ("event_type", "event_source", "event_data"): + val = getattr(record, key, None) + if val is not None: + obj[key] = val + if record.exc_info and record.exc_info[1] is not None: + obj["exception"] = self.formatException(record.exc_info) + return json.dumps(obj, default=str) + + +def _build_text_formatter(include_timestamps: bool) -> logging.Formatter: + parts = [] + if include_timestamps: + parts.append("%(asctime)s") + parts.extend(["%(levelname)-8s", "%(name)s", "%(message)s"]) + return logging.Formatter(" ".join(parts)) + + +# --------------------------------------------------------------------------- +# configure_logging +# --------------------------------------------------------------------------- + + +def configure_logging(config: Optional[FelixLogConfig] = None) -> None: + """Set up logging for all ``felix_agent_sdk.*`` loggers. + + Idempotent — safe to call multiple times. Subsequent calls replace + the handler and formatter but do not duplicate handlers. + + Args: + config: Logging configuration. Uses sensible defaults when ``None``. + """ + cfg = config or FelixLogConfig() + + root = logging.getLogger(_ROOT_LOGGER_NAME) + root.setLevel(getattr(logging, cfg.level.upper(), logging.INFO)) + + # Remove any handlers previously attached by this function + for h in list(root.handlers): + if getattr(h, "_felix_managed", False): + root.removeHandler(h) + + handler = cfg.output_handler or logging.StreamHandler() + handler._felix_managed = True # type: ignore[attr-defined] + + if cfg.format == "json": + handler.setFormatter(JSONFormatter()) + else: + handler.setFormatter(_build_text_formatter(cfg.include_timestamps)) + + root.addHandler(handler) + + # Per-subsystem overrides + for logger_name, level_str in cfg.subsystem_levels.items(): + sub = logging.getLogger(logger_name) + sub.setLevel(getattr(logging, level_str.upper(), logging.INFO)) + + +# --------------------------------------------------------------------------- +# EventLogBridge +# --------------------------------------------------------------------------- + + +class EventLogBridge: + """Subscribe to an :class:`EventBus` and log every event. + + Each event is logged to the ``felix_agent_sdk.events`` logger at a + level determined by the event type (see ``_DEFAULT_EVENT_LEVELS``). + Custom level mappings can be provided via *level_map*. + + Examples:: + + bus = EventBus() + configure_logging() + bridge = EventLogBridge(bus) + # Now every event emitted on *bus* appears in the log. + + # Cleanup + bridge.detach() + """ + + def __init__( + self, + bus: EventBus, + level_map: Optional[Dict[str, str]] = None, + ) -> None: + self._bus = bus + self._level_map = level_map or dict(_DEFAULT_EVENT_LEVELS) + self._logger = logging.getLogger(f"{_ROOT_LOGGER_NAME}.events") + bus.subscribe_all(self._on_event) + + # ------------------------------------------------------------------ + + def _resolve_level(self, event_type: str) -> int: + """Return the numeric log level for *event_type*.""" + # Exact match first + level_str = self._level_map.get(event_type) + if level_str: + return getattr(logging, level_str.upper(), logging.DEBUG) + # Prefix match + for prefix, lvl in self._level_map.items(): + if event_type.startswith(prefix): + return getattr(logging, lvl.upper(), logging.DEBUG) + return logging.DEBUG + + def _on_event(self, event: FelixEvent) -> None: + level = self._resolve_level(event.event_type) + extra = { + "event_type": event.event_type, + "event_source": event.source, + "event_data": event.data, + } + self._logger.log( + level, + "[%s] %s %s", + event.event_type, + event.source, + _summarise_data(event.data), + extra=extra, + ) + + # ------------------------------------------------------------------ + + def detach(self) -> None: + """Unsubscribe from the event bus.""" + self._bus.unsubscribe_all(self._on_event) + + +def _summarise_data(data: Dict[str, Any], max_len: int = 120) -> str: + """One-line summary of event data for text logs.""" + if not data: + return "" + parts = [f"{k}={v}" for k, v in data.items()] + text = " ".join(parts) + if len(text) > max_len: + return text[: max_len - 3] + "..." + return text diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..1ac70f8 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,246 @@ +"""Tests for structured logging configuration and EventLogBridge.""" + +from __future__ import annotations + +import json +import logging + +import pytest + +from felix_agent_sdk.events import EventBus, EventType, FelixEvent +from felix_agent_sdk.utils.logging import ( + EventLogBridge, + FelixLogConfig, + JSONFormatter, + configure_logging, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _capture_handler() -> logging.Handler: + """Return a handler that stores formatted records in handler.records.""" + handler = logging.StreamHandler() + handler.records: list[str] = [] # type: ignore[attr-defined] + original_emit = handler.emit + + def capturing_emit(record: logging.LogRecord) -> None: + handler.records.append(handler.format(record)) # type: ignore[attr-defined] + + handler.emit = capturing_emit # type: ignore[method-assign] + return handler + + +# --------------------------------------------------------------------------- +# configure_logging +# --------------------------------------------------------------------------- + + +class TestConfigureLogging: + def setup_method(self): + # Clean up any managed handlers from previous tests + root = logging.getLogger("felix_agent_sdk") + for h in list(root.handlers): + if getattr(h, "_felix_managed", False): + root.removeHandler(h) + + def test_default_config(self): + configure_logging() + root = logging.getLogger("felix_agent_sdk") + assert root.level == logging.INFO + managed = [h for h in root.handlers if getattr(h, "_felix_managed", False)] + assert len(managed) == 1 + + def test_custom_level(self): + configure_logging(FelixLogConfig(level="DEBUG")) + root = logging.getLogger("felix_agent_sdk") + assert root.level == logging.DEBUG + + def test_idempotent(self): + configure_logging() + configure_logging() + root = logging.getLogger("felix_agent_sdk") + managed = [h for h in root.handlers if getattr(h, "_felix_managed", False)] + assert len(managed) == 1 # not 2 + + def test_subsystem_levels(self): + configure_logging( + FelixLogConfig(subsystem_levels={"felix_agent_sdk.providers": "WARNING"}) + ) + sub = logging.getLogger("felix_agent_sdk.providers") + assert sub.level == logging.WARNING + + def test_custom_handler(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(output_handler=handler)) + logger = logging.getLogger("felix_agent_sdk.test_custom") + logger.info("hello") + assert any("hello" in r for r in handler.records) # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# JSONFormatter +# --------------------------------------------------------------------------- + + +class TestJSONFormatter: + def test_valid_json(self): + formatter = JSONFormatter() + record = logging.LogRecord( + name="felix_agent_sdk.test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + parsed = json.loads(output) + assert parsed["level"] == "INFO" + assert parsed["logger"] == "felix_agent_sdk.test" + assert parsed["message"] == "test message" + assert "timestamp" in parsed + + def test_extra_fields(self): + formatter = JSONFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=0, + msg="msg", args=(), exc_info=None, + ) + record.event_type = "workflow.started" # type: ignore[attr-defined] + record.event_source = "workflow" # type: ignore[attr-defined] + record.event_data = {"task": "test"} # type: ignore[attr-defined] + + output = formatter.format(record) + parsed = json.loads(output) + assert parsed["event_type"] == "workflow.started" + assert parsed["event_source"] == "workflow" + assert parsed["event_data"] == {"task": "test"} + + def test_json_format_via_configure(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(format="json", output_handler=handler)) + logger = logging.getLogger("felix_agent_sdk.test_json") + logger.info("json test") + assert len(handler.records) > 0 # type: ignore[attr-defined] + parsed = json.loads(handler.records[0]) # type: ignore[attr-defined] + assert parsed["message"] == "json test" + + +# --------------------------------------------------------------------------- +# Text formatter +# --------------------------------------------------------------------------- + + +class TestTextFormatter: + def test_with_timestamps(self): + handler = _capture_handler() + configure_logging( + FelixLogConfig(format="text", include_timestamps=True, output_handler=handler) + ) + logger = logging.getLogger("felix_agent_sdk.test_text_ts") + logger.info("ts test") + # Should contain a timestamp-like pattern (year) + assert any("202" in r for r in handler.records) # type: ignore[attr-defined] + + def test_without_timestamps(self): + handler = _capture_handler() + configure_logging( + FelixLogConfig(format="text", include_timestamps=False, output_handler=handler) + ) + logger = logging.getLogger("felix_agent_sdk.test_text_nots") + logger.info("no ts") + # First token should be the level, not a timestamp + assert handler.records[0].startswith("INFO") # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# EventLogBridge +# --------------------------------------------------------------------------- + + +class TestEventLogBridge: + def test_logs_events(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(level="DEBUG", output_handler=handler)) + + bus = EventBus() + bridge = EventLogBridge(bus) + + bus.emit(FelixEvent( + event_type=EventType.WORKFLOW_STARTED, + source="workflow", + data={"task": "test"}, + )) + + assert any("workflow.started" in r for r in handler.records) # type: ignore[attr-defined] + + def test_json_bridge(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(level="DEBUG", format="json", output_handler=handler)) + + bus = EventBus() + bridge = EventLogBridge(bus) + + bus.emit(FelixEvent( + event_type=EventType.TASK_COMPLETED, + source="agent:research-001", + data={"confidence": 0.75}, + )) + + assert len(handler.records) > 0 # type: ignore[attr-defined] + parsed = json.loads(handler.records[0]) # type: ignore[attr-defined] + assert parsed["event_type"] == "task.completed" + assert parsed["event_source"] == "agent:research-001" + + def test_detach(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(level="DEBUG", output_handler=handler)) + + bus = EventBus() + bridge = EventLogBridge(bus) + bridge.detach() + + bus.emit(FelixEvent(event_type="test", source="test")) + # Should have no event log records after detach + event_records = [r for r in handler.records if "test" in r and "[test]" in r] # type: ignore[attr-defined] + assert len(event_records) == 0 + + def test_custom_level_map(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(level="DEBUG", output_handler=handler)) + + bus = EventBus() + bridge = EventLogBridge(bus, level_map={"custom.event": "WARNING"}) + + bus.emit(FelixEvent(event_type="custom.event", source="test")) + + assert any("WARNING" in r for r in handler.records) # type: ignore[attr-defined] + + def test_default_level_is_debug(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(level="DEBUG", output_handler=handler)) + + bus = EventBus() + bridge = EventLogBridge(bus) + + bus.emit(FelixEvent(event_type="unknown.event.type", source="test")) + + assert any("DEBUG" in r for r in handler.records) # type: ignore[attr-defined] + + def test_workflow_events_log_at_info(self): + handler = _capture_handler() + configure_logging(FelixLogConfig(level="DEBUG", output_handler=handler)) + + bus = EventBus() + bridge = EventLogBridge(bus) + + bus.emit(FelixEvent(event_type=EventType.WORKFLOW_STARTED, source="wf")) + bus.emit(FelixEvent(event_type=EventType.WORKFLOW_COMPLETED, source="wf")) + + info_records = [r for r in handler.records if "INFO" in r] # type: ignore[attr-defined] + assert len(info_records) >= 2 From 7b29e2cd19cfbf9da6fc632a2beada88e9e83265 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:37:05 -0400 Subject: [PATCH 04/14] fix(events): cap history size to prevent unbounded memory growth EventBus.enable_history() now accepts max_size (default 10,000). Oldest events are discarded when the limit is reached during emit(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/events/bus.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/felix_agent_sdk/events/bus.py b/src/felix_agent_sdk/events/bus.py index 3f36fc3..a466d71 100644 --- a/src/felix_agent_sdk/events/bus.py +++ b/src/felix_agent_sdk/events/bus.py @@ -19,6 +19,11 @@ EventCallback = Callable[[FelixEvent], None] +# Enough to capture a full workflow run (~20 rounds × ~10 agents × ~3 events) +# without unbounded growth in long-running processes. +_DEFAULT_MAX_HISTORY = 10_000 + + class EventBus: """Synchronous event bus with prefix-pattern subscriptions. @@ -48,16 +53,24 @@ def __init__(self) -> None: self._lock = threading.Lock() self._history: List[FelixEvent] = [] self._record_history = False + self._max_history_size = _DEFAULT_MAX_HISTORY # ------------------------------------------------------------------ # Configuration # ------------------------------------------------------------------ - def enable_history(self, enabled: bool = True) -> None: + def enable_history(self, enabled: bool = True, max_size: int = _DEFAULT_MAX_HISTORY) -> None: """Toggle event recording. When enabled, all emitted events are - stored in :attr:`history` for later inspection.""" + stored in :attr:`history` for later inspection. + + Args: + enabled: Whether to record events. + max_size: Maximum number of events to retain. Oldest events + are discarded when the limit is reached. + """ with self._lock: self._record_history = enabled + self._max_history_size = max_size if not enabled: self._history.clear() @@ -120,6 +133,8 @@ def emit(self, event: FelixEvent) -> None: with self._lock: if self._record_history: self._history.append(event) + if len(self._history) > self._max_history_size: + self._history = self._history[-self._max_history_size:] targets: List[EventCallback] = [] From 3c36512eb7928700ec292c9c0dda3751eed7dc93 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:49:04 -0400 Subject: [PATCH 05/14] feat(streaming): add streaming types, handlers, and accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamEvent/StreamEventType frozen dataclasses for token-level events - StreamHandler base class with dispatch routing (token/chunk/result/error) - CallbackStreamHandler for simple callable-based consumers - EventBusStreamHandler re-emits stream events onto the event bus - StreamAccumulator bridges provider StreamChunk to SDK StreamEvent, produces CompletionResult for parity with non-streaming path - LLMAgent.process_task_streaming(task, handler) — full streaming processing with same lifecycle as process_task() 23 new tests. 737 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/__init__.py | 4 + src/felix_agent_sdk/agents/llm_agent.py | 84 +++++++ src/felix_agent_sdk/streaming/__init__.py | 23 +- src/felix_agent_sdk/streaming/accumulator.py | 83 +++++++ src/felix_agent_sdk/streaming/handler.py | 105 ++++++++ src/felix_agent_sdk/streaming/types.py | 42 ++++ tests/unit/test_llm_agent_streaming.py | 157 ++++++++++++ tests/unit/test_streaming.py | 247 +++++++++++++++++++ 8 files changed, 740 insertions(+), 5 deletions(-) create mode 100644 src/felix_agent_sdk/streaming/accumulator.py create mode 100644 src/felix_agent_sdk/streaming/handler.py create mode 100644 src/felix_agent_sdk/streaming/types.py create mode 100644 tests/unit/test_llm_agent_streaming.py create mode 100644 tests/unit/test_streaming.py diff --git a/src/felix_agent_sdk/__init__.py b/src/felix_agent_sdk/__init__.py index 22e0f3c..f5c87f4 100644 --- a/src/felix_agent_sdk/__init__.py +++ b/src/felix_agent_sdk/__init__.py @@ -33,6 +33,7 @@ from felix_agent_sdk.communication import CentralPost, Message, MessageType, Spoke from felix_agent_sdk.events import EventBus, EventType, FelixEvent from felix_agent_sdk.memory import ContextCompressor, KnowledgeStore, TaskMemory +from felix_agent_sdk.streaming import StreamEvent, StreamHandler from felix_agent_sdk.tokens import TokenBudget from felix_agent_sdk.utils import configure_logging from felix_agent_sdk.workflows import FelixWorkflow, WorkflowConfig, run_felix_workflow @@ -72,6 +73,9 @@ "EventBus", "EventType", "FelixEvent", + # Streaming + "StreamEvent", + "StreamHandler", # Logging "configure_logging", # Tokens diff --git a/src/felix_agent_sdk/agents/llm_agent.py b/src/felix_agent_sdk/agents/llm_agent.py index 66d7890..f7b811d 100644 --- a/src/felix_agent_sdk/agents/llm_agent.py +++ b/src/felix_agent_sdk/agents/llm_agent.py @@ -24,6 +24,8 @@ from felix_agent_sdk.events.types import EventType from felix_agent_sdk.providers.base import BaseProvider from felix_agent_sdk.providers.types import ChatMessage, CompletionResult, MessageRole +from felix_agent_sdk.streaming.accumulator import StreamAccumulator +from felix_agent_sdk.streaming.handler import StreamHandler from felix_agent_sdk.tokens.budget import TokenBudget logger = logging.getLogger(__name__) @@ -367,6 +369,88 @@ def process_task(self, task: LLMTask) -> LLMResult: return result + def process_task_streaming( + self, task: LLMTask, handler: StreamHandler + ) -> LLMResult: + """Process a task with token-level streaming. + + Same lifecycle as :meth:`process_task` (prompt -> provider -> + confidence -> result) but uses ``provider.stream()`` and + dispatches each chunk through *handler*. + + Args: + task: The task to process. + handler: Stream handler to receive token events. + + Returns: + :class:`LLMResult` identical to what ``process_task`` would + return for the same content. + """ + start = time.monotonic() + + self.emit_event( + EventType.TASK_STARTED, + {"task_id": task.task_id, "agent_type": self.agent_type, "streaming": True}, + source=f"agent:{self.agent_id}", + ) + + temperature = self.get_adaptive_temperature() + system_prompt, user_prompt = self.create_position_aware_prompt(task) + max_tokens = self.max_tokens + + messages = [ + ChatMessage(role=MessageRole.SYSTEM, content=system_prompt), + ChatMessage(role=MessageRole.USER, content=user_prompt), + ] + + accumulator = StreamAccumulator( + agent_id=self.agent_id, + handler=handler, + model=getattr(self.provider, "model", ""), + ) + chunks = self.provider.stream( + messages, temperature=temperature, max_tokens=max_tokens + ) + accumulator.feed_all(chunks) + completion = accumulator.to_completion_result() + + confidence = self.calculate_confidence(completion.content) + self.record_confidence(confidence) + + elapsed = time.monotonic() - start + self.total_tokens_used += completion.total_tokens + self.total_processing_time += elapsed + + result = LLMResult( + agent_id=self.agent_id, + task_id=task.task_id, + content=completion.content, + position_info=self.get_position_info(), + completion_result=completion, + processing_time=elapsed, + confidence=confidence, + temperature_used=temperature, + token_budget_used=completion.total_tokens, + ) + self.processing_results.append(result) + + self.emit_event( + EventType.TASK_COMPLETED, + { + "task_id": task.task_id, + "agent_type": self.agent_type, + "confidence": round(confidence, 4), + "temperature": round(temperature, 4), + "tokens": completion.total_tokens, + "processing_time": round(elapsed, 4), + "phase": result.position_info.get("phase", ""), + "streaming": True, + }, + source=f"agent:{self.agent_id}", + ) + + return result + # ------------------------------------------------------------------ # Checkpoint gating # ------------------------------------------------------------------ diff --git a/src/felix_agent_sdk/streaming/__init__.py b/src/felix_agent_sdk/streaming/__init__.py index 5756d80..5040c33 100644 --- a/src/felix_agent_sdk/streaming/__init__.py +++ b/src/felix_agent_sdk/streaming/__init__.py @@ -1,9 +1,22 @@ """Felix real-time output streaming. -Stream event types and handlers for token-by-token output. -Planned exports: StreamEvent, StreamHandler. - -Status: Stub — implementation in feat/workflows branch. +Stream event types, handlers, and accumulator for token-by-token output +observation during agent processing. """ -__all__: list[str] = [] +from felix_agent_sdk.streaming.accumulator import StreamAccumulator +from felix_agent_sdk.streaming.handler import ( + CallbackStreamHandler, + EventBusStreamHandler, + StreamHandler, +) +from felix_agent_sdk.streaming.types import StreamEvent, StreamEventType + +__all__ = [ + "StreamAccumulator", + "StreamHandler", + "CallbackStreamHandler", + "EventBusStreamHandler", + "StreamEvent", + "StreamEventType", +] diff --git a/src/felix_agent_sdk/streaming/accumulator.py b/src/felix_agent_sdk/streaming/accumulator.py new file mode 100644 index 0000000..6a4f8d6 --- /dev/null +++ b/src/felix_agent_sdk/streaming/accumulator.py @@ -0,0 +1,83 @@ +"""Stream accumulator — bridges provider StreamChunks to SDK StreamEvents.""" + +from __future__ import annotations + +from typing import Iterator + +from felix_agent_sdk.providers.types import CompletionResult, StreamChunk +from felix_agent_sdk.streaming.handler import StreamHandler +from felix_agent_sdk.streaming.types import StreamEvent, StreamEventType + + +class StreamAccumulator: + """Consume :class:`StreamChunk` objects from a provider and produce + :class:`StreamEvent` objects dispatched to a :class:`StreamHandler`. + + After iteration completes, :meth:`to_completion_result` returns the + equivalent :class:`CompletionResult` so the caller can treat the + stream result identically to a non-streaming ``complete()`` call. + + Args: + agent_id: Agent producing the stream. + handler: Handler to dispatch events to. + model: Model identifier for the completion result. + """ + + def __init__(self, agent_id: str, handler: StreamHandler, model: str = "") -> None: + self._agent_id = agent_id + self._handler = handler + self._model = model + self._accumulated = "" + self._token_index = 0 + self._usage: dict[str, int] = {} + + def feed(self, chunk: StreamChunk) -> None: + """Process a single :class:`StreamChunk`.""" + self._accumulated += chunk.text + self._token_index += 1 + + if chunk.is_final: + self._usage = dict(chunk.usage) + event = StreamEvent( + agent_id=self._agent_id, + event_type=StreamEventType.RESULT, + content=chunk.text, + accumulated=self._accumulated, + token_index=self._token_index, + is_final=True, + usage=self._usage, + ) + self._handler.dispatch(event) + else: + event = StreamEvent( + agent_id=self._agent_id, + event_type=StreamEventType.TOKEN, + content=chunk.text, + accumulated=self._accumulated, + token_index=self._token_index, + is_final=False, + ) + self._handler.dispatch(event) + + def feed_all(self, chunks: Iterator[StreamChunk]) -> None: + """Consume all chunks from an iterator.""" + for chunk in chunks: + self.feed(chunk) + + def to_completion_result(self) -> CompletionResult: + """Build a :class:`CompletionResult` from the accumulated stream.""" + return CompletionResult( + content=self._accumulated, + model=self._model, + usage=self._usage, + ) + + @property + def accumulated_text(self) -> str: + """The full text accumulated so far.""" + return self._accumulated + + @property + def token_count(self) -> int: + """Number of chunks received.""" + return self._token_index diff --git a/src/felix_agent_sdk/streaming/handler.py b/src/felix_agent_sdk/streaming/handler.py new file mode 100644 index 0000000..c4036f7 --- /dev/null +++ b/src/felix_agent_sdk/streaming/handler.py @@ -0,0 +1,105 @@ +"""Stream handler base class and built-in implementations.""" + +from __future__ import annotations + +from typing import Callable, Optional + +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.types import EventType, FelixEvent +from felix_agent_sdk.streaming.types import StreamEvent, StreamEventType + + +class StreamHandler: + """Base class for consuming stream events. + + Override the ``on_*`` methods to handle specific event types. + Default implementations are no-ops. + + Examples:: + + class PrintHandler(StreamHandler): + def on_token(self, event: StreamEvent) -> None: + print(event.content, end="", flush=True) + + def on_result(self, event: StreamEvent) -> None: + print() # newline after stream completes + """ + + def on_token(self, event: StreamEvent) -> None: + """Called for each token/chunk received.""" + + def on_result(self, event: StreamEvent) -> None: + """Called when the stream completes with the full result.""" + + def on_error(self, event: StreamEvent) -> None: + """Called if an error occurs during streaming.""" + + def dispatch(self, event: StreamEvent) -> None: + """Route *event* to the appropriate ``on_*`` method.""" + if event.event_type == StreamEventType.TOKEN: + self.on_token(event) + elif event.event_type == StreamEventType.CHUNK: + self.on_token(event) # chunks are batched tokens + elif event.event_type == StreamEventType.RESULT: + self.on_result(event) + elif event.event_type == StreamEventType.ERROR: + self.on_error(event) + + +class CallbackStreamHandler(StreamHandler): + """Stream handler backed by simple callables. + + Examples:: + + handler = CallbackStreamHandler( + on_token=lambda e: print(e.content, end=""), + on_result=lambda e: print(f"\\nDone: {len(e.accumulated)} chars"), + ) + """ + + def __init__( + self, + on_token: Optional[Callable[[StreamEvent], None]] = None, + on_result: Optional[Callable[[StreamEvent], None]] = None, + on_error: Optional[Callable[[StreamEvent], None]] = None, + ) -> None: + self._on_token = on_token + self._on_result = on_result + self._on_error = on_error + + def on_token(self, event: StreamEvent) -> None: + if self._on_token is not None: + self._on_token(event) + + def on_result(self, event: StreamEvent) -> None: + if self._on_result is not None: + self._on_result(event) + + def on_error(self, event: StreamEvent) -> None: + if self._on_error is not None: + self._on_error(event) + + +class EventBusStreamHandler(StreamHandler): + """Stream handler that re-emits stream events onto an EventBus.""" + + def __init__(self, bus: EventBus) -> None: + self._bus = bus + + def on_token(self, event: StreamEvent) -> None: + self._bus.emit(FelixEvent( + event_type=EventType.STREAM_TOKEN, + source=f"agent:{event.agent_id}", + data={"content": event.content, "token_index": event.token_index}, + )) + + def on_result(self, event: StreamEvent) -> None: + self._bus.emit(FelixEvent( + event_type=EventType.STREAM_COMPLETED, + source=f"agent:{event.agent_id}", + data={ + "length": len(event.accumulated), + "token_count": event.token_index, + "usage": event.usage, + }, + )) diff --git a/src/felix_agent_sdk/streaming/types.py b/src/felix_agent_sdk/streaming/types.py new file mode 100644 index 0000000..1ad9ac2 --- /dev/null +++ b/src/felix_agent_sdk/streaming/types.py @@ -0,0 +1,42 @@ +"""Streaming event types for real-time agent output observation.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict + + +class StreamEventType(str, Enum): + """Types of streaming events.""" + + TOKEN = "token" + CHUNK = "chunk" + RESULT = "result" + ERROR = "error" + + +@dataclass(frozen=True) +class StreamEvent: + """A single event from a streaming agent response. + + Attributes: + agent_id: The agent producing the stream. + event_type: The kind of streaming event. + content: Incremental text content. + accumulated: Full text accumulated so far. + token_index: Number of tokens/chunks received so far. + is_final: Whether this is the last event in the stream. + usage: Token usage stats (populated on final event). + timestamp: Monotonic timestamp. + """ + + agent_id: str + event_type: StreamEventType + content: str + accumulated: str = "" + token_index: int = 0 + is_final: bool = False + usage: Dict[str, int] = field(default_factory=dict) + timestamp: float = field(default_factory=time.monotonic) diff --git a/tests/unit/test_llm_agent_streaming.py b/tests/unit/test_llm_agent_streaming.py new file mode 100644 index 0000000..fa516fe --- /dev/null +++ b/tests/unit/test_llm_agent_streaming.py @@ -0,0 +1,157 @@ +"""Tests for LLMAgent.process_task_streaming().""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from felix_agent_sdk.agents.llm_agent import LLMAgent, LLMTask +from felix_agent_sdk.core.helix import HelixConfig, HelixGeometry +from felix_agent_sdk.events import EventBus, EventType +from felix_agent_sdk.providers.base import BaseProvider +from felix_agent_sdk.providers.types import CompletionResult, StreamChunk +from felix_agent_sdk.streaming import CallbackStreamHandler, StreamHandler + + +def _make_helix() -> HelixGeometry: + cfg = HelixConfig.default() + return HelixGeometry(cfg.top_radius, cfg.bottom_radius, cfg.height, cfg.turns) + + +def _make_streaming_provider(text: str = "Hello streaming world!") -> BaseProvider: + """Mock provider whose stream() yields word-by-word chunks.""" + provider = MagicMock(spec=BaseProvider) + + words = text.split(" ") + chunks = [] + for i, word in enumerate(words): + is_last = i == len(words) - 1 + suffix = "" if is_last else " " + chunks.append(StreamChunk( + text=word + suffix, + is_final=is_last, + usage={"prompt_tokens": 10, "completion_tokens": len(words), "total_tokens": 10 + len(words)} if is_last else {}, + )) + + provider.stream.return_value = iter(chunks) + # Also set up complete() for comparison + provider.complete.return_value = CompletionResult( + content=text, + model="mock", + usage={"prompt_tokens": 10, "completion_tokens": len(words), "total_tokens": 10 + len(words)}, + ) + provider.count_tokens.return_value = 10 + return provider + + +class TestProcessTaskStreaming: + def test_produces_same_content_as_process_task(self): + text = "Research finds renewable energy growing rapidly." + helix = _make_helix() + + # Streaming path + provider_s = _make_streaming_provider(text) + agent_s = LLMAgent("s-001", provider_s, helix, agent_type="research") + agent_s.spawn(0.1) + agent_s.update_position(0.5) + task = LLMTask(task_id="t1", description="Test task") + result_s = agent_s.process_task_streaming(task, StreamHandler()) + + # Non-streaming path + provider_c = _make_streaming_provider(text) + agent_c = LLMAgent("c-001", provider_c, helix, agent_type="research") + agent_c.spawn(0.1) + agent_c.update_position(0.5) + result_c = agent_c.process_task(LLMTask(task_id="t1", description="Test task")) + + assert result_s.content == result_c.content + assert result_s.content == text + + def test_handler_receives_tokens(self): + tokens = [] + handler = CallbackStreamHandler(on_token=lambda e: tokens.append(e.content)) + + provider = _make_streaming_provider("one two three") + helix = _make_helix() + agent = LLMAgent("a-001", provider, helix, agent_type="research") + agent.spawn(0.1) + agent.update_position(0.5) + + result = agent.process_task_streaming( + LLMTask(task_id="t1", description="Test"), handler + ) + + assert tokens == ["one ", "two "] # "three" is final → dispatched as result + assert result.content == "one two three" + + def test_handler_receives_final_result(self): + results = [] + handler = CallbackStreamHandler(on_result=lambda e: results.append(e)) + + provider = _make_streaming_provider("hello world") + helix = _make_helix() + agent = LLMAgent("a-001", provider, helix, agent_type="analysis") + agent.spawn(0.1) + agent.update_position(0.5) + + agent.process_task_streaming( + LLMTask(task_id="t1", description="Test"), handler + ) + + assert len(results) == 1 + assert results[0].is_final is True + assert results[0].accumulated == "hello world" + + def test_updates_agent_state(self): + provider = _make_streaming_provider("test content") + helix = _make_helix() + agent = LLMAgent("a-001", provider, helix, agent_type="research") + agent.spawn(0.1) + agent.update_position(0.5) + + assert agent.total_tokens_used == 0 + assert agent.total_processing_time == 0.0 + assert len(agent.processing_results) == 0 + + agent.process_task_streaming( + LLMTask(task_id="t1", description="Test"), StreamHandler() + ) + + assert agent.total_tokens_used > 0 + assert agent.total_processing_time > 0.0 + assert len(agent.processing_results) == 1 + + def test_emits_events(self): + bus = EventBus() + bus.enable_history() + + provider = _make_streaming_provider("data") + helix = _make_helix() + agent = LLMAgent("a-001", provider, helix, agent_type="research", event_bus=bus) + agent.spawn(0.1) + agent.update_position(0.5) + + agent.process_task_streaming( + LLMTask(task_id="t1", description="Test"), StreamHandler() + ) + + types = [e.event_type for e in bus.history] + assert "task.started" in types + assert "task.completed" in types + + # Verify streaming flag in event data + started = [e for e in bus.history if e.event_type == "task.started"][0] + assert started.data["streaming"] is True + + def test_confidence_recorded(self): + provider = _make_streaming_provider("a solid analysis with clear evidence") + helix = _make_helix() + agent = LLMAgent("a-001", provider, helix, agent_type="analysis") + agent.spawn(0.1) + agent.update_position(0.5) + + result = agent.process_task_streaming( + LLMTask(task_id="t1", description="Test"), StreamHandler() + ) + + assert 0.0 < result.confidence <= 1.0 + assert len(agent._confidence_history) == 1 diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py new file mode 100644 index 0000000..0224532 --- /dev/null +++ b/tests/unit/test_streaming.py @@ -0,0 +1,247 @@ +"""Tests for the streaming module — types, handlers, and accumulator.""" + +from __future__ import annotations + +import pytest + +from felix_agent_sdk.events import EventBus, EventType +from felix_agent_sdk.providers.types import StreamChunk +from felix_agent_sdk.streaming import ( + CallbackStreamHandler, + EventBusStreamHandler, + StreamAccumulator, + StreamEvent, + StreamEventType, + StreamHandler, +) + + +# --------------------------------------------------------------------------- +# StreamEvent +# --------------------------------------------------------------------------- + + +class TestStreamEvent: + def test_construction(self): + evt = StreamEvent( + agent_id="research-001", + event_type=StreamEventType.TOKEN, + content="hello", + ) + assert evt.agent_id == "research-001" + assert evt.event_type == StreamEventType.TOKEN + assert evt.content == "hello" + assert evt.accumulated == "" + assert evt.token_index == 0 + assert evt.is_final is False + assert isinstance(evt.timestamp, float) + + def test_frozen(self): + evt = StreamEvent(agent_id="a", event_type=StreamEventType.TOKEN, content="x") + with pytest.raises(AttributeError): + evt.content = "y" # type: ignore[misc] + + +class TestStreamEventType: + def test_values(self): + assert StreamEventType.TOKEN.value == "token" + assert StreamEventType.CHUNK.value == "chunk" + assert StreamEventType.RESULT.value == "result" + assert StreamEventType.ERROR.value == "error" + + +# --------------------------------------------------------------------------- +# StreamHandler +# --------------------------------------------------------------------------- + + +class TestStreamHandler: + def test_base_is_noop(self): + handler = StreamHandler() + evt = StreamEvent(agent_id="a", event_type=StreamEventType.TOKEN, content="x") + # Should not raise + handler.on_token(evt) + handler.on_result(evt) + handler.on_error(evt) + + def test_dispatch_routes_token(self): + received = [] + + class MyHandler(StreamHandler): + def on_token(self, event: StreamEvent) -> None: + received.append(("token", event.content)) + + handler = MyHandler() + handler.dispatch(StreamEvent( + agent_id="a", event_type=StreamEventType.TOKEN, content="hello" + )) + assert received == [("token", "hello")] + + def test_dispatch_routes_chunk_to_on_token(self): + received = [] + + class MyHandler(StreamHandler): + def on_token(self, event: StreamEvent) -> None: + received.append(event.content) + + handler = MyHandler() + handler.dispatch(StreamEvent( + agent_id="a", event_type=StreamEventType.CHUNK, content="batch" + )) + assert received == ["batch"] + + def test_dispatch_routes_result(self): + received = [] + + class MyHandler(StreamHandler): + def on_result(self, event: StreamEvent) -> None: + received.append(event.accumulated) + + handler = MyHandler() + handler.dispatch(StreamEvent( + agent_id="a", event_type=StreamEventType.RESULT, + content="last", accumulated="full text", is_final=True, + )) + assert received == ["full text"] + + def test_dispatch_routes_error(self): + received = [] + + class MyHandler(StreamHandler): + def on_error(self, event: StreamEvent) -> None: + received.append(event.content) + + handler = MyHandler() + handler.dispatch(StreamEvent( + agent_id="a", event_type=StreamEventType.ERROR, content="boom" + )) + assert received == ["boom"] + + +# --------------------------------------------------------------------------- +# CallbackStreamHandler +# --------------------------------------------------------------------------- + + +class TestCallbackStreamHandler: + def test_token_callback(self): + tokens = [] + handler = CallbackStreamHandler(on_token=lambda e: tokens.append(e.content)) + handler.on_token(StreamEvent( + agent_id="a", event_type=StreamEventType.TOKEN, content="hi" + )) + assert tokens == ["hi"] + + def test_result_callback(self): + results = [] + handler = CallbackStreamHandler(on_result=lambda e: results.append(e.accumulated)) + handler.on_result(StreamEvent( + agent_id="a", event_type=StreamEventType.RESULT, + content="", accumulated="done", is_final=True, + )) + assert results == ["done"] + + def test_none_callbacks_are_noop(self): + handler = CallbackStreamHandler() + # Should not raise + handler.on_token(StreamEvent(agent_id="a", event_type=StreamEventType.TOKEN, content="x")) + handler.on_result(StreamEvent(agent_id="a", event_type=StreamEventType.RESULT, content="x")) + handler.on_error(StreamEvent(agent_id="a", event_type=StreamEventType.ERROR, content="x")) + + +# --------------------------------------------------------------------------- +# EventBusStreamHandler +# --------------------------------------------------------------------------- + + +class TestEventBusStreamHandler: + def test_token_emits_to_bus(self): + bus = EventBus() + bus.enable_history() + handler = EventBusStreamHandler(bus) + + handler.on_token(StreamEvent( + agent_id="research-001", event_type=StreamEventType.TOKEN, + content="hello", token_index=1, + )) + + assert len(bus.history) == 1 + assert bus.history[0].event_type == "stream.token" + assert bus.history[0].data["content"] == "hello" + + def test_result_emits_to_bus(self): + bus = EventBus() + bus.enable_history() + handler = EventBusStreamHandler(bus) + + handler.on_result(StreamEvent( + agent_id="research-001", event_type=StreamEventType.RESULT, + content="last", accumulated="hello world", token_index=5, + is_final=True, usage={"total_tokens": 50}, + )) + + assert len(bus.history) == 1 + assert bus.history[0].event_type == "stream.completed" + assert bus.history[0].data["length"] == 11 + assert bus.history[0].data["token_count"] == 5 + + +# --------------------------------------------------------------------------- +# StreamAccumulator +# --------------------------------------------------------------------------- + + +class TestStreamAccumulator: + def test_accumulates_text(self): + received = [] + handler = CallbackStreamHandler(on_token=lambda e: received.append(e.content)) + + acc = StreamAccumulator("agent-001", handler, model="test-model") + acc.feed(StreamChunk(text="Hello ")) + acc.feed(StreamChunk(text="world")) + acc.feed(StreamChunk(text="!", is_final=True, usage={"total_tokens": 10})) + + assert acc.accumulated_text == "Hello world!" + assert acc.token_count == 3 + # 2 token events + 1 result event dispatched through handler + assert received == ["Hello ", "world"] + + def test_to_completion_result(self): + handler = StreamHandler() # no-op + acc = StreamAccumulator("agent-001", handler, model="gpt-4o") + + acc.feed(StreamChunk(text="abc")) + acc.feed(StreamChunk(text="def", is_final=True, usage={"total_tokens": 5})) + + result = acc.to_completion_result() + assert result.content == "abcdef" + assert result.model == "gpt-4o" + assert result.usage == {"total_tokens": 5} + + def test_feed_all(self): + handler = StreamHandler() + acc = StreamAccumulator("agent-001", handler) + + chunks = [ + StreamChunk(text="a"), + StreamChunk(text="b"), + StreamChunk(text="c", is_final=True, usage={"total_tokens": 3}), + ] + acc.feed_all(iter(chunks)) + + assert acc.accumulated_text == "abc" + assert acc.token_count == 3 + + def test_result_event_dispatched_on_final(self): + results = [] + handler = CallbackStreamHandler(on_result=lambda e: results.append(e)) + + acc = StreamAccumulator("agent-001", handler) + acc.feed(StreamChunk(text="hello")) + assert len(results) == 0 + + acc.feed(StreamChunk(text="!", is_final=True, usage={"total_tokens": 2})) + assert len(results) == 1 + assert results[0].is_final is True + assert results[0].accumulated == "hello!" + assert results[0].usage == {"total_tokens": 2} From d4ef678382f11230d9a430d9815164924694c0d1 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 01:05:24 -0400 Subject: [PATCH 06/14] feat(spawning): add dynamic confidence-driven agent spawning - ConfidenceMonitor: tracks per-agent/team confidence trends, detects stagnation, recommends SPAWN/HOLD/PRUNE - ContentAnalyzer: keyword-based topic coverage gap analysis, recommends agent type to fill gaps - TeamSizeOptimizer: heuristic team size from task complexity signals - DynamicSpawner: orchestrates monitor + analyzer, creates agents via factory, connects via spoke manager, emits spawn events - WorkflowConfig: enable_dynamic_spawning and max_dynamic_agents fields - FelixWorkflow: _check_dynamic_spawning replaced with inline spawner integration, new agents join subsequent rounds 35 new tests. 772 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/__init__.py | 4 + src/felix_agent_sdk/spawning/__init__.py | 23 ++- .../spawning/confidence_monitor.py | 153 ++++++++++++++++ .../spawning/content_analyzer.py | 127 ++++++++++++++ src/felix_agent_sdk/spawning/optimizer.py | 75 ++++++++ src/felix_agent_sdk/spawning/spawner.py | 164 ++++++++++++++++++ src/felix_agent_sdk/workflows/config.py | 2 + src/felix_agent_sdk/workflows/runner.py | 33 ++-- tests/unit/test_confidence_monitor.py | 94 ++++++++++ tests/unit/test_content_analyzer.py | 86 +++++++++ tests/unit/test_dynamic_spawner.py | 151 ++++++++++++++++ tests/unit/test_team_size_optimizer.py | 56 ++++++ 12 files changed, 946 insertions(+), 22 deletions(-) create mode 100644 src/felix_agent_sdk/spawning/confidence_monitor.py create mode 100644 src/felix_agent_sdk/spawning/content_analyzer.py create mode 100644 src/felix_agent_sdk/spawning/optimizer.py create mode 100644 src/felix_agent_sdk/spawning/spawner.py create mode 100644 tests/unit/test_confidence_monitor.py create mode 100644 tests/unit/test_content_analyzer.py create mode 100644 tests/unit/test_dynamic_spawner.py create mode 100644 tests/unit/test_team_size_optimizer.py diff --git a/src/felix_agent_sdk/__init__.py b/src/felix_agent_sdk/__init__.py index f5c87f4..85be338 100644 --- a/src/felix_agent_sdk/__init__.py +++ b/src/felix_agent_sdk/__init__.py @@ -33,6 +33,7 @@ from felix_agent_sdk.communication import CentralPost, Message, MessageType, Spoke from felix_agent_sdk.events import EventBus, EventType, FelixEvent from felix_agent_sdk.memory import ContextCompressor, KnowledgeStore, TaskMemory +from felix_agent_sdk.spawning import ConfidenceMonitor, DynamicSpawner from felix_agent_sdk.streaming import StreamEvent, StreamHandler from felix_agent_sdk.tokens import TokenBudget from felix_agent_sdk.utils import configure_logging @@ -73,6 +74,9 @@ "EventBus", "EventType", "FelixEvent", + # Spawning + "DynamicSpawner", + "ConfidenceMonitor", # Streaming "StreamEvent", "StreamHandler", diff --git a/src/felix_agent_sdk/spawning/__init__.py b/src/felix_agent_sdk/spawning/__init__.py index fd6b1ad..b6b5307 100644 --- a/src/felix_agent_sdk/spawning/__init__.py +++ b/src/felix_agent_sdk/spawning/__init__.py @@ -1,10 +1,23 @@ """Felix dynamic team adaptation. Confidence-driven agent spawning that adapts team composition in real time. -Planned exports: DynamicSpawning, ConfidenceMonitor, ContentAnalyzer, -TeamSizeOptimizer. - -Status: Stub — implementation in feat/agents branch. """ -__all__: list[str] = [] +from felix_agent_sdk.spawning.confidence_monitor import ( + ConfidenceMonitor, + ConfidenceStatus, + SpawnRecommendation, +) +from felix_agent_sdk.spawning.content_analyzer import ContentAnalyzer, CoverageReport +from felix_agent_sdk.spawning.optimizer import TeamSizeOptimizer +from felix_agent_sdk.spawning.spawner import DynamicSpawner + +__all__ = [ + "ConfidenceMonitor", + "ConfidenceStatus", + "SpawnRecommendation", + "ContentAnalyzer", + "CoverageReport", + "TeamSizeOptimizer", + "DynamicSpawner", +] diff --git a/src/felix_agent_sdk/spawning/confidence_monitor.py b/src/felix_agent_sdk/spawning/confidence_monitor.py new file mode 100644 index 0000000..0c57e0d --- /dev/null +++ b/src/felix_agent_sdk/spawning/confidence_monitor.py @@ -0,0 +1,153 @@ +"""Confidence monitoring for dynamic spawning decisions. + +Tracks per-agent and team-wide confidence trends, detects stagnation, +and recommends whether the system should spawn, hold, or prune agents. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List + + +class SpawnRecommendation(str, Enum): + """What the monitor recommends the spawner do.""" + + SPAWN = "spawn" + HOLD = "hold" + PRUNE = "prune" + + +@dataclass(frozen=True) +class ConfidenceStatus: + """Snapshot of the team's confidence health.""" + + team_average: float + trend: str # "rising", "falling", "stable" + gap_to_threshold: float + is_stagnating: bool + recommendation: SpawnRecommendation + + +# If the team average is more than this below the threshold, spawn +# immediately regardless of trend. +_CRITICAL_GAP = 0.2 + +# Decimal places used when rounding confidence values in status reports. +_REPORT_PRECISION = 4 + + +class ConfidenceMonitor: + """Track team confidence and decide whether spawning is warranted. + + The monitor maintains a sliding window of per-agent confidence + values and computes team-level statistics each time :meth:`update` + is called. + + Args: + threshold: Team average confidence below which spawning is + recommended. + stagnation_window: Number of consecutive updates with + less than *stagnation_delta* improvement before the + team is considered stagnating. + stagnation_delta: Minimum improvement per update to avoid + stagnation. + """ + + def __init__( + self, + threshold: float = 0.80, + stagnation_window: int = 3, + stagnation_delta: float = 0.01, + ) -> None: + self._threshold = threshold + self._stagnation_window = stagnation_window + self._stagnation_delta = stagnation_delta + + self._agent_history: Dict[str, List[float]] = defaultdict(list) + self._team_averages: List[float] = [] + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def update(self, agent_id: str, confidence: float) -> None: + """Record a new confidence observation for *agent_id*.""" + self._agent_history[agent_id].append(confidence) + + def record_round(self, confidences: Dict[str, float]) -> None: + """Record a full round of confidence observations. + + Also updates the team average history for trend detection. + """ + for agent_id, confidence in confidences.items(): + self.update(agent_id, confidence) + + if confidences: + avg = sum(confidences.values()) / len(confidences) + self._team_averages.append(avg) + + def should_spawn(self) -> bool: + """Return ``True`` if the team would benefit from more agents.""" + return self.get_status().recommendation == SpawnRecommendation.SPAWN + + def get_status(self) -> ConfidenceStatus: + """Compute current confidence status and spawn recommendation.""" + avg = self._current_average() + trend = self._compute_trend() + gap = self._threshold - avg + stagnating = self._is_stagnating() + rec = self._decide(avg, trend, gap, stagnating) + + return ConfidenceStatus( + team_average=round(avg, _REPORT_PRECISION), + trend=trend, + gap_to_threshold=round(max(gap, 0.0), _REPORT_PRECISION), + is_stagnating=stagnating, + recommendation=rec, + ) + + def _decide( + self, avg: float, trend: str, gap: float, stagnating: bool + ) -> SpawnRecommendation: + """Pure decision function: given metrics, return a recommendation.""" + if avg >= self._threshold: + return SpawnRecommendation.HOLD + if stagnating or trend == "falling" or gap > _CRITICAL_GAP: + return SpawnRecommendation.SPAWN + return SpawnRecommendation.HOLD + + def reset(self) -> None: + """Clear all tracked state.""" + self._agent_history.clear() + self._team_averages.clear() + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _current_average(self) -> float: + if not self._team_averages: + return 0.0 + return self._team_averages[-1] + + def _compute_trend(self) -> str: + avgs = self._team_averages + if len(avgs) < 2: + return "stable" + delta = avgs[-1] - avgs[-2] + if delta > self._stagnation_delta: + return "rising" + elif delta < -self._stagnation_delta: + return "falling" + return "stable" + + def _is_stagnating(self) -> bool: + avgs = self._team_averages + if len(avgs) < self._stagnation_window: + return False + window = avgs[-self._stagnation_window:] + total_change = abs(window[-1] - window[0]) + return total_change < self._stagnation_delta diff --git a/src/felix_agent_sdk/spawning/content_analyzer.py b/src/felix_agent_sdk/spawning/content_analyzer.py new file mode 100644 index 0000000..067c725 --- /dev/null +++ b/src/felix_agent_sdk/spawning/content_analyzer.py @@ -0,0 +1,127 @@ +"""Content gap analysis for dynamic spawning decisions. + +Examines agent outputs to identify topic coverage gaps and recommend +which agent type would best address them. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Set + +# Words ignored during keyword extraction +_STOPWORDS: Set[str] = { + "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", + "have", "has", "had", "do", "does", "did", "will", "would", "could", + "should", "may", "might", "shall", "can", "need", "must", "to", "of", + "in", "for", "on", "with", "at", "by", "from", "as", "into", "about", + "this", "that", "these", "those", "it", "its", "not", "no", "and", + "or", "but", "if", "so", "than", "too", "very", "just", "also", +} + +# Minimum keyword length after stopword removal +_MIN_KEYWORD_LEN = 3 + +# Agent type recommendation thresholds +_LOW_COVERAGE = 0.3 +_MID_COVERAGE = 0.6 + + +@dataclass(frozen=True) +class CoverageReport: + """Result of content gap analysis. + + Attributes: + topics_covered: Keywords extracted from agent outputs. + topics_sparse: Keywords that appear in fewer than 2 outputs. + coverage_score: Ratio of well-covered topics to total topics. + recommended_type: The agent type best suited to fill gaps. + """ + + topics_covered: Set[str] = field(default_factory=set) + topics_sparse: Set[str] = field(default_factory=set) + coverage_score: float = 0.0 + recommended_type: str = "research" + + +class ContentAnalyzer: + """Analyse agent outputs to identify coverage gaps. + + Uses keyword extraction and topic overlap to determine whether + the team's outputs collectively cover the problem space or if + specific agent types are needed to fill gaps. + """ + + def analyze_coverage(self, results: List[Dict[str, Any]]) -> CoverageReport: + """Analyse a list of agent result dicts for topic coverage. + + Each dict should have at least ``content`` (str) and + ``agent_type`` (str) keys — matching the shape of + ``LLMResult`` position_info dicts or simple test dicts. + + Returns: + :class:`CoverageReport` with coverage metrics. + """ + if not results: + return CoverageReport() + + all_keywords: Set[str] = set() + per_result_keywords: List[Set[str]] = [] + + for r in results: + content = r.get("content", "") + kw = self._extract_keywords(content) + all_keywords.update(kw) + per_result_keywords.append(kw) + + # A topic is "sparse" if it appears in fewer than 2 results + topic_counts: Dict[str, int] = {} + for kw_set in per_result_keywords: + for kw in kw_set: + topic_counts[kw] = topic_counts.get(kw, 0) + 1 + + sparse = {kw for kw, count in topic_counts.items() if count < 2} + covered = all_keywords - sparse + + score = len(covered) / max(len(all_keywords), 1) + recommended = self._recommend_type(score, results) + + return CoverageReport( + topics_covered=covered, + topics_sparse=sparse, + coverage_score=round(score, 4), + recommended_type=recommended, + ) + + def recommend_agent_type(self, coverage: CoverageReport) -> str: + """Return the agent type string from a coverage report.""" + return coverage.recommended_type + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _extract_keywords(self, text: str) -> Set[str]: + """Extract meaningful keywords from text.""" + words = re.findall(r"[a-zA-Z]+", text.lower()) + return { + w for w in words + if w not in _STOPWORDS and len(w) >= _MIN_KEYWORD_LEN + } + + def _recommend_type( + self, score: float, results: List[Dict[str, Any]] + ) -> str: + """Decide which agent type to spawn based on coverage score.""" + if score < _LOW_COVERAGE: + return "research" + if score < _MID_COVERAGE: + # Check if we're light on analysis + types = [r.get("agent_type", "") for r in results] + analysis_count = sum(1 for t in types if t == "analysis") + if analysis_count < 2: + return "analysis" + return "research" + # High coverage — add a critic to validate + return "critic" diff --git a/src/felix_agent_sdk/spawning/optimizer.py b/src/felix_agent_sdk/spawning/optimizer.py new file mode 100644 index 0000000..d3d6c03 --- /dev/null +++ b/src/felix_agent_sdk/spawning/optimizer.py @@ -0,0 +1,75 @@ +"""Team size optimisation heuristic. + +Recommends optimal team size based on task complexity signals and +current result quality. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +# Base team size for a simple task +_BASE_TEAM_SIZE = 3 + +# Each complexity signal adds this many agents +_SIGNAL_INCREMENT = 1 + +# Hard cap to prevent runaway growth +_MAX_TEAM_SIZE = 15 + + +class TeamSizeOptimizer: + """Heuristic recommender for team size. + + Considers task description length, topic breadth (keyword count), + and current confidence spread to suggest an appropriate team size. + + Args: + min_size: Minimum team size to recommend. + max_size: Maximum team size to recommend. + """ + + def __init__(self, min_size: int = 3, max_size: int = _MAX_TEAM_SIZE) -> None: + self._min_size = min_size + self._max_size = max_size + + def recommend_team_size( + self, + task_description: str, + current_results: List[Dict[str, Any]] | None = None, + ) -> int: + """Return a recommended team size. + + Args: + task_description: The task text (length is a complexity signal). + current_results: Existing results (optional). Each dict should + have ``confidence`` (float) and ``content`` (str) keys. + + Returns: + Recommended team size clamped to [min_size, max_size]. + """ + size = _BASE_TEAM_SIZE + + # Signal 1: long task descriptions suggest complexity + if len(task_description) > 200: + size += _SIGNAL_INCREMENT + if len(task_description) > 500: + size += _SIGNAL_INCREMENT + + # Signal 2: low average confidence from existing results + if current_results: + confidences = [r.get("confidence", 0.5) for r in current_results] + avg_conf = sum(confidences) / len(confidences) + if avg_conf < 0.5: + size += _SIGNAL_INCREMENT * 2 + elif avg_conf < 0.7: + size += _SIGNAL_INCREMENT + + # Signal 3: wide confidence spread suggests disagreement + if current_results and len(current_results) >= 2: + confidences = [r.get("confidence", 0.5) for r in current_results] + spread = max(confidences) - min(confidences) + if spread > 0.3: + size += _SIGNAL_INCREMENT + + return max(self._min_size, min(self._max_size, size)) diff --git a/src/felix_agent_sdk/spawning/spawner.py b/src/felix_agent_sdk/spawning/spawner.py new file mode 100644 index 0000000..39ea507 --- /dev/null +++ b/src/felix_agent_sdk/spawning/spawner.py @@ -0,0 +1,164 @@ +"""Dynamic spawner — orchestrates confidence-driven agent creation. + +Combines :class:`ConfidenceMonitor` and :class:`ContentAnalyzer` to +decide whether and what type of agent to spawn each round. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from felix_agent_sdk.agents.factory import AgentFactory +from felix_agent_sdk.agents.llm_agent import LLMAgent, LLMResult +from felix_agent_sdk.communication.spoke import SpokeManager +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.mixins import EventEmitterMixin +from felix_agent_sdk.events.types import EventType +from felix_agent_sdk.spawning.confidence_monitor import ConfidenceMonitor +from felix_agent_sdk.spawning.content_analyzer import ContentAnalyzer + +logger = logging.getLogger(__name__) + + +class DynamicSpawner(EventEmitterMixin): + """Confidence-driven agent spawner. + + Called once per round by the workflow runner. Consults the + :class:`ConfidenceMonitor` and :class:`ContentAnalyzer` to decide + whether to create new agents. Newly spawned agents are connected + to the hub via the spoke manager and returned to the caller so + they participate in subsequent rounds. + + Args: + factory: Agent factory for creating new agents. + spoke_mgr: Spoke manager for hub connectivity. + monitor: Confidence monitor instance. + analyzer: Content analyzer instance. + max_spawned: Maximum number of agents to spawn across the + entire workflow. + event_bus: Optional event bus for observability. + """ + + def __init__( + self, + factory: AgentFactory, + spoke_mgr: SpokeManager, + monitor: Optional[ConfidenceMonitor] = None, + analyzer: Optional[ContentAnalyzer] = None, + max_spawned: int = 3, + event_bus: Optional[EventBus] = None, + ) -> None: + self._factory = factory + self._spoke_mgr = spoke_mgr + self._monitor = monitor or ConfidenceMonitor() + self._analyzer = analyzer or ContentAnalyzer() + self._max_spawned = max_spawned + self._total_spawned = 0 + if event_bus is not None: + self.set_event_bus(event_bus) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def check_and_spawn( + self, + agents: List[LLMAgent], + round_results: List[LLMResult], + current_time: float, + ) -> List[LLMAgent]: + """Evaluate whether to spawn and return any new agents. + + Args: + agents: Current team of agents. + round_results: Results from the most recent round. + current_time: Current helix time parameter. + + Returns: + List of newly spawned agents (empty if none spawned). + """ + if self._total_spawned >= self._max_spawned: + return [] + + if not round_results: + return [] + + # Feed confidence data to the monitor + confidences: Dict[str, float] = { + r.agent_id: r.confidence for r in round_results + } + self._monitor.record_round(confidences) + + # Check if spawning is recommended + if not self._monitor.should_spawn(): + return [] + + # Determine what type of agent to spawn + result_dicts = self._results_to_dicts(round_results) + coverage = self._analyzer.analyze_coverage(result_dicts) + agent_type = coverage.recommended_type + + # Spawn + status = self._monitor.get_status() + self.emit_event( + EventType.SPAWN_TRIGGERED, + { + "agent_type": agent_type, + "reason": status.recommendation.value, + "team_average": status.team_average, + "gap": status.gap_to_threshold, + "trend": status.trend, + }, + ) + + spawn_time = min(current_time, 0.9) + agent = self._factory.create_agent( + agent_type=agent_type, + spawn_time=spawn_time, + ) + if getattr(self, "_event_bus", None) is not None: + agent.set_event_bus(self._event_bus) # type: ignore[arg-type] + self._spoke_mgr.create_spoke(agent.agent_id, agent=agent) + self._total_spawned += 1 + + logger.info( + "Dynamic spawn: %s agent %s (total spawned: %d/%d)", + agent_type, + agent.agent_id, + self._total_spawned, + self._max_spawned, + ) + + self.emit_event( + EventType.SPAWN_COMPLETED, + { + "agent_id": agent.agent_id, + "agent_type": agent_type, + "spawn_time": spawn_time, + "total_spawned": self._total_spawned, + }, + ) + + return [agent] + + @property + def total_spawned(self) -> int: + """Number of agents spawned so far.""" + return self._total_spawned + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + @staticmethod + def _results_to_dicts(results: List[LLMResult]) -> List[Dict[str, Any]]: + """Convert LLMResult list to simple dicts for the analyzer.""" + return [ + { + "content": r.content, + "agent_type": r.position_info.get("agent_type", ""), + "confidence": r.confidence, + } + for r in results + ] diff --git a/src/felix_agent_sdk/workflows/config.py b/src/felix_agent_sdk/workflows/config.py index e4b0714..29a3b0a 100644 --- a/src/felix_agent_sdk/workflows/config.py +++ b/src/felix_agent_sdk/workflows/config.py @@ -63,6 +63,8 @@ class WorkflowConfig: max_agents: int = 10 enable_context_compression: bool = True compression_config: Optional[CompressionConfig] = None + enable_dynamic_spawning: bool = False + max_dynamic_agents: int = 3 # ------------------------------------------------------------------ diff --git a/src/felix_agent_sdk/workflows/runner.py b/src/felix_agent_sdk/workflows/runner.py index 42a5e5a..18e5412 100644 --- a/src/felix_agent_sdk/workflows/runner.py +++ b/src/felix_agent_sdk/workflows/runner.py @@ -25,6 +25,7 @@ from felix_agent_sdk.events.mixins import EventEmitterMixin from felix_agent_sdk.events.types import EventType from felix_agent_sdk.providers.base import BaseProvider +from felix_agent_sdk.spawning.spawner import DynamicSpawner from felix_agent_sdk.workflows.config import WorkflowConfig, WorkflowResult from felix_agent_sdk.workflows.context_builder import CollaborativeContextBuilder from felix_agent_sdk.workflows.synthesizer import WorkflowSynthesizer @@ -84,6 +85,15 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: builder = CollaborativeContextBuilder() all_results: list[LLMResult] = [] + spawner: Optional[DynamicSpawner] = None + if self._config.enable_dynamic_spawning: + spawner = DynamicSpawner( + factory=self._factory, + spoke_mgr=spoke_mgr, + max_spawned=self._config.max_dynamic_agents, + event_bus=getattr(self, "_event_bus", None), + ) + self.emit_event( EventType.WORKFLOW_STARTED, { @@ -132,8 +142,12 @@ def run(self, task_description: str, context: str = "") -> WorkflowResult: }, ) - # Dynamic spawning hook (no-op for now) - self._check_dynamic_spawning(agents, round_results) + # Dynamic spawning + if spawner is not None: + new_agents = spawner.check_and_spawn( + agents, round_results, current_time + ) + agents.extend(new_agents) # Convergence check if round_results and avg_confidence >= self._config.confidence_threshold: @@ -281,21 +295,6 @@ def _run_round( return round_results - # ------------------------------------------------------------------ - # Dynamic spawning hook - # ------------------------------------------------------------------ - - def _check_dynamic_spawning( - self, - agents: list[LLMAgent], - round_results: list[LLMResult], - ) -> None: - """Hook for confidence-driven dynamic spawning. - - Currently a no-op. Will be wired when the ``spawning/`` module - is implemented. - """ - # ------------------------------------------------------------------ # Convenience function # ------------------------------------------------------------------ diff --git a/tests/unit/test_confidence_monitor.py b/tests/unit/test_confidence_monitor.py new file mode 100644 index 0000000..2165176 --- /dev/null +++ b/tests/unit/test_confidence_monitor.py @@ -0,0 +1,94 @@ +"""Tests for ConfidenceMonitor.""" + +from __future__ import annotations + +from felix_agent_sdk.spawning.confidence_monitor import ( + ConfidenceMonitor, + SpawnRecommendation, +) + + +class TestConfidenceMonitorBasics: + def test_no_data_returns_hold(self): + mon = ConfidenceMonitor(threshold=0.8) + status = mon.get_status() + assert status.team_average == 0.0 + assert status.recommendation == SpawnRecommendation.SPAWN # big gap + + def test_above_threshold_holds(self): + mon = ConfidenceMonitor(threshold=0.7) + mon.record_round({"a": 0.8, "b": 0.9}) + assert mon.get_status().recommendation == SpawnRecommendation.HOLD + + def test_below_threshold_falling_spawns(self): + mon = ConfidenceMonitor(threshold=0.8) + mon.record_round({"a": 0.7, "b": 0.6}) + mon.record_round({"a": 0.5, "b": 0.4}) + status = mon.get_status() + assert status.trend == "falling" + assert status.recommendation == SpawnRecommendation.SPAWN + + def test_should_spawn_reflects_status(self): + mon = ConfidenceMonitor(threshold=0.9) + mon.record_round({"a": 0.3}) + assert mon.should_spawn() is True + + +class TestConfidenceMonitorTrend: + def test_rising(self): + mon = ConfidenceMonitor(threshold=0.9, stagnation_delta=0.01) + mon.record_round({"a": 0.4}) + mon.record_round({"a": 0.6}) + assert mon.get_status().trend == "rising" + + def test_falling(self): + mon = ConfidenceMonitor(threshold=0.9, stagnation_delta=0.01) + mon.record_round({"a": 0.6}) + mon.record_round({"a": 0.4}) + assert mon.get_status().trend == "falling" + + def test_stable(self): + mon = ConfidenceMonitor(threshold=0.9, stagnation_delta=0.05) + mon.record_round({"a": 0.5}) + mon.record_round({"a": 0.51}) + assert mon.get_status().trend == "stable" + + +class TestConfidenceMonitorStagnation: + def test_stagnation_detected(self): + mon = ConfidenceMonitor(threshold=0.8, stagnation_window=3, stagnation_delta=0.02) + mon.record_round({"a": 0.50}) + mon.record_round({"a": 0.50}) + mon.record_round({"a": 0.51}) + status = mon.get_status() + assert status.is_stagnating is True + assert status.recommendation == SpawnRecommendation.SPAWN + + def test_no_stagnation_if_improving(self): + mon = ConfidenceMonitor(threshold=0.8, stagnation_window=3, stagnation_delta=0.02) + mon.record_round({"a": 0.50}) + mon.record_round({"a": 0.55}) + mon.record_round({"a": 0.60}) + assert mon.get_status().is_stagnating is False + + def test_not_enough_data_for_stagnation(self): + mon = ConfidenceMonitor(stagnation_window=5) + mon.record_round({"a": 0.5}) + mon.record_round({"a": 0.5}) + assert mon.get_status().is_stagnating is False + + +class TestConfidenceMonitorCriticalGap: + def test_large_gap_spawns_immediately(self): + mon = ConfidenceMonitor(threshold=0.8) + mon.record_round({"a": 0.3}) # gap = 0.5 > 0.2 + assert mon.get_status().recommendation == SpawnRecommendation.SPAWN + + +class TestConfidenceMonitorReset: + def test_reset_clears_state(self): + mon = ConfidenceMonitor(threshold=0.8) + mon.record_round({"a": 0.5}) + mon.reset() + status = mon.get_status() + assert status.team_average == 0.0 diff --git a/tests/unit/test_content_analyzer.py b/tests/unit/test_content_analyzer.py new file mode 100644 index 0000000..117d995 --- /dev/null +++ b/tests/unit/test_content_analyzer.py @@ -0,0 +1,86 @@ +"""Tests for ContentAnalyzer.""" + +from __future__ import annotations + +from felix_agent_sdk.spawning.content_analyzer import ContentAnalyzer + + +class TestContentAnalyzerBasics: + def test_empty_results(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([]) + assert report.coverage_score == 0.0 + assert report.recommended_type == "research" + + def test_single_result_all_sparse(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([ + {"content": "Renewable energy solar panels efficiency", "agent_type": "research"} + ]) + # All topics appear only once → all sparse → coverage 0 + assert report.coverage_score == 0.0 + assert len(report.topics_sparse) > 0 + assert report.recommended_type == "research" + + def test_overlapping_results_increase_coverage(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([ + {"content": "Solar energy production increases globally", "agent_type": "research"}, + {"content": "Solar energy adoption in developing markets", "agent_type": "research"}, + ]) + # "solar" and "energy" appear in both → covered + assert report.coverage_score > 0.0 + assert "solar" in report.topics_covered + assert "energy" in report.topics_covered + + +class TestContentAnalyzerRecommendation: + def test_low_coverage_recommends_research(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([ + {"content": "Quantum computing basics", "agent_type": "research"}, + ]) + assert report.recommended_type == "research" + + def test_high_coverage_recommends_critic(self): + analyzer = ContentAnalyzer() + # All words overlap → high coverage + report = analyzer.analyze_coverage([ + {"content": "analysis results data findings", "agent_type": "research"}, + {"content": "analysis results data findings", "agent_type": "analysis"}, + {"content": "analysis results data findings", "agent_type": "analysis"}, + ]) + assert report.coverage_score > 0.6 + assert report.recommended_type == "critic" + + def test_mid_coverage_light_analysis_recommends_analysis(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([ + {"content": "renewable energy solar capacity growth data", "agent_type": "research"}, + {"content": "renewable energy market trends growth", "agent_type": "research"}, + ]) + # Mid coverage, no analysis agents → should recommend analysis + if 0.3 <= report.coverage_score < 0.6: + assert report.recommended_type == "analysis" + + +class TestContentAnalyzerKeywordExtraction: + def test_stopwords_removed(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([ + {"content": "the quick brown fox is very fast", "agent_type": "research"}, + {"content": "the quick brown fox jumps over", "agent_type": "research"}, + ]) + assert "the" not in report.topics_covered + assert "is" not in report.topics_covered + assert "quick" in report.topics_covered + + def test_short_words_removed(self): + analyzer = ContentAnalyzer() + report = analyzer.analyze_coverage([ + {"content": "an AI ML model for NLP", "agent_type": "research"}, + {"content": "an AI ML model works", "agent_type": "research"}, + ]) + # "ai", "ml" are < 3 chars → removed + assert "ai" not in report.topics_covered + assert "model" in report.topics_covered diff --git a/tests/unit/test_dynamic_spawner.py b/tests/unit/test_dynamic_spawner.py new file mode 100644 index 0000000..f1d58dc --- /dev/null +++ b/tests/unit/test_dynamic_spawner.py @@ -0,0 +1,151 @@ +"""Tests for DynamicSpawner integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from felix_agent_sdk.agents.factory import AgentFactory +from felix_agent_sdk.agents.llm_agent import LLMResult +from felix_agent_sdk.communication.central_post import CentralPost +from felix_agent_sdk.communication.spoke import SpokeManager +from felix_agent_sdk.core.helix import HelixConfig +from felix_agent_sdk.events import EventBus, EventType +from felix_agent_sdk.providers.base import BaseProvider +from felix_agent_sdk.providers.types import CompletionResult +from felix_agent_sdk.spawning import ConfidenceMonitor, DynamicSpawner +from felix_agent_sdk.workflows.config import WorkflowConfig +from felix_agent_sdk import run_felix_workflow + + +def _mock_provider() -> BaseProvider: + provider = MagicMock(spec=BaseProvider) + provider.complete.return_value = CompletionResult( + content="Low confidence filler text.", + model="mock", + usage={"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, + ) + provider.count_tokens.return_value = 20 + return provider + + +def _make_low_confidence_result(agent_id: str = "agent-001") -> LLMResult: + return LLMResult( + agent_id=agent_id, + task_id="t1", + content="Uncertain findings with weak evidence.", + position_info={"phase": "exploration", "agent_type": "research"}, + completion_result=CompletionResult( + content="text", model="mock", + usage={"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, + ), + processing_time=0.1, + confidence=0.3, + temperature_used=0.8, + token_budget_used=20, + ) + + +class TestDynamicSpawner: + def _setup(self, max_spawned: int = 3, event_bus: EventBus | None = None): + provider = _mock_provider() + hub = CentralPost(max_agents=20) + spoke_mgr = SpokeManager(hub=hub) + factory = AgentFactory(provider, helix_config=HelixConfig.default()) + monitor = ConfidenceMonitor(threshold=0.8) + spawner = DynamicSpawner( + factory=factory, + spoke_mgr=spoke_mgr, + monitor=monitor, + max_spawned=max_spawned, + event_bus=event_bus, + ) + return spawner, spoke_mgr, hub + + def test_spawns_when_confidence_low(self): + spawner, _, hub = self._setup() + results = [_make_low_confidence_result("a1"), _make_low_confidence_result("a2")] + # Large gap to threshold triggers spawn on first round + new1 = spawner.check_and_spawn([], results, 0.3) + assert len(new1) == 1 + new2 = spawner.check_and_spawn([], results, 0.5) + assert len(new2) == 1 + assert spawner.total_spawned == 2 + + def test_no_spawn_when_empty_results(self): + spawner, _, _ = self._setup() + new = spawner.check_and_spawn([], [], 0.3) + assert len(new) == 0 + + def test_respects_max_spawned(self): + spawner, _, _ = self._setup(max_spawned=1) + results = [_make_low_confidence_result()] + # First round seeds the monitor + spawner.check_and_spawn([], results, 0.3) + # Second round triggers spawn + spawner.check_and_spawn([], results, 0.5) + assert spawner.total_spawned == 1 + # Third round — already at max + new = spawner.check_and_spawn([], results, 0.7) + assert len(new) == 0 + assert spawner.total_spawned == 1 + + def test_emits_events(self): + bus = EventBus() + bus.enable_history() + spawner, _, _ = self._setup(event_bus=bus) + + results = [_make_low_confidence_result()] + spawner.check_and_spawn([], results, 0.3) + spawner.check_and_spawn([], results, 0.5) + + types = [e.event_type for e in bus.history] + assert "spawn.triggered" in types + assert "spawn.completed" in types + + def test_spawn_completed_has_agent_id(self): + bus = EventBus() + bus.enable_history() + spawner, _, _ = self._setup(event_bus=bus) + + results = [_make_low_confidence_result()] + spawner.check_and_spawn([], results, 0.3) + + completed = [e for e in bus.history if e.event_type == "spawn.completed"] + assert len(completed) == 1 + assert "agent_id" in completed[0].data + assert "agent_type" in completed[0].data + + +class TestDynamicSpawningWorkflowIntegration: + """Test that enable_dynamic_spawning works end-to-end in a workflow.""" + + def test_workflow_with_dynamic_spawning(self): + bus = EventBus() + bus.enable_history() + + provider = _mock_provider() + config = WorkflowConfig( + max_rounds=3, + confidence_threshold=0.99, # unreachable → forces all rounds + enable_dynamic_spawning=True, + max_dynamic_agents=2, + ) + + result = run_felix_workflow(config, provider, "Test task", event_bus=bus) + + # Workflow should complete + assert result.synthesis is not None + assert result.total_rounds == 3 + + # Check if spawn events were emitted (may or may not trigger + # depending on confidence, but the machinery ran without error) + types = [e.event_type for e in bus.history] + assert "workflow.started" in types + assert "workflow.completed" in types + + def test_workflow_without_dynamic_spawning(self): + """Default config should not spawn — no errors.""" + provider = _mock_provider() + config = WorkflowConfig(max_rounds=1) + result = run_felix_workflow(config, provider, "Test task") + assert result.synthesis is not None diff --git a/tests/unit/test_team_size_optimizer.py b/tests/unit/test_team_size_optimizer.py new file mode 100644 index 0000000..f6d5eb2 --- /dev/null +++ b/tests/unit/test_team_size_optimizer.py @@ -0,0 +1,56 @@ +"""Tests for TeamSizeOptimizer.""" + +from __future__ import annotations + +from felix_agent_sdk.spawning.optimizer import TeamSizeOptimizer + + +class TestTeamSizeOptimizer: + def test_default_for_simple_task(self): + opt = TeamSizeOptimizer() + size = opt.recommend_team_size("Short task") + assert size == 3 + + def test_long_task_increases_size(self): + opt = TeamSizeOptimizer() + short = opt.recommend_team_size("Short") + long_task = "x" * 250 + medium = opt.recommend_team_size(long_task) + assert medium > short + + def test_very_long_task(self): + opt = TeamSizeOptimizer() + size = opt.recommend_team_size("x" * 600) + assert size >= 5 # base 3 + 2 length signals + + def test_low_confidence_increases_size(self): + opt = TeamSizeOptimizer() + results = [{"confidence": 0.3, "content": "weak"} for _ in range(3)] + size = opt.recommend_team_size("task", results) + assert size > 3 + + def test_high_confidence_keeps_base(self): + opt = TeamSizeOptimizer() + results = [{"confidence": 0.9, "content": "strong"} for _ in range(3)] + size = opt.recommend_team_size("Short", results) + assert size == 3 + + def test_wide_spread_increases_size(self): + opt = TeamSizeOptimizer() + results = [ + {"confidence": 0.3, "content": "a"}, + {"confidence": 0.9, "content": "b"}, + ] + size = opt.recommend_team_size("Short", results) + # Low avg (0.6 < 0.7) + spread (0.6 > 0.3) = +2 + assert size > 3 + + def test_respects_max_size(self): + opt = TeamSizeOptimizer(max_size=4) + size = opt.recommend_team_size("x" * 600, [{"confidence": 0.1, "content": "x"}]) + assert size <= 4 + + def test_respects_min_size(self): + opt = TeamSizeOptimizer(min_size=5) + size = opt.recommend_team_size("Short") + assert size >= 5 From 8605a031907062dffc1851076d3439b12151ef9f Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 01:08:16 -0400 Subject: [PATCH 07/14] refactor: extract shared STOPWORDS to utils/text.py Eliminates triple duplication of stopword sets across compression.py, task_memory.py, and content_analyzer.py. Single frozenset in utils/text.py, aliased locally. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/memory/compression.py | 75 +------------------ src/felix_agent_sdk/memory/task_memory.py | 45 +---------- .../spawning/content_analyzer.py | 12 +-- src/felix_agent_sdk/utils/text.py | 35 +++++++++ 4 files changed, 45 insertions(+), 122 deletions(-) create mode 100644 src/felix_agent_sdk/utils/text.py diff --git a/src/felix_agent_sdk/memory/compression.py b/src/felix_agent_sdk/memory/compression.py index 0542d3d..b9a1153 100644 --- a/src/felix_agent_sdk/memory/compression.py +++ b/src/felix_agent_sdk/memory/compression.py @@ -12,6 +12,8 @@ import json import re import time + +from felix_agent_sdk.utils.text import STOPWORDS from dataclasses import dataclass, field from enum import Enum from typing import Any, Optional @@ -91,77 +93,8 @@ class CompressionConfig: relevance_threshold: float = 0.3 -# ------------------------------------------------------------------ -# Stopwords (shared with keyword extraction) -# ------------------------------------------------------------------ - -_STOPWORDS: set[str] = { - "the", - "and", - "for", - "are", - "but", - "not", - "you", - "all", - "can", - "had", - "her", - "was", - "one", - "our", - "out", - "day", - "get", - "has", - "him", - "his", - "how", - "its", - "may", - "new", - "now", - "old", - "see", - "two", - "who", - "boy", - "did", - "man", - "she", - "use", - "way", - "where", - "much", - "your", - "from", - "they", - "know", - "want", - "been", - "good", - "much", - "some", - "time", - "very", - "when", - "come", - "here", - "just", - "like", - "long", - "make", - "many", - "over", - "such", - "take", - "than", - "them", - "well", - "were", - "will", - "with", -} +# Stopwords imported from shared utils +_STOPWORDS = STOPWORDS # ------------------------------------------------------------------ diff --git a/src/felix_agent_sdk/memory/task_memory.py b/src/felix_agent_sdk/memory/task_memory.py index 5064180..a32871b 100644 --- a/src/felix_agent_sdk/memory/task_memory.py +++ b/src/felix_agent_sdk/memory/task_memory.py @@ -17,6 +17,8 @@ from enum import Enum from typing import Any, Optional +from felix_agent_sdk.utils.text import STOPWORDS + from felix_agent_sdk.memory.backends.base import BaseBackend from felix_agent_sdk.memory.backends.sqlite import SQLiteBackend @@ -49,47 +51,8 @@ class TaskComplexity(Enum): # Helpers # ------------------------------------------------------------------ -_STOPWORDS: set[str] = { - "the", - "and", - "for", - "are", - "but", - "not", - "you", - "all", - "can", - "had", - "her", - "was", - "one", - "our", - "out", - "day", - "get", - "has", - "him", - "his", - "how", - "its", - "may", - "new", - "now", - "old", - "see", - "two", - "who", - "boy", - "did", - "man", - "she", - "use", - "way", - "oil", - "sit", - "set", - "run", -} +# Stopwords imported from shared utils +_STOPWORDS = STOPWORDS def _get_enum_value(value: Any) -> str: diff --git a/src/felix_agent_sdk/spawning/content_analyzer.py b/src/felix_agent_sdk/spawning/content_analyzer.py index 067c725..1c0154a 100644 --- a/src/felix_agent_sdk/spawning/content_analyzer.py +++ b/src/felix_agent_sdk/spawning/content_analyzer.py @@ -10,15 +10,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Set -# Words ignored during keyword extraction -_STOPWORDS: Set[str] = { - "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", - "have", "has", "had", "do", "does", "did", "will", "would", "could", - "should", "may", "might", "shall", "can", "need", "must", "to", "of", - "in", "for", "on", "with", "at", "by", "from", "as", "into", "about", - "this", "that", "these", "those", "it", "its", "not", "no", "and", - "or", "but", "if", "so", "than", "too", "very", "just", "also", -} +from felix_agent_sdk.utils.text import STOPWORDS # Minimum keyword length after stopword removal _MIN_KEYWORD_LEN = 3 @@ -107,7 +99,7 @@ def _extract_keywords(self, text: str) -> Set[str]: words = re.findall(r"[a-zA-Z]+", text.lower()) return { w for w in words - if w not in _STOPWORDS and len(w) >= _MIN_KEYWORD_LEN + if w not in STOPWORDS and len(w) >= _MIN_KEYWORD_LEN } def _recommend_type( diff --git a/src/felix_agent_sdk/utils/text.py b/src/felix_agent_sdk/utils/text.py new file mode 100644 index 0000000..63d7e14 --- /dev/null +++ b/src/felix_agent_sdk/utils/text.py @@ -0,0 +1,35 @@ +"""Shared text processing utilities.""" + +from __future__ import annotations + +# Common English stopwords used by keyword extraction across the SDK. +# Used by: memory/compression.py, memory/task_memory.py, spawning/content_analyzer.py +STOPWORDS: frozenset[str] = frozenset({ + # Articles / determiners + "the", "a", "an", "this", "that", "these", "those", + # Pronouns + "it", "its", "he", "him", "his", "she", "her", "you", "your", + "our", "we", "they", "them", + # Be-verbs + "is", "are", "was", "were", "be", "been", "being", + # Have-verbs + "have", "has", "had", + # Do-verbs + "do", "does", "did", + # Modals + "will", "would", "could", "should", "may", "might", "shall", "can", + "need", "must", + # Prepositions + "to", "of", "in", "for", "on", "with", "at", "by", "from", "as", + "into", "about", "over", + # Conjunctions + "and", "or", "but", "if", "so", "than", + # Adverbs / misc + "not", "no", "too", "very", "just", "also", "here", "now", "how", + "all", "one", "two", "new", "old", "much", "many", "some", "such", + "well", "long", "good", "time", "make", "take", "come", "like", + "when", "where", "know", "want", + # Short common words (from memory module lists) + "out", "day", "get", "see", "who", "boy", "man", "use", "way", + "oil", "sit", "set", "run", +}) From 27429234744f046e7b17c7924484782d48536325 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 01:29:48 -0400 Subject: [PATCH 08/14] feat(cli): add felix console command with init, run, version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - felix init [--template] — scaffolds project with felix.yaml, main.py, requirements.txt, .env.example. Templates: research, analysis, review. - felix run [--provider] [--verbose] — loads YAML config, resolves provider, runs workflow, prints result. - felix version — prints SDK version. - yaml_loader: parses helix presets or custom dicts, team composition, all WorkflowConfig scalars including dynamic spawning fields. - Console script entry point in pyproject.toml. 20 new tests including init/run round-trip. 792 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 3 + src/felix_agent_sdk/cli/__init__.py | 1 + src/felix_agent_sdk/cli/init_command.py | 129 +++++++++++++++ src/felix_agent_sdk/cli/main.py | 65 ++++++++ src/felix_agent_sdk/cli/run_command.py | 90 ++++++++++ src/felix_agent_sdk/cli/yaml_loader.py | 132 +++++++++++++++ tests/unit/test_cli.py | 208 ++++++++++++++++++++++++ 7 files changed, 628 insertions(+) create mode 100644 src/felix_agent_sdk/cli/__init__.py create mode 100644 src/felix_agent_sdk/cli/init_command.py create mode 100644 src/felix_agent_sdk/cli/main.py create mode 100644 src/felix_agent_sdk/cli/run_command.py create mode 100644 src/felix_agent_sdk/cli/yaml_loader.py create mode 100644 tests/unit/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index ee46ca0..1853342 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ dev = [ "mkdocs-material>=9.0", ] +[project.scripts] +felix = "felix_agent_sdk.cli.main:main" + [project.urls] Homepage = "https://github.com/AppSprout-dev/felix-agent-sdk" Documentation = "https://felix-sdk.appsprout.dev" diff --git a/src/felix_agent_sdk/cli/__init__.py b/src/felix_agent_sdk/cli/__init__.py new file mode 100644 index 0000000..6a0a81c --- /dev/null +++ b/src/felix_agent_sdk/cli/__init__.py @@ -0,0 +1 @@ +"""Felix CLI — command-line interface for the Felix Agent SDK.""" diff --git a/src/felix_agent_sdk/cli/init_command.py b/src/felix_agent_sdk/cli/init_command.py new file mode 100644 index 0000000..936c80a --- /dev/null +++ b/src/felix_agent_sdk/cli/init_command.py @@ -0,0 +1,129 @@ +"""``felix init`` — scaffold a new Felix project.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_TEMPLATES = { + "research": { + "team": [ + {"type": "research"}, + {"type": "research"}, + {"type": "analysis"}, + {"type": "critic"}, + ], + "helix": "research_heavy", + "max_rounds": 4, + "confidence_threshold": 0.75, + }, + "analysis": { + "team": [ + {"type": "research"}, + {"type": "analysis"}, + {"type": "analysis"}, + {"type": "critic"}, + ], + "helix": "fast_convergence", + "max_rounds": 3, + "confidence_threshold": 0.85, + }, + "review": { + "team": [ + {"type": "research"}, + {"type": "analysis"}, + {"type": "critic"}, + {"type": "critic"}, + ], + "helix": "default", + "max_rounds": 3, + "confidence_threshold": 0.80, + }, +} + + +def run_init(name: str, template: str) -> int: + """Scaffold a new Felix project directory. + + Creates: + name/ + felix.yaml + main.py + requirements.txt + .env.example + + Returns: + Exit code (0 = success, 1 = error). + """ + target = Path(name) + if target.exists(): + print(f"Error: directory '{name}' already exists.", file=sys.stderr) + return 1 + + if template not in _TEMPLATES: + valid = ", ".join(_TEMPLATES) + print(f"Error: unknown template '{template}'. Valid: {valid}", file=sys.stderr) + return 1 + + target.mkdir(parents=True) + + # felix.yaml + cfg = _TEMPLATES[template] + team_lines = "\n".join(f' - type: {e["type"]}' for e in cfg["team"]) + yaml_content = f"""\ +# Felix workflow configuration +# Run with: felix run felix.yaml + +provider: openai +model: gpt-4o-mini + +helix: {cfg["helix"]} +max_rounds: {cfg["max_rounds"]} +confidence_threshold: {cfg["confidence_threshold"]} + +team: +{team_lines} + +task: "Your research question or task description here" +""" + (target / "felix.yaml").write_text(yaml_content) + + # main.py + main_py = """\ +#!/usr/bin/env python3 +\"\"\"Run a Felix workflow programmatically.\"\"\" + +from felix_agent_sdk import run_felix_workflow, WorkflowConfig +from felix_agent_sdk.providers import auto_detect_provider + +provider = auto_detect_provider() +config = WorkflowConfig(max_rounds=3) + +result = run_felix_workflow(config, provider, "Your task here") +print(result.synthesis) +""" + (target / "main.py").write_text(main_py) + + # requirements.txt + (target / "requirements.txt").write_text("felix-agent-sdk[openai]\n") + + # .env.example + (target / ".env.example").write_text( + "# Set your LLM provider API key\n" + "# OPENAI_API_KEY=sk-...\n" + "# ANTHROPIC_API_KEY=sk-ant-...\n" + "FELIX_PROVIDER=openai\n" + "FELIX_MODEL=gpt-4o-mini\n" + ) + + print(f"Created Felix project: {target}/") + print(f" Template: {template}") + print(f" Config: {target}/felix.yaml") + print(f" Entry: {target}/main.py") + print() + print("Next steps:") + print(f" cd {name}") + print(" pip install -r requirements.txt") + print(" # Copy .env.example to .env and add your API key") + print(" felix run felix.yaml") + return 0 diff --git a/src/felix_agent_sdk/cli/main.py b/src/felix_agent_sdk/cli/main.py new file mode 100644 index 0000000..5b861b2 --- /dev/null +++ b/src/felix_agent_sdk/cli/main.py @@ -0,0 +1,65 @@ +"""Felix CLI entry point. + +Usage: + felix init [--template research|analysis|review] + felix run [--provider NAME] [--verbose] + felix version +""" + +from __future__ import annotations + +import argparse +import sys + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the ``felix`` console command.""" + parser = argparse.ArgumentParser( + prog="felix", + description="Felix Agent SDK — helical multi-agent orchestration", + ) + subparsers = parser.add_subparsers(dest="command") + + # --- init --- + init_p = subparsers.add_parser("init", help="Scaffold a new Felix project") + init_p.add_argument("name", help="Project directory name") + init_p.add_argument( + "--template", "-t", + default="research", + choices=["research", "analysis", "review"], + help="Workflow template (default: research)", + ) + + # --- run --- + run_p = subparsers.add_parser("run", help="Run a workflow from YAML config") + run_p.add_argument("config", help="Path to felix.yaml") + run_p.add_argument("--provider", "-p", default=None, help="Override provider name") + run_p.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging") + + # --- version --- + subparsers.add_parser("version", help="Show SDK version") + + args = parser.parse_args(argv) + + if args.command == "init": + from felix_agent_sdk.cli.init_command import run_init + + return run_init(args.name, args.template) + + if args.command == "run": + from felix_agent_sdk.cli.run_command import run_workflow + + return run_workflow(args.config, args.provider, args.verbose) + + if args.command == "version": + from felix_agent_sdk._version import __version__ + + print(f"felix-agent-sdk {__version__}") + return 0 + + parser.print_help() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/felix_agent_sdk/cli/run_command.py b/src/felix_agent_sdk/cli/run_command.py new file mode 100644 index 0000000..36c92cc --- /dev/null +++ b/src/felix_agent_sdk/cli/run_command.py @@ -0,0 +1,90 @@ +"""``felix run`` — execute a workflow from a YAML config file.""" + +from __future__ import annotations + +import sys + +from felix_agent_sdk.cli.yaml_loader import load_workflow_yaml +from felix_agent_sdk.providers.base import BaseProvider +from felix_agent_sdk.utils.logging import FelixLogConfig, configure_logging +from felix_agent_sdk.workflows.runner import run_felix_workflow + + +def run_workflow(config_path: str, provider_name: str | None, verbose: bool) -> int: + """Load a YAML config, resolve the provider, and run the workflow. + + Returns: + Exit code (0 = success, 1 = error). + """ + if verbose: + configure_logging(FelixLogConfig(level="DEBUG")) + else: + configure_logging(FelixLogConfig(level="WARNING")) + + try: + config, task, provider_info = load_workflow_yaml(config_path) + except (FileNotFoundError, ValueError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + # Resolve provider + effective_provider = provider_name or provider_info.get("provider", "") + model = provider_info.get("model", "") + + provider = _resolve_provider(effective_provider, model) + if provider is None: + print( + f"Error: could not create provider '{effective_provider}'. " + "Check your API key and provider dependencies.", + file=sys.stderr, + ) + return 1 + + print("Running Felix workflow...") + print(f" Provider: {effective_provider or 'auto'}") + print(f" Task: {task[:80]}{'...' if len(task) > 80 else ''}") + print(f" Team: {len(config.team_composition)} agents, {config.max_rounds} rounds") + print() + + try: + result = run_felix_workflow(config, provider, task) + except Exception as e: + print(f"Error during workflow: {e}", file=sys.stderr) + return 1 + + print("=== Result ===") + print(f"Rounds: {result.total_rounds}") + print(f"Confidence: {result.final_confidence:.3f}") + print(f"Converged: {result.metadata.get('converged', False)}") + print(f"Tokens: {result.metadata.get('total_tokens', 0)}") + print(f"Time: {result.metadata.get('elapsed_seconds', 0)}s") + print(f"\nSynthesis:\n{result.synthesis}") + return 0 + + +def _resolve_provider(name: str, model: str) -> BaseProvider | None: + """Try to create a provider by name. Returns None on failure.""" + if not name or name == "auto": + try: + from felix_agent_sdk.providers import auto_detect_provider + + return auto_detect_provider() + except Exception: + return None + + try: + if name == "openai": + from felix_agent_sdk.providers import OpenAIProvider + + return OpenAIProvider(model=model or "gpt-4o-mini") + elif name == "anthropic": + from felix_agent_sdk.providers import AnthropicProvider + + return AnthropicProvider(model=model or "claude-sonnet-4-5") + elif name == "local": + from felix_agent_sdk.providers import LocalProvider + + return LocalProvider(model=model or "default") + except Exception: + pass + return None diff --git a/src/felix_agent_sdk/cli/yaml_loader.py b/src/felix_agent_sdk/cli/yaml_loader.py new file mode 100644 index 0000000..19f1132 --- /dev/null +++ b/src/felix_agent_sdk/cli/yaml_loader.py @@ -0,0 +1,132 @@ +"""YAML config loader for the Felix CLI. + +Parses a ``felix.yaml`` file into a :class:`WorkflowConfig`, a task +description string, and provider configuration. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Tuple + +import yaml + +from felix_agent_sdk.core.helix import HelixConfig +from felix_agent_sdk.workflows.config import SynthesisStrategy, WorkflowConfig + +_HELIX_PRESETS = { + "default": HelixConfig.default, + "research_heavy": HelixConfig.research_heavy, + "fast_convergence": HelixConfig.fast_convergence, +} + + +def load_workflow_yaml( + path: str | Path, +) -> Tuple[WorkflowConfig, str, Dict[str, Any]]: + """Parse a ``felix.yaml`` into SDK-native types. + + Args: + path: Path to the YAML config file. + + Returns: + Tuple of (WorkflowConfig, task_description, provider_info). + ``provider_info`` is a dict with keys ``provider`` and ``model``. + + Raises: + FileNotFoundError: If the file doesn't exist. + ValueError: If required fields are missing or invalid. + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + + with open(path) as f: + raw = yaml.safe_load(f) + + if not isinstance(raw, dict): + raise ValueError(f"Expected a YAML mapping, got {type(raw).__name__}") + + # --- Task (required) --- + task = raw.get("task") + if not task: + raise ValueError("Missing required 'task' field in config") + + # --- Helix --- + helix_config = _parse_helix(raw.get("helix", "default")) + + # --- Team composition --- + team = _parse_team(raw.get("team")) + + # --- Scalars --- + kwargs: Dict[str, Any] = {} + if "confidence_threshold" in raw: + kwargs["confidence_threshold"] = float(raw["confidence_threshold"]) + if "max_rounds" in raw: + kwargs["max_rounds"] = int(raw["max_rounds"]) + if "max_agents" in raw: + kwargs["max_agents"] = int(raw["max_agents"]) + if "synthesis_strategy" in raw: + kwargs["synthesis_strategy"] = SynthesisStrategy(raw["synthesis_strategy"]) + if "enable_dynamic_spawning" in raw: + kwargs["enable_dynamic_spawning"] = bool(raw["enable_dynamic_spawning"]) + if "max_dynamic_agents" in raw: + kwargs["max_dynamic_agents"] = int(raw["max_dynamic_agents"]) + + config = WorkflowConfig( + helix_config=helix_config, + team_composition=team, + **kwargs, + ) + + # --- Provider info --- + provider_info = { + "provider": raw.get("provider", ""), + "model": raw.get("model", ""), + } + + return config, str(task), provider_info + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + + +def _parse_helix(value: Any) -> HelixConfig: + """Parse helix field — string preset or dict of params.""" + if isinstance(value, str): + factory = _HELIX_PRESETS.get(value) + if factory is None: + valid = ", ".join(_HELIX_PRESETS) + raise ValueError(f"Unknown helix preset '{value}'. Valid: {valid}") + return factory() + if isinstance(value, dict): + return HelixConfig( + top_radius=float(value.get("top_radius", 3.0)), + bottom_radius=float(value.get("bottom_radius", 0.5)), + height=float(value.get("height", 8.0)), + turns=float(value.get("turns", 2.0)), + ) + raise ValueError(f"'helix' must be a preset name or dict, got {type(value).__name__}") + + +def _parse_team( + value: Any, +) -> list[tuple[str, dict[str, Any]]]: + """Parse team field — list of {type: ...} dicts.""" + if value is None: + return [("research", {}), ("analysis", {}), ("critic", {})] + if not isinstance(value, list): + raise ValueError(f"'team' must be a list, got {type(value).__name__}") + + result: list[tuple[str, dict[str, Any]]] = [] + for entry in value: + if isinstance(entry, str): + result.append((entry, {})) + elif isinstance(entry, dict): + agent_type = entry.pop("type", "llm") + result.append((str(agent_type), dict(entry))) + else: + raise ValueError(f"Team entry must be a string or dict, got {type(entry).__name__}") + return result diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..ee639a1 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,208 @@ +"""Tests for the Felix CLI — init, run, yaml_loader, and version.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import pytest +import yaml + +from felix_agent_sdk.cli.init_command import run_init +from felix_agent_sdk.cli.main import main +from felix_agent_sdk.cli.yaml_loader import load_workflow_yaml +from felix_agent_sdk.core.helix import HelixConfig +from felix_agent_sdk.workflows.config import SynthesisStrategy + + +# --------------------------------------------------------------------------- +# felix version +# --------------------------------------------------------------------------- + + +class TestVersion: + def test_version_command(self, capsys): + code = main(["version"]) + assert code == 0 + output = capsys.readouterr().out + assert "felix-agent-sdk" in output + + def test_no_command_shows_help(self, capsys): + code = main([]) + assert code == 0 + + +# --------------------------------------------------------------------------- +# felix init +# --------------------------------------------------------------------------- + + +class TestInit: + def test_scaffolds_project(self, tmp_path): + target = tmp_path / "myproject" + code = run_init(str(target), "research") + assert code == 0 + assert (target / "felix.yaml").exists() + assert (target / "main.py").exists() + assert (target / "requirements.txt").exists() + assert (target / ".env.example").exists() + + def test_yaml_is_valid(self, tmp_path): + target = tmp_path / "test_yaml" + run_init(str(target), "research") + with open(target / "felix.yaml") as f: + data = yaml.safe_load(f) + assert data["helix"] == "research_heavy" + assert data["task"] is not None + assert len(data["team"]) == 4 + + def test_analysis_template(self, tmp_path): + target = tmp_path / "analysis_proj" + code = run_init(str(target), "analysis") + assert code == 0 + with open(target / "felix.yaml") as f: + data = yaml.safe_load(f) + assert data["helix"] == "fast_convergence" + + def test_review_template(self, tmp_path): + target = tmp_path / "review_proj" + code = run_init(str(target), "review") + assert code == 0 + with open(target / "felix.yaml") as f: + data = yaml.safe_load(f) + assert data["helix"] == "default" + + def test_existing_directory_fails(self, tmp_path): + target = tmp_path / "existing" + target.mkdir() + code = run_init(str(target), "research") + assert code == 1 + + def test_unknown_template_fails(self, tmp_path): + code = run_init(str(tmp_path / "bad"), "nonexistent") + assert code == 1 + + def test_via_main(self, tmp_path, capsys): + target = tmp_path / "cli_init" + code = main(["init", str(target)]) + assert code == 0 + assert (target / "felix.yaml").exists() + + +# --------------------------------------------------------------------------- +# yaml_loader +# --------------------------------------------------------------------------- + + +class TestYamlLoader: + def _write_yaml(self, tmp_path: Path, data: dict) -> Path: + p = tmp_path / "felix.yaml" + with open(p, "w") as f: + yaml.dump(data, f) + return p + + def test_minimal_config(self, tmp_path): + path = self._write_yaml(tmp_path, {"task": "Test question"}) + config, task, provider_info = load_workflow_yaml(path) + assert task == "Test question" + assert len(config.team_composition) == 3 # default team + assert config.max_rounds == 3 + + def test_helix_preset(self, tmp_path): + path = self._write_yaml(tmp_path, { + "task": "Test", + "helix": "research_heavy", + }) + config, _, _ = load_workflow_yaml(path) + expected = HelixConfig.research_heavy() + assert config.helix_config.top_radius == expected.top_radius + + def test_helix_dict(self, tmp_path): + path = self._write_yaml(tmp_path, { + "task": "Test", + "helix": {"top_radius": 5.0, "bottom_radius": 0.1}, + }) + config, _, _ = load_workflow_yaml(path) + assert config.helix_config.top_radius == 5.0 + assert config.helix_config.bottom_radius == 0.1 + + def test_team_parsing(self, tmp_path): + path = self._write_yaml(tmp_path, { + "task": "Test", + "team": [ + {"type": "research"}, + {"type": "analysis"}, + {"type": "critic"}, + {"type": "critic"}, + ], + }) + config, _, _ = load_workflow_yaml(path) + assert len(config.team_composition) == 4 + types = [t for t, _ in config.team_composition] + assert types == ["research", "analysis", "critic", "critic"] + + def test_scalars(self, tmp_path): + path = self._write_yaml(tmp_path, { + "task": "Test", + "max_rounds": 5, + "confidence_threshold": 0.9, + "max_agents": 15, + "synthesis_strategy": "best_result", + "enable_dynamic_spawning": True, + "max_dynamic_agents": 5, + }) + config, _, _ = load_workflow_yaml(path) + assert config.max_rounds == 5 + assert config.confidence_threshold == 0.9 + assert config.max_agents == 15 + assert config.synthesis_strategy == SynthesisStrategy.BEST_RESULT + assert config.enable_dynamic_spawning is True + assert config.max_dynamic_agents == 5 + + def test_provider_info(self, tmp_path): + path = self._write_yaml(tmp_path, { + "task": "Test", + "provider": "anthropic", + "model": "claude-sonnet-4-5", + }) + _, _, info = load_workflow_yaml(path) + assert info["provider"] == "anthropic" + assert info["model"] == "claude-sonnet-4-5" + + def test_missing_task_raises(self, tmp_path): + path = self._write_yaml(tmp_path, {"helix": "default"}) + with pytest.raises(ValueError, match="Missing required 'task'"): + load_workflow_yaml(path) + + def test_missing_file_raises(self): + with pytest.raises(FileNotFoundError): + load_workflow_yaml("/nonexistent/felix.yaml") + + def test_invalid_helix_preset_raises(self, tmp_path): + path = self._write_yaml(tmp_path, {"task": "T", "helix": "bogus"}) + with pytest.raises(ValueError, match="Unknown helix preset"): + load_workflow_yaml(path) + + def test_string_team_entries(self, tmp_path): + path = self._write_yaml(tmp_path, { + "task": "Test", + "team": ["research", "analysis"], + }) + config, _, _ = load_workflow_yaml(path) + assert config.team_composition == [("research", {}), ("analysis", {})] + + +# --------------------------------------------------------------------------- +# felix init → felix run round-trip (scaffolded yaml is loadable) +# --------------------------------------------------------------------------- + + +class TestInitRunRoundTrip: + def test_scaffolded_yaml_is_loadable(self, tmp_path): + target = tmp_path / "roundtrip" + run_init(str(target), "research") + config, task, info = load_workflow_yaml(target / "felix.yaml") + assert task is not None + assert len(config.team_composition) > 0 + assert info["provider"] == "openai" From e9d48458f9c9aaab9b65761771e2b16fdd184745 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:25:58 -0400 Subject: [PATCH 09/14] feat: add reusable terminal helix visualizer module Extract and generalize the demo helix visualizer from examples/05_deep_research_live/ into a proper SDK module at src/felix_agent_sdk/visualization/. The new module provides: - terminal.py: ANSI color primitives, cursor control, progress bars - helix_visualizer.py: HelixVisualizer class with agent registration, live update, sidebar panel, phase boundaries, and team confidence - AgentDisplayState dataclass for per-agent rendering state - Exported via top-level __init__.py as HelixVisualizer Includes 23 unit tests covering registration, update, rendering, NO_COLOR support, context manager, and dimension customization. All 686 tests pass, ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/__init__.py | 3 + src/felix_agent_sdk/visualization/__init__.py | 18 + .../visualization/helix_visualizer.py | 485 ++++++++++++++++++ src/felix_agent_sdk/visualization/terminal.py | 101 ++++ tests/unit/test_helix_visualizer.py | 206 ++++++++ 5 files changed, 813 insertions(+) create mode 100644 src/felix_agent_sdk/visualization/__init__.py create mode 100644 src/felix_agent_sdk/visualization/helix_visualizer.py create mode 100644 src/felix_agent_sdk/visualization/terminal.py create mode 100644 tests/unit/test_helix_visualizer.py diff --git a/src/felix_agent_sdk/__init__.py b/src/felix_agent_sdk/__init__.py index 85be338..117cf52 100644 --- a/src/felix_agent_sdk/__init__.py +++ b/src/felix_agent_sdk/__init__.py @@ -38,6 +38,7 @@ from felix_agent_sdk.tokens import TokenBudget from felix_agent_sdk.utils import configure_logging from felix_agent_sdk.workflows import FelixWorkflow, WorkflowConfig, run_felix_workflow +from felix_agent_sdk.visualization import HelixVisualizer __all__ = [ "__version__", @@ -84,4 +85,6 @@ "configure_logging", # Tokens "TokenBudget", + # Visualization + "HelixVisualizer", ] diff --git a/src/felix_agent_sdk/visualization/__init__.py b/src/felix_agent_sdk/visualization/__init__.py new file mode 100644 index 0000000..a1ca936 --- /dev/null +++ b/src/felix_agent_sdk/visualization/__init__.py @@ -0,0 +1,18 @@ +"""Felix visualization module — terminal-based helix rendering. + +Provides a reusable ASCII visualizer that renders agents progressing down +a 3D helix in real time, showing phase boundaries, confidence levels, and +per-agent status in a sidebar panel. +""" + +from __future__ import annotations + +from felix_agent_sdk.visualization.helix_visualizer import ( + AgentDisplayState, + HelixVisualizer, +) + +__all__ = [ + "AgentDisplayState", + "HelixVisualizer", +] diff --git a/src/felix_agent_sdk/visualization/helix_visualizer.py b/src/felix_agent_sdk/visualization/helix_visualizer.py new file mode 100644 index 0000000..290e2b6 --- /dev/null +++ b/src/felix_agent_sdk/visualization/helix_visualizer.py @@ -0,0 +1,485 @@ +"""Reusable terminal helix visualizer — renders agents spiralling from exploration to synthesis. + +Draws a side-view cross-section of the helix in the terminal, showing each +agent's position, phase, confidence, and status in real time. Adapted from +the demo visualizer in ``examples/05_deep_research_live/helix_visualizer.py`` +and generalised for library-wide reuse. + +No external dependencies beyond the Felix SDK and the Python stdlib. +""" + +from __future__ import annotations + +import math +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Dict, Iterator, List, Optional + +from felix_agent_sdk.core.helix import ANALYSIS_END, EXPLORATION_END, HelixGeometry +from felix_agent_sdk.visualization.terminal import ( + BOLD, + DIM, + PHASE_COLORS, + RESET, + clear_screen, + colorize, + hide_cursor, + progress_bar, + show_cursor, +) + +# --------------------------------------------------------------------------- +# Colour-name → ANSI-code mapping +# --------------------------------------------------------------------------- + +_COLOR_MAP: Dict[str, str] = { + "cyan": "\033[96m", + "yellow": "\033[93m", + "green": "\033[92m", + "red": "\033[91m", + "magenta": "\033[95m", + "white": "\033[97m", +} + +# Phase boundary glyphs +_PHASE_ICONS: Dict[str, str] = { + "exploration": "~", + "analysis": "=", + "synthesis": "#", +} + + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + + +@dataclass +class AgentDisplayState: + """Display state for a registered agent. + + Attributes: + agent_id: Unique agent identifier. + label: Two-character short label rendered on the helix (e.g. ``"RM"``). + color: ANSI escape code for this agent's colour. + progress: Parametric position ``t`` in ``[0, 1]``. + confidence: Agent confidence in ``[0, 1]``. + phase: Current workflow phase name. + status: Short free-text status string. + """ + + agent_id: str + label: str + color: str + progress: float = 0.0 + confidence: float = 0.0 + phase: str = "exploration" + status: str = "" + + +# --------------------------------------------------------------------------- +# Visualizer +# --------------------------------------------------------------------------- + + +class HelixVisualizer: + """Renders an ASCII helix with live agent positions in the terminal. + + Usage:: + + from felix_agent_sdk.core.helix import HelixConfig + from felix_agent_sdk.visualization import HelixVisualizer + + helix = HelixConfig.default().to_geometry() + viz = HelixVisualizer(helix) + viz.register_agent("rm", label="RM", color="green") + viz.update("rm", progress=0.3, confidence=0.7, phase="exploration") + with viz.live() as v: + v.render(tick=1, day=1) + + Args: + helix: A ``HelixGeometry`` that defines the spiral shape. + title: Header title shown above the helix. + width: Character width of the helix canvas. + height: Character height of the helix canvas. + sidebar_width: Character width of the sidebar agent panel. + """ + + def __init__( + self, + helix: HelixGeometry, + *, + title: str = "F E L I X", + width: int = 50, + height: int = 28, + sidebar_width: int = 40, + ) -> None: + self._helix = helix + self._title = title + self._width = width + self._height = height + self._sidebar_width = sidebar_width + self._agents: Dict[str, AgentDisplayState] = {} + self._frame: int = 0 + + # ------------------------------------------------------------------ + # Registration & update + # ------------------------------------------------------------------ + + def register_agent( + self, + agent_id: str, + label: str, + color: str = "cyan", + ) -> None: + """Register an agent for display. + + Args: + agent_id: Unique identifier for this agent. + label: Two-character label rendered on the helix (e.g. ``"RM"``). + color: Colour name — one of ``'cyan'``, ``'yellow'``, ``'green'``, + ``'red'``, ``'magenta'``, ``'white'``. + """ + ansi = _COLOR_MAP.get(color, _COLOR_MAP["cyan"]) + self._agents[agent_id] = AgentDisplayState( + agent_id=agent_id, + label=label, + color=ansi, + ) + + def update( + self, + agent_id: str, + progress: float, + confidence: float, + phase: str = "", + status: str = "", + ) -> None: + """Update an agent's display state. + + Args: + agent_id: Must match a previously registered agent. + progress: Parametric position ``t`` in ``[0, 1]``. + confidence: Agent confidence in ``[0, 1]``. + phase: Phase name (``"exploration"``, ``"analysis"``, + ``"synthesis"``). Auto-derived from *progress* if empty. + status: Optional short status string for the sidebar. + + Raises: + KeyError: If *agent_id* was not registered. + """ + if agent_id not in self._agents: + raise KeyError(f"Agent {agent_id!r} is not registered") + agent = self._agents[agent_id] + agent.progress = progress + agent.confidence = confidence + if phase: + agent.phase = phase + else: + # Auto-derive phase from progress + if progress < EXPLORATION_END: + agent.phase = "exploration" + elif progress < ANALYSIS_END: + agent.phase = "analysis" + else: + agent.phase = "synthesis" + if status: + agent.status = status + + # ------------------------------------------------------------------ + # Rendering — public + # ------------------------------------------------------------------ + + def render( + self, + *, + tick: int = 0, + day: int = 0, + extra_info: Optional[Dict[str, Any]] = None, + ) -> None: + """Clear the terminal and print the current frame. + + Args: + tick: Current simulation tick for the header. + day: Current simulation day for the header. + extra_info: Arbitrary key-value pairs shown in the header. + """ + sys.stdout.write(clear_screen()) + sys.stdout.write( + self.render_to_string(tick=tick, day=day, extra_info=extra_info) + ) + sys.stdout.flush() + + def render_to_string( + self, + *, + tick: int = 0, + day: int = 0, + extra_info: Optional[Dict[str, Any]] = None, + ) -> str: + """Return the current frame as a string without writing to stdout. + + Args: + tick: Current simulation tick for the header. + day: Current simulation day for the header. + extra_info: Arbitrary key-value pairs shown in the header. + + Returns: + The rendered frame as a multi-line string. + """ + self._frame += 1 + canvas = self._blank_canvas() + self._draw_helix_backbone(canvas) + self._draw_phase_boundaries(canvas) + + agents = list(self._agents.values()) + for agent in sorted(agents, key=lambda a: a.progress): + self._place_agent(canvas, agent) + + sidebar = self._build_sidebar(agents) + lines = self._merge(canvas, sidebar) + header = self._build_header(tick, day, extra_info) + footer = self._build_footer(agents) + return "\n".join([header, *lines, footer, ""]) + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + @contextmanager + def live(self) -> Iterator[HelixVisualizer]: + """Context manager that hides the cursor on entry and restores it on exit. + + Yields: + This ``HelixVisualizer`` instance. + """ + try: + sys.stdout.write(hide_cursor()) + sys.stdout.flush() + yield self + finally: + sys.stdout.write(show_cursor()) + sys.stdout.flush() + + # ------------------------------------------------------------------ + # Canvas internals + # ------------------------------------------------------------------ + + def _blank_canvas(self) -> List[List[str]]: + """Create an empty canvas (list of character rows).""" + return [[" "] * self._width for _ in range(self._height)] + + def _draw_helix_backbone(self, canvas: List[List[str]]) -> None: + """Draw the spiral backbone as phase-coloured glyphs on the canvas.""" + centre = self._width // 2 + max_radius_chars = (self._width // 2) - 2 + + for row in range(self._height): + t = row / max(self._height - 1, 1) + + # Radius fraction normalised to [0, 1] + z = self._helix.height * (1.0 - t) + radius = self._helix.get_radius(z) + max_r = self._helix.get_radius(self._helix.height) # top radius + r_frac = radius / max_r if max_r > 0 else 0 + + # Angle with slow animation offset + angle = self._helix.get_angle_at_t(t) + self._frame * 0.05 + x_offset = int(r_frac * max_radius_chars * math.cos(angle)) + col = centre + x_offset + + # Phase-based glyph and colour + if t < EXPLORATION_END: + glyph = _PHASE_ICONS["exploration"] + colour = PHASE_COLORS["exploration"] + elif t < ANALYSIS_END: + glyph = _PHASE_ICONS["analysis"] + colour = PHASE_COLORS["analysis"] + else: + glyph = _PHASE_ICONS["synthesis"] + colour = PHASE_COLORS["synthesis"] + + if 0 <= col < self._width: + canvas[row][col] = colorize(DIM + colour, glyph) + + def _draw_phase_boundaries(self, canvas: List[List[str]]) -> None: + """Draw dashed horizontal lines at phase boundaries with labels.""" + explore_row = int(EXPLORATION_END * (self._height - 1)) + analysis_row = int(ANALYSIS_END * (self._height - 1)) + + for col in range(self._width): + if canvas[explore_row][col].strip() == "": + canvas[explore_row][col] = colorize( + DIM + PHASE_COLORS["exploration"], "-" + ) + if canvas[analysis_row][col].strip() == "": + canvas[analysis_row][col] = colorize( + DIM + PHASE_COLORS["analysis"], "-" + ) + + # Phase labels centred on the boundary rows + explore_label = " EXPLORATION " + analysis_label = " ANALYSIS " + e_start = max(0, (self._width - len(explore_label)) // 2) + a_start = max(0, (self._width - len(analysis_label)) // 2) + + for i, ch in enumerate(explore_label): + c = e_start + i + if 0 <= c < self._width: + canvas[explore_row][c] = colorize( + BOLD + PHASE_COLORS["exploration"], ch + ) + + for i, ch in enumerate(analysis_label): + c = a_start + i + if 0 <= c < self._width: + canvas[analysis_row][c] = colorize( + BOLD + PHASE_COLORS["analysis"], ch + ) + + def _place_agent( + self, canvas: List[List[str]], agent: AgentDisplayState + ) -> None: + """Place an agent label on the canvas at its helix position.""" + t = agent.progress + row = int(t * (self._height - 1)) + row = max(0, min(self._height - 1, row)) + + centre = self._width // 2 + max_radius_chars = (self._width // 2) - 2 + + z = self._helix.height * (1.0 - t) + radius = self._helix.get_radius(z) + max_r = self._helix.get_radius(self._helix.height) + r_frac = radius / max_r if max_r > 0 else 0 + + angle = self._helix.get_angle_at_t(t) + x_offset = int(r_frac * max_radius_chars * math.cos(angle)) + col = centre + x_offset + col = max(1, min(self._width - 2, col)) + + # Confidence-based intensity + intensity = BOLD if agent.confidence > 0.6 else "" + label_str = f"[{agent.label}]" + canvas[row][col] = colorize(intensity + agent.color, label_str) + + # Trim overlap from adjacent cells consumed by the multi-char label + if col + 1 < self._width: + canvas[row][col + 1] = "" + if col + 2 < self._width: + canvas[row][col + 2] = "" + + # ------------------------------------------------------------------ + # Sidebar + # ------------------------------------------------------------------ + + def _build_sidebar(self, agents: List[AgentDisplayState]) -> List[str]: + """Build the right-hand agent detail panel.""" + lines: List[str] = [] + lines.append(colorize(BOLD, " Agents")) + lines.append(" " + "-" * (self._sidebar_width - 2)) + + for agent in agents: + phase_colour = PHASE_COLORS.get(agent.phase, RESET) + + # Agent header + name = f"[{agent.label}] {agent.agent_id}" + lines.append(f" {colorize(BOLD + agent.color, name)}") + + # Progress bar + bar_width = self._sidebar_width - 14 + bar = progress_bar(agent.progress, bar_width) + pct = f"{agent.progress * 100:5.1f}%" + lines.append(f" {colorize(agent.color, bar)} {pct}") + + # Confidence + phase + conf_str = f"conf:{agent.confidence:.2f}" + phase_str = agent.phase[:5].upper() + lines.append( + f" {conf_str} {colorize(phase_colour, phase_str)}" + ) + + # Optional status line + if agent.status: + preview = agent.status[: self._sidebar_width - 6] + if len(agent.status) > self._sidebar_width - 6: + preview = preview[: self._sidebar_width - 9] + "..." + lines.append(f" {colorize(DIM, preview)}") + + lines.append("") + + # Pad to canvas height + while len(lines) < self._height: + lines.append("") + + return lines[: self._height] + + # ------------------------------------------------------------------ + # Header / footer + # ------------------------------------------------------------------ + + def _build_header( + self, + tick: int, + day: int, + extra_info: Optional[Dict[str, Any]], + ) -> str: + """Build the header block above the helix canvas.""" + parts = [f" {colorize(BOLD, self._title)}"] + if tick or day: + parts.append(f"Tick {tick}") + parts.append(f"Day {day}") + header_text = " | ".join(parts) + + # Extra info key-value pairs + extra_line = "" + if extra_info: + kv = " ".join(f"{k}={v}" for k, v in extra_info.items()) + extra_line = f"\n {colorize(DIM, kv)}" + + separator = " " + "\u2550" * (self._width + self._sidebar_width + 3) + + phase_legend = ( + f" Phases: {colorize(PHASE_COLORS['exploration'], '~ EXPLORATION')} " + f"{colorize(PHASE_COLORS['analysis'], '= ANALYSIS')} " + f"{colorize(PHASE_COLORS['synthesis'], '# SYNTHESIS')}" + ) + return f"\n{header_text}{extra_line}\n{separator}\n{phase_legend}\n" + + def _build_footer(self, agents: List[AgentDisplayState]) -> str: + """Build the footer with team confidence bar.""" + separator = " " + "\u2550" * (self._width + self._sidebar_width + 3) + + avg_conf = ( + sum(a.confidence for a in agents) / len(agents) if agents else 0.0 + ) + bar = progress_bar(avg_conf, 30) + + if avg_conf >= 0.75: + conf_colour = PHASE_COLORS["synthesis"] # green + elif avg_conf >= 0.5: + conf_colour = PHASE_COLORS["analysis"] # yellow + else: + conf_colour = "\033[91m" # red + + conf_line = ( + f" Team Confidence: {colorize(conf_colour, bar)} " + f"{colorize(BOLD + conf_colour, f'{avg_conf:.1%}')}" + ) + return f"{separator}\n{conf_line}" + + # ------------------------------------------------------------------ + # Merge + # ------------------------------------------------------------------ + + def _merge( + self, canvas: List[List[str]], sidebar: List[str] + ) -> List[str]: + """Merge the helix canvas rows with the sidebar rows.""" + lines: List[str] = [] + for row_idx in range(self._height): + canvas_line = "".join(canvas[row_idx]) + side = sidebar[row_idx] if row_idx < len(sidebar) else "" + lines.append(f" {canvas_line} \u2502{side}") + return lines diff --git a/src/felix_agent_sdk/visualization/terminal.py b/src/felix_agent_sdk/visualization/terminal.py new file mode 100644 index 0000000..64c6913 --- /dev/null +++ b/src/felix_agent_sdk/visualization/terminal.py @@ -0,0 +1,101 @@ +"""ANSI terminal rendering primitives for the helix visualizer. + +Pure-stdlib helpers for colour output, cursor control, and simple widgets. +No external dependencies. +""" + +from __future__ import annotations + +import os +import sys +from typing import Dict + +# --------------------------------------------------------------------------- +# ANSI colour constants +# --------------------------------------------------------------------------- + +CYAN: str = "\033[96m" +YELLOW: str = "\033[93m" +GREEN: str = "\033[92m" +RED: str = "\033[91m" +MAGENTA: str = "\033[95m" +DIM: str = "\033[2m" +BOLD: str = "\033[1m" +RESET: str = "\033[0m" + +# Phase-to-colour mapping +PHASE_COLORS: Dict[str, str] = { + "exploration": CYAN, + "analysis": YELLOW, + "synthesis": GREEN, +} + + +# --------------------------------------------------------------------------- +# Capability detection +# --------------------------------------------------------------------------- + + +def supports_color() -> bool: + """Return True if the terminal is likely to support ANSI colour codes. + + Checks, in order: + - ``NO_COLOR`` env var (https://no-color.org/) — disables colour. + - ``FORCE_COLOR`` env var — forces colour on. + - ``sys.stdout.isatty()`` — colour only when attached to a real terminal. + """ + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + + +def colorize(code: str, text: str) -> str: + """Wrap *text* in ANSI *code* if colour is supported. + + Args: + code: One or more ANSI escape sequences (e.g. ``BOLD + CYAN``). + text: The string to colour. + + Returns: + The coloured string, or the plain *text* when colour is disabled. + """ + if supports_color(): + return f"{code}{text}{RESET}" + return text + + +def clear_screen() -> str: + """Return the ANSI escape sequence to clear the terminal and home the cursor.""" + return "\033[2J\033[H" + + +def hide_cursor() -> str: + """Return the ANSI escape sequence to hide the text cursor.""" + return "\033[?25l" + + +def show_cursor() -> str: + """Return the ANSI escape sequence to show the text cursor.""" + return "\033[?25h" + + +def progress_bar(value: float, width: int = 20) -> str: + """Render a simple progress bar using block characters. + + Args: + value: Progress fraction in ``[0.0, 1.0]``. + width: Total character width of the bar. + + Returns: + A string like ``"████████░░░░░░░░░░░░"``. + """ + value = max(0.0, min(1.0, value)) + filled = int(value * width) + return "\u2588" * filled + "\u2591" * (width - filled) diff --git a/tests/unit/test_helix_visualizer.py b/tests/unit/test_helix_visualizer.py new file mode 100644 index 0000000..289cc58 --- /dev/null +++ b/tests/unit/test_helix_visualizer.py @@ -0,0 +1,206 @@ +"""Tests for the helix visualizer module.""" + +from __future__ import annotations + +import os + +import pytest + +from felix_agent_sdk.core.helix import HelixConfig +from felix_agent_sdk.visualization import AgentDisplayState, HelixVisualizer + + +@pytest.fixture +def helix(): + return HelixConfig.default().to_geometry() + + +@pytest.fixture +def viz(helix): + return HelixVisualizer(helix) + + +# ------------------------------------------------------------------ +# Registration +# ------------------------------------------------------------------ + + +class TestRegistration: + def test_register_agent(self, viz): + viz.register_agent("rm", label="RM", color="green") + assert "rm" in viz._agents + assert viz._agents["rm"].label == "RM" + + def test_register_multiple(self, viz): + viz.register_agent("a1", "A1") + viz.register_agent("a2", "A2") + assert len(viz._agents) == 2 + + def test_register_default_color(self, viz): + viz.register_agent("x", "XX") + # Default colour is cyan + assert viz._agents["x"].color == "\033[96m" + + +# ------------------------------------------------------------------ +# Update +# ------------------------------------------------------------------ + + +class TestUpdate: + def test_update_agent(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", progress=0.5, confidence=0.8, phase="analysis") + assert viz._agents["rm"].progress == 0.5 + assert viz._agents["rm"].confidence == 0.8 + assert viz._agents["rm"].phase == "analysis" + + def test_update_unknown_raises(self, viz): + with pytest.raises(KeyError): + viz.update("nonexistent", progress=0.5, confidence=0.5) + + def test_update_auto_phase_exploration(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", progress=0.1, confidence=0.5) + assert viz._agents["rm"].phase == "exploration" + + def test_update_auto_phase_analysis(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", progress=0.5, confidence=0.5) + assert viz._agents["rm"].phase == "analysis" + + def test_update_auto_phase_synthesis(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", progress=0.9, confidence=0.5) + assert viz._agents["rm"].phase == "synthesis" + + def test_update_status(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", progress=0.3, confidence=0.7, status="Searching...") + assert viz._agents["rm"].status == "Searching..." + + +# ------------------------------------------------------------------ +# Render +# ------------------------------------------------------------------ + + +class TestRender: + def test_render_to_string_returns_str(self, viz): + viz.register_agent("rm", "RM", "green") + viz.update("rm", 0.3, 0.7, "exploration") + output = viz.render_to_string(tick=1, day=1) + assert isinstance(output, str) + assert len(output) > 0 + + def test_render_contains_agent_labels(self, viz): + viz.register_agent("rm", "RM", "green") + viz.update("rm", 0.3, 0.7) + output = viz.render_to_string() + assert "RM" in output + + def test_render_contains_phase_names(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", 0.5, 0.5) + output = viz.render_to_string() + assert ( + "EXPLORATION" in output + or "ANALYSIS" in output + or "SYNTHESIS" in output + ) + + def test_render_header_info(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", 0.5, 0.5) + output = viz.render_to_string(tick=47, day=12, extra_info={"score": 0.763}) + assert "47" in output + assert "12" in output + + def test_render_extra_info(self, viz): + viz.register_agent("rm", "RM") + viz.update("rm", 0.5, 0.5) + output = viz.render_to_string(extra_info={"score": 0.763}) + assert "score" in output + + def test_render_team_confidence(self, viz): + viz.register_agent("a1", "A1") + viz.register_agent("a2", "A2") + viz.update("a1", 0.3, 0.8) + viz.update("a2", 0.5, 0.6) + output = viz.render_to_string() + assert "Confidence" in output or "confidence" in output.lower() + + def test_render_empty_agents(self, viz): + """Render with no agents should not raise.""" + output = viz.render_to_string() + assert isinstance(output, str) + + +# ------------------------------------------------------------------ +# NO_COLOR +# ------------------------------------------------------------------ + + +class TestNoColor: + def test_no_color_env(self, helix, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + from felix_agent_sdk.visualization import terminal + + assert not terminal.supports_color() + + def test_force_color_env(self, helix, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + from felix_agent_sdk.visualization import terminal + + assert terminal.supports_color() + + +# ------------------------------------------------------------------ +# Live context manager +# ------------------------------------------------------------------ + + +class TestLiveContext: + def test_live_context_manager(self, viz): + with viz.live() as v: + assert v is viz + + +# ------------------------------------------------------------------ +# Dimensions +# ------------------------------------------------------------------ + + +class TestDimensions: + def test_default_dimensions(self, helix): + viz = HelixVisualizer(helix) + assert viz._width == 50 + assert viz._height == 28 + assert viz._sidebar_width == 40 + + def test_custom_dimensions(self, helix): + viz = HelixVisualizer(helix, width=60, height=20, sidebar_width=30) + assert viz._width == 60 + assert viz._height == 20 + assert viz._sidebar_width == 30 + + def test_custom_title(self, helix): + viz = HelixVisualizer(helix, title="My Helix") + assert viz._title == "My Helix" + + +# ------------------------------------------------------------------ +# AgentDisplayState dataclass +# ------------------------------------------------------------------ + + +class TestAgentDisplayState: + def test_defaults(self): + state = AgentDisplayState( + agent_id="test", label="TT", color="\033[96m" + ) + assert state.progress == 0.0 + assert state.confidence == 0.0 + assert state.phase == "exploration" + assert state.status == "" From 7ac2ec6fd4b4fa50f1ba7298b682cd7969e9804b Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 00:50:47 -0400 Subject: [PATCH 10/14] fix: remove unused import and format visualizer files Addresses review feedback from @CalebisGross: - Remove unused `import os` in test file (ruff F401) - Apply `ruff format` to helix_visualizer.py and test file Co-Authored-By: Claude Opus 4.6 (1M context) --- .../visualization/helix_visualizer.py | 36 +++++-------------- tests/unit/test_helix_visualizer.py | 12 ++----- 2 files changed, 11 insertions(+), 37 deletions(-) diff --git a/src/felix_agent_sdk/visualization/helix_visualizer.py b/src/felix_agent_sdk/visualization/helix_visualizer.py index 290e2b6..fea4fbd 100644 --- a/src/felix_agent_sdk/visualization/helix_visualizer.py +++ b/src/felix_agent_sdk/visualization/helix_visualizer.py @@ -206,9 +206,7 @@ def render( extra_info: Arbitrary key-value pairs shown in the header. """ sys.stdout.write(clear_screen()) - sys.stdout.write( - self.render_to_string(tick=tick, day=day, extra_info=extra_info) - ) + sys.stdout.write(self.render_to_string(tick=tick, day=day, extra_info=extra_info)) sys.stdout.flush() def render_to_string( @@ -310,13 +308,9 @@ def _draw_phase_boundaries(self, canvas: List[List[str]]) -> None: for col in range(self._width): if canvas[explore_row][col].strip() == "": - canvas[explore_row][col] = colorize( - DIM + PHASE_COLORS["exploration"], "-" - ) + canvas[explore_row][col] = colorize(DIM + PHASE_COLORS["exploration"], "-") if canvas[analysis_row][col].strip() == "": - canvas[analysis_row][col] = colorize( - DIM + PHASE_COLORS["analysis"], "-" - ) + canvas[analysis_row][col] = colorize(DIM + PHASE_COLORS["analysis"], "-") # Phase labels centred on the boundary rows explore_label = " EXPLORATION " @@ -327,20 +321,14 @@ def _draw_phase_boundaries(self, canvas: List[List[str]]) -> None: for i, ch in enumerate(explore_label): c = e_start + i if 0 <= c < self._width: - canvas[explore_row][c] = colorize( - BOLD + PHASE_COLORS["exploration"], ch - ) + canvas[explore_row][c] = colorize(BOLD + PHASE_COLORS["exploration"], ch) for i, ch in enumerate(analysis_label): c = a_start + i if 0 <= c < self._width: - canvas[analysis_row][c] = colorize( - BOLD + PHASE_COLORS["analysis"], ch - ) + canvas[analysis_row][c] = colorize(BOLD + PHASE_COLORS["analysis"], ch) - def _place_agent( - self, canvas: List[List[str]], agent: AgentDisplayState - ) -> None: + def _place_agent(self, canvas: List[List[str]], agent: AgentDisplayState) -> None: """Place an agent label on the canvas at its helix position.""" t = agent.progress row = int(t * (self._height - 1)) @@ -396,9 +384,7 @@ def _build_sidebar(self, agents: List[AgentDisplayState]) -> List[str]: # Confidence + phase conf_str = f"conf:{agent.confidence:.2f}" phase_str = agent.phase[:5].upper() - lines.append( - f" {conf_str} {colorize(phase_colour, phase_str)}" - ) + lines.append(f" {conf_str} {colorize(phase_colour, phase_str)}") # Optional status line if agent.status: @@ -451,9 +437,7 @@ def _build_footer(self, agents: List[AgentDisplayState]) -> str: """Build the footer with team confidence bar.""" separator = " " + "\u2550" * (self._width + self._sidebar_width + 3) - avg_conf = ( - sum(a.confidence for a in agents) / len(agents) if agents else 0.0 - ) + avg_conf = sum(a.confidence for a in agents) / len(agents) if agents else 0.0 bar = progress_bar(avg_conf, 30) if avg_conf >= 0.75: @@ -473,9 +457,7 @@ def _build_footer(self, agents: List[AgentDisplayState]) -> str: # Merge # ------------------------------------------------------------------ - def _merge( - self, canvas: List[List[str]], sidebar: List[str] - ) -> List[str]: + def _merge(self, canvas: List[List[str]], sidebar: List[str]) -> List[str]: """Merge the helix canvas rows with the sidebar rows.""" lines: List[str] = [] for row_idx in range(self._height): diff --git a/tests/unit/test_helix_visualizer.py b/tests/unit/test_helix_visualizer.py index 289cc58..4dedd85 100644 --- a/tests/unit/test_helix_visualizer.py +++ b/tests/unit/test_helix_visualizer.py @@ -2,8 +2,6 @@ from __future__ import annotations -import os - import pytest from felix_agent_sdk.core.helix import HelixConfig @@ -103,11 +101,7 @@ def test_render_contains_phase_names(self, viz): viz.register_agent("rm", "RM") viz.update("rm", 0.5, 0.5) output = viz.render_to_string() - assert ( - "EXPLORATION" in output - or "ANALYSIS" in output - or "SYNTHESIS" in output - ) + assert "EXPLORATION" in output or "ANALYSIS" in output or "SYNTHESIS" in output def test_render_header_info(self, viz): viz.register_agent("rm", "RM") @@ -197,9 +191,7 @@ def test_custom_title(self, helix): class TestAgentDisplayState: def test_defaults(self): - state = AgentDisplayState( - agent_id="test", label="TT", color="\033[96m" - ) + state = AgentDisplayState(agent_id="test", label="TT", color="\033[96m") assert state.progress == 0.0 assert state.confidence == 0.0 assert state.phase == "exploration" From 00e45012776801e793e65da876e9b7447049278c Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 01:11:09 -0400 Subject: [PATCH 11/14] refactor: consolidate COLOR_MAP into terminal.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the colour-name → ANSI-code mapping from helix_visualizer.py into terminal.py as COLOR_MAP, eliminating duplicated constants. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../visualization/helix_visualizer.py | 16 ++-------------- src/felix_agent_sdk/visualization/terminal.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/felix_agent_sdk/visualization/helix_visualizer.py b/src/felix_agent_sdk/visualization/helix_visualizer.py index fea4fbd..ed1d015 100644 --- a/src/felix_agent_sdk/visualization/helix_visualizer.py +++ b/src/felix_agent_sdk/visualization/helix_visualizer.py @@ -19,6 +19,7 @@ from felix_agent_sdk.core.helix import ANALYSIS_END, EXPLORATION_END, HelixGeometry from felix_agent_sdk.visualization.terminal import ( BOLD, + COLOR_MAP, DIM, PHASE_COLORS, RESET, @@ -29,19 +30,6 @@ show_cursor, ) -# --------------------------------------------------------------------------- -# Colour-name → ANSI-code mapping -# --------------------------------------------------------------------------- - -_COLOR_MAP: Dict[str, str] = { - "cyan": "\033[96m", - "yellow": "\033[93m", - "green": "\033[92m", - "red": "\033[91m", - "magenta": "\033[95m", - "white": "\033[97m", -} - # Phase boundary glyphs _PHASE_ICONS: Dict[str, str] = { "exploration": "~", @@ -141,7 +129,7 @@ def register_agent( color: Colour name — one of ``'cyan'``, ``'yellow'``, ``'green'``, ``'red'``, ``'magenta'``, ``'white'``. """ - ansi = _COLOR_MAP.get(color, _COLOR_MAP["cyan"]) + ansi = COLOR_MAP.get(color, COLOR_MAP["cyan"]) self._agents[agent_id] = AgentDisplayState( agent_id=agent_id, label=label, diff --git a/src/felix_agent_sdk/visualization/terminal.py b/src/felix_agent_sdk/visualization/terminal.py index 64c6913..0c9b486 100644 --- a/src/felix_agent_sdk/visualization/terminal.py +++ b/src/felix_agent_sdk/visualization/terminal.py @@ -30,6 +30,16 @@ "synthesis": GREEN, } +# Friendly name → ANSI code mapping (used by HelixVisualizer.register_agent) +COLOR_MAP: Dict[str, str] = { + "cyan": CYAN, + "yellow": YELLOW, + "green": GREEN, + "red": RED, + "magenta": MAGENTA, + "white": "\033[97m", +} + # --------------------------------------------------------------------------- # Capability detection From 94b4189e1565dfa0ab476560a9408e0a76971302 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 01:53:42 -0400 Subject: [PATCH 12/14] feat(visualization): integrate events, streaming, spawning, and CLI Wire the HelixVisualizer into all four features that landed on dev/phase-2: - Event bus: attach_event_bus() auto-registers agents on AGENT_SPAWNED, updates progress on AGENT_POSITION_UPDATED, marks DONE/FAILED - Streaming: VisualizerStreamHandler shows live token count in sidebar - Spawning: set_monitor() displays confidence trend and spawn recommendation in the footer - CLI: felix run --visualize / -V flag for live helix during workflows 31 visualizer tests pass (23 existing + 8 new). 823 total tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/felix_agent_sdk/cli/main.py | 8 +- src/felix_agent_sdk/cli/run_command.py | 56 +++++-- src/felix_agent_sdk/visualization/__init__.py | 2 + .../visualization/helix_visualizer.py | 146 +++++++++++++++++- tests/unit/test_helix_visualizer.py | 123 ++++++++++++++- 5 files changed, 320 insertions(+), 15 deletions(-) diff --git a/src/felix_agent_sdk/cli/main.py b/src/felix_agent_sdk/cli/main.py index 5b861b2..756bf3b 100644 --- a/src/felix_agent_sdk/cli/main.py +++ b/src/felix_agent_sdk/cli/main.py @@ -24,7 +24,8 @@ def main(argv: list[str] | None = None) -> int: init_p = subparsers.add_parser("init", help="Scaffold a new Felix project") init_p.add_argument("name", help="Project directory name") init_p.add_argument( - "--template", "-t", + "--template", + "-t", default="research", choices=["research", "analysis", "review"], help="Workflow template (default: research)", @@ -35,6 +36,9 @@ def main(argv: list[str] | None = None) -> int: run_p.add_argument("config", help="Path to felix.yaml") run_p.add_argument("--provider", "-p", default=None, help="Override provider name") run_p.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging") + run_p.add_argument( + "--visualize", "-V", action="store_true", help="Show live helix visualization" + ) # --- version --- subparsers.add_parser("version", help="Show SDK version") @@ -49,7 +53,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "run": from felix_agent_sdk.cli.run_command import run_workflow - return run_workflow(args.config, args.provider, args.verbose) + return run_workflow(args.config, args.provider, args.verbose, args.visualize) if args.command == "version": from felix_agent_sdk._version import __version__ diff --git a/src/felix_agent_sdk/cli/run_command.py b/src/felix_agent_sdk/cli/run_command.py index 36c92cc..5d770e0 100644 --- a/src/felix_agent_sdk/cli/run_command.py +++ b/src/felix_agent_sdk/cli/run_command.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from contextlib import nullcontext from felix_agent_sdk.cli.yaml_loader import load_workflow_yaml from felix_agent_sdk.providers.base import BaseProvider @@ -10,9 +11,20 @@ from felix_agent_sdk.workflows.runner import run_felix_workflow -def run_workflow(config_path: str, provider_name: str | None, verbose: bool) -> int: +def run_workflow( + config_path: str, + provider_name: str | None, + verbose: bool, + visualize: bool = False, +) -> int: """Load a YAML config, resolve the provider, and run the workflow. + Args: + config_path: Path to the ``felix.yaml`` config file. + provider_name: Override provider name (``None`` = use config). + verbose: Enable debug logging. + visualize: Show live helix visualization during the run. + Returns: Exit code (0 = success, 1 = error). """ @@ -40,17 +52,39 @@ def run_workflow(config_path: str, provider_name: str | None, verbose: bool) -> ) return 1 - print("Running Felix workflow...") - print(f" Provider: {effective_provider or 'auto'}") - print(f" Task: {task[:80]}{'...' if len(task) > 80 else ''}") - print(f" Team: {len(config.team_composition)} agents, {config.max_rounds} rounds") - print() + # Optional visualization + event_bus = None + viz = None - try: - result = run_felix_workflow(config, provider, task) - except Exception as e: - print(f"Error during workflow: {e}", file=sys.stderr) - return 1 + if visualize: + from felix_agent_sdk.events import EventBus + from felix_agent_sdk.visualization import HelixVisualizer + + event_bus = EventBus() + helix = config.helix_config.to_geometry() + viz = HelixVisualizer(helix) + viz.attach_event_bus(event_bus) + + def _on_round_complete(event): # type: ignore[no-untyped-def] + viz.render(tick=event.data.get("round", 0)) + + event_bus.subscribe("workflow.round.completed", _on_round_complete) + else: + print("Running Felix workflow...") + print(f" Provider: {effective_provider or 'auto'}") + print(f" Task: {task[:80]}{'...' if len(task) > 80 else ''}") + print(f" Team: {len(config.team_composition)} agents, {config.max_rounds} rounds") + print() + + ctx = viz.live() if viz else nullcontext() + with ctx: + try: + result = run_felix_workflow(config, provider, task, event_bus=event_bus) + except Exception as e: + print(f"Error during workflow: {e}", file=sys.stderr) + return 1 + if viz: + viz.render() print("=== Result ===") print(f"Rounds: {result.total_rounds}") diff --git a/src/felix_agent_sdk/visualization/__init__.py b/src/felix_agent_sdk/visualization/__init__.py index a1ca936..78aa165 100644 --- a/src/felix_agent_sdk/visualization/__init__.py +++ b/src/felix_agent_sdk/visualization/__init__.py @@ -10,9 +10,11 @@ from felix_agent_sdk.visualization.helix_visualizer import ( AgentDisplayState, HelixVisualizer, + VisualizerStreamHandler, ) __all__ = [ "AgentDisplayState", "HelixVisualizer", + "VisualizerStreamHandler", ] diff --git a/src/felix_agent_sdk/visualization/helix_visualizer.py b/src/felix_agent_sdk/visualization/helix_visualizer.py index ed1d015..6ed7963 100644 --- a/src/felix_agent_sdk/visualization/helix_visualizer.py +++ b/src/felix_agent_sdk/visualization/helix_visualizer.py @@ -17,6 +17,11 @@ from typing import Any, Dict, Iterator, List, Optional from felix_agent_sdk.core.helix import ANALYSIS_END, EXPLORATION_END, HelixGeometry +from felix_agent_sdk.events.bus import EventBus +from felix_agent_sdk.events.types import EventType, FelixEvent +from felix_agent_sdk.spawning.confidence_monitor import ConfidenceMonitor +from felix_agent_sdk.streaming.handler import StreamHandler +from felix_agent_sdk.streaming.types import StreamEvent from felix_agent_sdk.visualization.terminal import ( BOLD, COLOR_MAP, @@ -37,6 +42,14 @@ "synthesis": "#", } +# Agent-type → colour-name mapping for auto-registration from events +_AGENT_TYPE_COLORS: Dict[str, str] = { + "research": "cyan", + "analysis": "yellow", + "critic": "magenta", + "synthesis": "green", +} + # --------------------------------------------------------------------------- # Data @@ -110,6 +123,8 @@ def __init__( self._sidebar_width = sidebar_width self._agents: Dict[str, AgentDisplayState] = {} self._frame: int = 0 + self._event_bus: Optional[EventBus] = None + self._monitor: Optional[ConfidenceMonitor] = None # ------------------------------------------------------------------ # Registration & update @@ -175,6 +190,84 @@ def update( if status: agent.status = status + # ------------------------------------------------------------------ + # Event bus integration + # ------------------------------------------------------------------ + + def attach_event_bus(self, bus: EventBus) -> None: + """Subscribe to agent lifecycle events for automatic display updates. + + Args: + bus: The event bus to subscribe to. + """ + self._event_bus = bus + bus.subscribe("agent.*", self._on_agent_event) + bus.subscribe("spawn.*", self._on_spawn_event) + + def detach_event_bus(self) -> None: + """Remove event subscriptions and detach the bus.""" + if self._event_bus is not None: + self._event_bus.unsubscribe("agent.*", self._on_agent_event) + self._event_bus.unsubscribe("spawn.*", self._on_spawn_event) + self._event_bus = None + + def _on_agent_event(self, event: FelixEvent) -> None: + """Handle agent lifecycle events.""" + agent_id = event.data.get("agent_id", "") + if not agent_id: + parts = event.source.split(":") + agent_id = parts[1] if len(parts) > 1 else event.source + + handler = self._AGENT_EVENT_HANDLERS.get(event.event_type) + if handler is not None: + handler(self, agent_id, event) + + def _handle_agent_spawned(self, agent_id: str, event: FelixEvent) -> None: + if agent_id not in self._agents: + agent_type = event.data.get("agent_type", "") + label = agent_type[:2].upper() or agent_id[:2].upper() + color = _AGENT_TYPE_COLORS.get(agent_type, "cyan") + self.register_agent(agent_id, label=label, color=color) + + def _handle_agent_position(self, agent_id: str, event: FelixEvent) -> None: + if agent_id in self._agents: + self.update( + agent_id, + progress=event.data.get("progress", 0.0), + confidence=event.data.get("confidence", 0.0), + phase=event.data.get("phase", ""), + ) + + def _handle_agent_completed(self, agent_id: str, event: FelixEvent) -> None: + if agent_id in self._agents: + self._agents[agent_id].status = "DONE" + + def _handle_agent_failed(self, agent_id: str, event: FelixEvent) -> None: + if agent_id in self._agents: + self._agents[agent_id].status = "FAILED" + + _AGENT_EVENT_HANDLERS: Dict[str, Any] = { + EventType.AGENT_SPAWNED.value: _handle_agent_spawned, + EventType.AGENT_POSITION_UPDATED.value: _handle_agent_position, + EventType.AGENT_COMPLETED.value: _handle_agent_completed, + EventType.AGENT_FAILED.value: _handle_agent_failed, + } + + def _on_spawn_event(self, event: FelixEvent) -> None: + """Handle dynamic spawning events (new agents picked up via AGENT_SPAWNED).""" + + # ------------------------------------------------------------------ + # Confidence monitor + # ------------------------------------------------------------------ + + def set_monitor(self, monitor: ConfidenceMonitor) -> None: + """Attach a confidence monitor for trend display in the footer. + + Args: + monitor: The confidence monitor to read status from. + """ + self._monitor = monitor + # ------------------------------------------------------------------ # Rendering — public # ------------------------------------------------------------------ @@ -439,7 +532,24 @@ def _build_footer(self, agents: List[AgentDisplayState]) -> str: f" Team Confidence: {colorize(conf_colour, bar)} " f"{colorize(BOLD + conf_colour, f'{avg_conf:.1%}')}" ) - return f"{separator}\n{conf_line}" + + monitor_line = "" + if self._monitor is not None: + status = self._monitor.get_status() + trend_icons = {"rising": "\u2191", "falling": "\u2193", "stable": "\u2192"} + trend_str = f"{trend_icons.get(status.trend, '?')} {status.trend}" + rec = status.recommendation.value.upper() + rec_colors = { + "spawn": "\033[91m", + "hold": PHASE_COLORS["synthesis"], + "prune": PHASE_COLORS["analysis"], + } + rec_color = rec_colors.get(rec.lower(), RESET) + monitor_line = ( + f"\n Trend: {trend_str} | Recommendation: {colorize(BOLD + rec_color, rec)}" + ) + + return f"{separator}\n{conf_line}{monitor_line}" # ------------------------------------------------------------------ # Merge @@ -453,3 +563,37 @@ def _merge(self, canvas: List[List[str]], sidebar: List[str]) -> List[str]: side = sidebar[row_idx] if row_idx < len(sidebar) else "" lines.append(f" {canvas_line} \u2502{side}") return lines + + +# --------------------------------------------------------------------------- +# Streaming integration +# --------------------------------------------------------------------------- + + +class VisualizerStreamHandler(StreamHandler): + """A :class:`~felix_agent_sdk.streaming.StreamHandler` that updates the + visualizer sidebar as tokens arrive. + + Args: + visualizer: The visualizer instance to update. + agent_id: The agent whose status line should reflect stream progress. + """ + + def __init__(self, visualizer: HelixVisualizer, agent_id: str) -> None: + self._viz = visualizer + self._agent_id = agent_id + + def on_token(self, event: StreamEvent) -> None: + """Update agent status with token count.""" + if self._agent_id in self._viz._agents: + self._viz._agents[self._agent_id].status = f"streaming... {event.token_index} tokens" + + def on_result(self, event: StreamEvent) -> None: + """Mark agent as complete.""" + if self._agent_id in self._viz._agents: + self._viz._agents[self._agent_id].status = "complete" + + def on_error(self, event: StreamEvent) -> None: + """Mark agent as errored.""" + if self._agent_id in self._viz._agents: + self._viz._agents[self._agent_id].status = "ERROR" diff --git a/tests/unit/test_helix_visualizer.py b/tests/unit/test_helix_visualizer.py index 4dedd85..fa742eb 100644 --- a/tests/unit/test_helix_visualizer.py +++ b/tests/unit/test_helix_visualizer.py @@ -5,7 +5,14 @@ import pytest from felix_agent_sdk.core.helix import HelixConfig -from felix_agent_sdk.visualization import AgentDisplayState, HelixVisualizer +from felix_agent_sdk.events import EventBus, EventType, FelixEvent +from felix_agent_sdk.spawning import ConfidenceMonitor +from felix_agent_sdk.streaming.types import StreamEvent, StreamEventType +from felix_agent_sdk.visualization import ( + AgentDisplayState, + HelixVisualizer, + VisualizerStreamHandler, +) @pytest.fixture @@ -196,3 +203,117 @@ def test_defaults(self): assert state.confidence == 0.0 assert state.phase == "exploration" assert state.status == "" + + +# ------------------------------------------------------------------ +# Event bus integration +# ------------------------------------------------------------------ + + +class TestEventBusIntegration: + def test_attach_auto_registers_on_spawn(self, viz): + bus = EventBus() + viz.attach_event_bus(bus) + bus.emit( + FelixEvent( + EventType.AGENT_SPAWNED, + "agent:test-001", + {"agent_id": "test-001", "agent_type": "research"}, + ) + ) + assert "test-001" in viz._agents + assert viz._agents["test-001"].label == "RE" + + def test_event_position_update(self, viz): + viz.register_agent("a1", "A1") + bus = EventBus() + viz.attach_event_bus(bus) + bus.emit( + FelixEvent( + EventType.AGENT_POSITION_UPDATED, + "agent:a1", + {"agent_id": "a1", "progress": 0.5, "confidence": 0.8}, + ) + ) + assert viz._agents["a1"].progress == pytest.approx(0.5) + assert viz._agents["a1"].confidence == pytest.approx(0.8) + + def test_event_agent_completed(self, viz): + viz.register_agent("a1", "A1") + bus = EventBus() + viz.attach_event_bus(bus) + bus.emit( + FelixEvent( + EventType.AGENT_COMPLETED, + "agent:a1", + {"agent_id": "a1"}, + ) + ) + assert viz._agents["a1"].status == "DONE" + + +# ------------------------------------------------------------------ +# Streaming handler +# ------------------------------------------------------------------ + + +class TestVisualizerStreamHandler: + def test_on_token_updates_status(self, viz): + viz.register_agent("a1", "A1") + handler = VisualizerStreamHandler(viz, "a1") + event = StreamEvent( + agent_id="a1", + event_type=StreamEventType.TOKEN, + content="hello", + token_index=5, + ) + handler.on_token(event) + assert "5 tokens" in viz._agents["a1"].status + + def test_on_result_marks_complete(self, viz): + viz.register_agent("a1", "A1") + handler = VisualizerStreamHandler(viz, "a1") + event = StreamEvent( + agent_id="a1", + event_type=StreamEventType.RESULT, + content="done", + is_final=True, + ) + handler.on_result(event) + assert viz._agents["a1"].status == "complete" + + def test_on_error_marks_error(self, viz): + viz.register_agent("a1", "A1") + handler = VisualizerStreamHandler(viz, "a1") + event = StreamEvent( + agent_id="a1", + event_type=StreamEventType.ERROR, + content="fail", + ) + handler.on_error(event) + assert viz._agents["a1"].status == "ERROR" + + +# ------------------------------------------------------------------ +# Confidence monitor integration +# ------------------------------------------------------------------ + + +class TestConfidenceMonitorIntegration: + def test_footer_with_monitor(self, viz): + viz.register_agent("a1", "A1") + viz.update("a1", 0.5, 0.7) + monitor = ConfidenceMonitor(threshold=0.8) + monitor.record_round({"a1": 0.7}) + viz.set_monitor(monitor) + output = viz.render_to_string() + assert "Trend" in output + assert "Recommendation" in output + + def test_footer_without_monitor(self, viz): + viz.register_agent("a1", "A1") + viz.update("a1", 0.5, 0.7) + output = viz.render_to_string() + assert "Confidence" in output + # Should not crash without monitor + assert "Trend" not in output From be92e622828d3064ba634830a26dad193e9aa9a1 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 02:03:39 -0400 Subject: [PATCH 13/14] feat: examples v2, version bump to 0.2.0, changelog - New examples: event_system, streaming, dynamic_spawning, structured_logging, yaml_workflow - Fix yaml_loader _parse_team .pop() mutation (use .get() + filter) - CHANGELOG.md: full v0.2.0 section covering all Phase 2 features - README: update roadmap to reflect Phase 2 completion - Version bump: 0.1.0 -> 0.2.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 51 ++++++++++++++++ README.md | 4 +- examples/06_event_system.py | 42 +++++++++++++ examples/07_streaming_output.py | 82 ++++++++++++++++++++++++++ examples/08_dynamic_spawning.py | 61 +++++++++++++++++++ examples/09_structured_logging.py | 52 ++++++++++++++++ examples/10_yaml_workflow/README.md | 27 +++++++++ examples/10_yaml_workflow/felix.yaml | 20 +++++++ src/felix_agent_sdk/_version.py | 2 +- src/felix_agent_sdk/cli/yaml_loader.py | 5 +- 10 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 examples/06_event_system.py create mode 100644 examples/07_streaming_output.py create mode 100644 examples/08_dynamic_spawning.py create mode 100644 examples/09_structured_logging.py create mode 100644 examples/10_yaml_workflow/README.md create mode 100644 examples/10_yaml_workflow/felix.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index c5874e6..bda8658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ All notable changes to Felix Agent SDK will be documented in this file. +## [0.2.0] — 2026-03-21 + +Phase 2: Developer Experience. Zero breaking changes to v0.1.0 API. + +### Event System +- `EventBus` — synchronous pub/sub with exact, prefix (`"agent.*"`), and catch-all subscriptions +- `FelixEvent` — frozen dataclass with event_type, source, data, timestamp +- `EventType` — enum covering agent, workflow, task, message, stream, and spawn events +- `EventEmitterMixin` — opt-in mixin for event emission from any component +- Wired into `FelixWorkflow`, `LLMAgent`, and `CentralPost` + +### Structured Logging +- `configure_logging()` — one-call setup for all `felix_agent_sdk.*` loggers +- `FelixLogConfig` — text or JSON format, per-subsystem level overrides +- `JSONFormatter` — structured JSON output with event metadata fields +- `EventLogBridge` — subscribes to EventBus and auto-logs events + +### Streaming +- `StreamEvent` / `StreamEventType` — token-level streaming events +- `StreamHandler`, `CallbackStreamHandler`, `EventBusStreamHandler` +- `StreamAccumulator` — bridges provider `stream()` to SDK `StreamEvent` +- `LLMAgent.process_task_streaming()` — full streaming with same lifecycle as `process_task()` + +### Dynamic Spawning +- `ConfidenceMonitor` — per-agent/team confidence tracking, stagnation detection +- `ContentAnalyzer` — keyword-based topic coverage gap analysis +- `TeamSizeOptimizer` — heuristic team size recommendations +- `DynamicSpawner` — orchestrates monitor + analyzer, emits spawn events +- `WorkflowConfig.enable_dynamic_spawning` and `max_dynamic_agents` fields +- Wired into `FelixWorkflow` round loop + +### CLI +- `felix init [--template]` — scaffold projects (research, analysis, review templates) +- `felix run [--provider] [--verbose]` — run workflows from YAML +- `felix version` — print SDK version +- YAML config loader with helix presets, team composition, all config fields + +### Examples +- `06_event_system.py` — event subscriptions and workflow timeline +- `07_streaming_output.py` — token-level streaming demo +- `08_dynamic_spawning.py` — confidence-driven agent creation +- `09_structured_logging.py` — JSON and text log output +- `10_yaml_workflow/` — CLI workflow from YAML config + +### Infrastructure +- Shared `STOPWORDS` extracted to `utils/text.py` (deduplicated from 3 modules) +- EventBus history capped at 10,000 events (configurable) +- 792+ tests, Python 3.10-3.12 + +--- + ## [0.1.0] — 2026-03-19 Initial public release of the Felix Agent SDK. diff --git a/README.md b/README.md index ba73ef9..821e95f 100644 --- a/README.md +++ b/README.md @@ -282,9 +282,9 @@ provider = auto_detect_provider() # Reads from environment ## Roadmap -**Phase 1 — Core SDK (Current):** Pip-installable package with provider abstraction, core primitives, and documentation. +**Phase 1 — Core SDK (v0.1.0):** Pip-installable package with provider abstraction, core primitives, and documentation. -**Phase 2 — Developer Experience:** CLI tooling, helix visualization dashboard, structured logging, expanded examples. +**Phase 2 — Developer Experience (v0.2.0, Current):** Event system, structured logging, streaming, dynamic spawning, CLI tooling (`felix init`, `felix run`), expanded examples. **Phase 3 — Community & Ecosystem:** MCP server integration, vector database connectors, observability adapters, community contribution framework. diff --git a/examples/06_event_system.py b/examples/06_event_system.py new file mode 100644 index 0000000..7a1d4d9 --- /dev/null +++ b/examples/06_event_system.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Event system — subscribe to workflow events and print a timeline. + +Demonstrates the EventBus, EventType, and event subscriptions added +in Phase 2. Runs entirely offline with a mock provider. + +Usage: + python examples/06_event_system.py +""" + +from _mock import make_mock_provider + +from felix_agent_sdk import EventBus, EventType, WorkflowConfig, run_felix_workflow + + +def main(): + bus = EventBus() + + # Subscribe to specific event types + def on_round(event): + print(f" [ROUND] {event.data}") + + def on_task(event): + agent = event.data.get("agent_type", "?") + conf = event.data.get("confidence", "?") + print(f" [TASK] {event.source} ({agent}) confidence={conf}") + + bus.subscribe(EventType.WORKFLOW_STARTED, lambda e: print(f">>> Workflow started: {e.data['task'][:60]}")) + bus.subscribe("workflow.round.*", on_round) + bus.subscribe(EventType.TASK_COMPLETED, on_task) + bus.subscribe(EventType.WORKFLOW_CONVERGED, lambda e: print(f">>> Converged at round {e.data['round']}!")) + bus.subscribe(EventType.WORKFLOW_COMPLETED, lambda e: print(f">>> Workflow done in {e.data['elapsed_seconds']}s")) + + provider = make_mock_provider() + config = WorkflowConfig(max_rounds=2) + + result = run_felix_workflow(config, provider, "Evaluate renewable energy trends", event_bus=bus) + print(f"\nFinal synthesis: {result.synthesis[:100]}...") + + +if __name__ == "__main__": + main() diff --git a/examples/07_streaming_output.py b/examples/07_streaming_output.py new file mode 100644 index 0000000..f6aae3c --- /dev/null +++ b/examples/07_streaming_output.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Streaming output — process a single agent task with token-level streaming. + +Demonstrates StreamHandler, CallbackStreamHandler, and +LLMAgent.process_task_streaming(). Runs offline with a mock provider. + +Usage: + python examples/07_streaming_output.py +""" + +from unittest.mock import MagicMock + +from felix_agent_sdk import LLMAgent, LLMTask +from felix_agent_sdk.core.helix import HelixConfig, HelixGeometry +from felix_agent_sdk.providers.base import BaseProvider +from felix_agent_sdk.providers.types import CompletionResult, StreamChunk +from felix_agent_sdk.streaming import CallbackStreamHandler + + +def _make_streaming_provider(): + """Mock provider whose stream() yields word-by-word chunks.""" + text = ( + "Solar energy deployment has reached unprecedented levels globally. " + "Key markets show 40% year-over-year capacity growth, driven by " + "declining panel costs and supportive policy frameworks. Grid-scale " + "battery storage is emerging as the critical enabler for intermittency " + "management, with lithium-ion costs declining 85% since 2010." + ) + words = text.split(" ") + provider = MagicMock(spec=BaseProvider) + + def _stream(messages, **kwargs): + for i, word in enumerate(words): + is_last = i == len(words) - 1 + yield StreamChunk( + text=word + ("" if is_last else " "), + is_final=is_last, + usage={"prompt_tokens": 30, "completion_tokens": len(words), "total_tokens": 30 + len(words)} if is_last else {}, + ) + + provider.stream.side_effect = _stream + provider.complete.return_value = CompletionResult( + content=text, model="mock", + usage={"prompt_tokens": 30, "completion_tokens": len(words), "total_tokens": 30 + len(words)}, + ) + provider.count_tokens.return_value = 30 + return provider + + +def main(): + provider = _make_streaming_provider() + cfg = HelixConfig.default() + helix = HelixGeometry(cfg.top_radius, cfg.bottom_radius, cfg.height, cfg.turns) + agent = LLMAgent("research-001", provider, helix, agent_type="research") + agent.spawn(0.1) + agent.update_position(0.5) + + print("=== Streaming Agent Output ===\n") + + token_count = [0] + + def on_token(event): + token_count[0] += 1 + print(event.content, end="", flush=True) + + def on_result(event): + print("\n\n--- Stream complete ---") + print(f"Tokens: {event.token_index}") + print(f"Length: {len(event.accumulated)} chars") + + handler = CallbackStreamHandler(on_token=on_token, on_result=on_result) + + task = LLMTask(task_id="demo-1", description="Analyse renewable energy trends") + result = agent.process_task_streaming(task, handler) + + print(f"Confidence: {result.confidence:.3f}") + print(f"Temperature: {result.temperature_used:.3f}") + print(f"Phase: {result.position_info.get('phase', 'unknown')}") + + +if __name__ == "__main__": + main() diff --git a/examples/08_dynamic_spawning.py b/examples/08_dynamic_spawning.py new file mode 100644 index 0000000..da29494 --- /dev/null +++ b/examples/08_dynamic_spawning.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Dynamic spawning — run a workflow with confidence-driven agent creation. + +Demonstrates enable_dynamic_spawning in WorkflowConfig. When confidence +is low, the system automatically spawns additional agents. Uses event +bus to observe spawn decisions. + +Usage: + python examples/08_dynamic_spawning.py +""" + +from _mock import make_mock_provider + +from felix_agent_sdk import EventBus, EventType, WorkflowConfig, run_felix_workflow + + +def main(): + bus = EventBus() + + # Watch for spawn events + def on_spawn(event): + print(f" >>> SPAWN: {event.data.get('agent_type', '?')} " + f"agent {event.data.get('agent_id', '?')} " + f"(reason: {event.data.get('reason', '?')})") + + def on_spawn_done(event): + print(f" >>> SPAWNED: {event.data.get('agent_id', '?')} " + f"(total: {event.data.get('total_spawned', '?')})") + + bus.subscribe(EventType.SPAWN_TRIGGERED, on_spawn) + bus.subscribe(EventType.SPAWN_COMPLETED, on_spawn_done) + bus.subscribe(EventType.WORKFLOW_ROUND_COMPLETED, lambda e: + print(f" Round {e.data['round']} done — avg confidence: {e.data['avg_confidence']:.3f}")) + + # Low-confidence responses to trigger spawning + responses = [ + "Preliminary findings are inconclusive.", + "Weak evidence suggests possible correlation.", + "Need more data to confirm hypothesis.", + "Results remain uncertain after review.", + ] + provider = make_mock_provider(responses) + + config = WorkflowConfig( + max_rounds=3, + confidence_threshold=0.99, # unreachable — forces all rounds + enable_dynamic_spawning=True, + max_dynamic_agents=2, + ) + + print("=== Dynamic Spawning Demo ===\n") + result = run_felix_workflow(config, provider, "Investigate uncertain hypothesis", event_bus=bus) + + print(f"\nFinal team size: {result.metadata['agents_count']}") + print(f"Rounds: {result.total_rounds}") + print(f"Confidence: {result.final_confidence:.3f}") + print(f"Synthesis: {result.synthesis[:100]}...") + + +if __name__ == "__main__": + main() diff --git a/examples/09_structured_logging.py b/examples/09_structured_logging.py new file mode 100644 index 0000000..be7ea92 --- /dev/null +++ b/examples/09_structured_logging.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Structured logging — run a workflow with JSON log output. + +Demonstrates configure_logging() with JSON format and EventLogBridge +for automatic event-to-log bridging. + +Usage: + python examples/09_structured_logging.py +""" + + +from _mock import make_mock_provider + +from felix_agent_sdk import EventBus, WorkflowConfig, configure_logging, run_felix_workflow +from felix_agent_sdk.utils.logging import EventLogBridge, FelixLogConfig + + +def main(): + # Configure JSON logging at DEBUG level + configure_logging(FelixLogConfig( + level="DEBUG", + format="json", + )) + + # Create event bus and bridge events to the logger + bus = EventBus() + bridge = EventLogBridge(bus) + + provider = make_mock_provider() + config = WorkflowConfig(max_rounds=1) + + print("=== Running with JSON structured logging ===\n") + result = run_felix_workflow(config, provider, "Test structured logging", event_bus=bus) + + print(f"\nSynthesis: {result.synthesis[:80]}...") + + # Clean up + bridge.detach() + + # Show that text format also works + print("\n=== Switching to text format ===\n") + configure_logging(FelixLogConfig(level="INFO", format="text")) + + bus2 = EventBus() + bridge2 = EventLogBridge(bus2) + + run_felix_workflow(config, make_mock_provider(), "Test text logging", event_bus=bus2) + bridge2.detach() + + +if __name__ == "__main__": + main() diff --git a/examples/10_yaml_workflow/README.md b/examples/10_yaml_workflow/README.md new file mode 100644 index 0000000..adaa4ed --- /dev/null +++ b/examples/10_yaml_workflow/README.md @@ -0,0 +1,27 @@ +# YAML Workflow Example + +Run a Felix workflow from a YAML config file using the CLI. + +## Prerequisites + +```bash +pip install felix-agent-sdk[openai] +export OPENAI_API_KEY=sk-... +``` + +## Usage + +```bash +# From the repo root +felix run examples/10_yaml_workflow/felix.yaml + +# With verbose logging +felix run examples/10_yaml_workflow/felix.yaml --verbose + +# Override provider +felix run examples/10_yaml_workflow/felix.yaml --provider anthropic +``` + +## Config Reference + +See `felix.yaml` in this directory for all available fields. diff --git a/examples/10_yaml_workflow/felix.yaml b/examples/10_yaml_workflow/felix.yaml new file mode 100644 index 0000000..40776c2 --- /dev/null +++ b/examples/10_yaml_workflow/felix.yaml @@ -0,0 +1,20 @@ +# Example Felix workflow config +# Run with: felix run examples/10_yaml_workflow/felix.yaml + +provider: openai +model: gpt-4o-mini + +helix: research_heavy +max_rounds: 3 +confidence_threshold: 0.78 + +team: + - type: research + - type: research + - type: analysis + - type: critic + +enable_dynamic_spawning: true +max_dynamic_agents: 2 + +task: "What are the key challenges and opportunities in quantum computing for cryptography?" diff --git a/src/felix_agent_sdk/_version.py b/src/felix_agent_sdk/_version.py index 3dc1f76..d3ec452 100644 --- a/src/felix_agent_sdk/_version.py +++ b/src/felix_agent_sdk/_version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/felix_agent_sdk/cli/yaml_loader.py b/src/felix_agent_sdk/cli/yaml_loader.py index 19f1132..b84a2ad 100644 --- a/src/felix_agent_sdk/cli/yaml_loader.py +++ b/src/felix_agent_sdk/cli/yaml_loader.py @@ -125,8 +125,9 @@ def _parse_team( if isinstance(entry, str): result.append((entry, {})) elif isinstance(entry, dict): - agent_type = entry.pop("type", "llm") - result.append((str(agent_type), dict(entry))) + agent_type = entry.get("type", "llm") + extra = {k: v for k, v in entry.items() if k != "type"} + result.append((str(agent_type), extra)) else: raise ValueError(f"Team entry must be a string or dict, got {type(entry).__name__}") return result From d704958a7540d219e62de52a6c8524fff911d258 Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Sat, 21 Mar 2026 02:07:12 -0400 Subject: [PATCH 14/14] fix(examples): address review notes - 06: extract lambdas to named functions for readability - 08: show gap-to-threshold instead of premature agent_id in SPAWN_TRIGGERED handler - 09: use INFO level for JSON demo (less noise than DEBUG) - 10: clarify API key requirement in README Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/06_event_system.py | 15 ++++++++++++--- examples/08_dynamic_spawning.py | 4 ++-- examples/09_structured_logging.py | 4 ++-- examples/10_yaml_workflow/README.md | 5 ++++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/examples/06_event_system.py b/examples/06_event_system.py index 7a1d4d9..1190f87 100644 --- a/examples/06_event_system.py +++ b/examples/06_event_system.py @@ -25,11 +25,20 @@ def on_task(event): conf = event.data.get("confidence", "?") print(f" [TASK] {event.source} ({agent}) confidence={conf}") - bus.subscribe(EventType.WORKFLOW_STARTED, lambda e: print(f">>> Workflow started: {e.data['task'][:60]}")) + def on_started(event): + print(f">>> Workflow started: {event.data['task'][:60]}") + + def on_converged(event): + print(f">>> Converged at round {event.data['round']}!") + + def on_completed(event): + print(f">>> Workflow done in {event.data['elapsed_seconds']}s") + + bus.subscribe(EventType.WORKFLOW_STARTED, on_started) bus.subscribe("workflow.round.*", on_round) bus.subscribe(EventType.TASK_COMPLETED, on_task) - bus.subscribe(EventType.WORKFLOW_CONVERGED, lambda e: print(f">>> Converged at round {e.data['round']}!")) - bus.subscribe(EventType.WORKFLOW_COMPLETED, lambda e: print(f">>> Workflow done in {e.data['elapsed_seconds']}s")) + bus.subscribe(EventType.WORKFLOW_CONVERGED, on_converged) + bus.subscribe(EventType.WORKFLOW_COMPLETED, on_completed) provider = make_mock_provider() config = WorkflowConfig(max_rounds=2) diff --git a/examples/08_dynamic_spawning.py b/examples/08_dynamic_spawning.py index da29494..4eaed63 100644 --- a/examples/08_dynamic_spawning.py +++ b/examples/08_dynamic_spawning.py @@ -20,8 +20,8 @@ def main(): # Watch for spawn events def on_spawn(event): print(f" >>> SPAWN: {event.data.get('agent_type', '?')} " - f"agent {event.data.get('agent_id', '?')} " - f"(reason: {event.data.get('reason', '?')})") + f"(reason: {event.data.get('reason', '?')}, " + f"gap: {event.data.get('gap', '?')})") def on_spawn_done(event): print(f" >>> SPAWNED: {event.data.get('agent_id', '?')} " diff --git a/examples/09_structured_logging.py b/examples/09_structured_logging.py index be7ea92..a677337 100644 --- a/examples/09_structured_logging.py +++ b/examples/09_structured_logging.py @@ -16,9 +16,9 @@ def main(): - # Configure JSON logging at DEBUG level + # Configure JSON logging at INFO level (use DEBUG for more detail) configure_logging(FelixLogConfig( - level="DEBUG", + level="INFO", format="json", )) diff --git a/examples/10_yaml_workflow/README.md b/examples/10_yaml_workflow/README.md index adaa4ed..4f7b167 100644 --- a/examples/10_yaml_workflow/README.md +++ b/examples/10_yaml_workflow/README.md @@ -2,11 +2,14 @@ Run a Felix workflow from a YAML config file using the CLI. +**Note:** This example requires a real LLM API key. Examples 01-09 run +offline with mock providers — use those first to verify your setup. + ## Prerequisites ```bash pip install felix-agent-sdk[openai] -export OPENAI_API_KEY=sk-... +export OPENAI_API_KEY=sk-... # required — no mock fallback ``` ## Usage