diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4d6d4c0f..f45726ae 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.13' - - run: uv sync --extra dev --extra rest --extra binary --extra vectorstores-sqlite-vec --extra openai-embeddings + - run: uv sync --extra dev --extra binary --extra vectorstores-sqlite-vec --extra vectorstores-pgvector --extra openai-embeddings - run: uv run pytest --cov --cov-report=term-missing --durations=50 report-failure: diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index be04bcc4..c0ef76d4 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -57,7 +57,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.13' - - run: uv sync --extra dev --extra rest --extra binary --extra vectorstores-sqlite-vec --extra openai-embeddings + - run: uv sync --extra dev --extra binary --extra vectorstores-sqlite-vec --extra openai-embeddings - run: uv run pyright test: @@ -72,7 +72,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.13' - - run: uv sync --extra dev --extra rest --extra binary --extra vectorstores-sqlite-vec --extra openai-embeddings + - run: uv sync --extra dev --extra binary --extra vectorstores-sqlite-vec --extra vectorstores-pgvector --extra openai-embeddings - run: uv run pytest -m "not nightly" --cov --cov-report=term-missing build: diff --git a/CHANGELOG.md b/CHANGELOG.md index b526f139..ac506986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2.0. +## [26.05.33] - 2026-05-31 + +### Removed + +- **BREAKING — REST/queue exposure layer.** Deleted the `fireflyframework_agentic.exposure` + package (FastAPI app factory, HTTP/WS controllers, health probes, SSE, CORS/rate-limit/auth + middleware, and Kafka/RabbitMQ/Redis consumer/producer hosts), the `rest`/`kafka`/`rabbitmq`/ + `redis`/`queues` extras, the `ExposureError`/`QueueConnectionError` exceptions, and the + REST-serving config fields `auth_api_keys`/`auth_bearer_tokens`/`cors_allowed_origins`. + Serving/hosting is now owned by the consuming service. The framework is a pure in-process + library: it serves no port and consumes no broker. +- **BREAKING — service/infra observability.** Removed `observability.configure_exporters` + (global OTel SDK provider/exporter wiring), the W3C trace-context propagation helpers + (`inject_trace_context`/`extract_trace_context`/`get_trace_context`/`set_trace_context`/ + `trace_context_scope`), the `WebhookSink`, and the `otlp_endpoint` config field. The + framework still emits model/agent spans/metrics via the OpenTelemetry API; configuring the + SDK/exporters and cross-service trace propagation is now the host's responsibility. +- **BREAKING — inbound RBAC auth.** Removed `security.RBACManager`/`require_permission`, the + `rbac_enabled`/`rbac_jwt_secret`/`rbac_multi_tenant` config fields, and the `pyjwt` + dependency from the `security` extra (`cryptography` stays for `EncryptedMemoryStore`). + Inbound-request authorization is a hosting concern owned by the service. + +### Changed + +- **`experiments`/`lab` documented as optional** leaf developer-tooling modules (no code or + dependency change; they were already not imported by the core). + ## [26.05.32] - 2026-05-31 ### Fixed diff --git a/README.md b/README.md index 3af6679f..a7b407e6 100644 --- a/README.md +++ b/README.md @@ -40,23 +40,22 @@ Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2. model-agnostic agents with structured output. But a production GenAI system demands far more than a single agent call. You need to orchestrate multi-step reasoning, validate and retry LLM outputs against schemas, manage conversation memory across -turns, observe every call with traces and metrics, run A/B experiments to compare -models, and expose the whole thing over REST or message queues — all without coupling -your domain logic to infrastructure concerns. +turns, observe every call with traces and metrics, and run A/B experiments to compare +models — all without coupling your domain logic to infrastructure concerns. **fireflyframework-agentic is the production framework built on top of Pydantic AI.** -It extends the engine with six composable layers — from core configuration through -agent management, intelligent reasoning, experimentation, pipeline orchestration, -and service exposure — so that every concern has a dedicated, protocol-driven module. +It extends the engine with composable layers — from core configuration through +agent management, intelligent reasoning, experimentation, and pipeline orchestration — +so that every concern has a dedicated, protocol-driven module. You write your business logic; the framework provides the architecture. **What "metaframework" means in practice:** - You keep Pydantic AI's familiar `Agent`, `Tool`, and `RunContext` APIs unchanged. - The framework wraps them with lifecycle hooks, registries, delegation routers, - memory managers, reasoning patterns, validation loops, DAG pipelines, and exposure - endpoints — all optional, all composable, all swappable through Python protocols. -- No vendor lock-in: switch models, swap memory backends, or replace the REST layer + memory managers, reasoning patterns, validation loops, and DAG pipelines — all + optional, all composable, all swappable through Python protocols. +- No vendor lock-in: switch models, swap memory backends, or replace components without touching your agent code. --- @@ -64,12 +63,11 @@ You write your business logic; the framework provides the architecture. ## Key Principles 1. **Protocol-driven contracts** — Every extension point is defined as a - `@runtime_checkable` `Protocol` or abstract base class. The framework ships thirteen + `@runtime_checkable` `Protocol` or abstract base class. The framework ships twelve protocols (`AgentLike`, `ToolProtocol`, `GuardProtocol`, `ReasoningPattern`, `DelegationStrategy`, `StepExecutor`, `CompressionStrategy`, `MemoryStore`, - `ValidationRule`, `Chunker`, `EmbeddingProtocol`, `VectorStoreProtocol`, - `QueueConsumer` / `QueueProducer`) so you can swap or extend any component - without modifying framework internals. + `ValidationRule`, `Chunker`, `EmbeddingProtocol`, `VectorStoreProtocol`) so you can + swap or extend any component without modifying framework internals. 2. **Convention over configuration** — Sensible defaults everywhere. `FireflyAgenticConfig` is a Pydantic Settings singleton that reads from environment @@ -77,16 +75,15 @@ You write your business logic; the framework provides the architecture. governs model defaults, retry counts, token limits, observability endpoints, memory backends, and validation thresholds — override only what you need. -3. **Layered composition** — Six layers with strict top-down dependency flow: - **Core → Agent → Intelligence → Experimentation → Orchestration → Exposure**. +3. **Layered composition** — Layers with strict top-down dependency flow: + **Core → Agent → Intelligence → Experimentation → Orchestration**. Higher layers depend on lower layers but never the reverse, keeping the dependency graph acyclic and each module independently testable. -4. **Optional dependencies** — Heavy libraries (`fastapi`, `aiokafka`, `aio-pika`, - `redis`, `chromadb`, `pinecone`, `openai`) are declared as pip extras (`[rest]`, - `[kafka]`, `[rabbitmq]`, `[redis]`, `[openai-embeddings]`, - `[vectorstores-chroma]`, `[all]`). The core framework imports them lazily inside - factory functions so that you install only what your deployment requires. +4. **Optional dependencies** — Heavy libraries (`chromadb`, `pinecone`, `openai`, + `asyncpg`) are declared as pip extras (`[openai-embeddings]`, + `[vectorstores-chroma]`, `[postgres]`, `[all]`). The core framework imports them + lazily inside factory functions so that you install only what your deployment requires. --- @@ -94,11 +91,6 @@ You write your business logic; the framework provides the architecture. ```mermaid graph TD - subgraph Exposure Layer - REST["REST API
create_agentic_app · SSE streaming
health · middleware · router
"] - QUEUES["Message Queues
Kafka · RabbitMQ · Redis
consumers · producers · QueueRouter
"] - end - subgraph Orchestration Layer PIPE["Pipeline / DAG Engine
DAG · DAGNode · DAGEdge
PipelineEngine · PipelineBuilder
AgentStep · ReasoningStep · CallableStep
FanOutStep · FanInStep
EmbeddingStep · RetrievalStep
"] end @@ -116,7 +108,7 @@ graph TD subgraph Intelligence Layer REASON["Reasoning Patterns
ReAct · CoT · PlanAndExecute
Reflexion · ToT · GoalDecomposition
ReasoningPipeline
"] VAL["Validation & QoS
OutputReviewer · OutputValidator
ConfidenceScorer · ConsistencyChecker
GroundingChecker · 5 rule types
"] - OBS["Observability
FireflyTracer · FireflyMetrics
FireflyEvents · UsageTracker
CostCalculator · @traced · @metered
configure_exporters
"] + OBS["Observability
FireflyTracer · FireflyMetrics
FireflyEvents · UsageTracker
CostCalculator · @traced · @metered
"] EXPL["Explainability
TraceRecorder · ExplanationGenerator
AuditTrail · ReportBuilder
"] end @@ -135,8 +127,6 @@ graph TD PLUG["Plugin System
PluginDiscovery
3 entry-point groups
"] end - REST --> PIPE - QUEUES --> PIPE PIPE --> AGT PIPE --> REASON PIPE --> VAL @@ -213,15 +203,6 @@ classDiagram +name: str +validate(value) ValidationRuleResult } - class QueueConsumer { - <> - +start() - +stop() - } - class QueueProducer { - <> - +publish(message) - } class EmbeddingProtocol { <> +embed(texts) EmbeddingResult @@ -265,12 +246,6 @@ classDiagram ValidationRule <|.. RangeRule ValidationRule <|.. EnumRule ValidationRule <|.. CustomRule - QueueConsumer <|.. KafkaAgentConsumer - QueueConsumer <|.. RabbitMQAgentConsumer - QueueConsumer <|.. RedisAgentConsumer - QueueProducer <|.. KafkaAgentProducer - QueueProducer <|.. RabbitMQAgentProducer - QueueProducer <|.. RedisAgentProducer EmbeddingProtocol <|.. BaseEmbedder VectorStoreProtocol <|.. BaseVectorStore ``` @@ -346,8 +321,9 @@ classDiagram tools, and reasoning steps. `FireflyMetrics` records tokens (total, prompt, completion), latency, cost, errors, and reasoning depth via the OTel metrics API. `FireflyEvents` emits structured log records. `@traced` and `@metered` decorators - instrument any function with one line. `configure_exporters` sets up OTLP or - console exporters. `UsageTracker` automatically records token usage, cost + instrument any function with one line. The framework emits model and agent + telemetry purely through the OpenTelemetry API; the host application owns OTel + SDK and exporter configuration. `UsageTracker` automatically records token usage, cost estimates, and latency for every agent run, reasoning step, and pipeline execution. `CostCalculator` supports a built-in static price table and optional `genai-prices` integration. Budget enforcement logs warnings when configurable @@ -370,14 +346,11 @@ classDiagram `EvalDataset` loads/saves test cases from JSON. `ModelComparison` runs the same prompts across multiple agents for side-by-side analysis. -- **Exposure** — `create_agentic_app()` produces a FastAPI application with - auto-generated `POST /agents/{name}/run` endpoints, SSE streaming via - `sse_stream`, health/readiness/liveness checks, CORS and request-ID middleware, - and multimodal input support. Queue consumers (`KafkaAgentConsumer`, - `RabbitMQAgentConsumer`, `RedisAgentConsumer`) route messages to agents. - Queue producers (`KafkaAgentProducer`, `RabbitMQAgentProducer`, - `RedisAgentProducer`) publish results back. `QueueRouter` provides - pattern-based message routing across agents. + > **Optional developer tooling.** `fireflyframework_agentic.experiments` (A/B + > experiments) and `fireflyframework_agentic.lab` (offline evaluation / + > benchmarking) are leaf modules — nothing in the core imports them and they add + > no third-party dependencies. Import them only if you run experiments or + > evaluations; agent-building consumers can ignore them. - **Embeddings** — `EmbeddingProtocol` (duck-typed) and `BaseEmbedder` (inheritance with auto-batching) provide provider-agnostic text embedding. @@ -425,17 +398,12 @@ classDiagram **Optional dependencies** (installed via extras): -- `[rest]` — [FastAPI](https://fastapi.tiangolo.com/) `>=0.115.0`, [Uvicorn](https://www.uvicorn.org/) `>=0.34.0`, [sse-starlette](https://github.com/sysid/sse-starlette) `>=2.0.0` -- `[kafka]` — [aiokafka](https://aiokafka.readthedocs.io/) `>=0.12.0` -- `[rabbitmq]` — [aio-pika](https://aio-pika.readthedocs.io/) `>=9.5.0` -- `[redis]` — [redis-py](https://redis-py.readthedocs.io/) `>=5.2.0` - `[costs]` — [genai-prices](https://pypi.org/project/genai-prices/) for up-to-date LLM pricing data -- `[queues]` — All queue backends (Kafka + RabbitMQ + Redis) - `[openai-embeddings]` — [openai](https://github.com/openai/openai-python) `>=1.0.0` for OpenAI/Azure embeddings - `[vectorstores-chroma]` — [chromadb](https://www.trychroma.com/) `>=0.5.0` - `[vectorstores-pinecone]` — [pinecone](https://www.pinecone.io/) `>=5.0.0` - `[vectorstores-qdrant]` — [qdrant-client](https://qdrant.tech/) `>=1.12.0` -- `[all]` — Everything (REST + queues + embeddings + vector stores + costs + security + HTTP) +- `[all]` — Everything (embeddings + vector stores + costs + security + HTTP) **LLM provider keys** (at least one): @@ -490,11 +458,6 @@ uv sync --all-extras # or: pip install -e ".[all]" | Extra | What it adds | When you need it | |---|---|---| -| `rest` | FastAPI, Uvicorn, SSE | Exposing agents as REST endpoints | -| `kafka` | aiokafka | Consuming/producing via Apache Kafka | -| `rabbitmq` | aio-pika | Consuming/producing via RabbitMQ | -| `redis` | redis-py | Consuming/producing via Redis Pub/Sub | -| `queues` | All of the above | Any message queue integration | | `postgres` | asyncpg, SQLAlchemy | PostgreSQL memory persistence | | `mongodb` | motor, pymongo | MongoDB memory persistence | | `security` | PyJWT, cryptography | RBAC, encryption, JWT auth | @@ -657,34 +620,6 @@ results = await store.search_text("machine learning languages", top_k=1) print(results[0].document.text) # Python is great for AI ``` -### 9. Expose via REST - -```python -from fireflyframework_agentic.exposure.rest import create_agentic_app - -app = create_agentic_app(title="My GenAI Service") -# uvicorn myapp:app --reload -``` - -### 10. Expose via Queues (Consumer) - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentConsumer - -consumer = KafkaAgentConsumer("assistant", topic="requests", bootstrap_servers="localhost:9092") -await consumer.start() -``` - -### 11. Publish via Queues (Producer) - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentProducer - -producer = KafkaAgentProducer(topic="results", bootstrap_servers="localhost:9092") -await producer.publish({"agent": "assistant", "output": "Done processing."}) -await producer.close() -``` - ## Using in Jupyter Notebooks firefly-agentic works seamlessly in Jupyter notebooks and JupyterLab. @@ -779,7 +714,7 @@ pipeline. Start here if you want to learn the framework thoroughly. **[docs/use-case-idp.md](docs/use-case-idp.md)** is a focused walkthrough of building a 7-phase IDP pipeline that ingests, splits, classifies, extracts, validates, assembles, and explains data from corporate documents — using agents, reasoning, document splitting, -content processing, validation, explainability, pipelines, and REST exposure. +content processing, validation, explainability, and pipelines. ### Module Reference @@ -801,8 +736,6 @@ Detailed guides for each module: - [Explainability](docs/explainability.md) — Decision recording, audit trails, reports - [Experiments](docs/experiments.md) — A/B testing, variant comparison - [Lab](docs/lab.md) — Benchmarks, datasets, evaluators -- [Exposure REST](docs/exposure-rest.md) — FastAPI integration, SSE streaming -- [Exposure Queues](docs/exposure-queues.md) — Kafka, RabbitMQ, Redis integration - Studio — moved to [fireflyframework-agentic-studio](https://github.com/fireflyframework/fireflyframework-agentic-studio) --- diff --git a/docs/README.md b/docs/README.md index 1063a382..9473b5f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,9 +8,9 @@ Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2. --- **fireflyframework-agentic** is the production-grade GenAI metaframework built on -[Pydantic AI](https://ai.pydantic.dev/). It extends the engine with six composable +[Pydantic AI](https://ai.pydantic.dev/). It extends the engine with composable layers — from core configuration through agent management, intelligent reasoning, -experimentation, pipeline orchestration, and service exposure — so that every concern +experimentation, and pipeline orchestration — so that every concern has a dedicated, protocol-driven module. --- @@ -28,14 +28,14 @@ has a dedicated, protocol-driven module. ## Documentation Map -The framework is organised into six layers. Each layer depends only on the layers +The framework is organised into layered modules. Each layer depends only on the layers below it, keeping the dependency graph acyclic and each module independently testable. ### Core Layer | | | |---|---| -| **[Architecture](architecture.md)** | Design principles, six-layer model, protocol hierarchy, dependency flow | +| **[Architecture](architecture.md)** | Design principles, layered model, protocol hierarchy, dependency flow | ### Agent Layer @@ -82,19 +82,16 @@ below it, keeping the dependency graph acyclic and each module independently tes | **[Experiments](experiments.md)** | `Experiment`, `Variant`, `ExperimentRunner`, `ExperimentTracker`, `VariantComparator` | | **[Lab](lab.md)** | `LabSession`, `Benchmark`, `EvalOrchestrator`, `EvalDataset`, `ModelComparison` | +> **Optional developer tooling.** `experiments` and `lab` are leaf modules — nothing +> in the core imports them and they add no third-party dependencies. Import them only +> if you run experiments or evaluations; agent-building consumers can ignore them. + ### Orchestration Layer | | | |---|---| | **[Pipeline](pipeline.md)** | `DAG`, `PipelineEngine`, `PipelineBuilder`, step types, parallel execution, retries | -### Exposure Layer - -| | | -|---|---| -| **[REST Exposure](exposure-rest.md)** | `create_agentic_app()`, auto-generated routes, SSE streaming, WebSocket, auth middleware, conversation CRUD, rate limiting, health checks | -| **[Queue Exposure](exposure-queues.md)** | Kafka, RabbitMQ, Redis consumers/producers, `QueueRouter` | - ### Studio Studio (visual IDE, project API, scheduling, tunnel exposure, BPM tutorial) @@ -109,7 +106,7 @@ lives in a separate repository: every concept from zero to expert through a real-world **Intelligent Document Processing** pipeline. It covers configuration, agents, tools, prompts, reasoning, content processing, memory, validation, pipelines, observability, explainability, -experiments, lab, REST and queue exposure, deployment, and advanced patterns. +experiments, lab, deployment, and advanced patterns. --- diff --git a/docs/agents.md b/docs/agents.md index 351577de..9a605956 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -909,15 +909,6 @@ async with await agent.run_stream("Question", streaming_mode="incremental") as s print(token, end="", flush=True) ``` -### REST API Integration - -The framework's REST API exposes both streaming modes: - -- **`POST /agents/{name}/stream`** — Buffered streaming (SSE) -- **`POST /agents/{name}/stream/incremental`** — Incremental streaming (SSE) - -See [REST API Guide](exposure-rest.md) for details. - --- ## Run Timeout diff --git a/docs/architecture.md b/docs/architecture.md index 560b6a74..ada45add 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,11 +20,11 @@ The framework follows four guiding principles: configuration and supports environment-variable overrides. 3. **Layered composition** -- Modules are organised into layers (Core, Agent, Intelligence, - Experimentation, Exposure). Higher layers depend on lower layers but never the reverse. + Experimentation, Orchestration). Higher layers depend on lower layers but never the reverse. -4. **Optional dependencies** -- Heavy third-party libraries (FastAPI, aiokafka, aio-pika, - redis) are declared as extras. The core framework imports them lazily so that users - only install what they need. +4. **Optional dependencies** -- Heavy third-party libraries (embedding providers, vector + store clients, storage backends) are declared as extras. The core framework imports them + lazily so that users only install what they need. --- @@ -32,11 +32,6 @@ The framework follows four guiding principles: ```mermaid graph TD - subgraph Exposure Layer - REST["REST API
create_agentic_app · SSE streaming · WebSocket
health · auth middleware · router · conversations
RateLimiter
"] - QUEUES["Message Queues
Kafka · RabbitMQ · Redis
consumers · producers · QueueRouter
"] - end - subgraph Orchestration Layer PIPE["Pipeline / DAG Engine
DAG · DAGNode · DAGEdge
PipelineEngine · PipelineBuilder · PipelineEventHandler
AgentStep · ReasoningStep · CallableStep · BranchStep
FanOutStep · FanInStep · exponential backoff + jitter
"] end @@ -49,7 +44,7 @@ graph TD subgraph Intelligence Layer REASON["Reasoning Patterns
ReAct · CoT · PlanAndExecute
Reflexion · ToT · GoalDecomposition
ReasoningPipeline
"] VAL["Validation & QoS
OutputReviewer · OutputValidator
ConfidenceScorer · ConsistencyChecker
GroundingChecker · 5 rule types
"] - OBS["Observability
FireflyTracer · FireflyMetrics
FireflyEvents · UsageTracker
CostCalculator · @traced · @metered
configure_exporters
"] + OBS["Observability
FireflyTracer · FireflyMetrics
FireflyEvents · UsageTracker
CostCalculator · @traced · @metered
"] EXPL["Explainability
TraceRecorder · ExplanationGenerator
AuditTrail · ReportBuilder
"] end @@ -72,8 +67,6 @@ graph TD PLUG["Plugin System
PluginDiscovery
3 entry-point groups
"] end - REST --> PIPE - QUEUES --> PIPE PIPE --> AGT PIPE --> REASON PIPE --> VAL @@ -146,15 +139,6 @@ classDiagram +name: str +validate(value) ValidationRuleResult } - class QueueConsumer { - <> - +start() - +stop() - } - class QueueProducer { - <> - +publish(message) - } AgentLike <|.. FireflyAgent AgentLike <|.. pydantic_ai.Agent @@ -192,12 +176,6 @@ classDiagram ValidationRule <|.. RangeRule ValidationRule <|.. EnumRule ValidationRule <|.. CustomRule - QueueConsumer <|.. KafkaAgentConsumer - QueueConsumer <|.. RabbitMQAgentConsumer - QueueConsumer <|.. RedisAgentConsumer - QueueProducer <|.. KafkaAgentProducer - QueueProducer <|.. RabbitMQAgentProducer - QueueProducer <|.. RedisAgentProducer ``` --- @@ -314,14 +292,6 @@ a global registry, delegation strategies, and declarative decorators. - **pipeline/context.py** -- `PipelineContext` shared data bus. - **pipeline/result.py** -- `NodeResult`, `PipelineResult`, `ExecutionTraceEntry`. -### Exposure Layer - -- **exposure/rest/** -- FastAPI application factory that auto-generates REST endpoints - for every registered agent, with rate limiting, authentication middleware, - WebSocket support, and conversation CRUD endpoints. -- **exposure/queues/** -- Abstract consumer/producer with Kafka, RabbitMQ, and Redis - implementations and a pattern-based message router. - ### Studio Layer - **studio/server.py** -- FastAPI application factory for Firefly Agentic Studio, @@ -346,15 +316,13 @@ a global registry, delegation strategies, and declarative decorators. ## Request Flow -The following diagram shows the typical lifecycle of a request entering through the -REST exposure layer, being processed by an agent with reasoning, and producing -observability and explainability artefacts. +The following diagram shows the typical lifecycle of an in-process agent run: a caller +resolves an agent from the registry and invokes it, the agent reasons with tools, and +observability and explainability artefacts are produced. ```mermaid sequenceDiagram - participant Client - participant REST as REST API
(create_agentic_app) - participant MW as Middleware
(CORS · RequestID) + participant Caller participant Reg as AgentRegistry participant Agent as FireflyAgent participant Mem as MemoryManager @@ -364,11 +332,9 @@ sequenceDiagram participant OBS as FireflyTracer
FireflyMetrics participant EXPL as TraceRecorder
AuditTrail - Client->>REST: POST /agents/{name}/run - REST->>MW: apply middleware chain - MW->>Reg: agent_registry.get(name) - Reg-->>MW: FireflyAgent instance - MW->>Agent: agent.run(prompt, conversation_id) + Caller->>Reg: agent_registry.get(name) + Reg-->>Caller: FireflyAgent instance + Caller->>Agent: agent.run(prompt, conversation_id) Agent->>OBS: tracer.start_span("agent.run") Agent->>Mem: load conversation history Mem-->>Agent: message_history @@ -386,8 +352,7 @@ sequenceDiagram Agent->>Mem: save conversation turn Agent->>OBS: tracer.end_span() · metrics.record_latency() Agent->>EXPL: audit_trail.append() - Agent-->>REST: AgentResponse - REST-->>Client: JSON response (or SSE stream) + Agent-->>Caller: AgentResponse ``` ### Pipeline Execution Flow diff --git a/docs/exposure-queues.md b/docs/exposure-queues.md deleted file mode 100644 index e03ec8b7..00000000 --- a/docs/exposure-queues.md +++ /dev/null @@ -1,171 +0,0 @@ -# Exposure Queues Guide - -Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2.0. - -The Exposure Queues module provides an abstract consumer/producer model with concrete -implementations for Apache Kafka, RabbitMQ, and Redis Pub/Sub, plus a pattern-based -message router. - ---- - -## Architecture - -```mermaid -flowchart TD - subgraph Message Brokers - KAFKA[Apache Kafka] - RABBIT[RabbitMQ] - REDIS[Redis Pub/Sub] - end - - subgraph Consumers - KC[KafkaAgentConsumer] - RC[RabbitMQAgentConsumer] - RDC[RedisAgentConsumer] - end - - KAFKA --> KC - RABBIT --> RC - REDIS --> RDC - - KC --> ROUTER[QueueRouter] - RC --> ROUTER - RDC --> ROUTER - ROUTER --> REG[Agent Registry] - REG --> AGENT[Agent] -``` - ---- - -## Quick Start - -Install the queue extra for your broker: - -```bash -uv add "fireflyframework-agentic[kafka]" # Kafka -uv add "fireflyframework-agentic[rabbitmq]" # RabbitMQ -uv add "fireflyframework-agentic[redis]" # Redis -uv add "fireflyframework-agentic[queues]" # All brokers -``` - -### Kafka - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentConsumer - -consumer = KafkaAgentConsumer( - agent_name="assistant", - topic="genai-requests", - bootstrap_servers="localhost:9092", - group_id="genai-workers", -) -await consumer.start() -``` - -### RabbitMQ - -```python -from fireflyframework_agentic.exposure.queues.rabbitmq import RabbitMQAgentConsumer - -consumer = RabbitMQAgentConsumer( - agent_name="assistant", - queue_name="genai-requests", - url="amqp://guest:guest@localhost/", -) -await consumer.start() -``` - -### Redis - -```python -from fireflyframework_agentic.exposure.queues.redis import RedisAgentConsumer - -consumer = RedisAgentConsumer( - agent_name="assistant", - channel="genai-requests", - url="redis://localhost:6379", -) -await consumer.start() -``` - ---- - -## QueueMessage - -All consumers and producers operate on `QueueMessage` objects: - -```python -from fireflyframework_agentic.exposure.queues import QueueMessage - -message = QueueMessage( - body="Summarise this document.", - headers={"user": "alice"}, - routing_key="summarisation", - reply_to="response-queue", -) -``` - ---- - -## Queue Router - -The `QueueRouter` maps incoming messages to agents based on routing-key patterns. -This is useful when a single consumer receives messages for multiple agents. - -```mermaid -flowchart TD - MSG[Incoming Message] --> QR[QueueRouter] - QR -->|routing_key ~ 'summary.*'| A1[Summariser Agent] - QR -->|routing_key ~ 'translate.*'| A2[Translator Agent] - QR -->|no match| A3[Default Agent] -``` - -```python -from fireflyframework_agentic.exposure.queues import QueueRouter, QueueMessage - -router = QueueRouter(default_agent="fallback") -router.add_route(r"summary\..*", "summariser") -router.add_route(r"translate\..*", "translator") - -message = QueueMessage(body="Bonjour", routing_key="translate.fr") -response = await router.route(message) -``` - ---- - -## Creating a Custom Consumer - -To integrate with a message broker not supported out of the box, extend -`BaseQueueConsumer` and implement the `start` and `stop` methods: - -```python -from fireflyframework_agentic.exposure.queues.base import BaseQueueConsumer - -class MyBrokerConsumer(BaseQueueConsumer): - async def start(self) -> None: - # Connect to the broker and begin consuming - ... - - async def stop(self) -> None: - # Disconnect - ... -``` - -The base class provides `_process_message(message)` which routes the message to the -configured agent automatically. - ---- - -## Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Created - Created --> Running : start() - Running --> Running : process message - Running --> Stopped : stop() - Stopped --> [*] -``` - -Consumers are designed to be long-running. Call `start()` to connect and begin -processing, and `stop()` to shut down gracefully. diff --git a/docs/exposure-rest.md b/docs/exposure-rest.md deleted file mode 100644 index 1aa00c02..00000000 --- a/docs/exposure-rest.md +++ /dev/null @@ -1,294 +0,0 @@ -# Exposure REST Guide - -Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2.0. - -The Exposure REST module provides a FastAPI application factory that auto-generates -REST endpoints for registered agents, with health checks, SSE streaming, middleware, -and CORS support. - ---- - -## Architecture - -```mermaid -flowchart TD - CLIENT[HTTP Client] --> MW["Middleware
(Request ID, CORS)"] - MW --> ROUTER[Agent Router] - MW --> HEALTH[Health Router] - ROUTER --> REG[Agent Registry] - REG --> AGENT[Agent] - AGENT --> STREAM[SSE Streaming] - STREAM --> CLIENT -``` - ---- - -## Quick Start - -Install the REST extra: - -```bash -uv add "fireflyframework-agentic[rest]" -``` - -Create a FastAPI application: - -```python -from fireflyframework_agentic.exposure.rest import create_agentic_app - -app = create_agentic_app(title="My GenAI Service", version="1.0.0") -``` - -Run with Uvicorn: - -```bash -uvicorn myapp:app --reload -``` - ---- - -## Application Factory - -The `create_agentic_app` function creates a configured FastAPI application with: - -- Agent routes auto-generated from the `AgentRegistry`. -- Health-check endpoints at `/health` and `/health/ready`. -- Request-ID middleware that injects or propagates `X-Request-ID` headers. -- CORS middleware with configurable origins. - -```python -app = create_agentic_app( - title="Production GenAI API", - version="2.0.0", - enable_cors=True, - cors_origins=["https://myapp.example.com"], -) -``` - ---- - -## Auto-Generated Endpoints - -The agent router creates two endpoints for every registered agent: - -- **GET /agents/** -- Lists all registered agents with their metadata. -- **POST /agents/{name}/run** -- Invokes an agent with a prompt and returns the response. - -### Request Schema - -```json -{ - "prompt": "Summarise this document.", - "deps": {} -} -``` - -### Response Schema - -```json -{ - "agent_name": "summariser", - "output": "The document discusses...", - "success": true, - "error": null, - "metadata": {} -} -``` - ---- - -## SSE Streaming - -For long-running agent invocations, the REST layer supports Server-Sent Events (SSE). -The `sse_stream` function yields SSE-formatted events as the agent produces output. -Streaming uses the same request body as the run endpoint. - -```mermaid -sequenceDiagram - participant Client - participant REST - participant Agent - - Client->>REST: POST /agents/writer/stream {"prompt": "..."} - REST->>Agent: run_stream(prompt) - loop Stream chunks - Agent-->>REST: text chunk - REST-->>Client: data: {"text": "..."} - end - REST-->>Client: data: [DONE] -``` - ---- - -## Middleware - -### Request ID - -Every request receives a unique `X-Request-ID` header. If the client sends one, it -is propagated; otherwise, the middleware generates a UUID. - -### CORS - -Cross-Origin Resource Sharing is configured via the application factory. By default -it allows all origins. In production, restrict this to your known domains. - ---- - -## Rate Limiting - -The REST layer includes a sliding-window rate limiter that can be applied -as middleware to protect agents from excessive traffic. - -```python -from fireflyframework_agentic.exposure.rest.middleware import add_rate_limit_middleware - -add_rate_limit_middleware( - app, - max_requests=100, - window_seconds=60.0, -) -``` - -When a client exceeds the limit, the middleware returns a `429 Too Many Requests` -response with a JSON body `{"detail": "Rate limit exceeded"}`. - -By default, the rate key is the client's IP address. Provide a custom -`key_func` to rate-limit by API key, user ID, or any other request attribute: - -```python -add_rate_limit_middleware( - app, - max_requests=20, - window_seconds=60.0, - key_func=lambda request: request.headers.get("X-API-Key", "anonymous"), -) -``` - -The `RateLimiter` class can also be used standalone outside of middleware: - -```python -from fireflyframework_agentic.exposure.rest.middleware import RateLimiter - -limiter = RateLimiter(max_requests=10, window_seconds=30.0) -if not limiter.is_allowed("client-123"): - raise HTTPException(status_code=429) -``` - ---- - -## Authentication Middleware - -The REST layer includes `add_auth_middleware()` that supports two authentication -modes: - -- **API Key** — checked via the `X-API-Key` header. -- **Bearer Token** — checked via the `Authorization: Bearer ` header. - -When both are configured, a request is accepted if **either** method succeeds. -Unauthenticated requests receive a `401 Unauthorized` response. - -```python -from fireflyframework_agentic.exposure.rest.middleware import add_auth_middleware - -add_auth_middleware( - app, - api_keys=["key-abc-123", "key-def-456"], - bearer_tokens=["token-xyz"], - exclude_paths=["/health", "/health/ready", "/docs"], -) -``` - -The authentication middleware is **auto-wired** when the config fields -`auth_api_keys` or `auth_bearer_tokens` are set: - -```bash -export FIREFLY_AGENTIC_AUTH_API_KEYS='["key-abc-123"]' -export FIREFLY_AGENTIC_AUTH_BEARER_TOKENS='["token-xyz"]' -``` - ---- - -## WebSocket Endpoint - -The REST layer includes a bidirectional WebSocket endpoint for real-time, -multi-turn agent conversations at `/ws/agents/{name}`. - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket /ws/agents/{name} - participant Agent - - Client->>WS: connect - WS-->>Client: accept - loop Conversation turns - Client->>WS: {"prompt": "Hello!", "conversation_id": "abc"} - WS->>Agent: run_stream / run - loop Streaming tokens - Agent-->>WS: token - WS-->>Client: {"type": "token", "data": "partial..."} - end - WS-->>Client: {"type": "result", "data": "full output", "success": true} - end - Client->>WS: disconnect -``` - -### Message Protocol - -**Client → Server** (JSON): - -```json -{ - "prompt": "Hello, agent!", - "conversation_id": "optional-id", - "deps": null -} -``` - -**Server → Client** (JSON, one or more): - -```json -{"type": "token", "data": "partial text..."} -{"type": "result", "data": "full output", "success": true} -{"type": "error", "data": "error message", "success": false} -``` - -If no `conversation_id` is provided, the server generates one and sends it -back as `{"type": "conversation_id", "data": "generated-id"}`. - -Each WebSocket connection gets an isolated memory scope to prevent cross-talk -between concurrent sessions. - ---- - -## Conversation Management Endpoints - -The agent router includes CRUD endpoints for managing conversations: - -- **POST /agents/conversations** — Create a new conversation. Returns - `{"conversation_id": "..."}`. -- **GET /agents/conversations/{conversation_id}** — Return the message history - with `conversation_id`, `message_count`, and serialised `messages`. -- **DELETE /agents/conversations/{conversation_id}** — Clear a conversation's - history. - -Pass `conversation_id` in the run or stream request body for multi-turn -conversations: - -```json -{ - "prompt": "What did we discuss earlier?", - "conversation_id": "abc123" -} -``` - ---- - -## Health Checks - -Two health endpoints are provided: - -- **GET /health** — Returns `{"status": "healthy"}` if the application is running. -- **GET /health/ready** — Returns `{"status": "ready"}` when all agents are initialised. - -These endpoints are suitable for Kubernetes liveness and readiness probes. diff --git a/docs/observability.md b/docs/observability.md index 6e1e1c72..c9d29d8a 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -5,6 +5,14 @@ Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2. The Observability module provides OpenTelemetry-native tracing, custom metrics, and event recording for GenAI workloads. +> **Framework emits, host exports.** The framework *emits* model/agent spans, +> metrics, and events through the OpenTelemetry **API**. It deliberately does +> **not** configure the OpenTelemetry SDK, install global tracer/meter +> providers, wire up exporters, or propagate trace context across services — +> that is the **host** application's responsibility. Configure your SDK and +> exporters once in the host process and the framework's telemetry flows into +> them automatically. + --- ## Architecture @@ -15,10 +23,10 @@ flowchart TD DEC --> TRACER[FireflyTracer] DEC --> METRICS[FireflyMetrics] APP --> EVENTS[FireflyEvents] - TRACER --> OTEL[OpenTelemetry SDK] + TRACER --> OTEL["OpenTelemetry API"] METRICS --> OTEL EVENTS --> OTEL - OTEL --> EXP["Exporters
(OTLP, Console, Jaeger)"] + OTEL --> HOST["Host-configured SDK
(providers + exporters)"] ``` --- @@ -53,54 +61,11 @@ async def process_request(prompt: str) -> str: ### Distributed Trace Correlation -The framework supports **W3C Trace Context** propagation for correlating traces -across service boundaries (HTTP, message queues, pipelines). - -**Trace Context Functions:** - -```python -from fireflyframework_agentic.observability.tracer import inject_trace_context, extract_trace_context - -# Inject trace context into HTTP headers -headers = {} -inject_trace_context(headers) -# headers now contain: traceparent, tracestate - -# Send request with trace context -response = await http_client.post(url, headers=headers) - -# On receiving side, extract trace context -incoming_headers = request.headers -context = extract_trace_context(incoming_headers) -# Continue trace with extracted context -``` - -**REST API Integration:** - -The framework's REST API automatically propagates trace context: - -```python -# Middleware injects trace context into responses -# and extracts from incoming requests -from fireflyframework_agentic.exposure.rest.middleware import add_trace_propagation_middleware - -add_trace_propagation_middleware(app) -``` - -**Queue Integration:** - -Message queue consumers/producers automatically propagate trace context: - -```python -# Kafka example - trace context in message headers -from fireflyframework_agentic.exposure.queues.kafka import KafkaConsumer - -consumer = KafkaConsumer( - topic="requests", - handler=process_message, -) -# Trace context automatically extracted from message headers -``` +Cross-service trace-context propagation (e.g. W3C Trace Context over HTTP or +message queues) is owned by the **host** application: configure the standard +OpenTelemetry propagators in your host and the spans the framework emits will +be parented correctly. The framework itself only emits spans; it does not +inject or extract `traceparent`/`tracestate` headers. **Pipeline Context:** @@ -160,24 +125,14 @@ events.emit("agent.started", {"agent": "writer", "model": "gpt-4o"}) --- -## Exporters - -The `configure_exporters` function sets up OpenTelemetry exporters based on the -framework's configuration: - -```python -from fireflyframework_agentic.observability import configure_exporters - -configure_exporters( - otlp_endpoint="http://localhost:4317", - console=True, -) -``` - -Supported exporters: +## Exporters and SDK Configuration -- **OTLP** -- Sends traces and metrics to any OpenTelemetry-compatible collector. -- **Console** -- Prints spans and metrics to standard output (useful for development). +The framework does **not** configure OpenTelemetry exporters or install global +providers. It emits spans and metrics through the OpenTelemetry API; the **host** +application owns SDK/exporter setup (OTLP collector, console, Jaeger, Azure +Monitor, etc.). Configure the SDK once in your host process — for example with +the standard `opentelemetry-sdk` / `opentelemetry-exporter-otlp` packages — and +the framework's telemetry flows into it automatically. --- @@ -268,7 +223,7 @@ For the single-tenant case, the `budget_limit_usd` config field auto-installs a ## Cost Sinks -`UsageTracker` fans every `UsageRecord` out to one or more `CostSink` instances. Built-ins: `OTelMetricsSink`, `EventBusSink`, `LoggingSink`, `JSONLFileSink`, `WebhookSink`. Custom sinks implement the protocol's `emit(record)` method. +`UsageTracker` fans every `UsageRecord` out to one or more `CostSink` instances. Built-ins: `OTelMetricsSink`, `EventBusSink`, `LoggingSink`, `JSONLFileSink`. Custom sinks implement the protocol's `emit(record)` method. ```python from fireflyframework_agentic.observability.sinks import ( diff --git a/docs/security.md b/docs/security.md index 6703e631..0b5334e7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -309,76 +309,6 @@ The `scan()` method returns an `OutputGuardResult` dataclass with: --- -## Role-Based Access Control (RBAC) - -The RBAC module provides JWT-based authentication and role/permission management -for multi-tenant agent deployments. - -```python -from fireflyframework_agentic.security.rbac import RBACManager, require_permission - -# Initialize RBAC with JWT secret -rbac = RBACManager(jwt_secret="your-secret-key-here") - -# Create roles and assign permissions -rbac.create_role("admin", permissions=["agent.create", "agent.delete", "agent.run"]) -rbac.create_role("user", permissions=["agent.run"]) - -# Assign roles to users -rbac.assign_role("user@example.com", "user") -rbac.assign_role("admin@example.com", "admin") - -# Generate JWT token -token = rbac.generate_token("user@example.com") - -# Validate token and check permissions -claims = rbac.validate_token(token) -if rbac.has_permission(claims["sub"], "agent.run"): - # Allow access - result = await agent.run(prompt) -``` - -### Decorator-Based Protection - -Protect agent endpoints with the `@require_permission` decorator: - -```python -from fireflyframework_agentic.security.rbac import require_permission - -@require_permission("agent.run") -async def call_agent(prompt: str, token: str): - # Token is validated and permission checked - return await agent.run(prompt) -``` - -### Multi-Tenant Isolation - -RBAC supports tenant-scoped permissions for SaaS applications: - -```python -# Create tenant-specific roles -rbac.create_role("tenant-1-user", permissions=["agent.run"], tenant="tenant-1") -rbac.create_role("tenant-2-user", permissions=["agent.run"], tenant="tenant-2") - -# Assign users to tenants -rbac.assign_role("user1@example.com", "tenant-1-user", tenant="tenant-1") - -# Check tenant-scoped permission -if rbac.has_permission("user1@example.com", "agent.run", tenant="tenant-1"): - # User can access tenant-1 resources only - pass -``` - -### Environment Configuration - -```bash -export FIREFLY_AGENTIC_RBAC_ENABLED=true -export FIREFLY_AGENTIC_RBAC_JWT_SECRET=your-secret-key -export FIREFLY_AGENTIC_RBAC_TOKEN_EXPIRY_SECONDS=3600 -``` - ---- - ## Data Encryption The encryption module provides AES-256-GCM encryption for sensitive data at rest. @@ -500,47 +430,6 @@ export FIREFLY_AGENTIC_DATABASE_ALLOW_UNSAFE_QUERIES=true --- -## CORS Security - -The REST API enforces restrictive CORS policies by default. - -### Default Policy (Secure) - -By default, **no origins** are allowed: - -```python -from fireflyframework_agentic.exposure.rest.middleware import add_cors_middleware - -# Default - blocks all cross-origin requests -add_cors_middleware(app) -``` - -### Explicit Allow List - -Specify allowed origins for production deployments: - -```python -add_cors_middleware( - app, - allow_origins=["https://app.example.com", "https://admin.example.com"], - allow_credentials=True, -) -``` - -### Environment Configuration - -```bash -export FIREFLY_AGENTIC_CORS_ALLOWED_ORIGINS='["https://app.example.com"]' -export FIREFLY_AGENTIC_CORS_ALLOW_CREDENTIALS=true -export FIREFLY_AGENTIC_CORS_ALLOW_METHODS='["GET", "POST"]' -export FIREFLY_AGENTIC_CORS_MAX_AGE=3600 -``` - -**Security Note:** Never use `allow_origins=["*"]` in production. Always -maintain an explicit allow list of trusted domains. - ---- - ## Security Best Practices ### Defence in Depth @@ -554,7 +443,6 @@ from fireflyframework_agentic.agents.builtin_middleware import ( OutputGuardMiddleware, CostGuardMiddleware, ) -from fireflyframework_agentic.security.rbac import require_permission from fireflyframework_agentic.security.encryption import EncryptedMemoryStore # Encrypted storage @@ -573,18 +461,14 @@ agent = FireflyAgent( ], ) -# Protected endpoint -@require_permission("agent.run") -async def secure_endpoint(prompt: str, token: str): - return await agent.run(prompt) +# Run the agent through the security middleware chain +result = await agent.run(prompt) ``` ### Production Checklist -- [x] Enable RBAC for multi-user access - [x] Encrypt sensitive data at rest - [x] Use parameterized queries for database access -- [x] Configure restrictive CORS policies - [x] Enable PromptGuard and OutputGuard middleware - [x] Set budget limits with CostGuardMiddleware - [x] Store secrets in a secure vault (not env vars) diff --git a/docs/tutorial.md b/docs/tutorial.md index b0614b78..ba9c9828 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -9,11 +9,10 @@ Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2. > > Each chapter introduces a concept, explains *why* it exists, shows *how* it works > with architecture diagrams, and immediately applies it to the IDP pipeline. By -> Chapter 20 you will have a production-grade GenAI application that uses agents, +> Chapter 18 you will have a production-grade GenAI application that uses agents, > tools, prompts, reasoning patterns, content processing, memory, validation, pipelines, -> observability, explainability, experiments, a REST API, message-queue consumers **and -> producers** (Kafka, RabbitMQ, Redis), multi-agent delegation, template agents, and -> a plugin system — all wired together. +> observability, explainability, experiments, multi-agent delegation, template agents, +> and a plugin system — all wired together. --- @@ -39,19 +38,17 @@ Copyright 2026 Firefly Software Foundation. Licensed under the Apache License 2. 11. [Observability](#chapter-11-observability) — Tracing, metrics, events, OpenTelemetry exporters 12. [Explainability](#chapter-12-explainability) — Decisions, explanations, audit trail, reports -**Part IV — Experimentation & Deployment** +**Part IV — Experimentation** 13. [Experiments](#chapter-13-experiments) — A/B testing, variant comparison, tracking 14. [Lab](#chapter-14-lab) — Interactive sessions, benchmarks, model comparison, eval datasets -15. [Exposure: REST API](#chapter-15-exposure-rest-api) — FastAPI factory, SSE streaming, health probes, CORS -16. [Exposure: Message Queues](#chapter-16-exposure-message-queues) — Consumers + producers for Kafka, RabbitMQ, Redis *(diagram)* **Part V — Advanced** -17. [Template Agents](#chapter-17-template-agents) — Summariser, classifier, extractor, conversational, router -18. [Multi-Agent Delegation](#chapter-18-multi-agent-delegation) — Delegation router, strategies, memory forking *(diagram)* -19. [Plugin System](#chapter-19-plugin-system) — Entry-point discovery, packaging agents/tools/patterns -20. [Putting It All Together](#chapter-20-putting-it-all-together) — Full IDP implementation, project structure, production checklist *(full system diagram)* +15. [Template Agents](#chapter-15-template-agents) — Summariser, classifier, extractor, conversational, router +16. [Multi-Agent Delegation](#chapter-16-multi-agent-delegation) — Delegation router, strategies, memory forking *(diagram)* +17. [Plugin System](#chapter-17-plugin-system) — Entry-point discovery, packaging agents/tools/patterns +18. [Putting It All Together](#chapter-18-putting-it-all-together) — Full IDP implementation, project structure, production checklist *(full system diagram)* --- @@ -76,7 +73,6 @@ to your destination. - **Backend engineers** building GenAI features into existing applications. - **ML/AI engineers** who want structured reasoning, validation, and observability out of the box. -- **Platform teams** who need a standard way to expose agents via REST APIs and queues. ### The Four Design Principles @@ -87,12 +83,12 @@ The framework is guided by four principles that show up in every module: 2. **Convention over configuration** — Sensible defaults everywhere. One `FireflyAgenticConfig` object (backed by Pydantic Settings) centralises every knob and reads from environment variables automatically. -3. **Layered composition** — Modules are organised into six layers (Core, Agent, - Intelligence, Experimentation, Orchestration, Exposure). Higher layers depend on +3. **Layered composition** — Modules are organised into layers (Core, Agent, + Intelligence, Experimentation, Orchestration). Higher layers depend on lower layers, never the reverse. -4. **Optional dependencies** — Heavy libraries (FastAPI, aiokafka, aio-pika, redis) are - declared as extras. The core framework imports them lazily so you only install what - you use. +4. **Optional dependencies** — Heavy libraries (embedding providers, vector store + clients, storage backends) are declared as extras. The core framework imports them + lazily so you only install what you use. ### The Running Example: Intelligent Document Processing @@ -103,7 +99,7 @@ Raw Document → Classify → Digitise (OCR) → Extract Fields → Validate → ``` Every chapter teaches a framework concept and immediately applies it to a phase of this -pipeline. By Chapter 20 you will have the complete, production-ready system. +pipeline. By Chapter 18 you will have the complete, production-ready system. --- @@ -130,22 +126,20 @@ This installs the core framework with its minimal dependencies: `pydantic-ai`, The framework provides optional extras for additional capabilities: ```bash -# REST API support (FastAPI + Uvicorn + SSE) -uv add "fireflyframework-agentic[rest]" +# Embedding providers (e.g. OpenAI / Azure) +uv add "fireflyframework-agentic[openai-embeddings]" -# Individual message queue backends -uv add "fireflyframework-agentic[kafka]" -uv add "fireflyframework-agentic[rabbitmq]" -uv add "fireflyframework-agentic[redis]" +# Vector store backends +uv add "fireflyframework-agentic[vectorstores-chroma]" -# All queue backends at once -uv add "fireflyframework-agentic[queues]" +# Memory persistence backends +uv add "fireflyframework-agentic[postgres]" -# Everything (REST + all queues) +# Everything uv add "fireflyframework-agentic[all]" ``` -For our IDP project we will eventually use REST and queues, so install everything: +For our IDP project we will eventually use several of these, so install everything: ```bash uv add "fireflyframework-agentic[all]" @@ -191,7 +185,6 @@ Here are the most commonly used configuration fields: - `default_temperature` — Sampling temperature (0.0–1.0). - `max_retries` — Default retry count for agent runs. - `observability_enabled` — Toggle OpenTelemetry instrumentation. -- `otlp_endpoint` — OTLP exporter endpoint (default: console). - `prompt_templates_dir` — Directory for Jinja2 prompt files. - `default_chunk_size` / `default_chunk_overlap` — Content chunking defaults. - `max_context_tokens` — Maximum context window (default 128,000). @@ -342,7 +335,7 @@ agent = FireflyAgent(name="local-agent", model=model) runtime. Both approaches work identically with every framework feature — tools, reasoning -patterns, pipelines, REST exposure, queue consumers, cost tracking, prompt caching, +patterns, pipelines, cost tracking, prompt caching, and all other modules. The framework's `model_utils` module normalizes model identity from both strings and `Model` objects, so observability and resilience features work uniformly across all providers. @@ -367,8 +360,8 @@ FIREFLY_AGENTIC_OBSERVABILITY_ENABLED=true Every GenAI application starts with a single question: *"How do I talk to the model?"* In raw Pydantic AI you create an `Agent`, give it a system prompt, and call `run()`. That works great for scripts — but the moment you need to register agents by name, -share them across REST endpoints and queue consumers, attach lifecycle hooks, or plug -them into reasoning patterns and pipelines, you need a thin coordination layer on top. +share them across pipelines, delegation, and reasoning patterns, attach lifecycle hooks, +or plug them into a larger system, you need a thin coordination layer on top. That is exactly what `FireflyAgent` is. It wraps a Pydantic AI `Agent` and adds three things the framework relies on: a **global registry** (so any module can look up an @@ -398,8 +391,6 @@ graph TB end subgraph Consumers - REST["REST / API"] - QUEUE["Queue Consumers"] PIPE["Pipelines"] DELEG["Delegation Router"] REASON["Reasoning Patterns"] @@ -411,8 +402,6 @@ graph TB FA -->|registers in| REG FA -->|carries| CTX FA -->|hooks| LC - REG -->|lookup by name| REST - REG -->|lookup by name| QUEUE REG -->|lookup by name| PIPE REG -->|lookup by name| DELEG REG -->|lookup by name| REASON @@ -442,7 +431,7 @@ What happens behind the scenes: 2. The decorated function becomes the agent's **dynamic instructions provider** — it is called at the start of every run and can use the context to customise the system prompt. 3. The agent is automatically registered in the global `AgentRegistry`, so any module - (REST endpoints, pipelines, delegation routers) can look it up by name. + (pipelines, delegation routers) can look it up by name. ### Creating an Agent with the Class @@ -466,7 +455,7 @@ classifier = FireflyAgent( output_type=dict, ) -# Register it so other parts of the framework (REST, queues, pipelines) can find it. +# Register it so other parts of the framework (pipelines, delegation) can find it. agent_registry.register(classifier) ``` @@ -492,8 +481,8 @@ async with classifier.run_stream("Classify this document: ...") as stream: ### The Agent Registry The `AgentRegistry` is a process-wide singleton that maps agent names to `FireflyAgent` -instances. This is the glue that lets any module — REST endpoints, queue consumers, -delegation routers, pipelines, reasoning patterns — discover and invoke agents without +instances. This is the glue that lets any module — delegation routers, pipelines, +reasoning patterns — discover and invoke agents without importing them directly: ```python @@ -976,7 +965,7 @@ agent = FireflyAgent( For our IDP pipeline, we need tools the extraction agent can call. We define them with `@firefly_tool`, group them into a `ToolKit`, and attach them to the agent via `as_pydantic_tools()`. This is the pattern you will see end-to-end in -Chapter 6 (reasoning patterns) and Chapter 20 (full IDP application). +Chapter 6 (reasoning patterns) and Chapter 18 (full IDP application). **Step 1 — Define the tools:** @@ -1023,7 +1012,7 @@ extractor_agent = FireflyAgent( > **What happens next:** In Chapter 6 we pass `extractor_agent` (with its tools > already attached) to reasoning patterns like Plan-and-Execute and Reflexion. > The pattern calls `agent.run()` internally — the tools are available because -> they were bound here. Chapter 20 shows the complete production module +> they were bound here. Chapter 18 shows the complete production module > (`idp/tools.py`) with retries, guards, and the full ToolKit. --- @@ -1762,7 +1751,7 @@ if not validation_passed: > **Architecture recap:** Reasoning patterns never see tools directly. They receive > an agent (which owns its tools) and call `agent.run()`. This is why tools must be > bound to the agent *before* passing it to a pattern — see the "Attaching Tools to -> Agents" section in Chapter 4 and the full `idp/tools.py` module in Chapter 20. +> Agents" section in Chapter 4 and the full `idp/tools.py` module in Chapter 18. --- @@ -2916,23 +2905,17 @@ events.emit("pipeline.step.completed", {"step": "classify", "duration_ms": 250}) ### Exporter Configuration -Configure where traces and metrics go: - -```python -from fireflyframework_agentic.observability import configure_exporters - -# Send to an OTLP collector (Jaeger, Grafana Tempo, etc.) -configure_exporters(otlp_endpoint="http://localhost:4317") - -# Or just print to console for development -configure_exporters(console=True) -``` +The framework emits spans and metrics purely through the OpenTelemetry API; it +does not configure the OTel SDK or any exporters itself. The host application +owns OTel SDK and exporter setup — wire up your `TracerProvider`, +`MeterProvider`, and the exporters (OTLP collector, console, etc.) however your +deployment requires, and the framework's telemetry flows through the globally +configured providers automatically. Configuration via environment variables: ```bash export FIREFLY_AGENTIC_OBSERVABILITY_ENABLED=true -export FIREFLY_AGENTIC_OTLP_ENDPOINT=http://localhost:4317 export FIREFLY_AGENTIC_LOG_LEVEL=DEBUG ``` @@ -3135,7 +3118,7 @@ print(report.build_markdown()) --- -# Part IV — Experimentation & Deployment +# Part IV — Experimentation --- @@ -3341,389 +3324,11 @@ print(f"Extraction accuracy: {report.avg_score:.1%}") --- -## Chapter 15: Exposure: REST API - -Your agents work, your pipeline passes validation, your experiments prove which model is -best. Now you need to put it all behind an HTTP endpoint so other services (or a UI) -can call it. The Exposure REST module gives you a one-liner FastAPI application factory -that auto-generates endpoints for every agent in the `AgentRegistry` — including -streaming via Server-Sent Events, health probes, CORS, and correlation-ID propagation. -You can also add custom endpoints for pipelines. - -### Quick Start - -```bash -uv add "fireflyframework-agentic[rest]" -``` - -```python -from fireflyframework_agentic.exposure.rest import create_agentic_app - -app = create_agentic_app(title="IDP Service", version="1.0.0") -``` - -```bash -uvicorn myapp:app --reload -``` - -That's it. The app auto-generates endpoints for every agent in the `AgentRegistry`. - -### What You Get Out of the Box - -- **GET /agents/** — Lists all registered agents with metadata. -- **POST /agents/{name}/run** — Invokes an agent with a JSON body. -- **GET /agents/{name}/stream** — SSE streaming for real-time output. -- **GET /health** — Liveness probe (`{"status": "healthy"}`). -- **GET /health/ready** — Readiness probe (`{"status": "ready"}`). -- **X-Request-ID** middleware — Injects or propagates request correlation IDs. -- **CORS** middleware — Configurable origins. - -### Request and Response - -```json -// POST /agents/extractor/run -{ - "prompt": "Extract fields from: Invoice #INV-001, Acme Corp, $500", - "deps": {} -} -``` - -```json -// Response -{ - "agent_name": "extractor", - "output": {"invoice_number": "INV-001", "vendor_name": "Acme Corp", ...}, - "success": true, - "error": null, - "metadata": {} -} -``` - -### SSE Streaming - -For long-running agent invocations: - -``` -GET /agents/extractor/stream?prompt=Extract+fields+from+... - -data: {"text": "Processing..."} -data: {"text": "Found invoice number..."} -data: [DONE] -``` - -### Configuration - -```python -app = create_agentic_app( - title="IDP Service", - version="1.0.0", - enable_cors=True, - cors_origins=["https://myapp.example.com"], -) -``` - -### Multi-Turn Conversations via REST - -Pass `conversation_id` in the request body: - -```json -{ - "prompt": "What did we discuss earlier?", - "conversation_id": "abc123" -} -``` - -### IDP Tie-In: Exposing the Pipeline as a REST API - -```python -from fireflyframework_agentic.exposure.rest import create_agentic_app -from fastapi import UploadFile - -app = create_agentic_app(title="IDP Service") - -# Custom endpoint for the full IDP pipeline -@app.post("/idp/process") -async def process_document(file: UploadFile): - content = await file.read() - ctx = PipelineContext( - inputs=content, - metadata={"filename": file.filename}, - ) - result = await idp_pipeline.run(context=ctx) - return result.model_dump() if hasattr(result, "model_dump") else result -``` - ---- - -## Chapter 16: Exposure: Message Queues - -REST is great for synchronous request/response, but many production systems are -**event-driven**: documents arrive on a Kafka topic, processing results go back on -another topic, and nothing blocks. The Queues module gives you both sides of that -coin — **consumers** that listen for incoming messages and route them to agents, and -**producers** that publish agent results back to the broker. - -Three brokers are supported out of the box: Apache Kafka, RabbitMQ, and Redis Pub/Sub. -Each follows the same `QueueConsumer` / `QueueProducer` protocol, so switching -brokers is a one-line change. - -```mermaid -flowchart LR - subgraph Broker - REQ["Requests Topic"] - RES["Results Topic"] - end - - subgraph fireflyframework-agentic - CONS["Consumer
KafkaAgentConsumer
RabbitMQAgentConsumer
RedisAgentConsumer
"] - ROUTER["QueueRouter
pattern-based routing"] - REG["AgentRegistry"] - AGT["FireflyAgent"] - PROD["Producer
KafkaAgentProducer
RabbitMQAgentProducer
RedisAgentProducer
"] - end - - REQ --> CONS - CONS --> ROUTER - ROUTER --> REG - REG --> AGT - AGT --> PROD - PROD --> RES -``` - -### Quick Start - -```bash -# Install the backend you need -uv add "fireflyframework-agentic[kafka]" -uv add "fireflyframework-agentic[rabbitmq]" -uv add "fireflyframework-agentic[redis]" -``` - -### Consumers - -Consumers listen on a topic/queue/channel and route each incoming message to a -registered agent. They run continuously — think of them as your agent's "inbox". - -#### Kafka Consumer - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentConsumer - -# This consumer reads from the "idp-incoming-documents" topic. -# Every message body is passed to the "document_classifier" agent's run() method. -consumer = KafkaAgentConsumer( - agent_name="document_classifier", - topic="idp-incoming-documents", - bootstrap_servers="localhost:9092", - group_id="idp-workers", # Kafka consumer group for load balancing -) -await consumer.start() # Blocks and processes messages until stopped -``` - -#### RabbitMQ Consumer - -```python -from fireflyframework_agentic.exposure.queues.rabbitmq import RabbitMQAgentConsumer - -consumer = RabbitMQAgentConsumer( - agent_name="document_classifier", - queue_name="idp-incoming-documents", - url="amqp://guest:guest@localhost/", -) -await consumer.start() -``` - -#### Redis Consumer - -```python -from fireflyframework_agentic.exposure.queues.redis import RedisAgentConsumer - -consumer = RedisAgentConsumer( - agent_name="document_classifier", - channel="idp-incoming-documents", - url="redis://localhost:6379", -) -await consumer.start() -``` - -### Producers - -Producers are the other half — they publish messages (typically agent results) back -to the broker. Each producer satisfies the `QueueProducer` protocol. - -#### Kafka Producer - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentProducer -from fireflyframework_agentic.exposure.queues import QueueMessage - -# Create a producer that publishes to the "idp-results" topic. -producer = KafkaAgentProducer( - topic="idp-results", - bootstrap_servers="localhost:9092", -) - -# Publish a result back to the broker. -await producer.publish(QueueMessage( - body='{"invoice_number": "INV-001", "status": "extracted"}', - headers={"agent": "field_extractor", "tenant": "acme-corp"}, -)) - -# When you're done, clean up. -await producer.stop() -``` - -#### RabbitMQ Producer - -```python -from fireflyframework_agentic.exposure.queues.rabbitmq import RabbitMQAgentProducer - -producer = RabbitMQAgentProducer( - queue_name="idp-results", - url="amqp://guest:guest@localhost/", -) -await producer.publish(QueueMessage(body='{"status": "done"}')) -await producer.stop() -``` - -#### Redis Producer - -```python -from fireflyframework_agentic.exposure.queues.redis import RedisAgentProducer - -producer = RedisAgentProducer( - channel="idp-results", - url="redis://localhost:6379", -) -await producer.publish(QueueMessage(body='{"status": "done"}')) -await producer.stop() -``` - -#### Consumer + Producer Pattern - -The most common pattern is a **consumer that processes messages and publishes results**. -This turns your agent into a microservice that reads from one topic and writes to another: - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentConsumer, KafkaAgentProducer -from fireflyframework_agentic.exposure.queues import QueueMessage -from fireflyframework_agentic.agents.registry import agent_registry - -# Set up both sides -consumer = KafkaAgentConsumer( - agent_name="field_extractor", - topic="idp-extract-requests", - bootstrap_servers="kafka:9092", -) -producer = KafkaAgentProducer( - topic="idp-extract-results", - bootstrap_servers="kafka:9092", -) - -# Process: consume → run agent → publish result -async def process_and_publish(): - agent = agent_registry.get("field_extractor") - # In practice, you'd integrate this into the consumer's message loop. - # Here we show the conceptual flow: - result = await agent.run("Extract fields from: Invoice #INV-001, Acme, $500") - await producer.publish(QueueMessage( - body=str(result.output), - headers={"agent": "field_extractor"}, - )) -``` - -### Queue Messages - -All consumers and producers work with `QueueMessage`: - -```python -from fireflyframework_agentic.exposure.queues import QueueMessage - -message = QueueMessage( - body="Process this invoice", - headers={"tenant": "acme-corp", "priority": "high"}, - routing_key="invoice.process", - reply_to="idp-responses", -) -``` - -### Queue Router - -Route messages to different agents based on routing-key patterns: - -```python -from fireflyframework_agentic.exposure.queues import QueueRouter - -router = QueueRouter(default_agent="fallback") -router.add_route(r"invoice\..*", "invoice_processor") -router.add_route(r"receipt\..*", "receipt_processor") -router.add_route(r"contract\..*", "contract_processor") - -# Incoming message with routing_key="invoice.classify" -# → routed to "invoice_processor" agent -``` - -### Custom Consumers - -For unsupported brokers, extend `BaseQueueConsumer`: - -```python -from fireflyframework_agentic.exposure.queues.base import BaseQueueConsumer - -class MyBrokerConsumer(BaseQueueConsumer): - async def start(self) -> None: - # Connect and begin consuming - ... - - async def stop(self) -> None: - # Disconnect gracefully - ... -``` - -The base class provides `_process_message(message)` which routes to the configured -agent automatically. - -### IDP Tie-In: Processing Documents from Kafka - -In our IDP system, documents arrive on a Kafka topic. The consumer classifies them -and routes to specialised extractors. Results go back on a results topic for -downstream systems to pick up: - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentConsumer, KafkaAgentProducer -from fireflyframework_agentic.exposure.queues import QueueRouter, QueueMessage - -# Route different document types to specialised extraction agents. -# Messages with routing_key "invoice.*" go to the invoice extractor, etc. -router = QueueRouter(default_agent="document_classifier") -router.add_route(r"invoice\..*", "invoice_extractor") -router.add_route(r"receipt\..*", "receipt_extractor") - -# Consumer: reads raw documents from Kafka -consumer = KafkaAgentConsumer( - agent_name="document_classifier", - topic="idp-documents", - bootstrap_servers="kafka:9092", - group_id="idp-consumers", -) - -# Producer: publishes extraction results back to Kafka -producer = KafkaAgentProducer( - topic="idp-results", - bootstrap_servers="kafka:9092", -) - -# Start both — the consumer runs in a loop, the producer is ready to publish. -await consumer.start() -``` - ---- - # Part V — Advanced --- -## Chapter 17: Template Agents +## Chapter 15: Template Agents By now you've written several agents from scratch — classifier, extractor, OCR. Each time you had to think about the system prompt, output type, and registration. But many @@ -3878,7 +3483,7 @@ extractor_agent = create_extractor_agent( --- -## Chapter 18: Multi-Agent Delegation +## Chapter 16: Multi-Agent Delegation Not every document is an invoice. Your IDP system might receive receipts, contracts, and forms — each requiring a specialised agent with different prompts, tools, and @@ -3993,7 +3598,7 @@ router = DelegationRouter( --- -## Chapter 19: Plugin System +## Chapter 17: Plugin System As your application grows, you'll want to share agents, tools, and reasoning patterns across projects — or let third-party teams contribute their own. The Plugin module @@ -4058,7 +3663,7 @@ agents automatically. --- -## Chapter 20: Putting It All Together +## Chapter 18: Putting It All Together You've learned every module in fireflyframework-agentic, each in isolation. Now it's time to see how they all fit together in a single, production-grade application. The diagram @@ -4068,11 +3673,8 @@ below shows the full system architecture — every layer, every connection: ```mermaid graph TB - subgraph "Exposure Layer" - REST["REST API\n(FastAPI + SSE)"] - KAFKA["Kafka Consumer"] - RABBIT["RabbitMQ Consumer"] - REDIS["Redis Consumer"] + subgraph "Caller" + APP["Host application\n(in-process)"] end subgraph "Orchestration Layer" @@ -4112,7 +3714,7 @@ graph TB PLUG["Plugin System\n(entry-point discovery)"] end - REST & KAFKA & RABBIT & REDIS --> PIPE & DELEG + APP --> PIPE & DELEG PIPE --> FA DELEG --> FA FA --> REASON @@ -4154,8 +3756,7 @@ idp-service/ │ ├── tools.py # Tool definitions │ ├── pipeline.py # Pipeline wiring │ ├── validation.py # Validation rules -│ ├── app.py # REST application -│ └── consumers.py # Queue consumers +│ └── main.py # In-process entry point └── tests/ └── test_pipeline.py ``` @@ -4167,7 +3768,6 @@ FIREFLY_AGENTIC_DEFAULT_MODEL=openai:gpt-4o FIREFLY_AGENTIC_DEFAULT_TEMPERATURE=0.1 FIREFLY_AGENTIC_MAX_RETRIES=3 FIREFLY_AGENTIC_OBSERVABILITY_ENABLED=true -FIREFLY_AGENTIC_OTLP_ENDPOINT=http://localhost:4317 FIREFLY_AGENTIC_MEMORY_BACKEND=file FIREFLY_AGENTIC_MEMORY_FILE_DIR=.firefly_memory FIREFLY_AGENTIC_DEFAULT_CHUNK_SIZE=4000 @@ -4372,59 +3972,33 @@ async def process_document(document_bytes: bytes, metadata: dict | None = None) return result.final_output if result.success else {"error": result.failed_nodes} ``` -### REST Application (app.py) +### Entry Point (main.py) + +`fireflyframework-agentic` is a pure in-process library: it serves no port and consumes +no broker. Your host service owns serving and calls `process_document` directly. The host +also owns OTel SDK and exporter configuration; the framework emits spans and metrics +through the OpenTelemetry API, so they flow through whatever providers the host has set up: ```python -from fireflyframework_agentic.exposure.rest import create_agentic_app -from fireflyframework_agentic.observability import configure_exporters -from fastapi import UploadFile -from .pipeline import process_document +import asyncio -# Configure observability -configure_exporters(otlp_endpoint="http://localhost:4317", console=True) +from .pipeline import process_document -# Create the app -app = create_agentic_app(title="IDP Service", version="1.0.0") -@app.post("/idp/process") -async def handle_document(file: UploadFile): - content = await file.read() - result = await process_document( - content, - metadata={"filename": file.filename, "source": "rest-api"}, +async def main(document_bytes: bytes, filename: str) -> dict: + return await process_document( + document_bytes, + metadata={"filename": filename, "source": "host-service"}, ) - return result -``` - -### Queue Consumers (consumers.py) - -```python -from fireflyframework_agentic.exposure.queues.kafka import KafkaAgentConsumer -from fireflyframework_agentic.exposure.queues import QueueRouter -# Route different document types to specialised processing -router = QueueRouter(default_agent="document_classifier") -router.add_route(r"invoice\..*", "field_extractor") -router.add_route(r"receipt\..*", "receipt_processor") -# Main Kafka consumer -consumer = KafkaAgentConsumer( - agent_name="document_classifier", - topic="idp-documents", - bootstrap_servers="kafka:9092", - group_id="idp-workers", -) +if __name__ == "__main__": + with open("invoice.pdf", "rb") as fh: + print(asyncio.run(main(fh.read(), "invoice.pdf"))) ``` -### Running the Service - -```bash -# Start the REST API -uvicorn idp_service.app:app --host 0.0.0.0 --port 8000 - -# Or start the Kafka consumer -python -m idp_service.consumers -``` +To expose this over HTTP or wire it to a message broker, embed `process_document` in +your host service's framework of choice — the agent library stays in-process. ### Production Checklist @@ -4440,8 +4014,6 @@ Before deploying to production, verify: for your use case. - [ ] **Retry limits** — Pipeline nodes have appropriate `retry_max` and `timeout_seconds`. -- [ ] **CORS** — REST API `cors_origins` is restricted to known domains. -- [ ] **Health checks** — Kubernetes probes point to `/health` and `/health/ready`. - [ ] **Experiments** — You've A/B tested your prompt and model variants. - [ ] **Audit trail** — Explainability is enabled for regulated workloads. @@ -4474,6 +4046,4 @@ paths to explore further: - [Explainability](explainability.md) - [Experiments](experiments.md) - [Lab](lab.md) -- [Exposure REST](exposure-rest.md) -- [Exposure Queues](exposure-queues.md) - [Use Case: IDP](use-case-idp.md) diff --git a/docs/use-case-idp.md b/docs/use-case-idp.md index 6c63f9e9..6c20f373 100644 --- a/docs/use-case-idp.md +++ b/docs/use-case-idp.md @@ -324,30 +324,6 @@ else: --- -## Exposing the Pipeline via REST - -Register the pipeline as a REST endpoint so it can be called from external systems: - -```python -from fireflyframework_agentic.exposure.rest import create_agentic_app - -# The IDP agents are already registered in the AgentRegistry. -# The REST app auto-generates endpoints for each agent. -app = create_agentic_app() - -# The pipeline itself can be exposed as a custom endpoint: -from fastapi import UploadFile - -@app.post("/idp/process") -async def process_document(file: UploadFile): - content = await file.read() - ctx = PipelineContext(inputs=content, metadata={"filename": file.filename}) - result = await idp_pipeline.run(context=ctx) - return result.model_dump() -``` - ---- - ## Key Framework Features Used This use case exercises the following framework capabilities: @@ -381,8 +357,5 @@ This use case exercises the following framework capabilities: (with `warn_only`, `per_call_limit_usd`), Observability, Explainability, Cache, Validation. - **Logging** -- `configure_logging` for structured framework-wide logging. -- **Exposure** -- REST API with authentication middleware (`add_auth_middleware`), - WebSocket endpoint (`/ws/agents/{name}`), conversation CRUD endpoints, and - SSE streaming. - **Observability** -- `PipelineResult.execution_trace` for per-node timing and status; bounded `UsageTracker` with `max_records` for production memory management. diff --git a/examples/cost_tracking.py b/examples/cost_tracking.py index 3a53718b..6ea773f0 100644 --- a/examples/cost_tracking.py +++ b/examples/cost_tracking.py @@ -10,12 +10,10 @@ On top of that it shows: -* Attaching custom sinks (``JSONLFileSink``) to the *existing* tracker so - every agent's cost lands on disk for offline inspection. -* Optional Azure Monitor export — when - ``APPLICATIONINSIGHTS_CONNECTION_STRING`` is in the environment, OTel - metrics flow to Application Insights; otherwise the demo falls back to - local sinks only. +* Attaching custom sinks (``JSONLFileSink``, ``OTelMetricsSink``) to the + *existing* tracker so every agent's cost lands on disk for offline + inspection and is emitted via the OpenTelemetry API. Configuring the OTel + SDK/exporters (where those metrics ultimately land) is the host's job. * A :class:`BudgetGate` with HARD/SOFT rules installed on the default tracker so it applies to real agent traffic. * A model-specific :class:`CostFn` (``fixed_rate_cost``) backed by @@ -58,7 +56,6 @@ DEFAULT_RESOLVERS, CostContext, ) -from fireflyframework_agentic.observability.exporters import configure_exporters from fireflyframework_agentic.observability.sinks import ( JSONLFileSink, OTelMetricsSink, @@ -101,31 +98,11 @@ def fixed_rate_cost(ctx: CostContext) -> float | None: return ctx.input_tokens * input_price + ctx.output_tokens * output_price -def _try_attach_app_insights() -> bool: - """Wire Azure Monitor exporters if a connection string is present.""" - cs = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING") - if not cs: - print("APPLICATIONINSIGHTS_CONNECTION_STRING not set; skipping App Insights.") - return False - try: - configure_exporters( - service_name="firefly-cost-demo", - azure_monitor_connection_string=cs, - metric_export_interval_ms=5_000, - ) - except Exception as exc: # noqa: BLE001 - print(f"App Insights export not enabled ({type(exc).__name__}: {exc}); falling back to local sinks.") - return False - print("App Insights exporters attached.") - return True - - -def configure_default_tracker(*, with_otel: bool, inflated_prices: bool) -> None: +def configure_default_tracker(*, inflated_prices: bool) -> None: """Install sinks, optional fixed-rate resolver, and budget gate on the singleton.""" JSONL_PATH.unlink(missing_ok=True) default_usage_tracker.add_sink(JSONLFileSink(JSONL_PATH)) - if with_otel: - default_usage_tracker.add_sink(OTelMetricsSink()) + default_usage_tracker.add_sink(OTelMetricsSink()) resolvers = list(DEFAULT_RESOLVERS) if inflated_prices: @@ -210,8 +187,7 @@ def _print_breakdown(title: str, group: dict, *, width: int) -> None: async def main() -> None: args = parse_args() - app_insights_ready = _try_attach_app_insights() - configure_default_tracker(with_otel=app_insights_ready, inflated_prices=args.inflated_prices) + configure_default_tracker(inflated_prices=args.inflated_prices) try: await run_agents() except BudgetExceededError as exc: diff --git a/examples/distributed_tracing.py b/examples/distributed_tracing.py deleted file mode 100755 index 8fbcd46c..00000000 --- a/examples/distributed_tracing.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Distributed tracing with W3C Trace Context propagation. - -This example demonstrates how Firefly Agentic automatically propagates trace -context across service boundaries using the W3C Trace Context standard. - -Features demonstrated: -- Automatic trace context injection into HTTP requests -- Trace context extraction from incoming requests -- Multi-agent distributed tracing -- Queue-based trace propagation (Kafka, RabbitMQ, Redis) -- Jaeger UI visualization - -Prerequisites: - 1. Start Jaeger for trace visualization: - docker run -d --name jaeger \\ - -p 16686:16686 \\ - -p 4317:4317 \\ - jaegertracing/all-in-one:latest - - 2. Set environment variables: - export FIREFLY_AGENTIC_OTLP_ENDPOINT=http://localhost:4317 - export OPENAI_API_KEY=sk-... - - 3. View traces at: http://localhost:16686 - -Usage: - python examples/distributed_tracing.py -""" - -import asyncio - -from opentelemetry import trace - -from fireflyframework_agentic.agents.base import FireflyAgent -from fireflyframework_agentic.config import get_config -from fireflyframework_agentic.observability.tracer import ( - default_tracer, - extract_trace_context, - inject_trace_context, - trace_context_scope, -) - - -async def simulate_http_request(url: str, payload: str) -> str: - """Simulate an HTTP request with trace propagation. - - In a real application, this would be an actual HTTP client call. - """ - # Inject trace context into outgoing request headers - headers = {"Content-Type": "application/json"} - inject_trace_context(headers) - - print(f"→ HTTP POST {url}") - print(f" Headers: {headers}") - print(f" traceparent: {headers.get('traceparent', 'None')}") - - # Simulate receiving response headers - response_headers = {} - inject_trace_context(response_headers) - - return "Response from service" - - -async def agent_service_a() -> str: - """Service A: Initial request handler.""" - print("\n" + "=" * 70) - print("SERVICE A: Processing initial request") - print("=" * 70) - - agent = FireflyAgent( - name="service_a_agent", - model="openai:gpt-4o-mini", - description="First agent in the distributed trace", - ) - - # Create a span for Service A - with default_tracer.agent_span("service_a", model="gpt-4o-mini"): - result = await agent.run("Generate a short creative story opening (max 2 sentences).") - - # Service A calls Service B via HTTP - print("\n→ Service A calling Service B via HTTP...") - await simulate_http_request("http://service-b/process", result.output) - - return result.output - - -async def agent_service_b(incoming_headers: dict[str, str], prompt: str) -> str: - """Service B: Receives request from Service A with trace context.""" - print("\n" + "=" * 70) - print("SERVICE B: Processing request from Service A") - print("=" * 70) - print(f" Received traceparent: {incoming_headers.get('traceparent', 'None')}") - - # Extract trace context from incoming request - span_context = extract_trace_context(incoming_headers) - - agent = FireflyAgent( - name="service_b_agent", - model="openai:gpt-4o-mini", - description="Second agent in the distributed trace", - ) - - # Continue the trace from Service A - with trace_context_scope(span_context), default_tracer.agent_span("service_b", model="gpt-4o-mini"): - result = await agent.run(f"Continue this story with one more sentence: {prompt}") - - # Service B calls Service C - print("\n→ Service B calling Service C via HTTP...") - await simulate_http_request("http://service-c/finalize", result.output) - - return result.output - - -async def agent_service_c(incoming_headers: dict[str, str], prompt: str) -> str: - """Service C: Final service in the chain.""" - print("\n" + "=" * 70) - print("SERVICE C: Processing request from Service B") - print("=" * 70) - print(f" Received traceparent: {incoming_headers.get('traceparent', 'None')}") - - # Extract trace context from incoming request - span_context = extract_trace_context(incoming_headers) - - agent = FireflyAgent( - name="service_c_agent", - model="openai:gpt-4o-mini", - description="Final agent in the distributed trace", - ) - - # Continue the trace from Service B - with trace_context_scope(span_context), default_tracer.agent_span("service_c", model="gpt-4o-mini"): - result = await agent.run(f"Add a surprising plot twist to this story: {prompt}") - - return result.output - - -async def main() -> None: - """Demonstrate distributed tracing across multiple services.""" - - print("=" * 70) - print("Distributed Tracing Example") - print("=" * 70) - - cfg = get_config() - if cfg.otlp_endpoint: - print(f"\n✓ OTLP endpoint: {cfg.otlp_endpoint}") - print("✓ Traces will be exported to Jaeger") - print("✓ View at: http://localhost:16686") - else: - print("\n⚠ No OTLP endpoint configured - traces will be console-only") - print("Set FIREFLY_AGENTIC_OTLP_ENDPOINT=http://localhost:4317 to enable Jaeger") - - # Start distributed trace - tracer = trace.get_tracer(__name__) - - with tracer.start_as_current_span("distributed_trace_example") as root_span: - print("\n✓ Started root trace") - root_context = root_span.get_span_context() - print(f" Trace ID: {root_context.trace_id:032x}") - print(f" Root Span ID: {root_context.span_id:016x}") - - # Service A processes initial request - story_opening = await agent_service_a() - - # Simulate Service A calling Service B with trace propagation - headers_to_b = {} - inject_trace_context(headers_to_b) - story_continuation = await agent_service_b(headers_to_b, story_opening) - - # Simulate Service B calling Service C with trace propagation - headers_to_c = {} - inject_trace_context(headers_to_c) - story_final = await agent_service_c(headers_to_c, story_continuation) - - print("\n" + "=" * 70) - print("Final Story Result") - print("=" * 70) - print(story_final) - - print("\n" + "=" * 70) - print("Trace Propagation Summary") - print("=" * 70) - print("✓ Service A → Service B → Service C") - print("✓ All services share the same trace ID") - print("✓ Each service has its own span ID") - print("✓ Parent-child relationships are preserved") - print("=" * 70) - - print("\n" + "=" * 70) - print("View Trace in Jaeger") - print("=" * 70) - print("1. Open http://localhost:16686") - print("2. Select 'fireflyframework_agentic' service") - print("3. Click 'Find Traces'") - print(f"4. Look for trace ID: {root_context.trace_id:032x}") - print("5. Click the trace to see the full span hierarchy:") - print(" - distributed_trace_example (root)") - print(" - agent.service_a") - print(" - agent.service_b") - print(" - agent.service_c") - print("=" * 70) - - # Allow time for trace export - await asyncio.sleep(1) - - -async def demonstrate_queue_propagation(): - """Demonstrate trace propagation through message queues. - - This shows how trace context is automatically propagated through - Kafka, RabbitMQ, and Redis Pub/Sub. - """ - print("\n" + "=" * 70) - print("Queue-Based Trace Propagation") - print("=" * 70) - - # Example of injecting trace context into Kafka message - print("\n1. Kafka Message:") - headers = {} - inject_trace_context(headers) - kafka_headers = [(k, v.encode()) for k, v in headers.items()] - print(f" Headers: {kafka_headers}") - - # Example of injecting trace context into RabbitMQ message - print("\n2. RabbitMQ Message:") - headers = {} - inject_trace_context(headers) - print(f" Headers: {headers}") - - # Example of injecting trace context into Redis message - print("\n3. Redis Pub/Sub Message (JSON-wrapped):") - import json - - headers = {} - inject_trace_context(headers) - redis_message = json.dumps({"headers": headers, "body": "message content"}) - print(f" Wrapped message: {redis_message}") - - print("\n✓ Queue consumers automatically extract trace context") - print("✓ Traces span across async message boundaries") - print("=" * 70) - - -if __name__ == "__main__": - asyncio.run(main()) - asyncio.run(demonstrate_queue_propagation()) diff --git a/examples/full_integration.py b/examples/full_integration.py index 4638dbf6..5f0afa5c 100644 --- a/examples/full_integration.py +++ b/examples/full_integration.py @@ -17,9 +17,9 @@ This example shows all production-ready features working together: - Database persistence (PostgreSQL/MongoDB) -- Distributed tracing (W3C Trace Context) +- Model/agent telemetry (OpenTelemetry spans and metrics) - API quota management -- Security (RBAC, encryption, SQL injection prevention) +- Security (encryption, SQL injection prevention) - HTTP connection pooling - Incremental streaming - Batch processing @@ -245,33 +245,20 @@ async def demo_security_features(): """Demonstrate security features integration.""" print("\n\n=== Security Features Integration ===\n") - # RBAC (if enabled) - print("1. RBAC (Role-Based Access Control):") - print(" Configure with: FIREFLY_AGENTIC_RBAC_ENABLED=true") - print(" Set JWT secret: FIREFLY_AGENTIC_RBAC_JWT_SECRET=your-secret") - print(" Use @require_permission decorator on agent endpoints") - print() - # Encryption (if enabled) - print("2. Data Encryption:") + print("1. Data Encryption:") print(" Configure with: FIREFLY_AGENTIC_ENCRYPTION_ENABLED=true") print(" Set encryption key: FIREFLY_AGENTIC_ENCRYPTION_KEY=your-key-32-bytes") print(" Use EncryptedMemoryStore wrapper for sensitive data") print() # SQL Injection Prevention - print("3. SQL Injection Prevention:") + print("2. SQL Injection Prevention:") print(" Automatically enabled in DatabaseTool") print(" Detects 15+ dangerous SQL patterns") print(" Enforces parameterized queries") print() - # CORS Security - print("4. CORS Security:") - print(" Default: No origins allowed (secure)") - print(" Configure: FIREFLY_AGENTIC_CORS_ALLOWED_ORIGINS=['https://app.example.com']") - print() - async def demo_observability_integration(): """Demonstrate observability features.""" @@ -279,11 +266,9 @@ async def demo_observability_integration(): config = get_config() - print("1. Distributed Tracing:") - print(f" Enabled: {config.observability_enabled}") - print(f" OTLP endpoint: {config.otlp_endpoint or 'Not configured'}") - print(f" Service name: {config.service_name}") - print(" W3C Trace Context propagation: Enabled") + print("1. Telemetry:") + print(f" Model/agent observability enabled: {config.observability_enabled}") + print(" Spans/metrics are emitted via the OpenTelemetry API; the host configures exporters.") print() print("2. Usage Tracking:") @@ -312,10 +297,8 @@ async def demo_configuration_integration(): print("export FIREFLY_AGENTIC_MEMORY_MONGODB_URL=mongodb://localhost:27017/") print() - print("# Distributed Tracing") + print("# Telemetry (the host owns OTel SDK/exporter configuration)") print("export FIREFLY_AGENTIC_OBSERVABILITY_ENABLED=true") - print("export FIREFLY_AGENTIC_OTLP_ENDPOINT=http://localhost:4317") - print("export FIREFLY_AGENTIC_SERVICE_NAME=my-genai-app") print() print("# Quota Management") @@ -325,11 +308,8 @@ async def demo_configuration_integration(): print() print("# Security") - print("export FIREFLY_AGENTIC_RBAC_ENABLED=true") - print("export FIREFLY_AGENTIC_RBAC_JWT_SECRET=your-secret-key") print("export FIREFLY_AGENTIC_ENCRYPTION_ENABLED=true") print("export FIREFLY_AGENTIC_ENCRYPTION_KEY=your-32-byte-key") - print("export FIREFLY_AGENTIC_CORS_ALLOWED_ORIGINS=['https://app.example.com']") print() print("# HTTP Connection Pooling") @@ -351,9 +331,9 @@ async def main(): print() print("This example demonstrates all production-ready features working together:") print("✓ Database persistence (PostgreSQL/MongoDB)") - print("✓ Distributed tracing (W3C Trace Context)") + print("✓ Model/agent telemetry (OpenTelemetry spans and metrics)") print("✓ API quota management") - print("✓ Security (RBAC, encryption, SQL injection prevention)") + print("✓ Security (encryption, SQL injection prevention)") print("✓ HTTP connection pooling") print("✓ Incremental streaming") print("✓ Batch processing") @@ -377,7 +357,6 @@ async def main(): print("✓ Configuration is unified through environment variables") print("✓ Middleware provides composable production features") print("✓ Pipelines support all agent capabilities") - print("✓ REST API exposes all functionality") print() print("Quick Start:") print(" 1. Set environment variables for desired features") @@ -387,7 +366,7 @@ async def main(): print("For detailed documentation:") print(" - docs/deployment.md - Production deployment guide") print(" - docs/observability.md - Tracing and monitoring") - print(" - docs/security.md - RBAC and encryption") + print(" - docs/security.md - Encryption and SQL injection prevention") print(" - docs/memory.md - Database persistence") print() diff --git a/examples/incremental_streaming.py b/examples/incremental_streaming.py index f5b46075..224a7b45 100644 --- a/examples/incremental_streaming.py +++ b/examples/incremental_streaming.py @@ -269,10 +269,6 @@ async def main(): print("\n # With debouncing to reduce message frequency") print(" async for token in stream.stream_tokens(debounce_ms=50.0):") print(" ...") - print("\nREST API:") - print(" POST /agents/{name}/stream/incremental") - print(" - Returns SSE events with individual tokens") - print(" - Query param: debounce_ms (optional)") print() diff --git a/fireflyframework_agentic/__init__.py b/fireflyframework_agentic/__init__.py index 2eadcab8..993b0248 100644 --- a/fireflyframework_agentic/__init__.py +++ b/fireflyframework_agentic/__init__.py @@ -16,8 +16,7 @@ This package provides production-grade abstractions for building GenAI applications including agents, reasoning patterns, prompt engineering, -tools, observability, explainability, experimentation, and exposure -via REST APIs and message queues. +tools, observability, explainability, and experimentation. Quick start:: @@ -51,7 +50,6 @@ EmbeddingProviderError, ExperimentError, ExplainabilityError, - ExposureError, FireflyAgenticError, FireflyMemoryError, MemoryError, @@ -63,7 +61,6 @@ PromptNotFoundError, PromptValidationError, QoSError, - QueueConnectionError, QuotaError, RateLimitError, ReasoningError, @@ -122,8 +119,6 @@ "ExperimentError", "ObservabilityError", "ExplainabilityError", - "ExposureError", - "QueueConnectionError", "ChunkingError", "CompressionError", "OutputReviewError", diff --git a/fireflyframework_agentic/agents/base.py b/fireflyframework_agentic/agents/base.py index 9e93106c..b2ad6b89 100644 --- a/fireflyframework_agentic/agents/base.py +++ b/fireflyframework_agentic/agents/base.py @@ -106,8 +106,8 @@ class FireflyAgent(Generic[AgentDepsT, OutputT]): output_type: The Pydantic model (or scalar type) for structured output. deps_type: The dependency type expected at run time. tools: Sequence of tool functions or :class:`pydantic_ai.Tool` objects. - description: Free-form description shown in documentation and the REST - exposure layer. + description: Free-form description shown in documentation and agent + discovery listings. version: Semantic version string for this agent definition. tags: Iterable of tags used for capability-based discovery. metadata: Arbitrary key-value pairs attached to the agent. diff --git a/fireflyframework_agentic/agents/registry.py b/fireflyframework_agentic/agents/registry.py index 86c20f20..7c7b6647 100644 --- a/fireflyframework_agentic/agents/registry.py +++ b/fireflyframework_agentic/agents/registry.py @@ -44,11 +44,11 @@ class AgentRegistry: The registry enables: - * **Discovery** -- the REST exposure layer queries the registry to - auto-generate endpoints for every agent. + * **Discovery** -- host services query the registry to discover agents + by name. * **Delegation** -- the :class:`DelegationRouter` selects among registered agents based on capability tags. - * **Lifecycle** -- the exposure layer can iterate over agents to run + * **Lifecycle** -- callers can iterate over registered agents to run warmup / shutdown hooks. """ diff --git a/fireflyframework_agentic/config.py b/fireflyframework_agentic/config.py index a1f3bc88..d7e84e18 100644 --- a/fireflyframework_agentic/config.py +++ b/fireflyframework_agentic/config.py @@ -58,9 +58,6 @@ class FireflyAgenticConfig(BaseSettings): observability_enabled: bool = True """Whether OpenTelemetry instrumentation is active.""" - otlp_endpoint: str | None = None - """OTLP exporter endpoint. When *None*, traces are exported to the console.""" - log_level: str = "INFO" """Logging level for the framework's internal logger.""" @@ -151,14 +148,6 @@ class FireflyAgenticConfig(BaseSettings): memory_mongodb_pool_size: int = 10 """Maximum connections in MongoDB pool.""" - # -- Authentication ------------------------------------------------------- - auth_api_keys: list[str] | None = None - """List of valid API keys for REST endpoint authentication. When set, - the auth middleware is automatically enabled.""" - - auth_bearer_tokens: list[str] | None = None - """List of valid bearer tokens for REST endpoint authentication.""" - # -- Usage tracker ------------------------------------------------------- usage_tracker_max_records: int = 10_000 """Maximum number of usage records retained in memory. Oldest records @@ -187,25 +176,13 @@ class FireflyAgenticConfig(BaseSettings): rate_limit_max_delay: float = 60.0 """Maximum delay (seconds) between rate limit retries.""" - # -- Security (RBAC & Encryption) ---------------------------------------- - rbac_enabled: bool = False - """Whether Role-Based Access Control is active.""" - - rbac_jwt_secret: str | None = None - """JWT secret key for token signing and verification.""" - - rbac_multi_tenant: bool = False - """Whether to enforce tenant isolation in RBAC.""" - + # -- Security (Encryption) ----------------------------------------------- encryption_enabled: bool = False """Whether data encryption at rest is active.""" encryption_key: str | None = None """Encryption key for AES-256-GCM (32 bytes, or password for key derivation).""" - cors_allowed_origins: list[str] = [] - """List of allowed CORS origins. Empty list = no origins allowed (secure default).""" - # -- HTTP Connection Pooling --------------------------------------------- http_pool_enabled: bool = True """Whether to use HTTP connection pooling (requires httpx).""" @@ -240,11 +217,21 @@ class FireflyAgenticConfig(BaseSettings): @classmethod def _reject_removed_cost_fields(cls, data: Any) -> Any: if isinstance(data, dict): - removed = {"cost_calculator", "budget_alert_threshold_usd"} & set(data) + removed = { + "cost_calculator", + "budget_alert_threshold_usd", + "auth_api_keys", + "auth_bearer_tokens", + "cors_allowed_origins", + "otlp_endpoint", + "rbac_enabled", + "rbac_jwt_secret", + "rbac_multi_tenant", + } & set(data) if removed: raise ValueError( - f"Removed cost-tracking config fields: {sorted(removed)}. " - "See docs/observability.md for the new BudgetGate / resolver API." + f"Removed config fields: {sorted(removed)}. Serving/exposure (REST/queue " + "auth, CORS) is now owned by the host service; see CHANGELOG." ) return data diff --git a/fireflyframework_agentic/exceptions.py b/fireflyframework_agentic/exceptions.py index 8a42c709..095dc96d 100644 --- a/fireflyframework_agentic/exceptions.py +++ b/fireflyframework_agentic/exceptions.py @@ -118,17 +118,6 @@ class ExplainabilityError(FireflyAgenticError): """Raised for errors in trace recording, explanation generation, or audit.""" -# -- Exposure ---------------------------------------------------------------- - - -class ExposureError(FireflyAgenticError): - """Raised for errors in REST API or queue-based agent exposure.""" - - -class QueueConnectionError(ExposureError): - """Raised when a queue backend connection fails.""" - - # -- Content processing ------------------------------------------------------ diff --git a/fireflyframework_agentic/exposure/__init__.py b/fireflyframework_agentic/exposure/__init__.py deleted file mode 100644 index eda2031e..00000000 --- a/fireflyframework_agentic/exposure/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Exposure package -- REST API and message queue agent exposure.""" - -from fireflyframework_agentic.exposure.queues import ( - BaseQueueConsumer, - QueueConsumer, - QueueMessage, - QueueProducer, - QueueRouter, -) - -__all__ = [ - "BaseQueueConsumer", - "QueueConsumer", - "QueueMessage", - "QueueProducer", - "QueueRouter", -] - - -def __getattr__(name: str): - """Lazy-load REST symbols so the package works without FastAPI installed.""" - _rest_names = {"create_agentic_app", "AgentRequest", "AgentResponse", "HealthResponse"} - if name in _rest_names: - # imports-top: optional dep (fastapi) loaded on demand inside __getattr__ - from fireflyframework_agentic.exposure.rest import ( # noqa: PLC0415 — optional dep loaded on demand - AgentRequest, - AgentResponse, - HealthResponse, - create_agentic_app, - ) - - _map = { - "create_agentic_app": create_agentic_app, - "AgentRequest": AgentRequest, - "AgentResponse": AgentResponse, - "HealthResponse": HealthResponse, - } - return _map[name] - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fireflyframework_agentic/exposure/queues/__init__.py b/fireflyframework_agentic/exposure/queues/__init__.py deleted file mode 100644 index 5a3f9a20..00000000 --- a/fireflyframework_agentic/exposure/queues/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Queues exposure subpackage -- Kafka, RabbitMQ, Redis consumers, producers, and routing.""" - -from fireflyframework_agentic.exposure.queues.base import ( - BaseQueueConsumer, - QueueConsumer, - QueueMessage, - QueueProducer, -) -from fireflyframework_agentic.exposure.queues.router import QueueRouter - -__all__ = [ - "BaseQueueConsumer", - "QueueConsumer", - "QueueMessage", - "QueueProducer", - "QueueRouter", -] - - -def __getattr__(name: str): - """Lazy-load queue backend implementations so the package works without - their optional dependencies (aiokafka, aio-pika, redis) installed.""" - _kafka_names = {"KafkaAgentConsumer", "KafkaAgentProducer"} - _rabbitmq_names = {"RabbitMQAgentConsumer", "RabbitMQAgentProducer"} - _redis_names = {"RedisAgentConsumer", "RedisAgentProducer"} - - if name in _kafka_names: - # imports-top: module-level __getattr__ lazy loader - from fireflyframework_agentic.exposure.queues.kafka import ( - KafkaAgentConsumer, - KafkaAgentProducer, - ) - - return {"KafkaAgentConsumer": KafkaAgentConsumer, "KafkaAgentProducer": KafkaAgentProducer}[name] - - if name in _rabbitmq_names: - # imports-top: module-level __getattr__ lazy loader - from fireflyframework_agentic.exposure.queues.rabbitmq import ( - RabbitMQAgentConsumer, - RabbitMQAgentProducer, - ) - - return {"RabbitMQAgentConsumer": RabbitMQAgentConsumer, "RabbitMQAgentProducer": RabbitMQAgentProducer}[name] - - if name in _redis_names: - # imports-top: module-level __getattr__ lazy loader - from fireflyframework_agentic.exposure.queues.redis import ( - RedisAgentConsumer, - RedisAgentProducer, - ) - - return {"RedisAgentConsumer": RedisAgentConsumer, "RedisAgentProducer": RedisAgentProducer}[name] - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fireflyframework_agentic/exposure/queues/base.py b/fireflyframework_agentic/exposure/queues/base.py deleted file mode 100644 index ee421897..00000000 --- a/fireflyframework_agentic/exposure/queues/base.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Abstract queue consumer and producer protocols.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Protocol, runtime_checkable - -from pydantic import BaseModel - -from fireflyframework_agentic.agents.registry import agent_registry - - -class QueueMessage(BaseModel): - """A message consumed from or produced to a queue.""" - - body: str - headers: dict[str, str] = {} - routing_key: str = "" - reply_to: str = "" - - -@runtime_checkable -class QueueConsumer(Protocol): - """Protocol for queue consumers.""" - - async def start(self) -> None: ... - async def stop(self) -> None: ... - - -@runtime_checkable -class QueueProducer(Protocol): - """Protocol for queue producers.""" - - async def publish(self, message: QueueMessage) -> None: ... - - -class BaseQueueConsumer(ABC): - """Abstract base class for queue consumers that route messages to agents. - - Parameters: - agent_name: Name of the agent to route messages to. - """ - - def __init__(self, agent_name: str) -> None: - self._agent_name = agent_name - self._running = False - - @property - def agent_name(self) -> str: - return self._agent_name - - @property - def is_running(self) -> bool: - return self._running - - @abstractmethod - async def start(self) -> None: - """Connect and begin consuming messages.""" - ... - - @abstractmethod - async def stop(self) -> None: - """Gracefully stop consuming and disconnect.""" - ... - - async def _process_message(self, message: QueueMessage) -> str: - """Route the message to the configured agent and return the response.""" - agent = agent_registry.get(self._agent_name) - result = await agent.run(message.body) - return str(result.output if hasattr(result, "output") else result) diff --git a/fireflyframework_agentic/exposure/queues/kafka.py b/fireflyframework_agentic/exposure/queues/kafka.py deleted file mode 100644 index 23d9203a..00000000 --- a/fireflyframework_agentic/exposure/queues/kafka.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Kafka consumer/producer for agent exposure. - -Requires the ``aiokafka`` optional dependency (install via -``pip install fireflyframework-agentic[kafka]``). -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from aiokafka import AIOKafkaConsumer, AIOKafkaProducer # pyright: ignore[reportMissingImports] -else: - try: - from aiokafka import AIOKafkaConsumer, AIOKafkaProducer # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - AIOKafkaConsumer = None - AIOKafkaProducer = None - -from fireflyframework_agentic.exposure.queues.base import BaseQueueConsumer, QueueMessage -from fireflyframework_agentic.observability.tracer import extract_trace_context, trace_context_scope - -logger = logging.getLogger(__name__) - - -_AIOKAFKA_IMPORT_ERROR = ( - "aiokafka is required for Kafka support. Install it with: pip install fireflyframework-agentic[kafka]" -) - - -class KafkaAgentConsumer(BaseQueueConsumer): - """Consume messages from a Kafka topic and route to an agent. - - Parameters: - agent_name: Name of the agent to invoke. - topic: Kafka topic to consume from. - bootstrap_servers: Kafka bootstrap servers. - group_id: Consumer group ID. - """ - - def __init__( - self, - agent_name: str, - *, - topic: str, - bootstrap_servers: str = "localhost:9092", - group_id: str = "firefly-agentic", - ) -> None: - super().__init__(agent_name) - self._topic = topic - self._bootstrap_servers = bootstrap_servers - self._group_id = group_id - self._consumer: Any = None - - async def start(self) -> None: - """Connect to Kafka and begin consuming.""" - if AIOKafkaConsumer is None: - raise ImportError(_AIOKAFKA_IMPORT_ERROR) - - self._consumer = AIOKafkaConsumer( - self._topic, - bootstrap_servers=self._bootstrap_servers, - group_id=self._group_id, - ) - await self._consumer.start() - self._running = True - logger.info("Kafka consumer started on topic '%s'", self._topic) - - try: - async for msg in self._consumer: - # Extract trace context from message headers for distributed tracing - headers = {k: v.decode("utf-8") if isinstance(v, bytes) else v for k, v in (msg.headers or [])} - span_context = extract_trace_context(headers) - - message = QueueMessage(body=msg.value.decode("utf-8")) - - # Process message within trace context scope - with trace_context_scope(span_context): - 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() - - async def stop(self) -> None: - """Stop the Kafka consumer.""" - if self._consumer: - await self._consumer.stop() - self._running = False - logger.info("Kafka consumer stopped") - - -class KafkaAgentProducer: - """Publish messages to a Kafka topic. - - Satisfies the :class:`~fireflyframework_agentic.exposure.queues.base.QueueProducer` - protocol. - - Parameters: - topic: Kafka topic to publish to. - bootstrap_servers: Kafka bootstrap servers. - """ - - def __init__( - self, - *, - topic: str, - bootstrap_servers: str = "localhost:9092", - ) -> None: - self._topic = topic - self._bootstrap_servers = bootstrap_servers - self._producer: Any = None - - async def start(self) -> None: - """Connect the underlying Kafka producer.""" - if AIOKafkaProducer is None: - raise ImportError(_AIOKAFKA_IMPORT_ERROR) - - self._producer = AIOKafkaProducer( - bootstrap_servers=self._bootstrap_servers, - ) - await self._producer.start() - logger.info("Kafka producer started for topic '%s'", self._topic) - - async def publish(self, message: QueueMessage) -> None: - """Publish *message* to the configured Kafka topic.""" - if self._producer is None: - await self.start() - producer: Any = self._producer - await producer.send_and_wait( - self._topic, - value=message.body.encode("utf-8"), - headers=[(k, v.encode("utf-8")) for k, v in message.headers.items()] or None, - ) - - async def stop(self) -> None: - """Flush and stop the Kafka producer.""" - if self._producer: - await self._producer.stop() - self._producer = None - logger.info("Kafka producer stopped") diff --git a/fireflyframework_agentic/exposure/queues/rabbitmq.py b/fireflyframework_agentic/exposure/queues/rabbitmq.py deleted file mode 100644 index 786901f0..00000000 --- a/fireflyframework_agentic/exposure/queues/rabbitmq.py +++ /dev/null @@ -1,160 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""RabbitMQ consumer for agent exposure. - -Requires the ``aio-pika`` optional dependency (install via -``pip install fireflyframework-agentic[rabbitmq]``). -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any, cast - -if TYPE_CHECKING: - import aio_pika # pyright: ignore[reportMissingImports] -else: - try: - import aio_pika # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - aio_pika = None - -from fireflyframework_agentic.exposure.queues.base import BaseQueueConsumer, QueueMessage -from fireflyframework_agentic.observability.tracer import extract_trace_context, trace_context_scope - -logger = logging.getLogger(__name__) - - -_AIO_PIKA_IMPORT_ERROR = ( - "aio-pika is required for RabbitMQ support. Install it with: pip install fireflyframework-agentic[rabbitmq]" -) - - -class RabbitMQAgentConsumer(BaseQueueConsumer): - """Consume messages from a RabbitMQ queue and route to an agent. - - Parameters: - agent_name: Name of the agent to invoke. - queue_name: RabbitMQ queue to consume from. - url: AMQP connection URL. - """ - - def __init__( - self, - agent_name: str, - *, - queue_name: str, - url: str = "amqp://guest:guest@localhost/", - ) -> None: - super().__init__(agent_name) - self._queue_name = queue_name - self._url = url - self._connection: Any = None - - async def start(self) -> None: - """Connect to RabbitMQ and begin consuming.""" - if aio_pika is None: - raise ImportError(_AIO_PIKA_IMPORT_ERROR) - - self._connection = await aio_pika.connect_robust(self._url) - channel = await self._connection.channel() - queue = await channel.declare_queue(self._queue_name, durable=True) - self._running = True - logger.info("RabbitMQ consumer started on queue '%s'", self._queue_name) - - async with queue.iterator() as queue_iter: - async for amqp_message in queue_iter: - async with amqp_message.process(): - # Extract trace context from message headers for distributed tracing - headers = {} - if amqp_message.headers: - headers = {k: str(v) for k, v in amqp_message.headers.items()} - span_context = extract_trace_context(headers) - - message = QueueMessage(body=amqp_message.body.decode("utf-8")) - - # Process message within trace context scope - with trace_context_scope(span_context): - 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.""" - if self._connection: - await self._connection.close() - self._running = False - logger.info("RabbitMQ consumer stopped") - - -class RabbitMQAgentProducer: - """Publish messages to a RabbitMQ exchange. - - Satisfies the :class:`~fireflyframework_agentic.exposure.queues.base.QueueProducer` - protocol. - - Parameters: - exchange_name: RabbitMQ exchange to publish to. Use ``""`` for the - default exchange (messages are routed by *routing_key* directly - to a queue). - url: AMQP connection URL. - """ - - def __init__( - self, - *, - exchange_name: str = "", - url: str = "amqp://guest:guest@localhost/", - ) -> None: - self._exchange_name = exchange_name - self._url = url - self._connection: Any = None - self._channel: Any = None - - async def start(self) -> None: - """Open a connection and channel.""" - if aio_pika is None: - raise ImportError(_AIO_PIKA_IMPORT_ERROR) - - self._connection = await aio_pika.connect_robust(self._url) - self._channel = await self._connection.channel() - logger.info("RabbitMQ producer started (exchange='%s')", self._exchange_name) - - async def publish(self, message: QueueMessage) -> None: - """Publish *message* to the configured exchange.""" - if aio_pika is None: - raise ImportError(_AIO_PIKA_IMPORT_ERROR) - - if self._channel is None: - await self.start() - - channel: Any = self._channel - exchange = await channel.get_exchange(self._exchange_name) if self._exchange_name else channel.default_exchange - amqp_message = aio_pika.Message( - body=message.body.encode("utf-8"), - headers=cast("dict[str, Any]", message.headers) or None, - reply_to=message.reply_to or None, - ) - await exchange.publish(amqp_message, routing_key=message.routing_key) - - async def stop(self) -> None: - """Close the connection.""" - if self._connection: - await self._connection.close() - self._connection = None - self._channel = None - logger.info("RabbitMQ producer stopped") diff --git a/fireflyframework_agentic/exposure/queues/redis.py b/fireflyframework_agentic/exposure/queues/redis.py deleted file mode 100644 index e754ba6f..00000000 --- a/fireflyframework_agentic/exposure/queues/redis.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Redis Pub/Sub consumer for agent exposure. - -Requires the ``redis`` optional dependency (install via -``pip install fireflyframework-agentic[redis]``). -""" - -from __future__ import annotations - -import json -import logging -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - import redis.asyncio as aioredis # pyright: ignore[reportMissingImports] -else: - try: - import redis.asyncio as aioredis # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - aioredis = None - -from fireflyframework_agentic.exposure.queues.base import BaseQueueConsumer, QueueMessage -from fireflyframework_agentic.observability.tracer import extract_trace_context, trace_context_scope - -logger = logging.getLogger(__name__) - - -_REDIS_IMPORT_ERROR = ( - "redis[hiredis] is required for Redis support. Install it with: pip install fireflyframework-agentic[redis]" -) - - -class RedisAgentConsumer(BaseQueueConsumer): - """Subscribe to a Redis Pub/Sub channel and route messages to an agent. - - Parameters: - agent_name: Name of the agent to invoke. - channel: Redis Pub/Sub channel to subscribe to. - url: Redis connection URL. - """ - - def __init__( - self, - agent_name: str, - *, - channel: str, - url: str = "redis://localhost:6379", - ) -> None: - super().__init__(agent_name) - self._channel = channel - self._url = url - self._client: Any = None - - async def start(self) -> None: - """Connect to Redis and begin subscribing.""" - if aioredis is None: - raise ImportError(_REDIS_IMPORT_ERROR) - - self._client = aioredis.from_url(self._url) - pubsub = self._client.pubsub() - await pubsub.subscribe(self._channel) - self._running = True - logger.info("Redis consumer started on channel '%s'", self._channel) - - try: - async for raw_message in pubsub.listen(): - if raw_message["type"] == "message": - body = raw_message["data"] - if isinstance(body, bytes): - body = body.decode("utf-8") - - # Try to parse as JSON to extract trace context - # If not JSON, treat as plain text - span_context = None - try: - data = json.loads(body) - if isinstance(data, dict) and "headers" in data and "body" in data: - # Message is wrapped with metadata for trace propagation - span_context = extract_trace_context(data.get("headers", {})) - body = data["body"] - except (json.JSONDecodeError, KeyError): - # Not a wrapped message, use body as-is - pass - - message = QueueMessage(body=body) - - # Process message within trace context scope - with trace_context_scope(span_context): - 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() - - async def stop(self) -> None: - """Stop the Redis consumer.""" - if self._client: - await self._client.close() - self._running = False - logger.info("Redis consumer stopped") - - -class RedisAgentProducer: - """Publish messages to a Redis Pub/Sub channel. - - Satisfies the :class:`~fireflyframework_agentic.exposure.queues.base.QueueProducer` - protocol. - - Parameters: - channel: Redis Pub/Sub channel to publish to. - url: Redis connection URL. - """ - - def __init__( - self, - *, - channel: str, - url: str = "redis://localhost:6379", - ) -> None: - self._channel = channel - self._url = url - self._client: Any = None - - async def start(self) -> None: - """Open a Redis connection.""" - if aioredis is None: - raise ImportError(_REDIS_IMPORT_ERROR) - - self._client = aioredis.from_url(self._url) - logger.info("Redis producer started for channel '%s'", self._channel) - - async def publish(self, message: QueueMessage) -> None: - """Publish *message* to the configured Redis channel.""" - if self._client is None: - await self.start() - client: Any = self._client - await client.publish(self._channel, message.body.encode("utf-8")) - - async def stop(self) -> None: - """Close the Redis connection.""" - if self._client: - await self._client.close() - self._client = None - logger.info("Redis producer stopped") diff --git a/fireflyframework_agentic/exposure/queues/router.py b/fireflyframework_agentic/exposure/queues/router.py deleted file mode 100644 index 31f09566..00000000 --- a/fireflyframework_agentic/exposure/queues/router.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Queue message router: maps topic/queue patterns to specific agents.""" - -from __future__ import annotations - -import re - -from fireflyframework_agentic.agents.registry import agent_registry -from fireflyframework_agentic.exceptions import ExposureError -from fireflyframework_agentic.exposure.queues.base import QueueMessage - - -class QueueRouter: - """Routes queue messages to agents based on pattern matching. - - Rules are added via :meth:`add_route` and evaluated in order. - - Parameters: - default_agent: Agent name used when no rule matches. - """ - - def __init__(self, default_agent: str | None = None) -> None: - self._routes: list[tuple[re.Pattern[str], str]] = [] - self._default_agent = default_agent - - def add_route(self, pattern: str, agent_name: str) -> None: - """Add a routing rule: messages whose routing key matches *pattern* - are sent to *agent_name*.""" - self._routes.append((re.compile(pattern), agent_name)) - - async def route(self, message: QueueMessage) -> str: - """Route *message* to the appropriate agent and return the response.""" - agent_name = self._resolve(message.routing_key) - agent = agent_registry.get(agent_name) - result = await agent.run(message.body) - return str(result.output if hasattr(result, "output") else result) - - def _resolve(self, routing_key: str) -> str: - """Find the first matching agent name for *routing_key*.""" - for pattern, agent_name in self._routes: - if pattern.search(routing_key): - return agent_name - if self._default_agent: - return self._default_agent - raise ExposureError(f"No route matched routing key '{routing_key}' and no default agent set") diff --git a/fireflyframework_agentic/exposure/rest/__init__.py b/fireflyframework_agentic/exposure/rest/__init__.py deleted file mode 100644 index 8f493baf..00000000 --- a/fireflyframework_agentic/exposure/rest/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""REST exposure subpackage -- FastAPI app factory, router, middleware, streaming.""" - -from fireflyframework_agentic.exposure.rest.app import create_agentic_app -from fireflyframework_agentic.exposure.rest.schemas import AgentRequest, AgentResponse, HealthResponse - -__all__ = [ - "AgentRequest", - "AgentResponse", - "HealthResponse", - "create_agentic_app", -] diff --git a/fireflyframework_agentic/exposure/rest/app.py b/fireflyframework_agentic/exposure/rest/app.py deleted file mode 100644 index 6cddcdb7..00000000 --- a/fireflyframework_agentic/exposure/rest/app.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""FastAPI application factory for exposing Firefly agents over REST. - -Call :func:`create_agentic_app` to get a fully-configured FastAPI instance -with agent, health, and streaming endpoints. -""" - -from __future__ import annotations - -import logging -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - -try: - from fastapi import FastAPI # type: ignore[import-not-found] -except ImportError: # pragma: no cover - optional dep - FastAPI = None # type: ignore[assignment,misc] - -from fireflyframework_agentic.agents.lifecycle import agent_lifecycle -from fireflyframework_agentic.config import get_config -from fireflyframework_agentic.exposure.rest.health import create_health_router -from fireflyframework_agentic.exposure.rest.middleware import ( - add_auth_middleware, - add_cors_middleware, - add_rate_limit_middleware, - add_request_id_middleware, -) -from fireflyframework_agentic.exposure.rest.router import create_agent_router -from fireflyframework_agentic.exposure.rest.websocket import create_websocket_router -from fireflyframework_agentic.observability.exporters import configure_exporters -from fireflyframework_agentic.plugin import PluginDiscovery -from fireflyframework_agentic.reasoning.prompts import register_reasoning_prompts - -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def _lifespan(app: Any) -> AsyncIterator[None]: - """FastAPI lifespan: plugin discovery, warmup, OTel, and shutdown.""" - cfg = get_config() - - # -- Startup ----------------------------------------------------------- - if cfg.plugin_auto_discover: - result = PluginDiscovery.discover_all() - logger.info( - "Plugins: %d loaded, %d failed", - len(result.successful), - len(result.failed), - ) - - register_reasoning_prompts() - await agent_lifecycle.run_warmup() - - if cfg.observability_enabled: - configure_exporters( - otlp_endpoint=cfg.otlp_endpoint, - console=cfg.otlp_endpoint is None, - ) - - yield - - # -- Shutdown ---------------------------------------------------------- - await agent_lifecycle.run_shutdown() - - -def create_agentic_app( - *, - title: str = "Firefly Agentic", - version: str = "0.1.0", - cors: bool = True, - request_id: bool = True, - rate_limit: bool | dict[str, Any] = False, -) -> Any: - """Create a FastAPI application with agent exposure endpoints. - - Parameters: - title: Application title for OpenAPI docs. - version: Application version. - cors: Enable CORS middleware. - request_id: Enable request-ID injection middleware. - rate_limit: Enable rate-limiting middleware. Pass ``True`` for - defaults or a dict with ``max_requests``, ``window_seconds``, - and/or ``key_func`` to customise behaviour. - - Returns: - A configured :class:`fastapi.FastAPI` instance. - """ - if FastAPI is None: - raise ImportError("fastapi is required for create_agentic_app") - - app = FastAPI(title=title, version=version, lifespan=_lifespan) - - # Middleware - if cors: - add_cors_middleware(app) - if request_id: - add_request_id_middleware(app) - if rate_limit: - rl_kwargs = rate_limit if isinstance(rate_limit, dict) else {} - add_rate_limit_middleware(app, **rl_kwargs) - - # Auto-wire auth middleware from config - cfg = get_config() - if cfg.auth_api_keys or cfg.auth_bearer_tokens: - add_auth_middleware( - app, - api_keys=cfg.auth_api_keys, - bearer_tokens=cfg.auth_bearer_tokens, - ) - - # Routers - app.include_router(create_health_router()) - app.include_router(create_agent_router()) - - # WebSocket - app.include_router(create_websocket_router()) - - return app diff --git a/fireflyframework_agentic/exposure/rest/health.py b/fireflyframework_agentic/exposure/rest/health.py deleted file mode 100644 index e7a7074e..00000000 --- a/fireflyframework_agentic/exposure/rest/health.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Health check endpoint factory.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from fireflyframework_agentic.agents.registry import agent_registry -from fireflyframework_agentic.exposure.rest.schemas import HealthResponse - -if TYPE_CHECKING: - from fastapi import APIRouter -else: - try: - from fastapi import APIRouter # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - APIRouter = None - - -def create_health_router() -> APIRouter: - """Create a FastAPI router with health check endpoints.""" - if APIRouter is None: - raise ImportError("fastapi is required for REST exposure; install with `pip install fastapi`") - router = APIRouter(tags=["health"]) - - @router.get("/health", response_model=HealthResponse) - async def health() -> HealthResponse: - return HealthResponse( - status="ok", - agents=len(agent_registry), - ) - - @router.get("/health/ready") - async def readiness() -> dict[str, str]: - return {"status": "ready"} - - @router.get("/health/live") - async def liveness() -> dict[str, str]: - return {"status": "alive"} - - return router diff --git a/fireflyframework_agentic/exposure/rest/middleware.py b/fireflyframework_agentic/exposure/rest/middleware.py deleted file mode 100644 index 447ec98a..00000000 --- a/fireflyframework_agentic/exposure/rest/middleware.py +++ /dev/null @@ -1,301 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Middleware for the REST exposure layer: request ID injection, CORS, rate limiting.""" - -from __future__ import annotations - -import hmac -import logging -import time -import uuid -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from fastapi.middleware.cors import CORSMiddleware - from starlette.middleware.base import BaseHTTPMiddleware - from starlette.requests import Request - from starlette.responses import JSONResponse, Response -else: - try: - from starlette.middleware.base import BaseHTTPMiddleware - from starlette.requests import Request - from starlette.responses import JSONResponse, Response - except ImportError: # pragma: no cover - optional dep - BaseHTTPMiddleware = None - Request = None - Response = None - JSONResponse = None - - try: - from fastapi.middleware.cors import CORSMiddleware # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - CORSMiddleware = None - -from fireflyframework_agentic.observability.tracer import ( - extract_trace_context, - inject_trace_context, - trace_context_scope, -) - -logger = logging.getLogger(__name__) - - -def add_request_id_middleware(app: Any) -> None: - """Add middleware that injects a unique ``X-Request-ID`` header.""" - if BaseHTTPMiddleware is None: - raise ImportError("starlette is required for add_request_id_middleware") - - class RequestIDMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next: Any) -> Response: - request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) - response: Response = await call_next(request) - response.headers["X-Request-ID"] = request_id - return response - - app.add_middleware(RequestIDMiddleware) - - -def add_cors_middleware( - app: Any, - *, - allow_origins: list[str] | None = None, - allow_methods: list[str] | None = None, -) -> None: - """Add CORS middleware with configurable origins. - - Security Note: - By default, this middleware uses a restrictive CORS policy (no origins allowed) - for production security. You must explicitly specify allowed origins. - - **INSECURE (Development Only):** - add_cors_middleware(app, allow_origins=["*"]) - - **SECURE (Production):** - add_cors_middleware(app, allow_origins=["https://myapp.com"]) - - Args: - app: The FastAPI or Starlette application. - allow_origins: List of allowed origin URLs. Defaults to [] (no origins allowed). - allow_methods: List of allowed HTTP methods. Defaults to standard methods. - """ - if CORSMiddleware is None: - raise ImportError("fastapi is required for add_cors_middleware") - - # Secure default: no origins allowed - if allow_origins is None: - allow_origins = [] - logger.warning( - "CORS: No origins specified, defaulting to secure policy (no origins allowed). " - "Set allow_origins=['*'] for development or specify exact origins for production." - ) - - # Warn about wildcard usage - if "*" in allow_origins: - logger.warning( - "CORS: Wildcard origin ('*') allows requests from ANY domain. " - "This is INSECURE for production. Specify exact origins instead." - ) - - app.add_middleware( - CORSMiddleware, - allow_origins=allow_origins, - allow_credentials="*" not in allow_origins, - allow_methods=allow_methods or ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allow_headers=["*"], - ) - - -class RateLimiter: - """Simple in-memory sliding-window rate limiter. - - Parameters: - max_requests: Maximum requests per *window_seconds*. - window_seconds: Time window for the rate limit. - """ - - def __init__(self, max_requests: int = 60, window_seconds: float = 60.0) -> None: - self._max = max_requests - self._window = window_seconds - self._timestamps: dict[str, list[float]] = {} - - def is_allowed(self, key: str) -> bool: - """Return *True* if the request is within the rate limit.""" - now = time.monotonic() - # Cleanup stale entries to prevent unbounded memory growth - if len(self._timestamps) > 10000: - stale_keys = [k for k, v in self._timestamps.items() if not v or now - v[-1] > self._window] - for k in stale_keys: - del self._timestamps[k] - ts = self._timestamps.setdefault(key, []) - ts[:] = [t for t in ts if now - t < self._window] - if len(ts) >= self._max: - return False - ts.append(now) - return True - - -def add_auth_middleware( - app: Any, - *, - api_keys: list[str] | None = None, - bearer_tokens: list[str] | None = None, - auth_header: str = "Authorization", - api_key_header: str = "X-API-Key", - exclude_paths: list[str] | None = None, -) -> None: - """Add authentication middleware to a FastAPI/Starlette application. - - Supports two authentication modes: - - * **API Key** -- checked via the ``X-API-Key`` header. - * **Bearer Token** -- checked via the ``Authorization: Bearer `` header. - - When both are configured, a request is accepted if *either* method succeeds. - Unauthenticated requests receive a ``401 Unauthorized`` response. - - Parameters: - app: The FastAPI or Starlette application. - api_keys: List of valid API keys. - bearer_tokens: List of valid bearer tokens. - auth_header: Header name for bearer tokens. - api_key_header: Header name for API keys. - exclude_paths: URL paths excluded from auth (e.g. ``["/health"]``). - """ - if BaseHTTPMiddleware is None or JSONResponse is None: - raise ImportError("starlette is required for add_auth_middleware") - - _JSONResponse = JSONResponse # noqa: N806 — local rebinding to narrow Optional for nested class - _api_keys = set(api_keys or []) - _bearer_tokens = set(bearer_tokens or []) - _exclude = set(exclude_paths or ["/health", "/health/ready", "/health/live", "/docs", "/openapi.json"]) - - class AuthMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next: Any) -> Response: - if request.url.path in _exclude: - return await call_next(request) - - # Try API key - if _api_keys: - key = request.headers.get(api_key_header, "") - if any(hmac.compare_digest(key, k) for k in _api_keys): - return await call_next(request) - - # Try bearer token - if _bearer_tokens: - auth_value = request.headers.get(auth_header, "") - if auth_value.startswith("Bearer "): - token = auth_value[7:] - if any(hmac.compare_digest(token, t) for t in _bearer_tokens): - return await call_next(request) - - # If no auth methods configured, allow all requests - if not _api_keys and not _bearer_tokens: - return await call_next(request) - - return _JSONResponse( - {"detail": "Unauthorized"}, - status_code=401, - ) - - app.add_middleware(AuthMiddleware) - - -def add_rate_limit_middleware( - app: Any, - *, - max_requests: int = 60, - window_seconds: float = 60.0, - key_func: Any | None = None, -) -> None: - """Add rate-limiting middleware to a FastAPI/Starlette application. - - Parameters: - app: The FastAPI or Starlette application. - max_requests: Maximum requests per window per client. - window_seconds: Rate limit window in seconds. - key_func: Optional callable ``(Request) -> str`` for the rate key. - Defaults to the client's IP address. - """ - if BaseHTTPMiddleware is None or JSONResponse is None: - raise ImportError("starlette is required for add_rate_limit_middleware") - - _JSONResponse = JSONResponse # noqa: N806 — local rebinding to narrow Optional for nested class - limiter = RateLimiter(max_requests=max_requests, window_seconds=window_seconds) - - class RateLimitMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next: Any) -> Response: - rk = key_func(request) if key_func is not None else (request.client.host if request.client else "unknown") - if not limiter.is_allowed(rk): - return _JSONResponse( - {"detail": "Rate limit exceeded"}, - status_code=429, - ) - return await call_next(request) - - app.add_middleware(RateLimitMiddleware) - - -def add_trace_propagation_middleware(app: Any) -> None: - """Add W3C Trace Context propagation middleware. - - This middleware automatically extracts trace context from incoming HTTP - requests (via ``traceparent`` and ``tracestate`` headers) and injects - trace context into outgoing HTTP responses. - - This enables distributed tracing across microservices and external systems - that support the W3C Trace Context standard. - - Parameters: - app: The FastAPI or Starlette application. - - Example:: - - from fastapi import FastAPI - from fireflyframework_agentic.exposure.rest.middleware import add_trace_propagation_middleware - - app = FastAPI() - add_trace_propagation_middleware(app) - - # Now all requests will automatically participate in distributed traces - - See Also: - - https://www.w3.org/TR/trace-context/ - - :func:`~fireflyframework_agentic.observability.tracer.extract_trace_context` - - :func:`~fireflyframework_agentic.observability.tracer.inject_trace_context` - """ - if BaseHTTPMiddleware is None: - raise ImportError("starlette is required for add_trace_propagation_middleware") - - class TracePropagationMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next: Any) -> Response: - # Extract trace context from incoming request - headers = dict(request.headers) - span_context = extract_trace_context(headers) - - # Process request within trace context scope - with trace_context_scope(span_context): - response: Response = await call_next(request) - - # Inject trace context into outgoing response - response_headers = dict(response.headers) - inject_trace_context(response_headers) - for key, value in response_headers.items(): - if key.lower() not in response.headers: - response.headers[key] = value - - return response - - app.add_middleware(TracePropagationMiddleware) - logger.info("Trace propagation middleware enabled") diff --git a/fireflyframework_agentic/exposure/rest/router.py b/fireflyframework_agentic/exposure/rest/router.py deleted file mode 100644 index 70cd7e49..00000000 --- a/fireflyframework_agentic/exposure/rest/router.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Auto-generated agent routes. - -:func:`create_agent_router` generates REST endpoints for every registered -agent: ``POST /agents/{name}/run`` and ``GET /agents``. -""" - -from __future__ import annotations - -import base64 -import logging -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from fastapi import APIRouter, HTTPException - from pydantic_ai.messages import BinaryContent, DocumentUrl, ImageUrl - from starlette.responses import StreamingResponse -else: - try: - from fastapi import APIRouter, HTTPException # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - APIRouter = None - HTTPException = None - - try: - from pydantic_ai.messages import BinaryContent, DocumentUrl, ImageUrl - except ImportError: # pragma: no cover - optional dep - BinaryContent = None - DocumentUrl = None - ImageUrl = None - - try: - from starlette.responses import StreamingResponse - except ImportError: # pragma: no cover - optional dep - StreamingResponse = None - -from fireflyframework_agentic.agents.registry import agent_registry -from fireflyframework_agentic.exposure.rest.schemas import AgentRequest, AgentResponse -from fireflyframework_agentic.exposure.rest.streaming import sse_stream, sse_stream_incremental -from fireflyframework_agentic.memory.manager import MemoryManager - -logger = logging.getLogger(__name__) - -# Server-side memory manager for REST conversations -_rest_memory = MemoryManager(working_scope_id="rest") - - -def _resolve_prompt(request: AgentRequest) -> Any: - """Convert an AgentRequest prompt into a pydantic-ai compatible format.""" - if isinstance(request.prompt, str): - return request.prompt - - if BinaryContent is None or DocumentUrl is None or ImageUrl is None: - raise ImportError( - "pydantic-ai is required for multimodal prompts. " - "Install it with: pip install fireflyframework-agentic[rest]" - ) - - parts: list[Any] = [] - for part in request.prompt: - if part.type == "text": - parts.append(part.content) - elif part.type == "image_url": - parts.append(ImageUrl(url=part.content)) - elif part.type == "document_url": - parts.append(DocumentUrl(url=part.content)) - elif part.type == "binary" and part.media_type: - data = base64.b64decode(part.content) - parts.append(BinaryContent(data=data, media_type=part.media_type)) - else: - parts.append(part.content) - return parts - - -def create_agent_router() -> APIRouter: - """Create a FastAPI router with agent invocation endpoints.""" - if APIRouter is None or HTTPException is None or StreamingResponse is None: - raise ImportError( - "fastapi is required for the REST router. Install it with: pip install fireflyframework-agentic[rest]" - ) - - router = APIRouter(prefix="/agents", tags=["agents"]) - # Local rebindings so type checkers narrow inside nested functions - _HTTPException = HTTPException # noqa: N806 — local alias to narrow Optional - _StreamingResponse = StreamingResponse # noqa: N806 — local alias to narrow Optional - - @router.get("/") - async def list_agents() -> list[dict[str, Any]]: - return [info.model_dump() for info in agent_registry.list_agents()] - - @router.post("/{name}/run", response_model=AgentResponse) - async def run_agent(name: str, request: AgentRequest) -> AgentResponse: - if not agent_registry.has(name): - raise _HTTPException(status_code=404, detail=f"Agent '{name}' not found") - agent = agent_registry.get(name) - try: - prompt = _resolve_prompt(request) - conv_id = request.conversation_id - result = await agent.run(prompt, deps=request.deps, conversation_id=conv_id) - output = result.output if hasattr(result, "output") else str(result) - return AgentResponse(agent_name=name, output=output) - except Exception: - logger.exception("Agent '%s' run failed", name) - return AgentResponse(agent_name=name, output=None, success=False, error="Internal server error") - - @router.post("/{name}/stream") - async def stream_agent(name: str, request: AgentRequest) -> Any: - """Stream agent responses in buffered mode (chunks/messages). - - This endpoint uses buffered streaming where the model's output is - streamed in chunks or complete messages. Good for most use cases. - """ - if not agent_registry.has(name): - raise _HTTPException(status_code=404, detail=f"Agent '{name}' not found") - agent = agent_registry.get(name) - prompt = _resolve_prompt(request) - conv_id = request.conversation_id - return _StreamingResponse( - sse_stream(agent, prompt, deps=request.deps, conversation_id=conv_id), - media_type="text/event-stream", - ) - - @router.post("/{name}/stream/incremental") - async def stream_agent_incremental( - name: str, - request: AgentRequest, - debounce_ms: float = 0.0, - ) -> Any: - """Stream agent responses in incremental mode (token-by-token). - - This endpoint provides true token-by-token streaming with minimal - latency. Tokens are sent as soon as they arrive from the model, - without buffering. Ideal for interactive applications where users - want to see responses immediately. - - Args: - debounce_ms: Optional debounce delay in milliseconds to batch - rapid tokens. Default 0 = no debouncing. - """ - if not agent_registry.has(name): - raise _HTTPException(status_code=404, detail=f"Agent '{name}' not found") - agent = agent_registry.get(name) - prompt = _resolve_prompt(request) - conv_id = request.conversation_id - return _StreamingResponse( - sse_stream_incremental( - agent, - prompt, - debounce_ms=debounce_ms, - deps=request.deps, - conversation_id=conv_id, - ), - media_type="text/event-stream", - ) - - # -- Conversation management --------------------------------------------- - - @router.post("/conversations", tags=["conversations"]) - async def create_conversation() -> dict[str, str]: - """Create a new conversation and return its ID.""" - conv_id = _rest_memory.new_conversation() - return {"conversation_id": conv_id} - - @router.get("/conversations/{conversation_id}", tags=["conversations"]) - async def get_conversation(conversation_id: str) -> dict[str, Any]: - """Return the message history for a conversation.""" - messages = _rest_memory.get_message_history(conversation_id) - serialized = [] - for msg in messages: - dumper = getattr(msg, "model_dump", None) - if dumper is not None: - serialized.append(dumper(mode="json")) - else: - serialized.append({"content": str(msg)}) - return { - "conversation_id": conversation_id, - "message_count": len(messages), - "messages": serialized, - } - - @router.delete("/conversations/{conversation_id}", tags=["conversations"]) - async def delete_conversation(conversation_id: str) -> dict[str, str]: - """Clear a conversation's history.""" - _rest_memory.clear_conversation(conversation_id) - return {"status": "cleared", "conversation_id": conversation_id} - - return router diff --git a/fireflyframework_agentic/exposure/rest/schemas.py b/fireflyframework_agentic/exposure/rest/schemas.py deleted file mode 100644 index cf1d9bce..00000000 --- a/fireflyframework_agentic/exposure/rest/schemas.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Request and response Pydantic models for the REST exposure layer.""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel - - -class MultiModalPart(BaseModel): - """A single multimodal content part in a REST request. - - Attributes: - type: Content type: ``"text"``, ``"image_url"``, ``"document_url"``, - ``"audio_url"``, ``"video_url"``, or ``"binary"``. - content: The content value (text string, URL, or base64 data). - media_type: MIME type for binary content. - """ - - type: str = "text" - content: str = "" - media_type: str | None = None - - -class AgentRequest(BaseModel): - """Request body for agent invocation. - - *prompt* can be a plain string or a list of multimodal parts for VLM - use cases (images, documents, etc.). - - When *conversation_id* is provided, the server maintains - conversation history across requests. - """ - - prompt: str | list[MultiModalPart] = "" - deps: Any = None - model_settings: dict[str, Any] | None = None - conversation_id: str | None = None - - -class AgentResponse(BaseModel): - """Response body from an agent invocation.""" - - agent_name: str - output: Any - success: bool = True - error: str | None = None - - -class HealthResponse(BaseModel): - """Health check response.""" - - status: str = "ok" - agents: int = 0 - details: dict[str, str] = {} diff --git a/fireflyframework_agentic/exposure/rest/streaming.py b/fireflyframework_agentic/exposure/rest/streaming.py deleted file mode 100644 index e584d1e3..00000000 --- a/fireflyframework_agentic/exposure/rest/streaming.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Server-Sent Events (SSE) streaming support for agent responses.""" - -from __future__ import annotations - -import json -from collections.abc import AsyncIterator -from typing import Any, cast - -from fireflyframework_agentic.types import AgentLike - - -async def sse_stream(agent: AgentLike, prompt: Any, **kwargs: Any) -> AsyncIterator[str]: - """Yield SSE-formatted events from an agent's streaming response. - - Each yielded string is a complete SSE event (``data: ...\\n\\n``). - - This uses buffered streaming mode (chunks/messages). - """ - async with await cast("Any", agent).run_stream(prompt, **kwargs) as stream: - async for chunk in stream.stream_text(): - yield f"data: {json.dumps({'text': chunk})}\n\n" - yield "data: [DONE]\n\n" - - -async def sse_stream_incremental( - agent: AgentLike, - prompt: Any, - debounce_ms: float = 0.0, - **kwargs: Any, -) -> AsyncIterator[str]: - """Yield SSE-formatted events with true token-by-token streaming. - - This provides minimal latency streaming by yielding individual tokens - as they arrive from the model, without buffering into chunks. - - Args: - agent: The agent to run. - prompt: The prompt to send. - debounce_ms: Optional debounce delay in milliseconds to batch - rapid tokens. Default 0 = no debouncing. - **kwargs: Additional arguments passed to run_stream(). - - Yields: - SSE-formatted token events with minimal latency. - - Example SSE event: - data: {"token": "Hello"}\\n\\n - """ - async with await cast("Any", agent).run_stream(prompt, streaming_mode="incremental", **kwargs) as stream: - async for token in stream.stream_tokens(debounce_ms=debounce_ms): - yield f"data: {json.dumps({'token': token})}\n\n" - yield "data: [DONE]\n\n" diff --git a/fireflyframework_agentic/exposure/rest/websocket.py b/fireflyframework_agentic/exposure/rest/websocket.py deleted file mode 100644 index 141cf474..00000000 --- a/fireflyframework_agentic/exposure/rest/websocket.py +++ /dev/null @@ -1,163 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""WebSocket endpoint for bidirectional multi-turn agent conversations. - -Clients connect to ``/ws/agents/{name}`` and send JSON messages. -The server responds with streamed tokens and final results over the -same connection, enabling real-time conversational UIs. - -Message protocol ----------------- - -**Client → Server** (JSON):: - - { - "prompt": "Hello, agent!", - "conversation_id": "optional-id", - "deps": null - } - -**Server → Client** (JSON, one or more):: - - {"type": "token", "data": "partial text..."} - {"type": "result", "data": "full output", "success": true} - {"type": "error", "data": "error message", "success": false} -""" - -from __future__ import annotations - -import json -import logging -import uuid -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from fastapi import APIRouter, WebSocket, WebSocketDisconnect -else: - try: - from fastapi import APIRouter, WebSocket, WebSocketDisconnect # type: ignore[import-not-found] - except ImportError: # pragma: no cover - optional dep - APIRouter = None - WebSocket = None - WebSocketDisconnect = None - -from fireflyframework_agentic.agents.registry import agent_registry -from fireflyframework_agentic.memory.manager import MemoryManager - -logger = logging.getLogger(__name__) - - -def create_websocket_router() -> APIRouter: - """Create a FastAPI router with the agent WebSocket endpoint.""" - if APIRouter is None or WebSocketDisconnect is None: - raise ImportError( - "WebSocket support requires 'fastapi'. Install with: pip install fireflyframework-agentic[rest]" - ) - - _WebSocketDisconnect = WebSocketDisconnect # noqa: N806 — local alias to narrow Optional for `except` clause - router = APIRouter(tags=["websocket"]) - _ws_memory = MemoryManager(working_scope_id="ws") - - @router.websocket("/ws/agents/{name}") - async def agent_ws(websocket: WebSocket, name: str) -> None: - """Multi-turn WebSocket conversation with a registered agent.""" - if not agent_registry.has(name): - await websocket.close(code=4004, reason=f"Agent '{name}' not found") - return - - await websocket.accept() - agent = agent_registry.get(name) - conversation_id: str | None = None - - # Use a per-connection memory scope to avoid cross-talk between - # concurrent WebSocket sessions sharing the same agent. - conn_id = uuid.uuid4().hex[:8] - _ws_memory.fork(working_scope_id=f"ws:{conn_id}") - - try: - while True: - raw = await websocket.receive_text() - try: - msg: dict[str, Any] = json.loads(raw) - except json.JSONDecodeError: - await _send_error(websocket, "Invalid JSON") - continue - - prompt = msg.get("prompt", "") - if not prompt: - await _send_error(websocket, "Missing 'prompt' field") - continue - - # Conversation management - conversation_id = msg.get("conversation_id") or conversation_id - if conversation_id is None: - conversation_id = uuid.uuid4().hex - await websocket.send_json( - {"type": "conversation_id", "data": conversation_id}, - ) - - deps = msg.get("deps") - - # Attempt streaming; if it fails, report the error rather than - # falling through to run() which would double-process. - try: - final: str | None = None - - if hasattr(agent, "run_stream"): - try: - async with await agent.run_stream( # type: ignore[attr-defined] - prompt, - deps=deps, - conversation_id=conversation_id, - ) as stream: - full_output: list[str] = [] - async for token in stream.stream_text(delta=True): - full_output.append(token) - await websocket.send_json( - {"type": "token", "data": token}, - ) - final = "".join(full_output) - except Exception as exc: - # Streaming not supported or failed — fall back - logger.debug("Streaming failed for '%s': %s", name, exc) - final = None - - if final is None: - result = await agent.run( - prompt, - deps=deps, - conversation_id=conversation_id, - ) - final = result.output if hasattr(result, "output") else str(result) - - await websocket.send_json( - {"type": "result", "data": final, "success": True}, - ) - - except Exception as exc: - logger.exception("WebSocket agent error for '%s'", name) - await _send_error(websocket, str(exc)) - - except _WebSocketDisconnect: - logger.debug("WebSocket client disconnected from agent '%s'", name) - - return router - - -async def _send_error(websocket: Any, message: str) -> None: - """Send an error frame to the client.""" - await websocket.send_json( - {"type": "error", "data": message, "success": False}, - ) diff --git a/fireflyframework_agentic/observability/__init__.py b/fireflyframework_agentic/observability/__init__.py index db42dd92..c956a1e7 100644 --- a/fireflyframework_agentic/observability/__init__.py +++ b/fireflyframework_agentic/observability/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Observability subpackage -- tracing, metrics, events, and exporters.""" +"""Observability subpackage -- tracing, metrics, events, and cost/usage tracking.""" from fireflyframework_agentic.observability.budget import ( BudgetGate, @@ -31,7 +31,6 @@ ) from fireflyframework_agentic.observability.decorators import metered, traced from fireflyframework_agentic.observability.events import FireflyEvent, FireflyEvents, default_events -from fireflyframework_agentic.observability.exporters import ProviderBundle, configure_exporters from fireflyframework_agentic.observability.metrics import FireflyMetrics, default_metrics from fireflyframework_agentic.observability.sinks import ( CostSink, @@ -39,14 +38,10 @@ JSONLFileSink, LoggingSink, OTelMetricsSink, - WebhookSink, ) from fireflyframework_agentic.observability.tracer import ( FireflyTracer, default_tracer, - extract_trace_context, - inject_trace_context, - trace_context_scope, ) from fireflyframework_agentic.observability.usage import ( UsageRecord, @@ -71,24 +66,18 @@ "JSONLFileSink", "LoggingSink", "OTelMetricsSink", - "ProviderBundle", "ScopeContext", "UnknownModelCostError", "UsageRecord", "UsageSummary", "UsageTracker", - "WebhookSink", - "configure_exporters", "default_events", "default_metrics", "default_tracer", "default_usage_tracker", - "extract_trace_context", "genai_prices_cost", - "inject_trace_context", "metered", "provider_reported_cost", "resolve_cost", - "trace_context_scope", "traced", ] diff --git a/fireflyframework_agentic/observability/exporters.py b/fireflyframework_agentic/observability/exporters.py deleted file mode 100644 index db49b675..00000000 --- a/fireflyframework_agentic/observability/exporters.py +++ /dev/null @@ -1,234 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""OpenTelemetry exporter configuration helpers. - -:func:`configure_exporters` is the single place vendor knowledge lives. It -sets up traces, metrics, and logs providers and attaches the exporters chosen -by which kwargs are passed (console, OTLP). - -Application code never imports vendor exporters directly --- it uses -``trace.get_tracer(...)``, ``metrics.get_meter(...)``, and Python ``logging``, -all of which the providers configured here transparently route to whichever -backend is active. -""" - -from __future__ import annotations - -import logging -import socket -import uuid -from dataclasses import dataclass -from importlib.metadata import PackageNotFoundError, version - -from opentelemetry import _logs as otel_logs -from opentelemetry import metrics as otel_metrics -from opentelemetry import trace -from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor -from opentelemetry.sdk._logs.export import ConsoleLogExporter as _ConsoleLogExporter - -# Newer SDKs renamed ConsoleLogExporter to ConsoleLogRecordExporter; tolerate both. -try: - from opentelemetry.sdk._logs.export import ( - ConsoleLogRecordExporter as ConsoleLogExporter, # type: ignore[import-not-found] - ) -except ImportError: - ConsoleLogExporter = _ConsoleLogExporter # type: ignore[assignment, misc] -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import ( - ConsoleMetricExporter, - PeriodicExportingMetricReader, -) -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import ( - BatchSpanProcessor, - ConsoleSpanExporter, - SimpleSpanProcessor, -) - -try: - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # type: ignore[import-not-found] - OTLPLogExporter, - ) - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # type: ignore[import-not-found] - OTLPMetricExporter, - ) - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-not-found] - OTLPSpanExporter, - ) -except ImportError: # pragma: no cover - optional dep - OTLPLogExporter = None # type: ignore[assignment,misc] - OTLPMetricExporter = None # type: ignore[assignment,misc] - OTLPSpanExporter = None # type: ignore[assignment,misc] - -logger = logging.getLogger(__name__) - -_FIREFLY_LOGGER_NAME = "fireflyframework_agentic" - - -def _service_version() -> str: - """Return the installed package version for the OTel ``service.version`` - resource attribute. Falls back to ``"unknown"`` if the package is not - installed (e.g. running from a source checkout without ``uv sync``). - """ - try: - return version("fireflyframework-agentic") - except PackageNotFoundError: - return "unknown" - - -@dataclass(frozen=True) -class ProviderBundle: - """The three OTel providers configured by :func:`configure_exporters`. - - Returned so callers can attach extra processors, but the providers are - also registered globally so ``trace.get_tracer(...)`` / ``metrics.get_meter(...)`` - pick them up automatically. - - Use the ``tracer`` attribute to reach the ``TracerProvider`` directly, e.g. - ``configure_exporters(...).tracer.add_span_processor(...)``. - """ - - tracer: TracerProvider - meter: MeterProvider - log: LoggerProvider - - -class _ConfigState: - """Module-level guard so repeat calls don't double-register exporters or - stack a second LoggingHandler on the firefly logger. - - Encapsulated as a class rather than two ``module-level`` globals so - static analysers (CodeQL, the github-code-quality bot) correctly see - the read-then-write pattern as a real read-after-write rather than - incorrectly flagging the writes as "unused global variables". Same - runtime behaviour as the previous ``_configured_signature`` and - ``_logging_handler_installed`` globals. - """ - - signature: tuple[object, ...] | None = None - handler: LoggingHandler | None = None - - -_state = _ConfigState() - - -def configure_exporters( - *, - service_name: str = _FIREFLY_LOGGER_NAME, - otlp_endpoint: str | None = None, - console: bool = False, - metric_export_interval_ms: int = 60_000, -) -> ProviderBundle: - """Set up trace, metric, and log providers with the requested exporters. - - Parameters: - service_name: OTel ``service.name`` resource attribute. - otlp_endpoint: When set, attaches gRPC OTLP exporters for traces, - metrics, and logs. Vendor-neutral path (Jaeger, Tempo, ADOT, ...). - console: When *True*, attaches console exporters for all three signal - types. Useful for local development. - metric_export_interval_ms: How often the metric reader flushes - histograms and counters. Default 60s. - - Returns: - A :class:`ProviderBundle` exposing the three providers. The providers - are also registered globally; in most cases callers do not need to - touch the bundle. - - Notes: - - **Idempotent**: repeat calls with identical effective configuration - are a no-op. The kwargs become a signature tuple keyed against - ``_state.signature``. - - A :class:`LoggingHandler` is attached to the - ``fireflyframework_agentic`` parent logger so both - ``logger.info(...)`` calls and :class:`FireflyEvents` payloads are - delivered through OTel logs to whichever exporter is active. - """ - signature = ( - service_name, - otlp_endpoint, - console, - metric_export_interval_ms, - ) - if _state.signature == signature: - # Nothing to do; existing providers already serve get_tracer/get_meter. - return ProviderBundle( - tracer=trace.get_tracer_provider(), # type: ignore[return-value] - meter=otel_metrics.get_meter_provider(), # type: ignore[return-value] - log=otel_logs.get_logger_provider(), # type: ignore[return-value] - ) - - resource = Resource.create( - { - "service.name": service_name, - "service.version": _service_version(), - "service.instance.id": f"{socket.gethostname()}-{uuid.uuid4().hex[:8]}", - } - ) - - tracer_provider = TracerProvider(resource=resource) - metric_readers: list[PeriodicExportingMetricReader] = [] - logger_provider = LoggerProvider(resource=resource) - - # ── Console exporters ────────────────────────────────────────────────── - if console: - tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) - metric_readers.append( - PeriodicExportingMetricReader( - ConsoleMetricExporter(), - export_interval_millis=metric_export_interval_ms, - ) - ) - logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) - logger.info("Console exporters attached") - - # ── OTLP exporters (Jaeger, Tempo, ADOT, generic collectors) ────────── - if otlp_endpoint: - if OTLPSpanExporter is None or OTLPMetricExporter is None or OTLPLogExporter is None: - logger.warning( - "opentelemetry-exporter-otlp-proto-grpc is not installed; " - "OTLP export disabled. Install the package to enable it." - ) - else: - tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint))) - metric_readers.append( - PeriodicExportingMetricReader( - OTLPMetricExporter(endpoint=otlp_endpoint), - export_interval_millis=metric_export_interval_ms, - ) - ) - logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter(endpoint=otlp_endpoint))) - logger.info("OTLP exporters attached: %s", otlp_endpoint) - - # MeterProvider takes its readers up front, not via add_*(). - meter_provider = MeterProvider(resource=resource, metric_readers=metric_readers) - - trace.set_tracer_provider(tracer_provider) - otel_metrics.set_meter_provider(meter_provider) - otel_logs.set_logger_provider(logger_provider) - - # Bridge Python logging -> OTel logs once; replace any prior handler we - # installed so repeat calls with new providers point at the new ones. - firefly_logger = logging.getLogger(_FIREFLY_LOGGER_NAME) - if _state.handler is not None: - firefly_logger.removeHandler(_state.handler) - handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider) - firefly_logger.addHandler(handler) - _state.handler = handler - - _state.signature = signature - return ProviderBundle(tracer=tracer_provider, meter=meter_provider, log=logger_provider) diff --git a/fireflyframework_agentic/observability/sinks.py b/fireflyframework_agentic/observability/sinks.py index 564e4ed5..202916e1 100644 --- a/fireflyframework_agentic/observability/sinks.py +++ b/fireflyframework_agentic/observability/sinks.py @@ -7,14 +7,9 @@ import logging import threading -import time -from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path -from queue import Empty, Queue -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable - -import httpx +from typing import TYPE_CHECKING, Protocol, runtime_checkable from fireflyframework_agentic.observability.events import default_events from fireflyframework_agentic.observability.metrics import default_metrics @@ -138,113 +133,3 @@ def _maybe_rotate(self, incoming_bytes: int) -> None: def flush(self) -> None: ... def close(self) -> None: ... - - -def _default_post(url: str, json: list[dict], headers: dict, timeout: float) -> Any: - """Default POST function. Replaced in tests via WebhookSink(_post=...).""" - return httpx.post(url, json=json, headers=headers, timeout=timeout) - - -class WebhookSink: - """Batch records and POST them to an HTTP endpoint. - - Parameters: - url: Endpoint URL. - batch_size: Records per POST. Drained sooner on ``flush_interval_s``. - flush_interval_s: Background flush cadence in seconds. - headers: Extra HTTP headers (Authorization, etc.). - max_retries: How many times to retry a 5xx response before dropping. - timeout_s: Per-request HTTP timeout. - _post: Internal hook for tests. - """ - - def __init__( - self, - url: str, - *, - batch_size: int = 50, - flush_interval_s: float = 5.0, - headers: dict[str, str] | None = None, - max_retries: int = 3, - timeout_s: float = 10.0, - _post: Callable[[str, list[dict], dict, float], Any] | None = None, - ) -> None: - self._url = url - self._batch_size = batch_size - self._interval = flush_interval_s - self._headers = headers or {} - self._max_retries = max_retries - self._timeout = timeout_s - self._post = _post or _default_post - self._queue: Queue[UsageRecord] = Queue() - self._stop = threading.Event() - self._thread = threading.Thread(target=self._run, name="WebhookSink", daemon=True) - self._thread.start() - - def emit(self, record: UsageRecord) -> None: - self._queue.put(record) - - def _run(self) -> None: - buf: list[UsageRecord] = [] - last_flush = time.monotonic() - while not self._stop.is_set(): - try: - rec = self._queue.get(timeout=0.1) - buf.append(rec) - except Empty: - pass - now = time.monotonic() - if len(buf) >= self._batch_size or (buf and now - last_flush >= self._interval): - self._send(buf) - buf = [] - last_flush = now - # Drain remaining on stop. - while True: - try: - buf.append(self._queue.get_nowait()) - except Empty: - break - if buf: - self._send(buf) - - def _send(self, batch: list[UsageRecord]) -> None: - payload = [r.model_dump(mode="json") for r in batch] - delay = 0.1 - for attempt in range(self._max_retries + 1): - try: - resp = self._post(self._url, payload, self._headers, self._timeout) - status = int(getattr(resp, "status_code", 0)) - if 200 <= status < 300: - return - if 500 <= status < 600 and attempt < self._max_retries: - time.sleep(delay) - delay *= 2 - continue - logger.warning("WebhookSink: dropping batch (status %d)", status) - self._record_sink_error() - return - except Exception: # noqa: BLE001 - if attempt < self._max_retries: - time.sleep(delay) - delay *= 2 - continue - logger.warning("WebhookSink: dropping batch after exhausted retries", exc_info=True) - self._record_sink_error() - return - - @staticmethod - def _record_sink_error() -> None: - try: - default_metrics.record_error(operation="cost_sink_errors") - except Exception: # noqa: BLE001 - logger.debug("Failed to emit cost_sink_errors metric", exc_info=True) - - def flush(self) -> None: - """Block until the queue is empty (best-effort).""" - while not self._queue.empty(): - time.sleep(0.01) - - def close(self) -> None: - """Stop background thread and drain remaining records.""" - self._stop.set() - self._thread.join(timeout=self._interval + 2.0) diff --git a/fireflyframework_agentic/observability/tracer.py b/fireflyframework_agentic/observability/tracer.py index 974439b6..a488fd7c 100644 --- a/fireflyframework_agentic/observability/tracer.py +++ b/fireflyframework_agentic/observability/tracer.py @@ -16,26 +16,19 @@ :class:`FireflyTracer` wraps the OpenTelemetry tracer with convenience methods for creating agent- and tool-scoped spans. - -This module also provides W3C Trace Context propagation utilities for -distributed tracing across HTTP and queue boundaries. """ from __future__ import annotations from collections.abc import Generator from contextlib import contextmanager -from contextvars import ContextVar from typing import Any from opentelemetry import trace -from opentelemetry.trace import Span, SpanContext, StatusCode, TraceFlags, Tracer, TraceState +from opentelemetry.trace import Span, StatusCode, Tracer _TRACER_NAME = "fireflyframework_agentic" -# Context variable for trace propagation across async boundaries -_trace_context: ContextVar[SpanContext | None] = ContextVar("trace_context", default=None) - class FireflyTracer: """High-level tracer that creates spans with Firefly-specific attributes. @@ -118,183 +111,3 @@ def set_error(span: Span, error: Exception) -> None: # Module-level default tracer default_tracer = FireflyTracer() - - -# -- W3C Trace Context Propagation ------------------------------------------ - - -def inject_trace_context(headers: dict[str, str]) -> None: - """Inject W3C Trace Context headers into an outgoing request/message. - - This function follows the W3C Trace Context specification to propagate - trace information across HTTP and message queue boundaries. It adds - ``traceparent`` and ``tracestate`` headers to the provided dictionary. - - Parameters: - headers: Dictionary of headers to inject trace context into. Modified in-place. - - Example: - Inject trace context for HTTP request:: - - headers = {} - inject_trace_context(headers) - response = await http_client.post(url, headers=headers, ...) - - Inject trace context for Kafka message:: - - headers = {} - inject_trace_context(headers) - await producer.send( - topic, - value=message, - headers=[(k, v.encode()) for k, v in headers.items()] - ) - - See Also: - - https://www.w3.org/TR/trace-context/ - - :func:`extract_trace_context` - """ - span = trace.get_current_span() - if span is None: - return - - ctx = span.get_span_context() - if not ctx.is_valid: - return - - # W3C traceparent header format: - # version-trace_id-parent_id-trace_flags - # Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 - traceparent = f"00-{ctx.trace_id:032x}-{ctx.span_id:016x}-{ctx.trace_flags:02x}" - headers["traceparent"] = traceparent - - # Include tracestate if present - if ctx.trace_state: - headers["tracestate"] = ctx.trace_state.to_header() - - -def extract_trace_context(headers: dict[str, str]) -> SpanContext | None: - """Extract W3C Trace Context from incoming request/message headers. - - This function parses ``traceparent`` and ``tracestate`` headers according - to the W3C Trace Context specification and returns a SpanContext that can - be used to continue a distributed trace. - - Parameters: - headers: Dictionary of headers containing trace context. Keys are - case-insensitive (``traceparent`` or ``Traceparent`` both work). - - Returns: - SpanContext if valid trace context is found, None otherwise. - - Example: - Extract trace context from HTTP request:: - - from opentelemetry import trace - - span_context = extract_trace_context(request.headers) - if span_context: - with trace.use_span( - trace.NonRecordingSpan(span_context), - end_on_exit=False - ): - # Your code runs within the distributed trace - await agent.run(prompt) - - Extract trace context from Kafka message:: - - headers = {k: v.decode() for k, v in message.headers} - span_context = extract_trace_context(headers) - if span_context: - _trace_context.set(span_context) - await process_message(message) - - See Also: - - https://www.w3.org/TR/trace-context/ - - :func:`inject_trace_context` - """ - # Case-insensitive header lookup - headers_lower = {k.lower(): v for k, v in headers.items()} - traceparent = headers_lower.get("traceparent") - - if not traceparent: - return None - - try: - # Parse W3C traceparent: version-trace_id-parent_id-trace_flags - parts = traceparent.split("-") - if len(parts) != 4: - return None - - version, trace_id_hex, span_id_hex, flags_hex = parts - - # Only support version 00 - if version != "00": - return None - - trace_id = int(trace_id_hex, 16) - span_id = int(span_id_hex, 16) - trace_flags = TraceFlags(int(flags_hex, 16)) - - # Parse tracestate if present - tracestate_header = headers_lower.get("tracestate") - trace_state = TraceState.from_header([tracestate_header]) if tracestate_header else None - - return SpanContext( - trace_id=trace_id, - span_id=span_id, - is_remote=True, - trace_flags=trace_flags, - trace_state=trace_state, - ) - except (ValueError, TypeError): - # Invalid trace context format - return None - - -def get_trace_context() -> SpanContext | None: - """Get the current trace context from the context variable. - - Returns: - The active SpanContext, or None if no context is set. - """ - return _trace_context.get() - - -def set_trace_context(context: SpanContext | None) -> None: - """Set the trace context in the context variable. - - Parameters: - context: SpanContext to set, or None to clear. - """ - _trace_context.set(context) - - -@contextmanager -def trace_context_scope(context: SpanContext | None) -> Generator[None]: - """Context manager that sets trace context for the duration of the scope. - - Parameters: - context: SpanContext to use within the scope. - - Example:: - - span_context = extract_trace_context(headers) - with trace_context_scope(span_context): - # All spans created here will be children of the extracted context - with default_tracer.agent_span("my_agent"): - result = await agent.run(prompt) - """ - token = _trace_context.set(context) - try: - if context: - # Make OpenTelemetry use this context as the parent - with trace.use_span( - trace.NonRecordingSpan(context), - end_on_exit=False, - ): - yield - else: - yield - finally: - _trace_context.reset(token) diff --git a/fireflyframework_agentic/security/__init__.py b/fireflyframework_agentic/security/__init__.py index 292343e7..d6e7c6ea 100644 --- a/fireflyframework_agentic/security/__init__.py +++ b/fireflyframework_agentic/security/__init__.py @@ -15,7 +15,6 @@ """Security features for production deployments. This module provides: -- **RBAC** (Role-Based Access Control) with JWT authentication - **Encryption** for sensitive data at rest - **SQL injection** prevention for database tools """ @@ -23,7 +22,6 @@ from fireflyframework_agentic.security.encryption import AESEncryptionProvider, EncryptedMemoryStore, EncryptionProvider from fireflyframework_agentic.security.output_guard import OutputGuard, default_output_guard from fireflyframework_agentic.security.prompt_guard import PromptGuard, default_prompt_guard -from fireflyframework_agentic.security.rbac import RBACManager, require_permission __all__ = [ "AESEncryptionProvider", @@ -31,8 +29,6 @@ "EncryptionProvider", "OutputGuard", "PromptGuard", - "RBACManager", "default_output_guard", "default_prompt_guard", - "require_permission", ] diff --git a/fireflyframework_agentic/security/rbac.py b/fireflyframework_agentic/security/rbac.py deleted file mode 100644 index b587d536..00000000 --- a/fireflyframework_agentic/security/rbac.py +++ /dev/null @@ -1,452 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Role-Based Access Control (RBAC) with JWT authentication. - -This module provides a production-ready RBAC system with: -- JWT token validation -- Role and permission management -- Multi-tenant support -- Decorator-based permission checking - -Example: - Basic RBAC setup:: - - from fireflyframework_agentic.security.rbac import RBACManager, require_permission - - rbac = RBACManager(jwt_secret="your-secret-key") - - # Validate token and check permissions - @require_permission("agents.execute", rbac=rbac) - async def run_agent(token: str, agent_name: str): - # This function only runs if the user has the permission - return await agent.run(...) - - Multi-tenant RBAC:: - - rbac = RBACManager(jwt_secret="secret", multi_tenant=True) - - # Token includes tenant_id - token = rbac.create_token( - user_id="user123", - roles=["agent_runner"], - tenant_id="acme_corp" - ) -""" - -from __future__ import annotations - -import functools -import inspect -import logging -from collections.abc import Callable -from datetime import UTC, datetime, timedelta -from typing import Any - -try: - import jwt -except ImportError: # pragma: no cover - optional dep - jwt = None # type: ignore[assignment] - -from fireflyframework_agentic.config import get_config - -logger = logging.getLogger(__name__) - - -class RBACManager: - """Role-Based Access Control manager with JWT support. - - Manages user roles, permissions, and JWT token validation for - securing agent execution and API access in production deployments. - - Parameters: - jwt_secret: Secret key for JWT token signing and verification. - jwt_algorithm: JWT algorithm (default: HS256). - token_expiry_hours: Hours until tokens expire. - multi_tenant: Whether to enforce tenant isolation. - roles: Role-to-permissions mapping. - - Example:: - - rbac = RBACManager( - jwt_secret="my-secret-key", - roles={ - "admin": ["*"], - "agent_runner": ["agents.execute", "agents.list"], - "viewer": ["agents.list"], - } - ) - - # Create token - token = rbac.create_token(user_id="user123", roles=["agent_runner"]) - - # Validate and extract claims - claims = rbac.validate_token(token) - - # Check permission - if rbac.has_permission(claims, "agents.execute"): - # Allow execution - pass - """ - - def __init__( - self, - jwt_secret: str | None = None, - *, - jwt_algorithm: str = "HS256", - token_expiry_hours: int = 24, - multi_tenant: bool = False, - roles: dict[str, list[str]] | None = None, - ) -> None: - # ``jwt_secret`` is optional so that subclasses or callers that only need - # the permission/role machinery (e.g. with externally-issued tokens from - # an external identity provider) can construct an RBACManager without a - # symmetric secret. - # ``create_token``/``validate_token`` raise if invoked without one. - self._jwt_secret = jwt_secret - self._jwt_algorithm = jwt_algorithm - self._token_expiry_hours = token_expiry_hours - self._multi_tenant = multi_tenant - - # Default role-to-permissions mapping - self._roles = roles or { - "admin": ["*"], # Wildcard: all permissions - "agent_runner": ["agents.execute", "agents.list", "tools.execute"], - "agent_viewer": ["agents.list"], - "pipeline_runner": ["pipelines.execute", "pipelines.list"], - } - - def create_token( - self, - user_id: str, - roles: list[str], - *, - tenant_id: str | None = None, - custom_claims: dict[str, Any] | None = None, - ) -> str: - """Create a JWT token with user claims. - - Args: - user_id: Unique user identifier. - roles: List of role names assigned to the user. - tenant_id: Optional tenant ID for multi-tenant deployments. - custom_claims: Additional custom claims to include in the token. - - Returns: - Signed JWT token string. - - Raises: - ImportError: If PyJWT is not installed. - """ - if jwt is None: - raise ImportError( - "JWT support requires 'pyjwt'. Install with: pip install fireflyframework-agentic[security]" - ) - - if self._jwt_secret is None: - raise ValueError("RBACManager has no jwt_secret; cannot create_token") - - now = datetime.now(UTC) - expiry = now + timedelta(hours=self._token_expiry_hours) - - payload = { - "sub": user_id, - "roles": roles, - "iat": now, - "exp": expiry, - } - - if self._multi_tenant and tenant_id: - payload["tenant_id"] = tenant_id - elif self._multi_tenant and not tenant_id: - raise ValueError("tenant_id is required when multi_tenant is enabled") - - if custom_claims: - payload.update(custom_claims) - - token = jwt.encode(payload, self._jwt_secret, algorithm=self._jwt_algorithm) - return token - - def validate_token(self, token: str) -> dict[str, Any]: - """Validate a JWT token and return its claims. - - Args: - token: JWT token string. - - Returns: - Dictionary of token claims (user_id, roles, etc.). - - Raises: - ValueError: If token is invalid or expired. - ImportError: If PyJWT is not installed. - """ - if jwt is None: - raise ImportError( - "JWT support requires 'pyjwt'. Install with: pip install fireflyframework-agentic[security]" - ) - - if self._jwt_secret is None: - raise ValueError("RBACManager has no jwt_secret; cannot validate HS256 tokens") - - try: - payload = jwt.decode( - token, - self._jwt_secret, - algorithms=[self._jwt_algorithm], - ) - return payload - except jwt.ExpiredSignatureError as exc: - raise ValueError("Token has expired") from exc - except jwt.InvalidTokenError as exc: - raise ValueError(f"Invalid token: {exc}") from exc - - def has_permission(self, claims: dict[str, Any], permission: str) -> bool: - """Check if the user has a specific permission. - - Args: - claims: Token claims from validate_token(). - permission: Permission string to check (e.g., "agents.execute"). - - Returns: - True if user has the permission, False otherwise. - """ - roles = claims.get("roles", []) - - for role in roles: - permissions = self._roles.get(role, []) - - # Wildcard permission grants everything - if "*" in permissions: - return True - - # Exact match - if permission in permissions: - return True - - # Prefix match (e.g., "agents.*" grants "agents.execute") - for perm in permissions: - if perm.endswith(".*"): - prefix = perm[:-2] - if permission.startswith(f"{prefix}."): - return True - - return False - - def check_tenant_access( - self, - claims: dict[str, Any], - tenant_id: str, - ) -> bool: - """Check if the user has access to a specific tenant. - - Args: - claims: Token claims from validate_token(). - tenant_id: Tenant ID to check access for. - - Returns: - True if user has access, False otherwise. - """ - if not self._multi_tenant: - return True # No tenant isolation - - token_tenant = claims.get("tenant_id") - return token_tenant == tenant_id - - def get_user_id(self, claims: dict[str, Any]) -> str: - """Extract user ID from token claims. - - Args: - claims: Token claims from validate_token(). - - Returns: - User ID string. - """ - return claims.get("sub", "") - - def get_roles(self, claims: dict[str, Any]) -> list[str]: - """Extract roles from token claims. - - Args: - claims: Token claims from validate_token(). - - Returns: - List of role names. - """ - return claims.get("roles", []) - - def get_permissions(self, claims: dict[str, Any]) -> list[str]: - """Get all permissions for the user based on their roles. - - Args: - claims: Token claims from validate_token(). - - Returns: - List of all permissions granted to the user. - """ - roles = self.get_roles(claims) - permissions = set() - - for role in roles: - role_perms = self._roles.get(role, []) - permissions.update(role_perms) - - return list(permissions) - - -def require_permission( - permission: str, - *, - rbac: RBACManager | None = None, - token_param: str = "token", -) -> Callable: - """Decorator to require a specific permission for a function. - - Args: - permission: Required permission string. - rbac: RBACManager instance. If None, uses default from config. - token_param: Name of the function parameter containing the JWT token. - - Returns: - Decorator function. - - Example:: - - @require_permission("agents.execute") - async def run_agent(token: str, agent_name: str, prompt: str): - # This only runs if token has "agents.execute" permission - return await agent.run(prompt) - - # Usage - try: - result = await run_agent( - token="eyJ...", - agent_name="my_agent", - prompt="Hello" - ) - except ValueError: - print("Permission denied or invalid token") - """ - - def decorator(func: Callable) -> Callable: - @functools.wraps(func) - async def async_wrapper(*args: Any, **kwargs: Any) -> Any: - # Get RBAC manager - manager = rbac or _get_default_rbac() - - if manager is None: - raise ValueError( - "No RBAC manager configured. Set FIREFLY_AGENTIC_RBAC_ENABLED=true " - "and FIREFLY_AGENTIC_RBAC_JWT_SECRET in environment." - ) - - # Extract token from args/kwargs using signature binding - try: - sig = inspect.signature(func) - bound = sig.bind(*args, **kwargs) - bound.apply_defaults() - token = bound.arguments.get(token_param) - except TypeError as exc: - raise ValueError(f"Missing required parameter: {token_param}") from exc - if not token: - raise ValueError(f"Missing required parameter: {token_param}") - - # Validate token and check permission - try: - claims = manager.validate_token(token) - except ValueError as exc: - logger.warning("Token validation failed: %s", exc) - raise - - if not manager.has_permission(claims, permission): - user_id = manager.get_user_id(claims) - roles = manager.get_roles(claims) - logger.warning( - "Permission denied: user=%s, roles=%s, required=%s", - user_id, - roles, - permission, - ) - raise ValueError(f"Permission denied: {permission}") - - # Call the original function - return await func(*args, **kwargs) - - @functools.wraps(func) - def sync_wrapper(*args: Any, **kwargs: Any) -> Any: - # Get RBAC manager - manager = rbac or _get_default_rbac() - - if manager is None: - raise ValueError( - "No RBAC manager configured. Set FIREFLY_AGENTIC_RBAC_ENABLED=true " - "and FIREFLY_AGENTIC_RBAC_JWT_SECRET in environment." - ) - - # Extract token from args/kwargs - try: - sig = inspect.signature(func) - bound = sig.bind(*args, **kwargs) - bound.apply_defaults() - token = bound.arguments.get(token_param) - except TypeError as exc: - raise ValueError(f"Missing required parameter: {token_param}") from exc - if not token: - raise ValueError(f"Missing required parameter: {token_param}") - - # Validate token and check permission - try: - claims = manager.validate_token(token) - except ValueError as exc: - logger.warning("Token validation failed: %s", exc) - raise - - if not manager.has_permission(claims, permission): - user_id = manager.get_user_id(claims) - roles = manager.get_roles(claims) - logger.warning( - "Permission denied: user=%s, roles=%s, required=%s", - user_id, - roles, - permission, - ) - raise ValueError(f"Permission denied: {permission}") - - # Call the original function - return func(*args, **kwargs) - - # Return appropriate wrapper based on function type - if inspect.iscoroutinefunction(func): - return async_wrapper - return sync_wrapper - - return decorator - - -def _get_default_rbac() -> RBACManager | None: - """Get the default RBAC manager from configuration.""" - try: - cfg = get_config() - if not cfg.rbac_enabled or not cfg.rbac_jwt_secret: - return None - - return RBACManager( - jwt_secret=cfg.rbac_jwt_secret, - multi_tenant=cfg.rbac_multi_tenant, - ) - except Exception: # noqa: BLE001 - return None - - -# Module-level default instance -default_rbac: RBACManager | None = _get_default_rbac() diff --git a/fireflyframework_agentic/vectorstores/pgvector_store.py b/fireflyframework_agentic/vectorstores/pgvector_store.py index 7f6a5882..76dd51c4 100644 --- a/fireflyframework_agentic/vectorstores/pgvector_store.py +++ b/fireflyframework_agentic/vectorstores/pgvector_store.py @@ -174,9 +174,7 @@ async def _upsert(self, documents: list[VectorDocument], namespace: str) -> None for doc in documents: if doc.embedding is None: raise VectorStoreError(f"VectorDocument {doc.id!r} has no embedding; pgvector requires one.") - rows.append( - (doc.id, namespace, _vector_literal(doc.embedding), doc.text, json.dumps(doc.metadata)) - ) + rows.append((doc.id, namespace, _vector_literal(doc.embedding), doc.text, json.dumps(doc.metadata))) if not rows: return pool = await self._ensure_pool() diff --git a/fireflyframework_agentic/vectorstores/scoped.py b/fireflyframework_agentic/vectorstores/scoped.py index 6261a2f0..100ac90a 100644 --- a/fireflyframework_agentic/vectorstores/scoped.py +++ b/fireflyframework_agentic/vectorstores/scoped.py @@ -64,9 +64,7 @@ def parse_scope_namespace(namespace: str) -> tuple[str, str]: """ parts = namespace.split("/") if len(parts) != 4 or parts[0] != "t" or parts[2] != "w" or not parts[1] or not parts[3]: - raise ValueError( - f"not a scope namespace: {namespace!r}; expected 't//w/'" - ) + raise ValueError(f"not a scope namespace: {namespace!r}; expected 't//w/'") return parts[1], parts[3] @@ -80,9 +78,7 @@ class ScopedVectorStore(Protocol): can never be lost silently. """ - async def upsert( - self, documents: list[VectorDocument], *, tenant_id: str, workspace_id: str - ) -> None: ... + async def upsert(self, documents: list[VectorDocument], *, tenant_id: str, workspace_id: str) -> None: ... async def search( self, @@ -119,9 +115,7 @@ def __init__(self, inner: VectorStoreProtocol, *, stamp_metadata: bool = True) - self._inner = inner self._stamp_metadata = stamp_metadata - async def upsert( - self, documents: list[VectorDocument], *, tenant_id: str, workspace_id: str - ) -> None: + async def upsert(self, documents: list[VectorDocument], *, tenant_id: str, workspace_id: str) -> None: namespace = scope_namespace(tenant_id, workspace_id) scoped_docs = [self._scope_document(doc, tenant_id, workspace_id, namespace) for doc in documents] await self._inner.upsert(scoped_docs, namespace=namespace) @@ -159,9 +153,7 @@ async def close(self) -> None: if close_fn is not None: await close_fn() - def _scope_document( - self, doc: VectorDocument, tenant_id: str, workspace_id: str, namespace: str - ) -> VectorDocument: + def _scope_document(self, doc: VectorDocument, tenant_id: str, workspace_id: str, namespace: str) -> VectorDocument: metadata = dict(doc.metadata) if self._stamp_metadata: metadata["tenant_id"] = tenant_id diff --git a/install.ps1 b/install.ps1 index 0af9d7b6..a7abb981 100644 --- a/install.ps1 +++ b/install.ps1 @@ -26,7 +26,7 @@ Run in non-interactive mode with default options. .PARAMETER Extras - Optional extras to install (rest, kafka, rabbitmq, redis, queues, all). + Optional extras to install (all). .EXAMPLE .\install.ps1 @@ -37,7 +37,7 @@ [CmdletBinding()] param( [switch]$NonInteractive, - [ValidateSet("", "rest", "kafka", "rabbitmq", "redis", "queues", "all")] + [ValidateSet("", "all")] [string]$Extras = "" ) @@ -286,23 +286,13 @@ function Select-Extras { $options = @( "Core only (no optional dependencies)", - "REST API (FastAPI + Uvicorn + SSE)", - "Kafka (aiokafka)", - "RabbitMQ (aio-pika)", - "Redis (redis-py)", - "All queues (Kafka + RabbitMQ + Redis)", - "Everything (REST + all queues + costs)" + "Everything (all optional dependencies)" ) $choice = Read-Choice "Choose a configuration:" $options switch ($choice) { - 2 { $script:SelectedExtras = "rest" } - 3 { $script:SelectedExtras = "kafka" } - 4 { $script:SelectedExtras = "rabbitmq" } - 5 { $script:SelectedExtras = "redis" } - 6 { $script:SelectedExtras = "queues" } - 7 { $script:SelectedExtras = "all" } + 2 { $script:SelectedExtras = "all" } default { $script:SelectedExtras = "" } } diff --git a/install.sh b/install.sh index 717bc5c5..74331e26 100755 --- a/install.sh +++ b/install.sh @@ -484,29 +484,19 @@ step_check_tools() { step_select_extras() { step_header 4 "Extras Selection" - info "Optional components add REST API and message queue support." + info "Optional components add embeddings, vector stores, and storage backends." printf "\n" local options=( "Core only ${DIM}— no optional dependencies${RESET}" - "REST API ${DIM}— FastAPI + Uvicorn + SSE streaming${RESET}" - "Kafka ${DIM}— aiokafka (Apache Kafka)${RESET}" - "RabbitMQ ${DIM}— aio-pika (AMQP)${RESET}" - "Redis ${DIM}— redis-py (Pub/Sub)${RESET}" - "All queues ${DIM}— Kafka + RabbitMQ + Redis${RESET}" - "Everything ${DIM}— REST + all queues + costs${RESET}" + "Everything ${DIM}— all optional dependencies${RESET}" ) local choice choice="$(prompt_choice "Select a configuration:" "${options[@]}")" case "$choice" in - 2) EXTRAS="rest" ;; - 3) EXTRAS="kafka" ;; - 4) EXTRAS="rabbitmq" ;; - 5) EXTRAS="redis" ;; - 6) EXTRAS="queues" ;; - 7) EXTRAS="all" ;; + 2) EXTRAS="all" ;; *) EXTRAS="" ;; esac diff --git a/pyproject.toml b/pyproject.toml index d7da4304..e575323e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fireflyframework-agentic" -version = "26.05.32" +version = "26.05.33" description = "A GenAI metaframework built on Pydantic AI for building production-grade GenAI applications with agents, reasoning patterns, prompt engineering, observability, and more." readme = "README.md" license = { text = "Apache-2.0" } @@ -45,23 +45,6 @@ dependencies = [ ] [project.optional-dependencies] -rest = [ - "fastapi>=0.115.0", - "uvicorn[standard]>=0.34.0", - "sse-starlette>=2.0.0", -] -kafka = [ - "aiokafka>=0.12.0", -] -rabbitmq = [ - "aio-pika>=9.5.0", -] -redis = [ - "redis>=5.2.0", -] -queues = [ - "fireflyframework-agentic[kafka,rabbitmq,redis]", -] postgres = [ "asyncpg>=0.30.0", "sqlalchemy>=2.0.0", @@ -71,7 +54,6 @@ mongodb = [ "pymongo>=4.10.0", ] security = [ - "pyjwt>=2.10.0", "cryptography>=44.0.0", ] embeddings = [ @@ -135,7 +117,7 @@ binary = [ "extract-msg>=0.51", ] all = [ - "fireflyframework-agentic[rest,queues,postgres,mongodb,security,embeddings,openai-embeddings,cohere-embeddings,google-embeddings,mistral-embeddings,voyage-embeddings,azure-embeddings,bedrock-embeddings,ollama-embeddings,vectorstores-chroma,vectorstores-pinecone,vectorstores-qdrant,vectorstores-pgvector,vectorstores-sqlite-vec,watch,binary]", + "fireflyframework-agentic[postgres,mongodb,security,embeddings,openai-embeddings,cohere-embeddings,google-embeddings,mistral-embeddings,voyage-embeddings,azure-embeddings,bedrock-embeddings,ollama-embeddings,vectorstores-chroma,vectorstores-pinecone,vectorstores-qdrant,vectorstores-pgvector,vectorstores-sqlite-vec,watch,binary]", ] dev = [ "pytest>=8.3.0", @@ -173,7 +155,6 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "TC", "PLC0415"] ignore = ["E501", "TC001", "TC002", "TC003", "UP040", "UP046", "B008"] [tool.ruff.lint.per-file-ignores] -"fireflyframework_agentic/exposure/queues/__init__.py" = ["PLC0415"] # content.binary lazy-imports its optional heavy deps (pypdf, Pillow, # pillow-heif, cairosvg, py7zr, extract-msg, httpx) inside the handlers so the # module imports cleanly without the ``[binary]`` extra; the deferred import diff --git a/tests/security/test_rbac.py b/tests/security/test_rbac.py deleted file mode 100644 index 6cf32388..00000000 --- a/tests/security/test_rbac.py +++ /dev/null @@ -1,244 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for RBAC (Role-Based Access Control).""" - -from __future__ import annotations - -import pytest - -# Check if JWT is available -pytest.importorskip("jwt", reason="JWT tests require pyjwt") - -from fireflyframework_agentic.security.rbac import RBACManager, require_permission - - -class TestRBACManager: - """Test suite for RBACManager.""" - - def test_create_and_validate_token(self): - """Test creating and validating JWT tokens.""" - rbac = RBACManager(jwt_secret="test-secret") - - token = rbac.create_token(user_id="user123", roles=["agent_runner"]) - claims = rbac.validate_token(token) - - assert claims["sub"] == "user123" - assert claims["roles"] == ["agent_runner"] - - def test_invalid_token(self): - """Test that invalid tokens are rejected.""" - rbac = RBACManager(jwt_secret="test-secret") - - with pytest.raises(ValueError, match="Invalid token"): - rbac.validate_token("invalid.token.here") - - def test_wrong_secret(self): - """Test that tokens signed with different secret are rejected.""" - rbac1 = RBACManager(jwt_secret="secret1") - rbac2 = RBACManager(jwt_secret="secret2") - - token = rbac1.create_token(user_id="user123", roles=["admin"]) - - with pytest.raises(ValueError, match="Invalid token"): - rbac2.validate_token(token) - - def test_has_permission_exact_match(self): - """Test permission checking with exact match.""" - rbac = RBACManager( - jwt_secret="test-secret", - roles={ - "agent_runner": ["agents.execute", "agents.list"], - "viewer": ["agents.list"], - }, - ) - - token = rbac.create_token(user_id="user123", roles=["agent_runner"]) - claims = rbac.validate_token(token) - - assert rbac.has_permission(claims, "agents.execute") - assert rbac.has_permission(claims, "agents.list") - assert not rbac.has_permission(claims, "pipelines.execute") - - def test_has_permission_wildcard(self): - """Test that wildcard permission grants everything.""" - rbac = RBACManager(jwt_secret="test-secret", roles={"admin": ["*"]}) - - token = rbac.create_token(user_id="admin", roles=["admin"]) - claims = rbac.validate_token(token) - - assert rbac.has_permission(claims, "agents.execute") - assert rbac.has_permission(claims, "anything.anything") - assert rbac.has_permission(claims, "foo.bar.baz") - - def test_has_permission_prefix_match(self): - """Test permission checking with prefix wildcard.""" - rbac = RBACManager(jwt_secret="test-secret", roles={"agent_admin": ["agents.*"]}) - - token = rbac.create_token(user_id="user123", roles=["agent_admin"]) - claims = rbac.validate_token(token) - - assert rbac.has_permission(claims, "agents.execute") - assert rbac.has_permission(claims, "agents.list") - assert rbac.has_permission(claims, "agents.delete") - assert not rbac.has_permission(claims, "pipelines.execute") - - def test_has_permission_no_role(self): - """Test that users without matching roles have no permissions.""" - rbac = RBACManager(jwt_secret="test-secret", roles={"admin": ["*"]}) - - token = rbac.create_token(user_id="user123", roles=["unknown_role"]) - claims = rbac.validate_token(token) - - assert not rbac.has_permission(claims, "agents.execute") - - def test_multi_tenant_token(self): - """Test multi-tenant token creation and validation.""" - rbac = RBACManager(jwt_secret="test-secret", multi_tenant=True) - - token = rbac.create_token(user_id="user123", roles=["agent_runner"], tenant_id="acme_corp") - claims = rbac.validate_token(token) - - assert claims["tenant_id"] == "acme_corp" - - def test_multi_tenant_requires_tenant_id(self): - """Test that multi-tenant mode requires tenant_id.""" - rbac = RBACManager(jwt_secret="test-secret", multi_tenant=True) - - with pytest.raises(ValueError, match="tenant_id is required"): - rbac.create_token(user_id="user123", roles=["admin"]) - - def test_check_tenant_access(self): - """Test tenant access checking.""" - rbac = RBACManager(jwt_secret="test-secret", multi_tenant=True) - - token = rbac.create_token(user_id="user123", roles=["admin"], tenant_id="tenant_a") - claims = rbac.validate_token(token) - - assert rbac.check_tenant_access(claims, "tenant_a") - assert not rbac.check_tenant_access(claims, "tenant_b") - - def test_custom_claims(self): - """Test adding custom claims to tokens.""" - rbac = RBACManager(jwt_secret="test-secret") - - token = rbac.create_token( - user_id="user123", roles=["admin"], custom_claims={"department": "engineering", "level": 5} - ) - claims = rbac.validate_token(token) - - assert claims["department"] == "engineering" - assert claims["level"] == 5 - - def test_get_user_id(self): - """Test extracting user ID from claims.""" - rbac = RBACManager(jwt_secret="test-secret") - - token = rbac.create_token(user_id="user123", roles=["admin"]) - claims = rbac.validate_token(token) - - assert rbac.get_user_id(claims) == "user123" - - def test_get_roles(self): - """Test extracting roles from claims.""" - rbac = RBACManager(jwt_secret="test-secret") - - token = rbac.create_token(user_id="user123", roles=["admin", "agent_runner"]) - claims = rbac.validate_token(token) - - assert rbac.get_roles(claims) == ["admin", "agent_runner"] - - def test_get_permissions(self): - """Test getting all permissions for a user.""" - rbac = RBACManager( - jwt_secret="test-secret", - roles={ - "agent_runner": ["agents.execute", "agents.list"], - "pipeline_runner": ["pipelines.execute"], - }, - ) - - token = rbac.create_token(user_id="user123", roles=["agent_runner", "pipeline_runner"]) - claims = rbac.validate_token(token) - - permissions = rbac.get_permissions(claims) - assert "agents.execute" in permissions - assert "agents.list" in permissions - assert "pipelines.execute" in permissions - - -@pytest.mark.asyncio -class TestRequirePermissionDecorator: - """Test suite for @require_permission decorator.""" - - async def test_decorator_allows_with_permission(self): - """Test that decorator allows function when permission is granted.""" - rbac = RBACManager(jwt_secret="test-secret", roles={"agent_runner": ["agents.execute"]}) - - @require_permission("agents.execute", rbac=rbac) - async def protected_function(token: str, data: str) -> str: - return f"Success: {data}" - - token = rbac.create_token(user_id="user123", roles=["agent_runner"]) - result = await protected_function(token=token, data="test") - - assert result == "Success: test" - - async def test_decorator_denies_without_permission(self): - """Test that decorator blocks function when permission is missing.""" - rbac = RBACManager(jwt_secret="test-secret", roles={"viewer": ["agents.list"]}) - - @require_permission("agents.execute", rbac=rbac) - async def protected_function(token: str, data: str) -> str: - return f"Success: {data}" - - token = rbac.create_token(user_id="user123", roles=["viewer"]) - - with pytest.raises(ValueError, match="Permission denied"): - await protected_function(token=token, data="test") - - async def test_decorator_rejects_invalid_token(self): - """Test that decorator rejects invalid tokens.""" - rbac = RBACManager(jwt_secret="test-secret") - - @require_permission("agents.execute", rbac=rbac) - async def protected_function(token: str) -> str: - return "Success" - - with pytest.raises(ValueError, match="Invalid token"): - await protected_function(token="invalid.token") - - async def test_decorator_requires_token_parameter(self): - """Test that decorator requires token parameter.""" - rbac = RBACManager(jwt_secret="test-secret") - - @require_permission("agents.execute", rbac=rbac) - async def protected_function(token: str) -> str: - return "Success" - - with pytest.raises(ValueError, match="Missing required parameter: token"): - await protected_function() - - def test_decorator_with_sync_function(self): - """Test that decorator works with synchronous functions.""" - rbac = RBACManager(jwt_secret="test-secret", roles={"admin": ["*"]}) - - @require_permission("agents.execute", rbac=rbac) - def protected_sync_function(token: str, data: str) -> str: - return f"Sync success: {data}" - - token = rbac.create_token(user_id="admin", roles=["admin"]) - result = protected_sync_function(token=token, data="test") - - assert result == "Sync success: test" diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index a1cc2cc5..f0005c78 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -29,35 +29,35 @@ def test_default_config_is_valid(self) -> None: assert cfg.qos_consistency_runs >= 2 def test_removed_cost_calculator_field_raises(self) -> None: - with pytest.raises(ValidationError, match="Removed cost-tracking config fields"): + with pytest.raises(ValidationError, match="Removed config fields"): FireflyAgenticConfig(cost_calculator="auto") def test_removed_budget_alert_threshold_field_raises(self) -> None: - with pytest.raises(ValidationError, match="Removed cost-tracking config fields"): + with pytest.raises(ValidationError, match="Removed config fields"): FireflyAgenticConfig(budget_alert_threshold_usd=5.0) + def test_removed_auth_api_keys_field_raises(self) -> None: + with pytest.raises(ValidationError, match="Removed config fields"): + FireflyAgenticConfig(auth_api_keys=["key1"]) -class TestConfigAuthAndUsageFields: - def test_auth_api_keys_default(self) -> None: - cfg = FireflyAgenticConfig() - assert cfg.auth_api_keys is None + def test_removed_auth_bearer_tokens_field_raises(self) -> None: + with pytest.raises(ValidationError, match="Removed config fields"): + FireflyAgenticConfig(auth_bearer_tokens=["tok1"]) + + def test_removed_cors_allowed_origins_field_raises(self) -> None: + with pytest.raises(ValidationError, match="Removed config fields"): + FireflyAgenticConfig(cors_allowed_origins=["https://app.example.com"]) - def test_auth_bearer_tokens_default(self) -> None: - cfg = FireflyAgenticConfig() - assert cfg.auth_bearer_tokens is None +class TestConfigUsageFields: def test_usage_tracker_max_records_default(self) -> None: cfg = FireflyAgenticConfig() assert cfg.usage_tracker_max_records == 10_000 def test_custom_values(self) -> None: cfg = FireflyAgenticConfig( - auth_api_keys=["key1"], - auth_bearer_tokens=["tok1"], usage_tracker_max_records=500, ) - assert cfg.auth_api_keys == ["key1"] - assert cfg.auth_bearer_tokens == ["tok1"] assert cfg.usage_tracker_max_records == 500 diff --git a/tests/unit/exposure/__init__.py b/tests/unit/exposure/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/exposure/test_queues.py b/tests/unit/exposure/test_queues.py deleted file mode 100644 index 2e45d32a..00000000 --- a/tests/unit/exposure/test_queues.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for queue exposure.""" - -from __future__ import annotations - -import pytest - -from fireflyframework_agentic.exceptions import ExposureError -from fireflyframework_agentic.exposure.queues.base import QueueMessage -from fireflyframework_agentic.exposure.queues.router import QueueRouter - - -class TestQueueMessage: - def test_message_creation(self): - msg = QueueMessage(body="hello", routing_key="test.key") - assert msg.body == "hello" - assert msg.routing_key == "test.key" - - def test_message_defaults(self): - msg = QueueMessage(body="hi") - assert msg.headers == {} - assert msg.routing_key == "" - assert msg.reply_to == "" - - -class TestQueueRouter: - def test_add_route(self): - router = QueueRouter() - router.add_route(r"test\..*", "test_agent") - assert len(router._routes) == 1 - - def test_resolve_matches_pattern(self): - router = QueueRouter() - router.add_route(r"summary\..*", "summariser") - assert router._resolve("summary.en") == "summariser" - - def test_resolve_default_agent(self): - router = QueueRouter(default_agent="fallback") - assert router._resolve("unknown.key") == "fallback" - - def test_resolve_no_match_no_default_raises(self): - router = QueueRouter() - with pytest.raises(ExposureError): - router._resolve("unknown.key") diff --git a/tests/unit/exposure/test_rate_limit.py b/tests/unit/exposure/test_rate_limit.py deleted file mode 100644 index a176b6a5..00000000 --- a/tests/unit/exposure/test_rate_limit.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Tests for exposure/rest/middleware.py rate limiting.""" - -from __future__ import annotations - -from fireflyframework_agentic.exposure.rest.middleware import RateLimiter - - -class TestRateLimiter: - def test_allows_under_limit(self) -> None: - limiter = RateLimiter(max_requests=3, window_seconds=10) - assert limiter.is_allowed("client1") is True - assert limiter.is_allowed("client1") is True - assert limiter.is_allowed("client1") is True - - def test_blocks_over_limit(self) -> None: - limiter = RateLimiter(max_requests=2, window_seconds=10) - limiter.is_allowed("c") - limiter.is_allowed("c") - assert limiter.is_allowed("c") is False - - def test_separate_keys(self) -> None: - limiter = RateLimiter(max_requests=1, window_seconds=10) - assert limiter.is_allowed("a") is True - assert limiter.is_allowed("b") is True - assert limiter.is_allowed("a") is False diff --git a/tests/unit/exposure/test_rest_app.py b/tests/unit/exposure/test_rest_app.py deleted file mode 100644 index 6b023972..00000000 --- a/tests/unit/exposure/test_rest_app.py +++ /dev/null @@ -1,176 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Integration tests for the REST exposure layer. - -Tests the full FastAPI app factory, agent router, health endpoints, -and middleware wiring using ``httpx.AsyncClient`` with ``ASGITransport``. -""" - -from __future__ import annotations - -import pytest - -pytest.importorskip("fastapi", reason="fastapi not installed") -pytest.importorskip("httpx", reason="httpx not installed") - -import httpx - -from fireflyframework_agentic.exposure.rest.app import create_agentic_app - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_app(**kwargs): - """Create a test app with lifespan disabled.""" - from fastapi import FastAPI - - from fireflyframework_agentic.exposure.rest.health import create_health_router - from fireflyframework_agentic.exposure.rest.router import create_agent_router - - app = FastAPI(title="test") - app.include_router(create_health_router()) - app.include_router(create_agent_router()) - return app - - -@pytest.fixture() -def app(): - """Provide a minimal test application.""" - return _make_app() - - -@pytest.fixture() -async def client(app): - """Provide an async httpx client bound to the test app.""" - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: - yield c - - -# --------------------------------------------------------------------------- -# Health endpoints -# --------------------------------------------------------------------------- - - -class TestHealthEndpoints: - async def test_health(self, client: httpx.AsyncClient): - resp = await client.get("/health") - assert resp.status_code == 200 - body = resp.json() - assert body["status"] == "ok" - assert "agents" in body - - async def test_readiness(self, client: httpx.AsyncClient): - resp = await client.get("/health/ready") - assert resp.status_code == 200 - assert resp.json()["status"] == "ready" - - async def test_liveness(self, client: httpx.AsyncClient): - resp = await client.get("/health/live") - assert resp.status_code == 200 - assert resp.json()["status"] == "alive" - - -# --------------------------------------------------------------------------- -# Agent router -# --------------------------------------------------------------------------- - - -class TestAgentRouter: - async def test_list_agents(self, client: httpx.AsyncClient): - resp = await client.get("/agents/") - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - - async def test_run_missing_agent_returns_404(self, client: httpx.AsyncClient): - resp = await client.post( - "/agents/nonexistent/run", - json={"prompt": "hello"}, - ) - assert resp.status_code == 404 - - async def test_stream_missing_agent_returns_404(self, client: httpx.AsyncClient): - resp = await client.post( - "/agents/nonexistent/stream", - json={"prompt": "hello"}, - ) - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# Conversation endpoints -# --------------------------------------------------------------------------- - - -class TestConversationEndpoints: - async def test_create_conversation(self, client: httpx.AsyncClient): - resp = await client.post("/agents/conversations") - assert resp.status_code == 200 - body = resp.json() - assert "conversation_id" in body - - async def test_get_conversation(self, client: httpx.AsyncClient): - # First create one - create_resp = await client.post("/agents/conversations") - cid = create_resp.json()["conversation_id"] - - resp = await client.get(f"/agents/conversations/{cid}") - assert resp.status_code == 200 - body = resp.json() - assert body["conversation_id"] == cid - assert body["message_count"] == 0 - - async def test_delete_conversation(self, client: httpx.AsyncClient): - create_resp = await client.post("/agents/conversations") - cid = create_resp.json()["conversation_id"] - - resp = await client.delete(f"/agents/conversations/{cid}") - assert resp.status_code == 200 - assert resp.json()["status"] == "cleared" - - -# --------------------------------------------------------------------------- -# App factory -# --------------------------------------------------------------------------- - - -class TestAppFactory: - def test_create_agentic_app_returns_fastapi(self): - """Verify the factory produces a FastAPI instance with expected routes.""" - from fastapi import FastAPI - - app = create_agentic_app(cors=False, request_id=False) - assert isinstance(app, FastAPI) - - # Should have health and agent routes - paths = {r.path for r in app.routes} - assert "/health" in paths - assert "/agents/" in paths - - def test_create_agentic_app_rate_limit(self): - """Verify rate-limit middleware wiring doesn't crash.""" - app = create_agentic_app(rate_limit=True, cors=False, request_id=False) - assert app is not None - - def test_create_agentic_app_rate_limit_custom(self): - """Verify custom rate-limit config dict is accepted.""" - app = create_agentic_app( - rate_limit={"max_requests": 10, "window_seconds": 30}, - cors=False, - request_id=False, - ) - assert app is not None diff --git a/tests/unit/exposure/test_rest_utils.py b/tests/unit/exposure/test_rest_utils.py deleted file mode 100644 index 29ddd0d5..00000000 --- a/tests/unit/exposure/test_rest_utils.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tests for REST exposure utilities — auth middleware and WebSocket router.""" - -from __future__ import annotations - - -class TestAuthMiddleware: - def test_add_auth_middleware_callable(self): - """Verify add_auth_middleware is a callable that accepts expected args.""" - import inspect - - from fireflyframework_agentic.exposure.rest.middleware import add_auth_middleware - - sig = inspect.signature(add_auth_middleware) - params = list(sig.parameters.keys()) - assert "app" in params - assert "api_keys" in params or "bearer_tokens" in params - - -class TestWebSocketRouter: - def test_create_websocket_router_callable(self): - """Verify the factory function exists and is callable.""" - from fireflyframework_agentic.exposure.rest.websocket import create_websocket_router - - assert callable(create_websocket_router) diff --git a/tests/unit/exposure/test_schemas.py b/tests/unit/exposure/test_schemas.py deleted file mode 100644 index 6696a6f4..00000000 --- a/tests/unit/exposure/test_schemas.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for REST exposure schemas.""" - -from __future__ import annotations - -from fireflyframework_agentic.exposure.rest.schemas import ( - AgentRequest, - AgentResponse, - HealthResponse, -) - - -class TestSchemas: - def test_agent_request(self): - req = AgentRequest(prompt="hello") - assert req.prompt == "hello" - - def test_agent_response(self): - resp = AgentResponse(agent_name="test", output="world") - assert resp.agent_name == "test" - assert resp.success is True - - def test_agent_response_failure(self): - resp = AgentResponse(agent_name="test", output=None, success=False, error="boom") - assert not resp.success - assert resp.error == "boom" - - def test_health_response(self): - h = HealthResponse(status="healthy") - assert h.status == "healthy" diff --git a/tests/unit/observability/test_exporters.py b/tests/unit/observability/test_exporters.py deleted file mode 100644 index c181095d..00000000 --- a/tests/unit/observability/test_exporters.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for :func:`fireflyframework_agentic.observability.configure_exporters`.""" - -from __future__ import annotations - -import logging - -import pytest - -from fireflyframework_agentic.observability import exporters as exporters_mod -from fireflyframework_agentic.observability.exporters import ( - ProviderBundle, - configure_exporters, -) - - -@pytest.fixture(autouse=True) -def _reset_configured_signature(): - """Each test starts with no prior configuration.""" - exporters_mod._state.signature = None - if exporters_mod._state.handler is not None: - logging.getLogger("fireflyframework_agentic").removeHandler(exporters_mod._state.handler) - exporters_mod._state.handler = None - yield - - -def test_returns_provider_bundle_with_three_providers(): - bundle = configure_exporters(service_name="test") - assert isinstance(bundle, ProviderBundle) - assert bundle.tracer is not None - assert bundle.meter is not None - assert bundle.log is not None - - -def test_console_exporters_attach_without_errors(): - bundle = configure_exporters(service_name="test", console=True) - # The TracerProvider has at least one span processor when console=True. - # MeterProvider holds the reader internally; we can't introspect the - # list portably, but constructing with console=True must not raise. - assert bundle.tracer is not None - - -def test_no_kwargs_still_builds_providers_but_attaches_no_exporters(): - # Useful for tests / when the caller wants a noop telemetry pipeline. - bundle = configure_exporters(service_name="test-empty") - assert bundle.tracer is not None - - -def test_logging_handler_attached_to_firefly_logger(): - configure_exporters(service_name="test", console=True) - firefly_logger = logging.getLogger("fireflyframework_agentic") - # Our handler from observability.exporters must be present. - assert exporters_mod._state.handler is not None - assert exporters_mod._state.handler in firefly_logger.handlers - - -def test_idempotent_repeat_call_is_no_op(): - first = configure_exporters(service_name="test", console=True) - prior_tracer = first.tracer - prior_meter = first.meter - prior_log = first.log - second = configure_exporters(service_name="test", console=True) - # The module guard should prevent re-registration; we assert idempotency - # via the guard variable, not provider identity, because the no-op branch - # returns whatever global providers are currently registered. - assert exporters_mod._state.signature is not None - assert second.tracer is not None # always returns something usable - assert prior_tracer is not None - assert prior_meter is not None - assert prior_log is not None - - -def test_idempotent_repeat_call_does_not_double_attach_logging_handler(): - configure_exporters(service_name="test", console=True) - firefly_logger = logging.getLogger("fireflyframework_agentic") - handlers_after_first = list(firefly_logger.handlers) - configure_exporters(service_name="test", console=True) - handlers_after_second = list(firefly_logger.handlers) - assert len(handlers_after_first) == len(handlers_after_second) - - -def test_changing_signature_replaces_logging_handler(): - configure_exporters(service_name="svc-a", console=True) - firefly_logger = logging.getLogger("fireflyframework_agentic") - first_handler = exporters_mod._state.handler - configure_exporters(service_name="svc-b", console=True) - second_handler = exporters_mod._state.handler - assert second_handler is not first_handler - assert first_handler not in firefly_logger.handlers - assert second_handler in firefly_logger.handlers - - -def test_otlp_missing_dependency_warns_does_not_raise(caplog): - # In the test environment opentelemetry-exporter-otlp-proto-grpc may - # not be installed; configuring with otlp must degrade gracefully. - with caplog.at_level(logging.WARNING): - bundle = configure_exporters( - service_name="test", - otlp_endpoint="http://localhost:4317", - ) - assert bundle is not None # graceful degradation, not an exception diff --git a/tests/unit/observability/test_sinks.py b/tests/unit/observability/test_sinks.py index bcf48656..0a9616a7 100644 --- a/tests/unit/observability/test_sinks.py +++ b/tests/unit/observability/test_sinks.py @@ -6,7 +6,7 @@ import json import logging from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -16,7 +16,6 @@ JSONLFileSink, LoggingSink, OTelMetricsSink, - WebhookSink, _emit_safely, ) from fireflyframework_agentic.observability.usage import UsageRecord @@ -123,47 +122,3 @@ def test_jsonl_file_sink_rotation(tmp_path: Path) -> None: sink.close() rotated = list(tmp_path.glob("cost.jsonl*")) assert len(rotated) > 1 - - -def test_webhook_sink_batches_and_flushes() -> None: - posts: list[list[dict]] = [] - - def fake_post(url: str, json: list[dict], headers: dict, timeout: float) -> MagicMock: - posts.append(json) - m = MagicMock() - m.status_code = 200 - return m - - sink = WebhookSink("https://example.test/cost", batch_size=3, flush_interval_s=10.0, _post=fake_post) - for i in range(5): - sink.emit(UsageRecord(agent=f"a{i}", cost_usd=0.01)) - sink.close() # forces drain - assert sum(len(b) for b in posts) == 5 - - -def test_webhook_sink_retries_5xx_then_succeeds() -> None: - attempts = {"n": 0} - - def fake_post(url: str, json: list[dict], headers: dict, timeout: float) -> MagicMock: - attempts["n"] += 1 - m = MagicMock() - m.status_code = 500 if attempts["n"] < 2 else 200 - return m - - sink = WebhookSink("https://example.test/cost", batch_size=1, flush_interval_s=10.0, max_retries=3, _post=fake_post) - sink.emit(UsageRecord(agent="a", cost_usd=0.01)) - sink.close() - assert attempts["n"] >= 2 - - -def test_webhook_sink_drops_after_max_retries(caplog: pytest.LogCaptureFixture) -> None: - def always_fail(url: str, json: list[dict], headers: dict, timeout: float) -> MagicMock: - m = MagicMock() - m.status_code = 500 - return m - - sink = WebhookSink("https://x", batch_size=1, flush_interval_s=10.0, max_retries=2, _post=always_fail) - with caplog.at_level(logging.WARNING): - sink.emit(UsageRecord(agent="a", cost_usd=0.01)) - sink.close() - assert any("drop" in r.message.lower() or "fail" in r.message.lower() for r in caplog.records) diff --git a/tests/unit/observability/test_trace_propagation.py b/tests/unit/observability/test_trace_propagation.py deleted file mode 100644 index f40e5f57..00000000 --- a/tests/unit/observability/test_trace_propagation.py +++ /dev/null @@ -1,271 +0,0 @@ -# Copyright 2026 Firefly Software Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for W3C Trace Context propagation.""" - -from __future__ import annotations - -import pytest -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.trace import SpanContext, TraceFlags - -from fireflyframework_agentic.observability.tracer import ( - extract_trace_context, - get_trace_context, - inject_trace_context, - set_trace_context, - trace_context_scope, -) - - -@pytest.fixture(scope="module", autouse=True) -def setup_tracing(): - """Set up OpenTelemetry tracer provider for tests.""" - provider = TracerProvider() - trace.set_tracer_provider(provider) - yield - # Reset after tests - trace._TRACER_PROVIDER = None - - -class TestTraceContextInjection: - """Test suite for trace context injection.""" - - def test_inject_with_active_span(self): - """Test that inject adds traceparent header when span is active.""" - tracer = trace.get_tracer(__name__) - - headers = {} - with tracer.start_as_current_span("test-span"): - inject_trace_context(headers) - - assert "traceparent" in headers - # Validate format: 00-{trace_id}-{span_id}-{flags} - parts = headers["traceparent"].split("-") - assert len(parts) == 4 - assert parts[0] == "00" # version - assert len(parts[1]) == 32 # trace_id (128-bit hex) - assert len(parts[2]) == 16 # span_id (64-bit hex) - assert len(parts[3]) == 2 # flags (8-bit hex) - - def test_inject_without_active_span(self): - """Test that inject does nothing when no span is active.""" - headers = {} - inject_trace_context(headers) - - assert "traceparent" not in headers - - def test_inject_preserves_existing_headers(self): - """Test that inject doesn't overwrite other headers.""" - tracer = trace.get_tracer(__name__) - - headers = {"x-custom-header": "value"} - with tracer.start_as_current_span("test-span"): - inject_trace_context(headers) - - assert "x-custom-header" in headers - assert headers["x-custom-header"] == "value" - assert "traceparent" in headers - - -class TestTraceContextExtraction: - """Test suite for trace context extraction.""" - - def test_extract_valid_traceparent(self): - """Test extraction of valid W3C traceparent header.""" - headers = {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"} - - context = extract_trace_context(headers) - - assert context is not None - assert context.trace_id == 0x0AF7651916CD43DD8448EB211C80319C - assert context.span_id == 0xB7AD6B7169203331 - assert context.trace_flags == TraceFlags.SAMPLED - assert context.is_remote - - def test_extract_case_insensitive(self): - """Test that header names are case-insensitive.""" - headers = {"TraceParent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"} - - context = extract_trace_context(headers) - - assert context is not None - assert context.trace_id == 0x0AF7651916CD43DD8448EB211C80319C - - def test_extract_missing_header(self): - """Test extraction returns None when traceparent is missing.""" - headers = {} - - context = extract_trace_context(headers) - - assert context is None - - def test_extract_invalid_format(self): - """Test extraction returns None for malformed traceparent.""" - invalid_headers = [ - {"traceparent": "invalid"}, - {"traceparent": "00-abc"}, # Too few parts - {"traceparent": "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}, # Unsupported version - {"traceparent": "00-xxx-yyy-01"}, # Invalid hex - ] - - for headers in invalid_headers: - context = extract_trace_context(headers) - assert context is None - - def test_extract_with_tracestate(self): - """Test extraction of traceparent with tracestate.""" - headers = { - "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", - "tracestate": "vendor1=value1,vendor2=value2", - } - - context = extract_trace_context(headers) - - assert context is not None - assert context.trace_state is not None - # Note: TraceState parsing is handled by OpenTelemetry - - -class TestTraceContextScope: - """Test suite for trace context scope management.""" - - def test_context_scope_sets_and_resets(self): - """Test that context scope properly sets and resets context.""" - # Create a mock span context - context = SpanContext( - trace_id=0x0AF7651916CD43DD8448EB211C80319C, - span_id=0xB7AD6B7169203331, - is_remote=True, - trace_flags=TraceFlags.SAMPLED, - ) - - # Initially no context - assert get_trace_context() is None - - # Inside scope, context is set - with trace_context_scope(context): - assert get_trace_context() == context - - # After scope, context is reset - assert get_trace_context() is None - - def test_nested_context_scopes(self): - """Test that nested scopes work correctly.""" - context1 = SpanContext( - trace_id=1, - span_id=1, - is_remote=True, - trace_flags=TraceFlags.DEFAULT, - ) - context2 = SpanContext( - trace_id=2, - span_id=2, - is_remote=True, - trace_flags=TraceFlags.DEFAULT, - ) - - with trace_context_scope(context1): - assert get_trace_context() == context1 - - with trace_context_scope(context2): - assert get_trace_context() == context2 - - # Outer context restored - assert get_trace_context() == context1 - - # All contexts cleared - assert get_trace_context() is None - - def test_context_scope_with_none(self): - """Test that scope can be used with None context.""" - with trace_context_scope(None): - assert get_trace_context() is None - - -class TestTraceContextAccessors: - """Test suite for context variable accessors.""" - - def test_get_and_set_context(self): - """Test getting and setting trace context.""" - context = SpanContext( - trace_id=0x0AF7651916CD43DD8448EB211C80319C, - span_id=0xB7AD6B7169203331, - is_remote=True, - trace_flags=TraceFlags.SAMPLED, - ) - - set_trace_context(context) - assert get_trace_context() == context - - set_trace_context(None) - assert get_trace_context() is None - - -class TestRoundTripPropagation: - """Test suite for full inject -> extract round-trip.""" - - def test_round_trip_preserves_context(self): - """Test that inject followed by extract preserves trace information.""" - tracer = trace.get_tracer(__name__) - - # Start a span and inject its context - with tracer.start_as_current_span("test-span") as span: - original_context = span.get_span_context() - - headers = {} - inject_trace_context(headers) - - # Extract the context from headers - extracted_context = extract_trace_context(headers) - - assert extracted_context is not None - assert extracted_context.trace_id == original_context.trace_id - assert extracted_context.span_id == original_context.span_id - assert extracted_context.trace_flags == original_context.trace_flags - - def test_multiple_services_chain(self): - """Test trace context propagation through multiple service hops.""" - tracer = trace.get_tracer(__name__) - - # Service A starts a trace - with tracer.start_as_current_span("service-a") as span_a: - context_a = span_a.get_span_context() - - # Service A sends request to Service B - headers_to_b = {} - inject_trace_context(headers_to_b) - - # Service B receives request - context_b = extract_trace_context(headers_to_b) - assert context_b is not None - - # Service B continues the trace - with trace_context_scope(context_b), tracer.start_as_current_span("service-b") as span_b: - # Service B sends request to Service C - headers_to_c = {} - inject_trace_context(headers_to_c) - - # Service C receives request - context_c = extract_trace_context(headers_to_c) - assert context_c is not None - - # All services share the same trace ID - assert context_a.trace_id == context_b.trace_id == context_c.trace_id - # context_b has context_a's span as parent, context_c has context_b's span as parent - # (extracted contexts contain the parent span ID) - assert context_b.span_id == context_a.span_id # B's parent is A - # The actual span created in B will have a different ID - assert span_b.get_span_context().span_id != context_a.span_id diff --git a/tests/unit/vectorstores/test_scoped.py b/tests/unit/vectorstores/test_scoped.py index 2bfa94b0..3cea7031 100644 --- a/tests/unit/vectorstores/test_scoped.py +++ b/tests/unit/vectorstores/test_scoped.py @@ -123,12 +123,8 @@ async def test_upsert_does_not_mutate_caller_documents(self) -> None: async def test_search_is_scope_isolated(self) -> None: inner = _FakeStore() store = TenantScopedVectorStore(inner) - await store.upsert( - [VectorDocument(id="1", text="a", embedding=[1.0])], tenant_id="acme", workspace_id="main" - ) - await store.upsert( - [VectorDocument(id="2", text="b", embedding=[1.0])], tenant_id="other", workspace_id="main" - ) + await store.upsert([VectorDocument(id="1", text="a", embedding=[1.0])], tenant_id="acme", workspace_id="main") + await store.upsert([VectorDocument(id="2", text="b", embedding=[1.0])], tenant_id="other", workspace_id="main") mine = await store.search([1.0], tenant_id="acme", workspace_id="main") assert [r.document.id for r in mine] == ["1"] foreign = await store.search([1.0], tenant_id="nobody", workspace_id="main") @@ -137,9 +133,7 @@ async def test_search_is_scope_isolated(self) -> None: async def test_delete_is_scoped(self) -> None: inner = _FakeStore() store = TenantScopedVectorStore(inner) - await store.upsert( - [VectorDocument(id="1", text="a", embedding=[1.0])], tenant_id="acme", workspace_id="main" - ) + await store.upsert([VectorDocument(id="1", text="a", embedding=[1.0])], tenant_id="acme", workspace_id="main") await store.delete(["1"], tenant_id="acme", workspace_id="main") assert await store.search([1.0], tenant_id="acme", workspace_id="main") == [] diff --git a/uv.lock b/uv.lock index 37fceef5..7e3b501c 100644 --- a/uv.lock +++ b/uv.lock @@ -22,19 +22,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/74/913c9b8fc566c6da650aecbddf25a5d8186b54138df265eb9eb546f56141/ag_ui_protocol-0.1.18-py3-none-any.whl", hash = "sha256:d151c0f0a34160647f1571163f7185746f4326b15a56d1560de5082a7a0e7a12", size = 12607, upload-time = "2026-04-21T20:45:00.097Z" }, ] -[[package]] -name = "aio-pika" -version = "9.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiormq" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/63/56354526f2e6e915c93bee6e4dedb35888fe82d6bc1a19f35f5a77e795ff/aio_pika-9.6.2.tar.gz", hash = "sha256:c49e9246080dc8ffa1bb0e4aca407bf3d8ad78c3ee3a93df88b68fe65d7a49b9", size = 70851, upload-time = "2026-03-22T19:03:20.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/05/256fa313f48bed075056d13593b92ce804be05d75f4f312be24edb82860a/aio_pika-9.6.2-py3-none-any.whl", hash = "sha256:2a5478af920d169795071c9c09c7542cd8cdece60438cf7804533dcbcce93b7f", size = 56269, upload-time = "2026-03-22T19:03:19.558Z" }, -] - [[package]] name = "aiofile" version = "3.11.1" @@ -124,31 +111,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] -[[package]] -name = "aiokafka" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/b0/c9384541b2e4cc52a16402fc53fb9d44af0d78d37954cf8c7271c376ad47/aiokafka-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db16e43fac4c1c5006131046c1bf370c580d6ac4495a10ac7778245710943179", size = 345859, upload-time = "2026-04-29T10:42:45.449Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d1/fc266d9f4ffba4f197356c6ffdfbb0fe32e7cb874e240f299935d058ac06/aiokafka-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32a8e91d88cf3ccf0778927715610d6579888c5f4748db4c2022cda25d628a48", size = 348284, upload-time = "2026-04-29T10:42:47.104Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8e/0c4c270786dac79f3fca74c6166c3a25b61b0d26132be0d69f0d7f206f0a/aiokafka-0.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aad4a575a506e7784e25e430f27026fe2f4378560b21b7f4e8c9a54f0d06eaee", size = 1117867, upload-time = "2026-04-29T10:42:48.394Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7f/3b89fbd0a3be9edfd5b51e20bb5cd695c851219b63c501c051cf84367fa9/aiokafka-0.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75e4a003502c9c3b5c705fa7c00d634ba146bf38fa5d525b80bb6ff6e3e779fe", size = 1108860, upload-time = "2026-04-29T10:42:50.249Z" }, - { url = "https://files.pythonhosted.org/packages/b3/59/849aba75cff93277bf6bf8b630de79e902949ff7ec48e4b12a64e6e32cae/aiokafka-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a128e213cbc2bce0ea3db65a68920e52cebeeb8209bf001ac7aa022a8bd54d7d", size = 310889, upload-time = "2026-04-29T10:42:52.038Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e5/52eab8f8515d23da7b5d90e2c5ba10eab9494a0314f749e3f73e003f4a50/aiokafka-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:d6fa16bef3544be87bd1a7a8317b9d85e3da59f3202326d9ff22735ed052746e", size = 329470, upload-time = "2026-04-29T10:42:53.536Z" }, - { url = "https://files.pythonhosted.org/packages/50/9d/984803315fe2b883ea6e08b1d9c8a752bd5c16e966d8714bacc67c72c417/aiokafka-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5d70615d1530ad19d0c4da8d87abaec0a12b9fdaabffdcd4e400efa0c50ef80c", size = 346672, upload-time = "2026-04-29T10:42:55.267Z" }, - { url = "https://files.pythonhosted.org/packages/49/df/da314966b7f3c3117bd78b082563cb03dbe3007848cb8f4b0932faf390a0/aiokafka-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7e2392360c370b1ba6564c57d2889e154ecdb43157a8f7b7d7afe5e3c02fcc1a", size = 349594, upload-time = "2026-04-29T10:42:56.565Z" }, - { url = "https://files.pythonhosted.org/packages/57/7a/160516944ea0e0f68ea78e38f944c52f5248c7c7df26cba22a40b9f25709/aiokafka-0.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:201e38ecc595f9f65a945f1ef9085157ddf28f25cd2e482fd9efa1fcf4638213", size = 1114112, upload-time = "2026-04-29T10:42:57.869Z" }, - { url = "https://files.pythonhosted.org/packages/68/c4/9841118a2157e913e8ebfbc0a2b58f7b60f1f7202040c3e1df8925ed1184/aiokafka-0.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cd651e1f56571baae306fdd0b5509047ab9625797a24cd75902e139c5a20318", size = 1098571, upload-time = "2026-04-29T10:42:59.356Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a1/0af8a37849a4108ae227f46c4c62f6beab31863cf66ba318fb73b0be5b26/aiokafka-0.14.0-cp314-cp314-win32.whl", hash = "sha256:128127eb96dab98150b636bb5f480c80e15f02f82a118eec206a521c8cf7cf7c", size = 314107, upload-time = "2026-04-29T10:43:01.111Z" }, - { url = "https://files.pythonhosted.org/packages/fa/18/fb46c65f758900c71d0f1c73b7802720f99cabcb1f4a11676573f9bc1b8f/aiokafka-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa385039aa9b235359319bbdcf48c9c86a75d81c9c547d645056d00361238903", size = 333320, upload-time = "2026-04-29T10:43:02.424Z" }, -] - [[package]] name = "aiolimiter" version = "1.2.1" @@ -158,19 +120,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, ] -[[package]] -name = "aiormq" -version = "6.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pamqp" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/0e/db90154d52d399108903fe603e5110a533c42065180265dd003788264080/aiormq-6.9.4.tar.gz", hash = "sha256:0e7c01b662804e1cc7ace9a17794e8c1192a27fc2afa96162362a6e61ae8e8ef", size = 49232, upload-time = "2026-03-23T09:18:19.493Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/48/1ce3773f392f02ceda37aee168fade9d725483a9592c202d06044cd093ff/aiormq-6.9.4-py3-none-any.whl", hash = "sha256:726a8586695e863fba68cf88842065ab12348c9438dcebdfc9d0bddaf6083277", size = 32166, upload-time = "2026-03-23T09:18:17.523Z" }, -] - [[package]] name = "aiosignal" version = "1.4.0" @@ -241,15 +190,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "asyncpg" version = "0.31.0" @@ -1080,22 +1020,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/81/87d5241036046ea17c5c8db228f4c9e04e07e53b627015d4496a99449aaf/extract_msg-0.55.0-py3-none-any.whl", hash = "sha256:baf0cdee9a8d267b70c366bc57ceb03dbfa1e7ab2dca6824169a7fe623f0917c", size = 336033, upload-time = "2025-08-12T16:07:54.886Z" }, ] -[[package]] -name = "fastapi" -version = "0.136.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, -] - [[package]] name = "fastavro" version = "1.12.2" @@ -1211,7 +1135,7 @@ wheels = [ [[package]] name = "fireflyframework-agentic" -version = "26.5.31" +version = "26.5.33" source = { editable = "." } dependencies = [ { name = "genai-prices" }, @@ -1228,8 +1152,6 @@ dependencies = [ [package.optional-dependencies] all = [ - { name = "aio-pika" }, - { name = "aiokafka" }, { name = "asyncpg" }, { name = "boto3" }, { name = "cairosvg" }, @@ -1237,7 +1159,6 @@ all = [ { name = "cohere" }, { name = "cryptography" }, { name = "extract-msg" }, - { name = "fastapi" }, { name = "google-generativeai" }, { name = "httpx" }, { name = "mistralai" }, @@ -1248,15 +1169,11 @@ all = [ { name = "pillow-heif" }, { name = "pinecone" }, { name = "py7zr" }, - { name = "pyjwt" }, { name = "pymongo" }, { name = "pypdf" }, { name = "qdrant-client" }, - { name = "redis" }, { name = "sqlalchemy" }, { name = "sqlite-vec" }, - { name = "sse-starlette" }, - { name = "uvicorn", extra = ["standard"] }, { name = "voyageai" }, { name = "watchfiles" }, ] @@ -1295,9 +1212,6 @@ embeddings = [ google-embeddings = [ { name = "google-generativeai" }, ] -kafka = [ - { name = "aiokafka" }, -] mistral-embeddings = [ { name = "mistralai" }, ] @@ -1315,29 +1229,12 @@ postgres = [ { name = "asyncpg" }, { name = "sqlalchemy" }, ] -queues = [ - { name = "aio-pika" }, - { name = "aiokafka" }, - { name = "redis" }, -] -rabbitmq = [ - { name = "aio-pika" }, -] reasoning-eval = [ { name = "numpy" }, { name = "pandas" }, ] -redis = [ - { name = "redis" }, -] -rest = [ - { name = "fastapi" }, - { name = "sse-starlette" }, - { name = "uvicorn", extra = ["standard"] }, -] security = [ { name = "cryptography" }, - { name = "pyjwt" }, ] vectorstores-chroma = [ { name = "chromadb" }, @@ -1363,8 +1260,6 @@ watch = [ [package.metadata] requires-dist = [ - { name = "aio-pika", marker = "extra == 'rabbitmq'", specifier = ">=9.5.0" }, - { name = "aiokafka", marker = "extra == 'kafka'", specifier = ">=0.12.0" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.30.0" }, { name = "asyncpg", marker = "extra == 'vectorstores-pgvector'", specifier = ">=0.30.0" }, { name = "boto3", marker = "extra == 'bedrock-embeddings'", specifier = ">=1.35.0" }, @@ -1373,9 +1268,7 @@ requires-dist = [ { name = "cohere", marker = "extra == 'cohere-embeddings'", specifier = ">=5.0.0" }, { name = "cryptography", marker = "extra == 'security'", specifier = ">=44.0.0" }, { name = "extract-msg", marker = "extra == 'binary'", specifier = ">=0.51" }, - { name = "fastapi", marker = "extra == 'rest'", specifier = ">=0.115.0" }, - { name = "fireflyframework-agentic", extras = ["kafka", "rabbitmq", "redis"], marker = "extra == 'queues'" }, - { name = "fireflyframework-agentic", extras = ["rest", "queues", "postgres", "mongodb", "security", "embeddings", "openai-embeddings", "cohere-embeddings", "google-embeddings", "mistral-embeddings", "voyage-embeddings", "azure-embeddings", "bedrock-embeddings", "ollama-embeddings", "vectorstores-chroma", "vectorstores-pinecone", "vectorstores-qdrant", "vectorstores-pgvector", "vectorstores-sqlite-vec", "watch", "binary"], marker = "extra == 'all'" }, + { name = "fireflyframework-agentic", extras = ["postgres", "mongodb", "security", "embeddings", "openai-embeddings", "cohere-embeddings", "google-embeddings", "mistral-embeddings", "voyage-embeddings", "azure-embeddings", "bedrock-embeddings", "ollama-embeddings", "vectorstores-chroma", "vectorstores-pinecone", "vectorstores-qdrant", "vectorstores-pgvector", "vectorstores-sqlite-vec", "watch", "binary"], marker = "extra == 'all'" }, { name = "genai-prices", specifier = ">=0.0.1" }, { name = "google-generativeai", marker = "extra == 'google-embeddings'", specifier = ">=0.8.0" }, { name = "httpx", specifier = ">=0.28.0" }, @@ -1401,7 +1294,6 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic-ai", specifier = ">=1.99.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" }, - { name = "pyjwt", marker = "extra == 'security'", specifier = ">=2.10.0" }, { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.10.0" }, { name = "pypdf", marker = "extra == 'binary'", specifier = ">=4.3.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, @@ -1411,17 +1303,14 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "qdrant-client", marker = "extra == 'vectorstores-qdrant'", specifier = ">=1.12.0" }, - { name = "redis", marker = "extra == 'redis'", specifier = ">=5.2.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "sqlalchemy", marker = "extra == 'postgres'", specifier = ">=2.0.0" }, { name = "sqlite-vec", marker = "extra == 'vectorstores-sqlite-vec'", specifier = ">=0.1.6" }, - { name = "sse-starlette", marker = "extra == 'rest'", specifier = ">=2.0.0" }, { name = "testcontainers", marker = "extra == 'dev'", specifier = ">=4.10.0" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'rest'", specifier = ">=0.34.0" }, { name = "voyageai", marker = "extra == 'voyage-embeddings'", specifier = ">=0.3.0" }, { name = "watchfiles", marker = "extra == 'watch'", specifier = ">=0.24.0" }, ] -provides-extras = ["rest", "kafka", "rabbitmq", "redis", "queues", "postgres", "mongodb", "security", "embeddings", "openai-embeddings", "cohere-embeddings", "google-embeddings", "mistral-embeddings", "voyage-embeddings", "azure-embeddings", "bedrock-embeddings", "ollama-embeddings", "reasoning-eval", "vectorstores-chroma", "vectorstores-sqlite-vec", "vectorstores-pinecone", "vectorstores-qdrant", "vectorstores-pgvector", "watch", "binary", "all", "dev"] +provides-extras = ["postgres", "mongodb", "security", "embeddings", "openai-embeddings", "cohere-embeddings", "google-embeddings", "mistral-embeddings", "voyage-embeddings", "azure-embeddings", "bedrock-embeddings", "ollama-embeddings", "reasoning-eval", "vectorstores-chroma", "vectorstores-sqlite-vec", "vectorstores-pinecone", "vectorstores-qdrant", "vectorstores-pgvector", "watch", "binary", "all", "dev"] [[package]] name = "flatbuffers" @@ -3094,15 +2983,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "pamqp" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993, upload-time = "2024-01-12T20:37:25.085Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" }, -] - [[package]] name = "pandas" version = "3.0.3" @@ -4342,15 +4222,6 @@ version = "1.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/48/75/bfa342a2ebfc9623b701f1c6995b9906fd6dd2cedf6bce777d09e23303ac/red-black-tree-mod-1.22.tar.gz", hash = "sha256:38e3652903a2bf96379c27c2082ca0b7b905158662dd7ef0c97f4fd93a9aa908", size = 34173, upload-time = "2023-12-26T14:00:22.056Z" } -[[package]] -name = "redis" -version = "7.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, -] - [[package]] name = "referencing" version = "0.37.0"