Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,77 @@ Copyright 2026 Firefly Software Solutions Inc. Licensed under the Apache License
Supports `--port`, `--host`, `--no-browser`, and `--dev` flags. Defaults
to the `studio` subcommand when run without arguments.

### Changed

- **Middleware Protocol** -- Renamed `before`/`after` to `before_run`/`after_run`
on `PromptCacheMiddleware` and `CircuitBreakerMiddleware` to conform to the
`AgentMiddleware` protocol contract.
- **Exception Hierarchy** -- Renamed `MemoryError` to `FireflyMemoryError` to
avoid shadowing the Python built-in. A deprecated alias is kept for backwards
compatibility.
- **Quota Defaults** -- `quota_enabled` now defaults to `False` to avoid
unexpected enforcement on first install.
- **Cost Calculator Type** -- `cost_calculator` config field is now
`Literal["auto", "genai_prices", "static"]`.

### Security

- **ShellTool** -- Replaced `create_subprocess_shell` with
`create_subprocess_exec` to prevent command-injection via shell metacharacters.
- **FileSystemTool** -- Replaced `str.startswith` path check with
`Path.is_relative_to` to prevent symlink-based path traversal.
- **RBAC Decorator** -- Fixed `require_permission` to use `inspect.signature`
for positional argument binding and replaced `nonlocal` mutation with local
`manager` variable.
- **Encryption** -- Each `AESEncryptionProvider.encrypt()` call now generates a
random 16-byte salt for PBKDF2 key derivation, stored as
`salt[16]+nonce[12]+ciphertext+tag`.
- **REST Middleware** -- `allow_credentials` is now automatically set to `False`
when `allow_origins=["*"]`. API key comparison uses `hmac.compare_digest`.
- **REST Router** -- Exception details are no longer exposed to clients; errors
are logged server-side and a generic message is returned.
- **Database Store** -- Schema name is validated against `^[a-zA-Z_][a-zA-Z0-9_]*$`
to prevent SQL injection.
- **FileStore** -- Added `Path.is_relative_to` check in `_path()` to prevent
namespace-based path traversal.

### Fixed

- **Thread Safety** -- Added `threading.Lock` to `InMemoryStore`, `CachedTool`,
`RateLimitGuard`, `ConversationMemory.get_turns/get_total_tokens/clear/
clear_all/new_conversation/conversation_ids`.
- **Pipeline Engine** -- `_gather_inputs` now correctly extracts `output_key`
from dict and object results. `started_at` is initialised before the retry
loop.
- **asyncio.run Crash** -- `database_store.py` and `manager.py` sync wrappers
now detect a running event loop and offload to a `ThreadPoolExecutor` instead
of crashing.
- **TextTool ReDoS** -- Regex operations in `_extract`, `_replace`, `_split` now
run via `asyncio.to_thread` with a 5-second timeout.
- **SandboxGuard ReDoS** -- User-supplied patterns are compiled with a safe
`_safe_compile` helper.
- **Observability Decorators** -- `@metered` now records latency in a `finally`
block so it is captured even on exceptions.
- **Logging** -- `ColoredFormatter.format` now operates on a `copy.copy(record)`
to avoid mutating shared log records.
- **SlidingWindowManager** -- Uses `collections.deque` and `_running_tokens`
counter instead of re-estimating the entire window on every eviction.
- **PromptTemplate** -- Added `_UNSET` sentinel for `PromptVariable.default` so
that `default=None` is correctly propagated.
- **Queue Consumers** -- Kafka, RabbitMQ, and Redis consumers now wrap
`_process_message` in try/except to prevent one bad message from killing the
consumer loop.
- **Goal Decomposition** -- `_execute_task` now passes `memory=memory` to the
delegated `_task_pattern.execute()`.
- **ConversationMemory** -- `clear()` and `clear_all()` now also clear
`_summaries` to prevent stale summary leaks.
- **Reasoning Registry** -- Six built-in patterns are auto-registered at import
time.
- **Observability Exports** -- `extract_trace_context`, `inject_trace_context`,
and `trace_context_scope` are now re-exported from `observability/__init__.py`.
- **UsageTracker** -- `_check_budget` exception handler now logs at DEBUG instead
of silently passing.

## [26.02.07] - 2026-02-17

### Added
Expand Down
5 changes: 5 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,11 @@ memory = MemoryManager(store=encrypted_store)
All data is encrypted before writing and decrypted after reading, with no
changes to application code.

Each call to `encrypt()` generates a random 16-byte salt for PBKDF2 key
derivation and a random 12-byte nonce for AES-GCM. The ciphertext is stored as
`salt[16] + nonce[12] + ciphertext + tag`, ensuring that identical plaintexts
produce different ciphertexts.

### Environment Configuration

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ The framework ships with nine ready-to-use tools in `tools/builtins/`.
- **TextTool** -- Text utilities: count (words/chars/sentences/lines), extract (regex), truncate, replace, and split.
- **HttpTool** -- Make HTTP requests (GET, POST, PUT, DELETE). Uses `asyncio.to_thread` to keep the event loop non-blocking.
- **FileSystemTool** -- Read, write, and list files within a sandboxed base directory. Path-traversal attacks are rejected.
- **ShellTool** -- Execute shell commands restricted to an explicit allow-list. Empty allow-list rejects all commands (safe default).
- **ShellTool** -- Execute shell commands restricted to an explicit allow-list using `create_subprocess_exec` (no shell metacharacter injection). Empty allow-list rejects all commands (safe default).

### Abstract tools (subclass to use)

Expand Down
12 changes: 12 additions & 0 deletions src/fireflyframework_genai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@
from fireflyframework_genai.exceptions import (
AgentError,
AgentNotFoundError,
BudgetExceededError,
ChunkingError,
CompressionError,
ConfigurationError,
DatabaseConnectionError,
DatabaseStoreError,
DelegationError,
ExperimentError,
ExplainabilityError,
ExposureError,
FireflyGenAIError,
FireflyMemoryError,
MemoryError,
ObservabilityError,
OutputReviewError,
Expand All @@ -50,6 +54,8 @@
PromptValidationError,
QoSError,
QueueConnectionError,
QuotaError,
RateLimitError,
ReasoningError,
ReasoningPatternNotFoundError,
ReasoningStepLimitError,
Expand Down Expand Up @@ -85,6 +91,7 @@
"get_config",
"reset_config",
"FireflyGenAIError",
"FireflyMemoryError",
"ConfigurationError",
"AgentError",
"AgentNotFoundError",
Expand All @@ -110,6 +117,11 @@
"OutputValidationError",
"PipelineError",
"QoSError",
"QuotaError",
"BudgetExceededError",
"RateLimitError",
"DatabaseStoreError",
"DatabaseConnectionError",
"MemoryError",
"AgentDepsT",
"AgentLike",
Expand Down
5 changes: 5 additions & 0 deletions src/fireflyframework_genai/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
OutputGuardMiddleware,
PromptGuardError,
PromptGuardMiddleware,
RetryMiddleware,
ValidationMiddleware,
)
from fireflyframework_genai.agents.prompt_cache import CacheStatistics, PromptCacheMiddleware
from fireflyframework_genai.agents.cache import ResultCache
from fireflyframework_genai.agents.context import AgentContext
from fireflyframework_genai.agents.decorators import firefly_agent
Expand Down Expand Up @@ -64,6 +66,7 @@
"AgentRegistry",
"BudgetExceededError",
"CacheMiddleware",
"CacheStatistics",
"CapabilityStrategy",
"ContentBasedStrategy",
"CostAwareStrategy",
Expand All @@ -80,8 +83,10 @@
"OutputGuardError",
"OutputGuardMiddleware",
"PromptGuardError",
"PromptCacheMiddleware",
"PromptGuardMiddleware",
"ResultCache",
"RetryMiddleware",
"RoundRobinStrategy",
"ValidationMiddleware",
"agent_registry",
Expand Down
6 changes: 4 additions & 2 deletions src/fireflyframework_genai/agents/delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class RoundRobinStrategy:
def __init__(self) -> None:
# Lazily initialised so the cycle resets if the agent pool changes.
self._cycle: itertools.cycle[FireflyAgent[Any, Any]] | None = None
self._last_agents: list[FireflyAgent[Any, Any]] = []

async def select(
self,
Expand All @@ -70,8 +71,9 @@ async def select(
) -> FireflyAgent[Any, Any]:
if not agents:
raise DelegationError("No agents available for delegation")
if self._cycle is None:
if self._cycle is None or self._last_agents != list(agents):
self._cycle = itertools.cycle(agents)
self._last_agents = list(agents)
return next(self._cycle)


Expand Down Expand Up @@ -214,7 +216,7 @@ async def select(
best_cost = float("inf")

for agent in agents:
model_name = getattr(agent, "model_name", "")
model_name = getattr(agent, "model_name", "") or getattr(agent, "_model_identifier", "")
cost = self._cost_tier(model_name) if model_name else 3
if cost < best_cost:
best_cost = cost
Expand Down
4 changes: 2 additions & 2 deletions src/fireflyframework_genai/agents/prompt_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def __init__(
self._cache_ttl_seconds = cache_ttl_seconds
self._enabled = enabled

async def before(self, context: Any) -> None:
async def before_run(self, context: Any) -> None:
"""Configure prompt caching before agent execution.

This method modifies the agent run parameters to enable provider-specific
Expand Down Expand Up @@ -163,7 +163,7 @@ async def before(self, context: Any) -> None:
family,
)

async def after(self, context: Any, result: Any) -> Any:
async def after_run(self, context: Any, result: Any) -> Any:
"""Record cache usage metrics after agent execution.

Args:
Expand Down
5 changes: 3 additions & 2 deletions src/fireflyframework_genai/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from __future__ import annotations

import threading
from typing import Literal

from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Expand Down Expand Up @@ -109,7 +110,7 @@ class FireflyGenAIConfig(BaseSettings):
budget_alert_threshold_usd: float | None = None
"""Soft alert threshold in USD. A warning is logged when reached."""

cost_calculator: str = "auto"
cost_calculator: Literal["auto", "genai_prices", "static"] = "auto"
"""Cost calculator preference: ``"auto"``, ``"genai_prices"``, or ``"static"``."""

# -- Memory -------------------------------------------------------------
Expand Down Expand Up @@ -165,7 +166,7 @@ class FireflyGenAIConfig(BaseSettings):
are evicted when this limit is reached (FIFO)."""

# -- Quota & Rate Limiting -----------------------------------------------
quota_enabled: bool = True
quota_enabled: bool = False
"""Whether API quota management and rate limiting is active."""

quota_budget_daily_usd: float | None = None
Expand Down
8 changes: 6 additions & 2 deletions src/fireflyframework_genai/content/chunking.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,14 @@ def _chunk_by_token(self, content: str) -> list[Chunk]:
step = max(1, words_per_chunk - overlap_words)

chunks: list[Chunk] = []
search_offset = 0
for start_idx in range(0, len(words), step):
end_idx = min(start_idx + words_per_chunk, len(words))
chunk_words = words[start_idx:end_idx]
text = " ".join(chunk_words)
char_start = content.index(chunk_words[0]) if chunk_words else 0
char_start = content.index(chunk_words[0], search_offset) if chunk_words else search_offset
char_end = char_start + len(text)
search_offset = char_start + 1
chunks.append(
Chunk(
content=text,
Expand Down Expand Up @@ -238,7 +240,9 @@ def split(self, content: str) -> list[Chunk]:
metadata={"type": "document_segment"},
)
)
offset += len(part)
offset = start + len(stripped)
else:
offset += len(part)

for c in chunks:
c.total_chunks = len(chunks)
Expand Down
14 changes: 10 additions & 4 deletions src/fireflyframework_genai/content/compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from __future__ import annotations

import logging
from collections import deque
from typing import Any, Protocol, runtime_checkable

from fireflyframework_genai.content.chunking import TextChunker
Expand Down Expand Up @@ -241,11 +242,14 @@ def __init__(
) -> None:
self._max_tokens = max_tokens
self._estimator = estimator or TokenEstimator()
self._segments: list[str] = []
self._segments: deque[str] = deque()
self._running_tokens = 0

def add(self, segment: str) -> None:
"""Append a new segment to the window, evicting oldest if needed."""
seg_tokens = self._estimator.estimate(segment)
self._segments.append(segment)
self._running_tokens += seg_tokens
self._evict()

def get_context(self) -> str:
Expand All @@ -258,12 +262,14 @@ def segment_count(self) -> int:

@property
def estimated_tokens(self) -> int:
return self._estimator.estimate(self.get_context()) if self._segments else 0
return self._running_tokens if self._segments else 0

def clear(self) -> None:
self._segments.clear()
self._running_tokens = 0

def _evict(self) -> None:
"""Remove oldest segments until the window fits."""
while len(self._segments) > 1 and self._estimator.estimate(self.get_context()) > self._max_tokens:
self._segments.pop(0)
while len(self._segments) > 1 and self._running_tokens > self._max_tokens:
removed = self._segments.popleft()
self._running_tokens -= self._estimator.estimate(removed)
8 changes: 6 additions & 2 deletions src/fireflyframework_genai/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,15 @@ class PipelineError(FireflyGenAIError):
# -- Memory ------------------------------------------------------------------


class MemoryError(FireflyGenAIError):
class FireflyMemoryError(FireflyGenAIError):
"""Raised for errors during memory storage, retrieval, or management."""


class DatabaseStoreError(MemoryError):
# Deprecated alias for backwards compatibility
MemoryError = FireflyMemoryError


class DatabaseStoreError(FireflyMemoryError):
"""Raised for errors in database-backed memory store operations."""


Expand Down
6 changes: 5 additions & 1 deletion src/fireflyframework_genai/exposure/queues/kafka.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ async def start(self) -> None:

# Process message within trace context scope
with trace_context_scope(span_context):
await self._process_message(message)
try:
await self._process_message(message)
except Exception:
logger.exception("Failed to process Kafka message on topic '%s'", self._topic)
continue
finally:
await self.stop()

Expand Down
6 changes: 5 additions & 1 deletion src/fireflyframework_genai/exposure/queues/rabbitmq.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ async def start(self) -> None:

# Process message within trace context scope
with trace_context_scope(span_context):
await self._process_message(message)
try:
await self._process_message(message)
except Exception:
logger.exception("Failed to process RabbitMQ message on queue '%s'", self._queue_name)
continue

async def stop(self) -> None:
"""Stop the RabbitMQ consumer."""
Expand Down
6 changes: 5 additions & 1 deletion src/fireflyframework_genai/exposure/queues/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ async def start(self) -> None:

# Process message within trace context scope
with trace_context_scope(span_context):
await self._process_message(message)
try:
await self._process_message(message)
except Exception:
logger.exception("Failed to process Redis message on channel '%s'", self._channel)
continue
finally:
await self.stop()

Expand Down
Loading
Loading