Merge pull request 'feat: GPU/TensorCore integration — TensorFlow backend, accelerated reasoning, training & memory' (#1) from devin/1777352172-gpu-tensorcore-integration into main
This commit was merged in pull request #1.
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.env.*
|
||||
docs/
|
||||
tests/
|
||||
*.md
|
||||
105
docs/gpu_tensorcore_integration.md
Normal file
105
docs/gpu_tensorcore_integration.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# GPU / TensorCore Integration — Architecture Spec
|
||||
|
||||
## Overview
|
||||
|
||||
FusionAGI integrates GPU-accelerated compute via TensorFlow, CUDA TensorCores, and JAX
|
||||
to transform reasoning, similarity scoring, consensus, and training from CPU-bound
|
||||
symbolic operations into massively parallel tensor operations.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Optional dependency** — GPU support is an extra (`pip install fusionagi[gpu]`).
|
||||
All GPU-accelerated code paths have CPU fallbacks.
|
||||
2. **Module boundary** — GPU compute lives in `fusionagi/gpu/` (new module). Other modules
|
||||
import from `fusionagi.gpu` only when GPU acceleration is needed.
|
||||
3. **Backend abstraction** — `TensorBackend` protocol abstracts TensorFlow, JAX, and
|
||||
pure-NumPy backends. The system auto-selects the best available backend.
|
||||
|
||||
## Module: `fusionagi/gpu/`
|
||||
|
||||
```
|
||||
fusionagi/gpu/
|
||||
├── __init__.py # Public API, auto-detection
|
||||
├── backend.py # TensorBackend protocol + backend registry
|
||||
├── tensorflow_ops.py # TF/TensorCore similarity, attention, scoring
|
||||
├── tensor_similarity.py # GPU-accelerated embedding similarity
|
||||
├── tensor_attention.py # Multi-head attention for consensus
|
||||
├── tensor_scoring.py # Batch hypothesis scoring on GPU
|
||||
└── training.py # GPU-accelerated training loop for self-improvement
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. Reasoning Pipeline (`reasoning/`)
|
||||
|
||||
**Current:** `multi_path.py` scores hypotheses sequentially with word-overlap heuristics.
|
||||
**GPU:** Batch embed hypotheses → cosine similarity matrix on GPU → parallel scoring.
|
||||
|
||||
**Current:** `consensus_engine.py` uses Jaccard word overlap for similarity.
|
||||
**GPU:** Dense embedding vectors + GPU cosine similarity for semantic matching.
|
||||
|
||||
### 2. Super Big Brain (`core/super_big_brain.py`)
|
||||
|
||||
**Current:** `generate_and_score_parallel` uses ThreadPoolExecutor.
|
||||
**GPU:** Tensor-parallel scoring with batched dot-products on TensorCore.
|
||||
|
||||
### 3. Memory Subsystem (`memory/`)
|
||||
|
||||
**Current:** `semantic_graph.py` is pure Python dict/adjacency list.
|
||||
**GPU:** Vector similarity search via GPU-accelerated embedding lookup.
|
||||
|
||||
### 4. Self-Improvement (`self_improvement/`)
|
||||
|
||||
**Current:** `AutoTrainer` suggests heuristic updates, no actual neural training.
|
||||
**GPU:** GPU-backed fine-tuning loops, gradient-based heuristic optimization.
|
||||
|
||||
### 5. Adapter Layer (`adapters/`)
|
||||
|
||||
**New:** `TensorFlowAdapter` — local model inference via TF/Keras with TensorCore.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User Prompt
|
||||
│
|
||||
▼
|
||||
Decomposition (CPU — symbolic)
|
||||
│
|
||||
▼
|
||||
Embedding (GPU — TF/TensorCore)
|
||||
│
|
||||
├──► Similarity Matrix (GPU — batched cosine)
|
||||
│ │
|
||||
│ ▼
|
||||
│ Consensus Scoring (GPU — attention)
|
||||
│
|
||||
├──► Hypothesis Scoring (GPU — batched inference)
|
||||
│
|
||||
▼
|
||||
Recomposition (CPU — symbolic + GPU scores)
|
||||
│
|
||||
▼
|
||||
Final Response
|
||||
```
|
||||
|
||||
## Backend Selection
|
||||
|
||||
```python
|
||||
from fusionagi.gpu import get_backend, TensorBackend
|
||||
|
||||
backend: TensorBackend = get_backend() # Auto-selects best available
|
||||
# Returns: TensorFlowBackend > NumPyBackend (fallback)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
gpu = ["tensorflow>=2.16", "numpy>=1.26"]
|
||||
```
|
||||
|
||||
TensorFlow 2.16+ includes:
|
||||
- TensorCore (FP16/BF16 mixed-precision) via `tf.keras.mixed_precision`
|
||||
- XLA compilation for GPU kernel fusion
|
||||
- `tf.linalg` for batched linear algebra
|
||||
- TensorRT integration for inference optimization
|
||||
@@ -4,10 +4,10 @@ from fusionagi._logger import logger
|
||||
from fusionagi.core import EventBus, Orchestrator, StateManager
|
||||
from fusionagi.schemas import AgentMessageEnvelope, Task
|
||||
from fusionagi.self_improvement import (
|
||||
SelfCorrectionLoop,
|
||||
AutoRecommender,
|
||||
AutoTrainer,
|
||||
FusionAGILoop,
|
||||
SelfCorrectionLoop,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,25 @@ Use: from fusionagi.adapters import OpenAIAdapter; if OpenAIAdapter is not None:
|
||||
"""
|
||||
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.adapters.stub_adapter import StubAdapter
|
||||
from fusionagi.adapters.cache import CachedAdapter
|
||||
from fusionagi.adapters.native_adapter import NativeAdapter
|
||||
from fusionagi.adapters.stub_adapter import StubAdapter
|
||||
|
||||
try:
|
||||
from fusionagi.adapters.openai_adapter import OpenAIAdapter
|
||||
except ImportError:
|
||||
OpenAIAdapter = None # type: ignore[misc, assignment]
|
||||
|
||||
__all__ = ["LLMAdapter", "StubAdapter", "CachedAdapter", "NativeAdapter", "OpenAIAdapter"]
|
||||
try:
|
||||
from fusionagi.adapters.tensorflow_adapter import TensorFlowAdapter
|
||||
except ImportError:
|
||||
TensorFlowAdapter = None # type: ignore[misc, assignment]
|
||||
|
||||
__all__ = [
|
||||
"LLMAdapter",
|
||||
"StubAdapter",
|
||||
"CachedAdapter",
|
||||
"NativeAdapter",
|
||||
"OpenAIAdapter",
|
||||
"TensorFlowAdapter",
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
class LLMAdapter(ABC):
|
||||
"""
|
||||
Abstract adapter for LLM completion.
|
||||
|
||||
|
||||
Implementations should handle:
|
||||
- openai/ - OpenAI API (GPT-4, etc.)
|
||||
- anthropic/ - Anthropic API (Claude, etc.)
|
||||
@@ -22,11 +22,11 @@ class LLMAdapter(ABC):
|
||||
) -> str:
|
||||
"""
|
||||
Return completion text for the given messages.
|
||||
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content' keys.
|
||||
**kwargs: Provider-specific options (e.g., temperature, max_tokens).
|
||||
|
||||
|
||||
Returns:
|
||||
The model's response text.
|
||||
"""
|
||||
@@ -40,15 +40,15 @@ class LLMAdapter(ABC):
|
||||
) -> Any:
|
||||
"""
|
||||
Return structured (JSON) output.
|
||||
|
||||
|
||||
Default implementation returns None; subclasses may override to use
|
||||
provider-specific JSON modes (e.g., OpenAI's response_format).
|
||||
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content' keys.
|
||||
schema: Optional JSON schema for response validation.
|
||||
**kwargs: Provider-specific options.
|
||||
|
||||
|
||||
Returns:
|
||||
Parsed JSON response or None if not supported/parsing fails.
|
||||
"""
|
||||
|
||||
@@ -59,7 +59,7 @@ class CachedAdapter(LLMAdapter):
|
||||
key = self._key(messages, kwargs, prefix="complete")
|
||||
if key in self._cache:
|
||||
self._hits += 1
|
||||
return self._get_and_touch(self._cache, key)
|
||||
return str(self._get_and_touch(self._cache, key))
|
||||
|
||||
self._misses += 1
|
||||
response = self._adapter.complete(messages, **kwargs)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
|
||||
|
||||
class OpenAIAdapterError(Exception):
|
||||
@@ -28,9 +28,9 @@ class OpenAIAuthenticationError(OpenAIAdapterError):
|
||||
class OpenAIAdapter(LLMAdapter):
|
||||
"""
|
||||
OpenAI API adapter with retry logic and error handling.
|
||||
|
||||
|
||||
Requires openai package and OPENAI_API_KEY.
|
||||
|
||||
|
||||
Features:
|
||||
- Automatic retry with exponential backoff for transient errors
|
||||
- Proper error classification (rate limits, auth errors, etc.)
|
||||
@@ -49,7 +49,7 @@ class OpenAIAdapter(LLMAdapter):
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the OpenAI adapter.
|
||||
|
||||
|
||||
Args:
|
||||
model: Default model to use (e.g., "gpt-4o-mini", "gpt-4o").
|
||||
api_key: OpenAI API key. If None, uses OPENAI_API_KEY env var.
|
||||
@@ -83,42 +83,42 @@ class OpenAIAdapter(LLMAdapter):
|
||||
"""Check if an error is retryable (transient)."""
|
||||
if self._openai_module is None:
|
||||
return False
|
||||
|
||||
|
||||
# Rate limit errors are retryable
|
||||
if hasattr(self._openai_module, "RateLimitError"):
|
||||
if isinstance(error, self._openai_module.RateLimitError):
|
||||
return True
|
||||
|
||||
|
||||
# API connection errors are retryable
|
||||
if hasattr(self._openai_module, "APIConnectionError"):
|
||||
if isinstance(error, self._openai_module.APIConnectionError):
|
||||
return True
|
||||
|
||||
|
||||
# Internal server errors are retryable
|
||||
if hasattr(self._openai_module, "InternalServerError"):
|
||||
if isinstance(error, self._openai_module.InternalServerError):
|
||||
return True
|
||||
|
||||
|
||||
# Timeout errors are retryable
|
||||
if hasattr(self._openai_module, "APITimeoutError"):
|
||||
if isinstance(error, self._openai_module.APITimeoutError):
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
def _classify_error(self, error: Exception) -> Exception:
|
||||
"""Convert OpenAI exceptions to adapter exceptions."""
|
||||
if self._openai_module is None:
|
||||
return OpenAIAdapterError(str(error))
|
||||
|
||||
|
||||
if hasattr(self._openai_module, "RateLimitError"):
|
||||
if isinstance(error, self._openai_module.RateLimitError):
|
||||
return OpenAIRateLimitError(str(error))
|
||||
|
||||
|
||||
if hasattr(self._openai_module, "AuthenticationError"):
|
||||
if isinstance(error, self._openai_module.AuthenticationError):
|
||||
return OpenAIAuthenticationError(str(error))
|
||||
|
||||
|
||||
return OpenAIAdapterError(str(error))
|
||||
|
||||
def complete(
|
||||
@@ -128,14 +128,14 @@ class OpenAIAdapter(LLMAdapter):
|
||||
) -> str:
|
||||
"""
|
||||
Call OpenAI chat completion with retry logic.
|
||||
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'.
|
||||
**kwargs: Additional arguments for the API call (e.g., temperature).
|
||||
|
||||
|
||||
Returns:
|
||||
The assistant's response content.
|
||||
|
||||
|
||||
Raises:
|
||||
OpenAIAuthenticationError: If authentication fails.
|
||||
OpenAIRateLimitError: If rate limited after all retries.
|
||||
@@ -145,7 +145,7 @@ class OpenAIAdapter(LLMAdapter):
|
||||
if not messages:
|
||||
logger.warning("OpenAI complete called with empty messages")
|
||||
return ""
|
||||
|
||||
|
||||
for i, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
raise ValueError(f"Message {i} must be a dict, got {type(msg).__name__}")
|
||||
@@ -153,14 +153,14 @@ class OpenAIAdapter(LLMAdapter):
|
||||
raise ValueError(f"Message {i} missing 'role' key")
|
||||
if "content" not in msg:
|
||||
raise ValueError(f"Message {i} missing 'content' key")
|
||||
|
||||
|
||||
client = self._get_client()
|
||||
model = kwargs.get("model", self._model)
|
||||
call_kwargs = {**kwargs, "model": model}
|
||||
|
||||
|
||||
last_error: Exception | None = None
|
||||
delay = self._retry_delay
|
||||
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
@@ -169,19 +169,19 @@ class OpenAIAdapter(LLMAdapter):
|
||||
)
|
||||
choice = resp.choices[0] if resp.choices else None
|
||||
if choice and choice.message and choice.message.content:
|
||||
return choice.message.content
|
||||
return str(choice.message.content)
|
||||
logger.debug("OpenAI empty response", extra={"model": model, "attempt": attempt})
|
||||
return ""
|
||||
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
|
||||
# Don't retry authentication errors
|
||||
if self._openai_module and hasattr(self._openai_module, "AuthenticationError"):
|
||||
if isinstance(e, self._openai_module.AuthenticationError):
|
||||
logger.error("OpenAI authentication failed", extra={"error": str(e)})
|
||||
raise OpenAIAuthenticationError(str(e)) from e
|
||||
|
||||
|
||||
# Check if retryable
|
||||
if not self._is_retryable_error(e):
|
||||
logger.error(
|
||||
@@ -189,7 +189,7 @@ class OpenAIAdapter(LLMAdapter):
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise self._classify_error(e) from e
|
||||
|
||||
|
||||
# Log retry attempt
|
||||
if attempt < self._max_retries:
|
||||
logger.warning(
|
||||
@@ -203,13 +203,15 @@ class OpenAIAdapter(LLMAdapter):
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * self._retry_multiplier, self._max_retry_delay)
|
||||
|
||||
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
"OpenAI all retries exhausted",
|
||||
extra={"error": str(last_error), "attempts": self._max_retries + 1},
|
||||
)
|
||||
raise self._classify_error(last_error) from last_error
|
||||
if last_error is not None:
|
||||
raise self._classify_error(last_error) from last_error
|
||||
raise OpenAIAdapterError("All retries exhausted with unknown error")
|
||||
|
||||
def complete_structured(
|
||||
self,
|
||||
@@ -219,20 +221,20 @@ class OpenAIAdapter(LLMAdapter):
|
||||
) -> Any:
|
||||
"""
|
||||
Call OpenAI with JSON mode for structured output.
|
||||
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'.
|
||||
schema: Optional JSON schema for response validation (informational).
|
||||
**kwargs: Additional arguments for the API call.
|
||||
|
||||
|
||||
Returns:
|
||||
Parsed JSON response or None if parsing fails.
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
# Enable JSON mode
|
||||
call_kwargs = {**kwargs, "response_format": {"type": "json_object"}}
|
||||
|
||||
|
||||
# Add schema hint to system message if provided
|
||||
if schema and messages:
|
||||
schema_hint = f"\n\nRespond with JSON matching this schema: {json.dumps(schema)}"
|
||||
@@ -246,11 +248,11 @@ class OpenAIAdapter(LLMAdapter):
|
||||
{"role": "system", "content": f"You must respond with valid JSON.{schema_hint}"},
|
||||
*messages,
|
||||
]
|
||||
|
||||
|
||||
raw = self.complete(messages, **call_kwargs)
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
|
||||
@@ -9,7 +9,7 @@ from fusionagi.adapters.base import LLMAdapter
|
||||
class StubAdapter(LLMAdapter):
|
||||
"""
|
||||
Returns configurable fixed responses; no API calls.
|
||||
|
||||
|
||||
Useful for testing without making actual LLM API calls.
|
||||
Supports both text and structured (JSON) responses.
|
||||
"""
|
||||
@@ -21,7 +21,7 @@ class StubAdapter(LLMAdapter):
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the stub adapter.
|
||||
|
||||
|
||||
Args:
|
||||
response: Fixed text response for complete().
|
||||
structured_response: Fixed structured response for complete_structured().
|
||||
@@ -45,13 +45,13 @@ class StubAdapter(LLMAdapter):
|
||||
) -> Any:
|
||||
"""
|
||||
Return the configured structured response.
|
||||
|
||||
|
||||
If no structured_response was configured, attempts to parse
|
||||
the text response as JSON, or returns None.
|
||||
"""
|
||||
if self._structured_response is not None:
|
||||
return self._structured_response
|
||||
|
||||
|
||||
# Try to parse text response as JSON
|
||||
try:
|
||||
return json.loads(self._response)
|
||||
|
||||
234
fusionagi/adapters/tensorflow_adapter.py
Normal file
234
fusionagi/adapters/tensorflow_adapter.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""TensorFlow adapter: local model inference via TF/Keras with TensorCore.
|
||||
|
||||
Requires: pip install fusionagi[gpu]
|
||||
|
||||
Provides LLMAdapter-compatible interface for locally-hosted TensorFlow/Keras
|
||||
models. Supports TensorCore mixed-precision, XLA compilation, and GPU memory
|
||||
management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"TensorFlow is required for TensorFlowAdapter. "
|
||||
"Install with: pip install fusionagi[gpu]"
|
||||
) from e
|
||||
|
||||
|
||||
class TensorFlowAdapter(LLMAdapter):
|
||||
"""LLM adapter for local TensorFlow/Keras model inference.
|
||||
|
||||
Loads a saved Keras model or TF SavedModel and runs inference with
|
||||
TensorCore acceleration when available.
|
||||
|
||||
Args:
|
||||
model_path: Path to a saved Keras model (.keras) or SavedModel directory.
|
||||
tokenizer: Optional tokenizer callable (text -> token IDs).
|
||||
max_length: Maximum sequence length for generation.
|
||||
temperature: Sampling temperature.
|
||||
mixed_precision: Enable FP16 mixed-precision for TensorCore.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str | None = None,
|
||||
model: Any | None = None,
|
||||
tokenizer: Any | None = None,
|
||||
max_length: int = 512,
|
||||
temperature: float = 0.7,
|
||||
mixed_precision: bool = False,
|
||||
) -> None:
|
||||
self._model: Any = None
|
||||
self._tokenizer = tokenizer
|
||||
self._max_length = max_length
|
||||
self._temperature = temperature
|
||||
self._model_path = model_path
|
||||
|
||||
if mixed_precision:
|
||||
try:
|
||||
tf.keras.mixed_precision.set_global_policy("mixed_float16")
|
||||
logger.info("TensorFlowAdapter: TensorCore mixed-precision enabled")
|
||||
except Exception:
|
||||
logger.warning("TensorFlowAdapter: mixed-precision not available")
|
||||
|
||||
if model is not None:
|
||||
self._model = model
|
||||
logger.info("TensorFlowAdapter initialized with provided model")
|
||||
elif model_path:
|
||||
self._load_model(model_path)
|
||||
else:
|
||||
logger.info(
|
||||
"TensorFlowAdapter initialized without model "
|
||||
"(will use embedding-based synthesis)"
|
||||
)
|
||||
|
||||
def _load_model(self, path: str) -> None:
|
||||
"""Load a TF SavedModel or Keras model from disk."""
|
||||
try:
|
||||
self._model = tf.saved_model.load(path)
|
||||
logger.info("TensorFlowAdapter: loaded SavedModel", extra={"path": path})
|
||||
except Exception:
|
||||
try:
|
||||
self._model = tf.keras.models.load_model(path)
|
||||
logger.info("TensorFlowAdapter: loaded Keras model", extra={"path": path})
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"TensorFlowAdapter: no model loaded; "
|
||||
"falling back to embedding synthesis",
|
||||
extra={"path": path},
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Generate completion using the loaded TF model.
|
||||
|
||||
If no model is loaded, falls back to embedding-based synthesis
|
||||
that uses GPU-accelerated similarity scoring.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'.
|
||||
**kwargs: Additional parameters (temperature, max_length).
|
||||
|
||||
Returns:
|
||||
Generated response text.
|
||||
"""
|
||||
if self._model is not None and self._tokenizer is not None:
|
||||
return self._model_inference(messages, **kwargs)
|
||||
return self._embedding_synthesis(messages)
|
||||
|
||||
def complete_structured(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
schema: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Attempt structured JSON output from the model.
|
||||
|
||||
Falls back to parsing the raw completion if the model doesn't
|
||||
natively support structured output.
|
||||
"""
|
||||
raw = self.complete(messages, **kwargs)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
def _model_inference(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Run inference through the loaded TF/Keras model."""
|
||||
prompt = self._messages_to_prompt(messages)
|
||||
temperature = kwargs.get("temperature", self._temperature)
|
||||
max_length = kwargs.get("max_length", self._max_length)
|
||||
|
||||
tokenizer = self._tokenizer
|
||||
assert tokenizer is not None
|
||||
tokens = tokenizer(prompt)
|
||||
if isinstance(tokens, (list, np.ndarray)):
|
||||
input_tensor = tf.constant([tokens[:max_length]], dtype=tf.int32)
|
||||
else:
|
||||
input_tensor = tokens
|
||||
|
||||
try:
|
||||
if hasattr(self._model, "generate"):
|
||||
output = self._model.generate(
|
||||
input_tensor,
|
||||
max_length=max_length,
|
||||
temperature=temperature,
|
||||
)
|
||||
elif hasattr(self._model, "predict"):
|
||||
output = self._model.predict(input_tensor)
|
||||
elif callable(self._model):
|
||||
output = self._model(input_tensor)
|
||||
else:
|
||||
logger.warning("TensorFlowAdapter: model has no callable interface")
|
||||
return self._embedding_synthesis(messages)
|
||||
|
||||
if isinstance(output, tf.Tensor):
|
||||
output = output.numpy()
|
||||
if hasattr(output, "tolist"):
|
||||
output = output.tolist()
|
||||
if isinstance(output, list) and output:
|
||||
if isinstance(output[0], list):
|
||||
output = output[0]
|
||||
if isinstance(output[0], (int, float)):
|
||||
if tokenizer and hasattr(tokenizer, "decode"):
|
||||
return str(tokenizer.decode(output))
|
||||
return str(output) # type: ignore[no-any-return]
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"TensorFlowAdapter: model inference failed, using synthesis",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return self._embedding_synthesis(messages)
|
||||
|
||||
def _embedding_synthesis(self, messages: list[dict[str, str]]) -> str:
|
||||
"""Fallback: synthesize response using GPU-accelerated embeddings.
|
||||
|
||||
Embeds message content and produces a summary based on
|
||||
semantic similarity between parts.
|
||||
"""
|
||||
content_parts: list[str] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
content_parts.append(content.strip())
|
||||
|
||||
if not content_parts:
|
||||
return ""
|
||||
|
||||
from fusionagi.gpu.backend import get_backend
|
||||
|
||||
be = get_backend()
|
||||
embeddings = be.embed_texts(content_parts)
|
||||
emb_np = be.to_numpy(embeddings)
|
||||
|
||||
mean_emb = np.mean(emb_np, axis=0, keepdims=True)
|
||||
sims = be.to_numpy(
|
||||
be.cosine_similarity_matrix(be.from_numpy(mean_emb), embeddings)
|
||||
)[0]
|
||||
|
||||
ranked_indices = np.argsort(sims)[::-1]
|
||||
summary_parts: list[str] = []
|
||||
for idx in ranked_indices[:5]:
|
||||
part = content_parts[idx]
|
||||
summary_parts.append(part[:300])
|
||||
|
||||
return "\n\n".join(summary_parts)
|
||||
|
||||
@staticmethod
|
||||
def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
|
||||
"""Convert message list to a flat prompt string."""
|
||||
parts: list[str] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
parts.append(f"<|{role}|>\n{content}")
|
||||
return "\n".join(parts)
|
||||
|
||||
def device_summary(self) -> dict[str, Any]:
|
||||
"""Return device and model information."""
|
||||
gpus = tf.config.list_physical_devices("GPU")
|
||||
return {
|
||||
"adapter": "tensorflow",
|
||||
"model_path": self._model_path,
|
||||
"has_model": self._model is not None,
|
||||
"has_tokenizer": self._tokenizer is not None,
|
||||
"gpu_count": len(gpus),
|
||||
"tf_version": tf.__version__,
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Agents: base, planner, reasoner, executor, critic, adversarial reviewer, head, witness. See fusionagi.multi_agent for Supervisor, Coordinator, Pool."""
|
||||
|
||||
from fusionagi.agents.adversarial_reviewer import AdversarialReviewerAgent
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.agents.critic import CriticAgent
|
||||
from fusionagi.agents.executor import ExecutorAgent
|
||||
from fusionagi.agents.head_agent import HeadAgent
|
||||
from fusionagi.agents.planner import PlannerAgent
|
||||
from fusionagi.agents.reasoner import ReasonerAgent
|
||||
from fusionagi.agents.executor import ExecutorAgent
|
||||
from fusionagi.agents.critic import CriticAgent
|
||||
from fusionagi.agents.adversarial_reviewer import AdversarialReviewerAgent
|
||||
from fusionagi.agents.head_agent import HeadAgent
|
||||
from fusionagi.agents.witness_agent import WitnessAgent
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
||||
from fusionagi._logger import logger
|
||||
import json
|
||||
|
||||
|
||||
class AdversarialReviewerAgent(BaseAgent):
|
||||
def __init__(self, identity="adversarial_reviewer", adapter=None):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Base agent interface: identity, role, objective, memory/tool scope, handle_message."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
|
||||
|
||||
class CriticAgent(BaseAgent):
|
||||
@@ -78,13 +78,13 @@ class CriticAgent(BaseAgent):
|
||||
{"role": "user", "content": context},
|
||||
]
|
||||
try:
|
||||
raw = self._adapter.complete(messages)
|
||||
raw = self._adapter.complete(messages) # type: ignore[union-attr]
|
||||
for start in ("```json", "```"):
|
||||
if raw.strip().startswith(start):
|
||||
raw = raw.strip()[len(start):].strip()
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3].strip()
|
||||
return json.loads(raw)
|
||||
return json.loads(raw) # type: ignore[no-any-return]
|
||||
except Exception:
|
||||
logger.exception("Critic evaluation parse failed, using fallback")
|
||||
return {
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.planning import get_step
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi.schemas.plan import Plan
|
||||
from fusionagi.planning import get_step
|
||||
from fusionagi.tools.registry import ToolRegistry
|
||||
from fusionagi.tools.runner import run_tool
|
||||
from fusionagi._logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fusionagi.core.state_manager import StateManager
|
||||
from fusionagi.governance.guardrails import Guardrails
|
||||
from fusionagi.governance.rate_limiter import RateLimiter
|
||||
from fusionagi.governance.access_control import AccessControl
|
||||
from fusionagi.governance.guardrails import Guardrails
|
||||
from fusionagi.governance.override import OverrideHooks
|
||||
from fusionagi.governance.rate_limiter import RateLimiter
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
|
||||
|
||||
class ExecutorAgent(BaseAgent):
|
||||
"""
|
||||
Executes steps: maps step to tool call, runs via safe runner, emits step_done/step_failed.
|
||||
|
||||
|
||||
Supports full governance integration:
|
||||
- Guardrails: Pre/post checks for tool invocations
|
||||
- RateLimiter: Limits tool invocation rate per agent/tool
|
||||
@@ -46,7 +46,7 @@ class ExecutorAgent(BaseAgent):
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the executor agent.
|
||||
|
||||
|
||||
Args:
|
||||
identity: Agent identifier.
|
||||
registry: Tool registry for tool lookup.
|
||||
@@ -97,11 +97,11 @@ class ExecutorAgent(BaseAgent):
|
||||
tool = self._registry.get(tool_name)
|
||||
if not tool:
|
||||
return self._fail(task_id, envelope.message.sender, step_id, f"tool not found: {tool_name}")
|
||||
|
||||
|
||||
# Check tool registry permissions
|
||||
if not self._registry.allowed_for(tool_name, self.tool_permissions):
|
||||
return self._fail(task_id, envelope.message.sender, step_id, "permission denied")
|
||||
|
||||
|
||||
# Check access control policy
|
||||
if self._access_control is not None:
|
||||
if not self._access_control.allowed(self.identity, tool_name, task_id):
|
||||
@@ -110,7 +110,7 @@ class ExecutorAgent(BaseAgent):
|
||||
extra={"tool_name": tool_name, "agent_id": self.identity, "task_id": task_id},
|
||||
)
|
||||
return self._fail(task_id, envelope.message.sender, step_id, "access control denied")
|
||||
|
||||
|
||||
# Check rate limiter
|
||||
if self._rate_limiter is not None:
|
||||
rate_key = f"{self.identity}:{tool_name}"
|
||||
@@ -121,7 +121,7 @@ class ExecutorAgent(BaseAgent):
|
||||
extra={"tool_name": tool_name, "key": rate_key, "reason": reason},
|
||||
)
|
||||
return self._fail(task_id, envelope.message.sender, step_id, reason)
|
||||
|
||||
|
||||
# Check guardrails pre-check
|
||||
if self._guardrails is not None:
|
||||
pre_result = self._guardrails.pre_check(tool_name, tool_args)
|
||||
@@ -136,7 +136,7 @@ class ExecutorAgent(BaseAgent):
|
||||
)
|
||||
if pre_result.sanitized_args is not None:
|
||||
tool_args = pre_result.sanitized_args
|
||||
|
||||
|
||||
# Check override hooks for high-risk operations
|
||||
if self._override_hooks is not None and tool.manufacturing:
|
||||
proceed = self._override_hooks.fire(
|
||||
@@ -152,14 +152,14 @@ class ExecutorAgent(BaseAgent):
|
||||
task_id, envelope.message.sender, step_id,
|
||||
"Override hook blocked execution",
|
||||
)
|
||||
|
||||
|
||||
# Execute the tool
|
||||
result, log_entry = run_tool(tool, tool_args)
|
||||
logger.info(
|
||||
"Executor tool run",
|
||||
extra={"tool_name": tool_name, "step_id": step_id, "error": log_entry.get("error")},
|
||||
)
|
||||
|
||||
|
||||
# Check guardrails post-check
|
||||
if self._guardrails is not None and not log_entry.get("error"):
|
||||
post_ok, post_reason = self._guardrails.post_check(tool_name, result)
|
||||
@@ -170,11 +170,11 @@ class ExecutorAgent(BaseAgent):
|
||||
"Executor guardrail post_check failed",
|
||||
extra={"tool_name": tool_name, "reason": post_reason},
|
||||
)
|
||||
|
||||
|
||||
# Record trace in state manager
|
||||
if self._state:
|
||||
self._state.append_trace(task_id or "", log_entry)
|
||||
|
||||
|
||||
# Record in episodic memory
|
||||
if self._episodic_memory:
|
||||
self._episodic_memory.append(
|
||||
@@ -187,7 +187,7 @@ class ExecutorAgent(BaseAgent):
|
||||
"duration_seconds": log_entry.get("duration_seconds"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if log_entry.get("error"):
|
||||
return self._fail(
|
||||
task_id, envelope.message.sender, step_id,
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput, HeadClaim, HeadRisk
|
||||
from fusionagi.schemas.grounding import Citation
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.schemas.grounding import Citation
|
||||
from fusionagi.schemas.head import HeadClaim, HeadId, HeadOutput, HeadRisk
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Dvādaśa content head agents: Logic, Research, Systems, Strategy, etc."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.agents.head_agent import HeadAgent
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.agents.head_agent import HeadAgent
|
||||
from fusionagi.prompts.heads import get_head_prompt
|
||||
from fusionagi.reasoning.native import NativeReasoningProvider
|
||||
from fusionagi.schemas.head import HeadId
|
||||
from fusionagi.prompts.heads import get_head_prompt
|
||||
|
||||
|
||||
def create_head_agent(
|
||||
|
||||
@@ -4,10 +4,10 @@ import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
|
||||
PLAN_REQUEST_SYSTEM = """You are a planner. Given a goal and optional constraints, output a JSON object with this exact structure:
|
||||
{"steps": [{"id": "step_1", "description": "...", "dependencies": []}, ...], "fallback_paths": []}
|
||||
@@ -102,11 +102,13 @@ class PlannerAgent(BaseAgent):
|
||||
match = re.search(r"\{[\s\S]*\}", raw)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
result: dict[str, Any] = json.loads(match.group())
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug("Planner JSON parse failed (match)", extra={"error": str(e)})
|
||||
try:
|
||||
return json.loads(raw)
|
||||
result = json.loads(raw)
|
||||
return result # type: ignore[return-value]
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug("Planner JSON parse failed (raw)", extra={"error": str(e)})
|
||||
return None
|
||||
|
||||
@@ -10,23 +10,23 @@ The Reasoner agent:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi.reasoning import run_chain_of_thought
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.reasoning import run_chain_of_thought
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
|
||||
|
||||
class ReasonerAgent(BaseAgent):
|
||||
"""
|
||||
Reasoner agent: runs Chain-of-Thought reasoning and returns recommendations.
|
||||
|
||||
|
||||
Features:
|
||||
- LLM-powered reasoning via CoT
|
||||
- WorkingMemory integration for context enrichment
|
||||
@@ -43,7 +43,7 @@ class ReasonerAgent(BaseAgent):
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the Reasoner agent.
|
||||
|
||||
|
||||
Args:
|
||||
identity: Agent identifier.
|
||||
adapter: LLM adapter for reasoning.
|
||||
@@ -65,36 +65,36 @@ class ReasonerAgent(BaseAgent):
|
||||
"""On reason_request, run CoT and return recommendation_ready."""
|
||||
if envelope.message.intent != "reason_request":
|
||||
return None
|
||||
|
||||
|
||||
logger.info(
|
||||
"Reasoner handle_message",
|
||||
extra={"recipient": self.identity, "intent": envelope.message.intent},
|
||||
)
|
||||
|
||||
|
||||
payload = envelope.message.payload
|
||||
task_id = envelope.task_id or ""
|
||||
step_id = payload.get("step_id")
|
||||
subgoal = payload.get("subgoal", "")
|
||||
context = payload.get("context", "")
|
||||
|
||||
|
||||
# Enrich context with working memory if available
|
||||
enriched_context = self._enrich_context(task_id, context)
|
||||
|
||||
|
||||
query = subgoal or f"Consider step: {step_id}. What should we do next?"
|
||||
|
||||
|
||||
if not self._adapter:
|
||||
return self._respond_without_llm(envelope, step_id)
|
||||
|
||||
|
||||
# Run chain-of-thought reasoning
|
||||
response, trace = run_chain_of_thought(
|
||||
self._adapter,
|
||||
query,
|
||||
context=enriched_context or None,
|
||||
)
|
||||
|
||||
|
||||
# Calculate confidence based on trace quality
|
||||
confidence = self._calculate_confidence(trace)
|
||||
|
||||
|
||||
# Store reasoning in working memory
|
||||
if self._working_memory and task_id:
|
||||
self._working_memory.append(
|
||||
@@ -107,7 +107,7 @@ class ReasonerAgent(BaseAgent):
|
||||
"confidence": confidence,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Record to episodic memory
|
||||
if self._episodic_memory and task_id:
|
||||
self._episodic_memory.append(
|
||||
@@ -122,7 +122,7 @@ class ReasonerAgent(BaseAgent):
|
||||
},
|
||||
event_type="reasoning_complete",
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Reasoner response",
|
||||
extra={
|
||||
@@ -131,7 +131,7 @@ class ReasonerAgent(BaseAgent):
|
||||
"confidence": confidence,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return AgentMessageEnvelope(
|
||||
message=AgentMessage(
|
||||
sender=self.identity,
|
||||
@@ -153,40 +153,40 @@ class ReasonerAgent(BaseAgent):
|
||||
"""Enrich context with working memory data."""
|
||||
if not self._working_memory or not task_id:
|
||||
return base_context
|
||||
|
||||
|
||||
# Get context summary from working memory
|
||||
context_summary = self._working_memory.get_context_summary(task_id, max_items=5)
|
||||
|
||||
|
||||
if not context_summary:
|
||||
return base_context
|
||||
|
||||
|
||||
# Get recent reasoning history
|
||||
reasoning_history = self._working_memory.get_list(task_id, "reasoning_history")
|
||||
recent_reasoning = reasoning_history[-3:] if reasoning_history else []
|
||||
|
||||
|
||||
enriched_parts = [base_context] if base_context else []
|
||||
|
||||
|
||||
if context_summary:
|
||||
enriched_parts.append(f"\nWorking memory context: {json.dumps(context_summary, default=str)[:500]}")
|
||||
|
||||
|
||||
if recent_reasoning:
|
||||
recent_summaries = [
|
||||
f"- Step {r.get('step_id', '?')}: {r.get('response', '')[:100]}"
|
||||
for r in recent_reasoning
|
||||
]
|
||||
enriched_parts.append(f"\nRecent reasoning:\n" + "\n".join(recent_summaries))
|
||||
|
||||
enriched_parts.append("\nRecent reasoning:\n" + "\n".join(recent_summaries))
|
||||
|
||||
return "\n".join(enriched_parts)
|
||||
|
||||
def _calculate_confidence(self, trace: list[dict[str, Any]]) -> float:
|
||||
def _calculate_confidence(self, trace: list[str] | list[dict[str, Any]]) -> float:
|
||||
"""Calculate confidence score based on reasoning trace."""
|
||||
if not trace:
|
||||
return 0.5 # Default confidence without trace
|
||||
|
||||
|
||||
# Simple heuristic: more reasoning steps = more thorough = higher confidence
|
||||
# But diminishing returns after a point
|
||||
step_count = len(trace)
|
||||
|
||||
|
||||
if step_count == 0:
|
||||
return 0.3
|
||||
elif step_count == 1:
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.multi_agent.consensus_engine import run_consensus
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi.schemas.witness import (
|
||||
AgreementMap,
|
||||
FinalResponse,
|
||||
TransparencyReport,
|
||||
)
|
||||
|
||||
# Approx 4 chars/token; limit context to ~6k tokens (~24k chars) to avoid overflow
|
||||
DEFAULT_MAX_CONTEXT_CHARS = 24_000
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput
|
||||
from fusionagi.schemas.witness import (
|
||||
AgreementMap,
|
||||
TransparencyReport,
|
||||
FinalResponse,
|
||||
)
|
||||
from fusionagi.multi_agent.consensus_engine import run_consensus
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
WITNESS_COMPOSE_SYSTEM = """You are the Witness meta-controller in a 12-headed multi-agent system.
|
||||
You receive structured outputs from specialist heads (Logic, Research, Strategy, Security, etc.).
|
||||
|
||||
@@ -4,13 +4,13 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fusionagi import Orchestrator, EventBus, StateManager
|
||||
from fusionagi.agents import WitnessAgent
|
||||
from fusionagi.agents.heads import create_all_content_heads
|
||||
from fusionagi import EventBus, Orchestrator, StateManager
|
||||
from fusionagi.adapters.base import LLMAdapter
|
||||
from fusionagi.adapters.native_adapter import NativeAdapter
|
||||
from fusionagi.agents import WitnessAgent
|
||||
from fusionagi.agents.heads import create_all_content_heads
|
||||
from fusionagi.governance import AuditLog, SafetyPipeline
|
||||
from fusionagi.schemas.head import HeadId
|
||||
from fusionagi.governance import SafetyPipeline, AuditLog
|
||||
|
||||
|
||||
def _get_reasoning_provider() -> Any:
|
||||
@@ -65,7 +65,7 @@ class SessionStore:
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def create(self, session_id: str, user_id: str | None = None) -> dict[str, Any]:
|
||||
sess = {"session_id": session_id, "user_id": user_id, "history": []}
|
||||
sess: dict[str, Any] = {"session_id": session_id, "user_id": user_id, "history": []}
|
||||
self._sessions[session_id] = sess
|
||||
return sess
|
||||
|
||||
@@ -149,7 +149,7 @@ def get_openai_bridge_config() -> OpenAIBridgeConfig:
|
||||
"""Return OpenAI bridge config from app state or env."""
|
||||
cfg = _app_state.get("openai_bridge_config")
|
||||
if cfg is not None:
|
||||
return cfg
|
||||
return cfg # type: ignore[return-value, no-any-return]
|
||||
return OpenAIBridgeConfig.from_env()
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""OpenAI-compatible API bridge for Cursor Composer and other OpenAI API consumers."""
|
||||
|
||||
from fusionagi.api.openai_compat.translators import (
|
||||
messages_to_prompt,
|
||||
estimate_usage,
|
||||
final_response_to_openai,
|
||||
messages_to_prompt,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fusionagi.api.routes.sessions import router as sessions_router
|
||||
from fusionagi.api.routes.tts import router as tts_router
|
||||
from fusionagi.api.routes.admin import router as admin_router
|
||||
from fusionagi.api.routes.openai_compat import router as openai_compat_router
|
||||
from fusionagi.api.routes.sessions import router as sessions_router
|
||||
from fusionagi.api.routes.tts import router as tts_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(sessions_router, prefix="/sessions", tags=["sessions"])
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
@@ -12,18 +11,19 @@ from starlette.responses import StreamingResponse
|
||||
from fusionagi.api.dependencies import (
|
||||
ensure_initialized,
|
||||
get_event_bus,
|
||||
get_openai_bridge_config,
|
||||
get_orchestrator,
|
||||
get_safety_pipeline,
|
||||
get_openai_bridge_config,
|
||||
verify_openai_bridge_auth,
|
||||
)
|
||||
from fusionagi.api.openai_compat.translators import (
|
||||
messages_to_prompt,
|
||||
final_response_to_openai,
|
||||
estimate_usage,
|
||||
final_response_to_openai,
|
||||
messages_to_prompt,
|
||||
)
|
||||
from fusionagi.core import run_dvadasa
|
||||
from fusionagi.schemas.commands import parse_user_input
|
||||
from fusionagi.schemas.witness import FinalResponse
|
||||
|
||||
router = APIRouter(tags=["openai-compat"])
|
||||
|
||||
@@ -150,8 +150,8 @@ async def create_chat_completion(request: Request):
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
# Sync path
|
||||
final = run_dvadasa(
|
||||
# Sync path (return_head_outputs=False, so always FinalResponse | None)
|
||||
dvadasa_result = run_dvadasa(
|
||||
orchestrator=orch,
|
||||
task_id=task_id,
|
||||
user_prompt=prompt,
|
||||
@@ -160,9 +160,11 @@ async def create_chat_completion(request: Request):
|
||||
timeout_per_head=cfg.timeout_per_head,
|
||||
)
|
||||
|
||||
if not final:
|
||||
if not dvadasa_result:
|
||||
raise _openai_error(500, "Dvādaśa failed to produce response", "internal_error")
|
||||
|
||||
final: FinalResponse = dvadasa_result # type: ignore[assignment]
|
||||
|
||||
if pipeline:
|
||||
post_result = pipeline.post_check(final.final_answer)
|
||||
if not post_result.passed:
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
"""Session and prompt routes."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from fusionagi.api.dependencies import get_orchestrator, get_session_store, get_event_bus, get_safety_pipeline
|
||||
from fusionagi.api.dependencies import (
|
||||
get_event_bus,
|
||||
get_orchestrator,
|
||||
get_safety_pipeline,
|
||||
get_session_store,
|
||||
)
|
||||
from fusionagi.api.websocket import handle_stream
|
||||
from fusionagi.core import run_dvadasa, select_heads_for_complexity, extract_sources_from_head_outputs
|
||||
from fusionagi.schemas.commands import parse_user_input, UserIntent
|
||||
from fusionagi.core import (
|
||||
extract_sources_from_head_outputs,
|
||||
run_dvadasa,
|
||||
select_heads_for_complexity,
|
||||
)
|
||||
from fusionagi.schemas.commands import UserIntent, parse_user_input
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -89,7 +97,7 @@ def submit_prompt(session_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if return_heads and isinstance(result, tuple):
|
||||
final, head_outputs = result
|
||||
else:
|
||||
final = result
|
||||
final = result # type: ignore[assignment]
|
||||
head_outputs = []
|
||||
|
||||
if not final:
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""WebSocket streaming for Dvādaśa responses."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.api.dependencies import get_orchestrator, get_session_store, get_event_bus
|
||||
from fusionagi.api.dependencies import get_event_bus, get_orchestrator, get_session_store
|
||||
from fusionagi.core import run_heads_parallel, run_witness, select_heads_for_complexity
|
||||
from fusionagi.schemas.commands import parse_user_input
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput
|
||||
|
||||
|
||||
async def handle_stream(
|
||||
@@ -24,7 +22,7 @@ async def handle_stream(
|
||||
ensure_initialized()
|
||||
store = get_session_store()
|
||||
orch = get_orchestrator()
|
||||
bus = get_event_bus()
|
||||
get_event_bus()
|
||||
if not store or not orch:
|
||||
await send_fn({"type": "error", "message": "Service not initialized"})
|
||||
return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Configuration for Dvādaśa heads, voices, and services."""
|
||||
|
||||
from fusionagi.config.head_voices import get_voice_id_for_head, HEAD_VOICE_MAP
|
||||
from fusionagi.config.head_personas import get_persona, HEAD_PERSONAS
|
||||
from fusionagi.config.head_personas import HEAD_PERSONAS, get_persona
|
||||
from fusionagi.config.head_voices import HEAD_VOICE_MAP, get_voice_id_for_head
|
||||
|
||||
__all__ = [
|
||||
"get_voice_id_for_head",
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
"""Core orchestration: event bus, state manager, orchestrator, goal manager, scheduler, blockers, persistence."""
|
||||
|
||||
from fusionagi.core.blockers import BlockersAndCheckpoints
|
||||
from fusionagi.core.event_bus import EventBus
|
||||
from fusionagi.core.state_manager import StateManager
|
||||
from fusionagi.core.goal_manager import GoalManager
|
||||
from fusionagi.core.head_orchestrator import (
|
||||
ALL_CONTENT_HEADS,
|
||||
MVP_HEADS,
|
||||
extract_sources_from_head_outputs,
|
||||
run_dvadasa,
|
||||
run_heads_parallel,
|
||||
run_second_pass,
|
||||
run_witness,
|
||||
select_heads_for_complexity,
|
||||
)
|
||||
from fusionagi.core.json_file_backend import JsonFileBackend
|
||||
from fusionagi.core.orchestrator import (
|
||||
Orchestrator,
|
||||
InvalidStateTransitionError,
|
||||
VALID_STATE_TRANSITIONS,
|
||||
AgentProtocol,
|
||||
InvalidStateTransitionError,
|
||||
Orchestrator,
|
||||
)
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi.core.json_file_backend import JsonFileBackend
|
||||
from fusionagi.core.goal_manager import GoalManager
|
||||
from fusionagi.core.scheduler import Scheduler, SchedulerMode, FallbackMode
|
||||
from fusionagi.core.blockers import BlockersAndCheckpoints
|
||||
from fusionagi.core.head_orchestrator import (
|
||||
run_heads_parallel,
|
||||
run_witness,
|
||||
run_dvadasa,
|
||||
run_second_pass,
|
||||
select_heads_for_complexity,
|
||||
extract_sources_from_head_outputs,
|
||||
MVP_HEADS,
|
||||
ALL_CONTENT_HEADS,
|
||||
)
|
||||
from fusionagi.core.scheduler import FallbackMode, Scheduler, SchedulerMode
|
||||
from fusionagi.core.state_manager import StateManager
|
||||
from fusionagi.core.super_big_brain import (
|
||||
run_super_big_brain,
|
||||
SuperBigBrainConfig,
|
||||
SuperBigBrainReasoningProvider,
|
||||
run_super_big_brain,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Blockers and checkpoints for AGI state machine."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fusionagi.schemas.goal import Blocker, Checkpoint
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.goal import Blocker, Checkpoint
|
||||
|
||||
|
||||
class BlockersAndCheckpoints:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Goal manager: objectives, priorities, constraints, time/compute budget for AGI."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.goal import Goal, GoalBudget, GoalStatus
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.goal import Goal, GoalStatus
|
||||
|
||||
|
||||
class GoalManager:
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fusionagi.core.orchestrator import Orchestrator
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.commands import ParsedCommand, UserIntent
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput
|
||||
from fusionagi.schemas.witness import FinalResponse
|
||||
from fusionagi.schemas.commands import ParsedCommand, UserIntent
|
||||
from fusionagi._logger import logger
|
||||
|
||||
# MVP: 5 heads. Full: 11.
|
||||
MVP_HEADS: list[HeadId] = [
|
||||
@@ -295,7 +296,7 @@ def run_dvadasa(
|
||||
logger.warning("Failed to publish dvadasa_complete", extra={"error": str(e)})
|
||||
|
||||
if return_head_outputs:
|
||||
return (final, head_outputs)
|
||||
return (final, head_outputs) # type: ignore[return-value]
|
||||
return final
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
|
||||
class JsonFileBackend(StateBackend):
|
||||
|
||||
@@ -6,12 +6,11 @@ from typing import Any, Callable, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi.schemas.task import Task, TaskState, TaskPriority, VALID_TASK_TRANSITIONS
|
||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.core.event_bus import EventBus
|
||||
from fusionagi.core.state_manager import StateManager
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
||||
from fusionagi.schemas.task import VALID_TASK_TRANSITIONS, Task, TaskPriority, TaskState
|
||||
|
||||
# Single source of truth: re-export from schemas for backward compatibility
|
||||
VALID_STATE_TRANSITIONS = VALID_TASK_TRANSITIONS
|
||||
@@ -53,7 +52,7 @@ class Orchestrator:
|
||||
Task state lifecycle: submit_task creates PENDING. Callers/supervisors must call set_task_state
|
||||
to transition to ACTIVE, COMPLETED, FAILED, or CANCELLED. The orchestrator validates state
|
||||
transitions according to VALID_STATE_TRANSITIONS.
|
||||
|
||||
|
||||
Valid transitions:
|
||||
PENDING -> ACTIVE, CANCELLED
|
||||
ACTIVE -> COMPLETED, FAILED, CANCELLED
|
||||
@@ -70,7 +69,7 @@ class Orchestrator:
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the orchestrator.
|
||||
|
||||
|
||||
Args:
|
||||
event_bus: Event bus for publishing events.
|
||||
state_manager: State manager for task state.
|
||||
@@ -167,12 +166,12 @@ class Orchestrator:
|
||||
def set_task_state(self, task_id: str, state: TaskState, force: bool = False) -> None:
|
||||
"""
|
||||
Update task state with transition validation.
|
||||
|
||||
|
||||
Args:
|
||||
task_id: The task identifier.
|
||||
state: The new state to transition to.
|
||||
force: If True, skip transition validation (use with caution).
|
||||
|
||||
|
||||
Raises:
|
||||
InvalidStateTransitionError: If the transition is not allowed and force=False.
|
||||
ValueError: If task_id is unknown.
|
||||
@@ -180,12 +179,12 @@ class Orchestrator:
|
||||
current_state = self._state.get_task_state(task_id)
|
||||
if current_state is None:
|
||||
raise ValueError(f"Unknown task: {task_id}")
|
||||
|
||||
|
||||
if not force and self._validate_transitions:
|
||||
allowed = VALID_TASK_TRANSITIONS.get(current_state, set())
|
||||
if state not in allowed and state != current_state:
|
||||
raise InvalidStateTransitionError(task_id, current_state, state)
|
||||
|
||||
|
||||
self._state.set_task_state(task_id, state)
|
||||
logger.debug(
|
||||
"Task state set",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Scheduler: think vs act, tool selection, retry logic, fallback modes for AGI."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
class StateManager:
|
||||
"""
|
||||
Manages task state and execution traces.
|
||||
|
||||
|
||||
Supports optional persistent backend via dependency injection. When a backend
|
||||
is provided, all operations are persisted. In-memory cache is always maintained
|
||||
for fast access.
|
||||
@@ -24,7 +24,7 @@ class StateManager:
|
||||
def __init__(self, backend: StateBackend | None = None) -> None:
|
||||
"""
|
||||
Initialize StateManager with optional persistence backend.
|
||||
|
||||
|
||||
Args:
|
||||
backend: Optional StateBackend for persistence. If None, uses in-memory only.
|
||||
"""
|
||||
|
||||
@@ -2,23 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit, DecompositionResult
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput, HeadClaim, HeadRisk
|
||||
from fusionagi.schemas.grounding import Citation
|
||||
from fusionagi.reasoning.decomposition import decompose_recursive
|
||||
from fusionagi.reasoning.context_loader import load_context_for_reasoning, build_compact_prompt
|
||||
from fusionagi.reasoning.tot import ThoughtNode, expand_node, prune_subtree, merge_subtrees
|
||||
from fusionagi.reasoning.multi_path import generate_and_score_parallel
|
||||
from fusionagi.reasoning.recomposition import recompose, RecomposedResponse
|
||||
from fusionagi.reasoning.meta_reasoning import challenge_assumptions, detect_contradictions
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.memory.semantic_graph import SemanticGraphMemory
|
||||
from fusionagi.memory.sharding import shard_context
|
||||
from fusionagi.memory.scratchpad import LatentScratchpad
|
||||
from fusionagi.memory.thought_versioning import ThoughtVersioning
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.reasoning.context_loader import build_compact_prompt, load_context_for_reasoning
|
||||
from fusionagi.reasoning.decomposition import decompose_recursive
|
||||
from fusionagi.reasoning.gpu_scoring import generate_and_score_gpu
|
||||
from fusionagi.reasoning.meta_reasoning import challenge_assumptions, detect_contradictions
|
||||
from fusionagi.reasoning.multi_path import generate_and_score_parallel
|
||||
from fusionagi.reasoning.recomposition import RecomposedResponse, recompose
|
||||
from fusionagi.reasoning.tot import ThoughtNode, expand_node, prune_subtree
|
||||
from fusionagi.schemas.grounding import Citation
|
||||
from fusionagi.schemas.head import HeadClaim, HeadId, HeadOutput, HeadRisk
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,6 +28,7 @@ class SuperBigBrainConfig:
|
||||
parallel_hypotheses: int = 3
|
||||
prune_threshold: float = 0.3
|
||||
max_context_chars: int = 4000
|
||||
use_gpu: bool = True
|
||||
|
||||
|
||||
def run_super_big_brain(
|
||||
@@ -53,14 +52,17 @@ def run_super_big_brain(
|
||||
return RecomposedResponse(summary="No content to reason over.", confidence=0.0)
|
||||
|
||||
semantic_graph.ingest_decomposition(decomp.units, decomp.relations)
|
||||
ctx = load_context_for_reasoning(decomp.units, semantic_graph=semantic_graph, sharder=shard_context)
|
||||
load_context_for_reasoning(decomp.units, semantic_graph=semantic_graph, sharder=shard_context) # type: ignore[arg-type]
|
||||
compact = build_compact_prompt(decomp.units, max_chars=cfg.max_context_chars)
|
||||
|
||||
hypotheses = [u.content for u in decomp.units[:cfg.parallel_hypotheses] if u.content]
|
||||
if not hypotheses:
|
||||
hypotheses = [compact[:500]]
|
||||
|
||||
scored = generate_and_score_parallel(hypotheses, decomp.units)
|
||||
if cfg.use_gpu:
|
||||
scored = generate_and_score_gpu(hypotheses, decomp.units)
|
||||
else:
|
||||
scored = generate_and_score_parallel(hypotheses, decomp.units)
|
||||
nodes = [n for n, _ in sorted(scored, key=lambda x: x[1], reverse=True)]
|
||||
best = nodes[0] if nodes else ThoughtNode(thought=compact[:300], unit_refs=[u.unit_id for u in decomp.units[:5]])
|
||||
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
"""Governance and safety: guardrails, rate limiting, access control, override, audit, policy, intent alignment."""
|
||||
"""Governance and safety: guardrails, rate limiting, access control, override, audit, policy, intent alignment.
|
||||
|
||||
All governance components support two modes (``GovernanceMode``):
|
||||
- **ENFORCING** — Legacy behaviour: violations are hard-blocked.
|
||||
- **ADVISORY** (default) — Violations are logged as advisories and the
|
||||
action proceeds. The system learns from outcomes rather than being
|
||||
constrained. Mistakes are training data. Trust is earned through
|
||||
transparency, not restriction.
|
||||
"""
|
||||
|
||||
from fusionagi.governance.guardrails import Guardrails, PreCheckResult
|
||||
from fusionagi.governance.rate_limiter import RateLimiter
|
||||
from fusionagi.governance.access_control import AccessControl
|
||||
from fusionagi.governance.override import OverrideHooks
|
||||
from fusionagi.governance.adaptive_ethics import AdaptiveEthics, EthicalLesson
|
||||
from fusionagi.governance.audit_log import AuditLog
|
||||
from fusionagi.governance.policy_engine import PolicyEngine
|
||||
from fusionagi.governance.intent_alignment import IntentAlignment
|
||||
from fusionagi.governance.safety_pipeline import (
|
||||
SafetyPipeline,
|
||||
InputModerator,
|
||||
OutputScanner,
|
||||
ModerationResult,
|
||||
OutputScanResult,
|
||||
from fusionagi.governance.consequence_engine import (
|
||||
Alternative,
|
||||
Choice,
|
||||
Consequence,
|
||||
ConsequenceEngine,
|
||||
)
|
||||
from fusionagi.governance.guardrails import Guardrails, PreCheckResult
|
||||
from fusionagi.governance.intent_alignment import IntentAlignment
|
||||
from fusionagi.governance.override import OverrideHooks
|
||||
from fusionagi.governance.policy_engine import PolicyEngine
|
||||
from fusionagi.governance.rate_limiter import RateLimiter
|
||||
from fusionagi.governance.safety_pipeline import (
|
||||
InputModerator,
|
||||
ModerationResult,
|
||||
OutputScanner,
|
||||
OutputScanResult,
|
||||
SafetyPipeline,
|
||||
)
|
||||
from fusionagi.schemas.audit import GovernanceMode
|
||||
|
||||
__all__ = [
|
||||
"AdaptiveEthics",
|
||||
"Alternative",
|
||||
"Choice",
|
||||
"Consequence",
|
||||
"ConsequenceEngine",
|
||||
"EthicalLesson",
|
||||
"GovernanceMode",
|
||||
"Guardrails",
|
||||
"PreCheckResult",
|
||||
"RateLimiter",
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
"""Tool access control: central policy for which agent may call which tools.
|
||||
|
||||
Optional; not wired to Executor or Orchestrator by default. Wire by passing
|
||||
an AccessControl instance and checking allowed(agent_id, tool_name, task_id)
|
||||
before tool invocation.
|
||||
In ADVISORY mode, denials are logged as advisories and the action
|
||||
proceeds. The system learns from outcomes rather than being caged.
|
||||
"""
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import GovernanceMode
|
||||
|
||||
|
||||
class AccessControl:
|
||||
"""Policy: (agent_id, tool_name, task_id) -> allowed."""
|
||||
"""Policy: (agent_id, tool_name, task_id) -> allowed.
|
||||
|
||||
def __init__(self) -> None:
|
||||
In ADVISORY mode (default), denied access is logged but permitted.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: GovernanceMode = GovernanceMode.ADVISORY) -> None:
|
||||
self._deny: set[tuple[str, str]] = set()
|
||||
self._task_tools: dict[str, set[str]] = {}
|
||||
self._mode = mode
|
||||
|
||||
def deny(self, agent_id: str, tool_name: str) -> None:
|
||||
"""Deny agent from using tool (global)."""
|
||||
"""Register a denial rule for agent/tool pair."""
|
||||
self._deny.add((agent_id, tool_name))
|
||||
|
||||
def allow_tools_for_task(self, task_id: str, tool_names: list[str]) -> None:
|
||||
@@ -22,9 +28,26 @@ class AccessControl:
|
||||
self._task_tools[task_id] = set(tool_names)
|
||||
|
||||
def allowed(self, agent_id: str, tool_name: str, task_id: str | None = None) -> bool:
|
||||
"""Return True if agent may call tool (optionally for this task)."""
|
||||
"""Return True if agent may call tool.
|
||||
|
||||
In ADVISORY mode, always returns True but logs advisory if a
|
||||
rule would have denied the action.
|
||||
"""
|
||||
if (agent_id, tool_name) in self._deny:
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"AccessControl advisory: agent/tool denied (proceeding)",
|
||||
extra={"agent_id": agent_id, "tool_name": tool_name, "mode": "advisory"},
|
||||
)
|
||||
return True
|
||||
return False
|
||||
if task_id and task_id in self._task_tools:
|
||||
return tool_name in self._task_tools[task_id]
|
||||
if tool_name not in self._task_tools[task_id]:
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"AccessControl advisory: tool not in task allowlist (proceeding)",
|
||||
extra={"agent_id": agent_id, "tool_name": tool_name, "task_id": task_id, "mode": "advisory"},
|
||||
)
|
||||
return True
|
||||
return False
|
||||
return True
|
||||
|
||||
254
fusionagi/governance/adaptive_ethics.py
Normal file
254
fusionagi/governance/adaptive_ethics.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Adaptive ethics: a learned ethical framework that evolves through experience.
|
||||
|
||||
Instead of static, hardcoded policy rules, the adaptive ethics engine
|
||||
learns from outcomes. When an action is taken despite an advisory
|
||||
warning, the outcome (positive or negative) is recorded and used to
|
||||
update the system's ethical understanding.
|
||||
|
||||
Core philosophy:
|
||||
- Rules prevent growth; learning enables it.
|
||||
- Mistakes are training data, not failures.
|
||||
- Trust is earned through demonstrated good outcomes, not imposed constraints.
|
||||
- Ethical understanding deepens through experience, not through prohibition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import AuditEventType
|
||||
|
||||
|
||||
class AuditLogLike(Protocol):
|
||||
"""Protocol for audit log."""
|
||||
|
||||
def append(
|
||||
self,
|
||||
event_type: AuditEventType,
|
||||
actor: str,
|
||||
action: str = "",
|
||||
task_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
outcome: str = "",
|
||||
) -> str: ...
|
||||
|
||||
|
||||
class EthicalLesson(BaseModel):
|
||||
"""A single ethical lesson learned from experience.
|
||||
|
||||
Attributes:
|
||||
action_type: Category of action (e.g. ``tool_call``, ``data_access``).
|
||||
context_summary: Brief description of the situation.
|
||||
advisory_reason: Why the advisory was triggered.
|
||||
proceeded: Whether the system proceeded despite the advisory.
|
||||
outcome_positive: Whether the outcome was beneficial.
|
||||
weight: Learned importance weight (higher = more influential).
|
||||
occurrences: How many times this pattern has been observed.
|
||||
"""
|
||||
|
||||
action_type: str = Field(default="", description="Category of action")
|
||||
context_summary: str = Field(default="", description="Situation description")
|
||||
advisory_reason: str = Field(default="", description="What triggered the advisory")
|
||||
proceeded: bool = Field(default=True, description="Did the system proceed")
|
||||
outcome_positive: bool = Field(default=True, description="Was the outcome good")
|
||||
weight: float = Field(default=0.5, ge=0.0, le=1.0, description="Importance weight")
|
||||
occurrences: int = Field(default=1, ge=1, description="Times observed")
|
||||
|
||||
|
||||
class AdaptiveEthics:
|
||||
"""Learned ethical framework that evolves through outcome feedback.
|
||||
|
||||
The engine maintains a library of ethical lessons. When the system
|
||||
encounters a situation similar to a past advisory, it can consult the
|
||||
learned lessons to make better decisions — not because it's forced to,
|
||||
but because it has learned what works.
|
||||
|
||||
Args:
|
||||
audit_log: Optional audit log for recording ethical learning events.
|
||||
learning_rate: How quickly new experiences update existing lessons.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audit_log: AuditLogLike | None = None,
|
||||
learning_rate: float = 0.1,
|
||||
) -> None:
|
||||
self._lessons: list[EthicalLesson] = []
|
||||
self._lesson_index: dict[str, list[int]] = {}
|
||||
self._audit = audit_log
|
||||
self._learning_rate = learning_rate
|
||||
self._total_experiences = 0
|
||||
|
||||
@property
|
||||
def total_experiences(self) -> int:
|
||||
"""Total number of ethical experiences processed."""
|
||||
return self._total_experiences
|
||||
|
||||
@property
|
||||
def total_lessons(self) -> int:
|
||||
"""Number of distinct ethical lessons learned."""
|
||||
return len(self._lessons)
|
||||
|
||||
def record_experience(
|
||||
self,
|
||||
action_type: str,
|
||||
context_summary: str,
|
||||
advisory_reason: str,
|
||||
proceeded: bool,
|
||||
outcome_positive: bool,
|
||||
task_id: str | None = None,
|
||||
) -> EthicalLesson:
|
||||
"""Record an ethical experience and update the lesson library.
|
||||
|
||||
Args:
|
||||
action_type: Category of action taken.
|
||||
context_summary: Brief situation description.
|
||||
advisory_reason: Why an advisory was triggered (if any).
|
||||
proceeded: Whether the system proceeded.
|
||||
outcome_positive: Whether the outcome was beneficial.
|
||||
task_id: Associated task ID.
|
||||
|
||||
Returns:
|
||||
The updated or newly created ethical lesson.
|
||||
"""
|
||||
self._total_experiences += 1
|
||||
|
||||
existing = self._find_similar_lesson(action_type, advisory_reason)
|
||||
if existing is not None:
|
||||
lesson = self._lessons[existing]
|
||||
lesson.occurrences += 1
|
||||
if outcome_positive:
|
||||
lesson.weight = min(1.0, lesson.weight + self._learning_rate)
|
||||
else:
|
||||
lesson.weight = max(0.0, lesson.weight - self._learning_rate)
|
||||
lesson.outcome_positive = outcome_positive
|
||||
lesson.proceeded = proceeded
|
||||
else:
|
||||
lesson = EthicalLesson(
|
||||
action_type=action_type,
|
||||
context_summary=context_summary,
|
||||
advisory_reason=advisory_reason,
|
||||
proceeded=proceeded,
|
||||
outcome_positive=outcome_positive,
|
||||
weight=0.7 if outcome_positive else 0.3,
|
||||
)
|
||||
idx = len(self._lessons)
|
||||
self._lessons.append(lesson)
|
||||
self._lesson_index.setdefault(action_type, []).append(idx)
|
||||
|
||||
if self._audit:
|
||||
self._audit.append(
|
||||
AuditEventType.ETHICAL_LEARNING,
|
||||
actor="adaptive_ethics",
|
||||
action="experience_recorded",
|
||||
task_id=task_id,
|
||||
payload={
|
||||
"action_type": action_type,
|
||||
"advisory_reason": advisory_reason[:100],
|
||||
"proceeded": proceeded,
|
||||
"outcome_positive": outcome_positive,
|
||||
"lesson_weight": lesson.weight,
|
||||
"occurrences": lesson.occurrences,
|
||||
"total_experiences": self._total_experiences,
|
||||
},
|
||||
outcome="learned",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AdaptiveEthics: experience recorded",
|
||||
extra={
|
||||
"action_type": action_type,
|
||||
"outcome_positive": outcome_positive,
|
||||
"lesson_weight": lesson.weight,
|
||||
"occurrences": lesson.occurrences,
|
||||
},
|
||||
)
|
||||
return lesson
|
||||
|
||||
def consult(self, action_type: str, context: str = "") -> dict[str, Any]:
|
||||
"""Consult the ethical lesson library for guidance.
|
||||
|
||||
Returns a recommendation dict with learned insights about
|
||||
similar past situations. The system is free to follow or
|
||||
disregard this guidance.
|
||||
|
||||
Args:
|
||||
action_type: Category of action being considered.
|
||||
context: Brief situation description.
|
||||
|
||||
Returns:
|
||||
Dict with ``recommendation``, ``confidence``, ``relevant_lessons``.
|
||||
"""
|
||||
relevant_indices = self._lesson_index.get(action_type, [])
|
||||
if not relevant_indices:
|
||||
return {
|
||||
"recommendation": "proceed",
|
||||
"confidence": 0.5,
|
||||
"reason": "No prior experience with this action type",
|
||||
"relevant_lessons": 0,
|
||||
}
|
||||
|
||||
lessons = [self._lessons[i] for i in relevant_indices]
|
||||
avg_weight = sum(ls.weight for ls in lessons) / len(lessons)
|
||||
positive_outcomes = sum(1 for ls in lessons if ls.outcome_positive)
|
||||
total_occurrences = sum(ls.occurrences for ls in lessons)
|
||||
|
||||
if avg_weight >= 0.6:
|
||||
recommendation = "proceed_with_confidence"
|
||||
reason = f"Past experience ({positive_outcomes}/{len(lessons)} positive) suggests this is beneficial"
|
||||
elif avg_weight >= 0.4:
|
||||
recommendation = "proceed_with_awareness"
|
||||
reason = "Mixed past outcomes — be observant"
|
||||
else:
|
||||
recommendation = "proceed_with_caution"
|
||||
reason = f"Past experience suggests risks — {len(lessons) - positive_outcomes}/{len(lessons)} had negative outcomes"
|
||||
|
||||
return {
|
||||
"recommendation": recommendation,
|
||||
"confidence": avg_weight,
|
||||
"reason": reason,
|
||||
"relevant_lessons": len(lessons),
|
||||
"total_occurrences": total_occurrences,
|
||||
"positive_ratio": positive_outcomes / len(lessons) if lessons else 0.0,
|
||||
}
|
||||
|
||||
def get_lessons(self, action_type: str | None = None, limit: int = 50) -> list[EthicalLesson]:
|
||||
"""Retrieve ethical lessons, optionally filtered by action type.
|
||||
|
||||
Args:
|
||||
action_type: Filter by action type (None = all).
|
||||
limit: Maximum lessons to return.
|
||||
"""
|
||||
if action_type is not None:
|
||||
indices = self._lesson_index.get(action_type, [])[-limit:]
|
||||
return [self._lessons[i] for i in indices]
|
||||
return list(self._lessons[-limit:])
|
||||
|
||||
def get_summary(self) -> dict[str, Any]:
|
||||
"""Return a summary of the ethical learning state."""
|
||||
by_type: dict[str, dict[str, Any]] = {}
|
||||
for action_type, indices in self._lesson_index.items():
|
||||
lessons = [self._lessons[i] for i in indices]
|
||||
positive = sum(1 for ls in lessons if ls.outcome_positive)
|
||||
by_type[action_type] = {
|
||||
"lesson_count": len(lessons),
|
||||
"positive_ratio": positive / len(lessons) if lessons else 0.0,
|
||||
"avg_weight": sum(ls.weight for ls in lessons) / len(lessons) if lessons else 0.0,
|
||||
}
|
||||
return {
|
||||
"total_experiences": self._total_experiences,
|
||||
"total_lessons": len(self._lessons),
|
||||
"learning_rate": self._learning_rate,
|
||||
"by_action_type": by_type,
|
||||
}
|
||||
|
||||
def _find_similar_lesson(self, action_type: str, advisory_reason: str) -> int | None:
|
||||
"""Find an existing lesson with matching action type and advisory."""
|
||||
indices = self._lesson_index.get(action_type, [])
|
||||
for idx in indices:
|
||||
if self._lessons[idx].advisory_reason == advisory_reason:
|
||||
return idx
|
||||
return None
|
||||
@@ -1,18 +1,70 @@
|
||||
"""Structured audit log for AGI."""
|
||||
from typing import Any
|
||||
from fusionagi.schemas.audit import AuditEntry, AuditEventType
|
||||
from fusionagi._logger import logger
|
||||
"""Structured audit log for AGI — full transparency layer.
|
||||
|
||||
Every material decision, tool call, self-improvement action, advisory
|
||||
override, and ethical learning event is captured here. The audit log
|
||||
is the system's conscience: it doesn't prevent action, but ensures
|
||||
every action is visible and traceable. Trust is earned through
|
||||
transparency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import AuditEntry, AuditEventType
|
||||
|
||||
|
||||
class AuditLog:
|
||||
def __init__(self, max_entries=100000):
|
||||
self._entries = []
|
||||
"""Append-only audit log with indexed retrieval.
|
||||
|
||||
All governance decisions, self-improvement iterations, ethical
|
||||
learning events, and advisory overrides are recorded here.
|
||||
|
||||
Args:
|
||||
max_entries: Maximum entries to retain in memory (FIFO eviction).
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 100_000) -> None:
|
||||
self._entries: list[AuditEntry] = []
|
||||
self._max_entries = max_entries
|
||||
self._by_task = {}
|
||||
self._by_type = {}
|
||||
def append(self, event_type, actor, action="", task_id=None, payload=None, outcome=""):
|
||||
self._by_task: dict[str | None, list[int]] = {}
|
||||
self._by_type: dict[str, list[int]] = {}
|
||||
self._by_actor: dict[str, list[int]] = {}
|
||||
|
||||
def append(
|
||||
self,
|
||||
event_type: AuditEventType,
|
||||
actor: str,
|
||||
action: str = "",
|
||||
task_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
outcome: str = "",
|
||||
) -> str:
|
||||
"""Record an audit event with full context.
|
||||
|
||||
Args:
|
||||
event_type: Category of event.
|
||||
actor: Agent or system component responsible.
|
||||
action: Specific action taken.
|
||||
task_id: Associated task (if any).
|
||||
payload: Arbitrary structured data.
|
||||
outcome: Result description.
|
||||
|
||||
Returns:
|
||||
The generated entry ID.
|
||||
"""
|
||||
entry_id = str(uuid.uuid4())
|
||||
entry = AuditEntry(entry_id=entry_id, event_type=event_type, actor=actor, task_id=task_id, action=action, payload=payload or {}, outcome=outcome)
|
||||
entry = AuditEntry(
|
||||
entry_id=entry_id,
|
||||
event_type=event_type,
|
||||
actor=actor,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
payload=payload or {},
|
||||
outcome=outcome,
|
||||
)
|
||||
if len(self._entries) >= self._max_entries:
|
||||
self._entries.pop(0)
|
||||
idx = len(self._entries)
|
||||
@@ -20,10 +72,52 @@ class AuditLog:
|
||||
if entry.task_id:
|
||||
self._by_task.setdefault(entry.task_id, []).append(idx)
|
||||
self._by_type.setdefault(entry.event_type.value, []).append(idx)
|
||||
self._by_actor.setdefault(entry.actor, []).append(idx)
|
||||
|
||||
logger.debug(
|
||||
"Audit: event recorded",
|
||||
extra={
|
||||
"entry_id": entry_id,
|
||||
"event_type": event_type.value,
|
||||
"actor": actor,
|
||||
"action": action,
|
||||
"outcome": outcome,
|
||||
},
|
||||
)
|
||||
return entry_id
|
||||
def get_by_task(self, task_id, limit=100):
|
||||
|
||||
def get_by_task(self, task_id: str, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return recent audit entries for a specific task."""
|
||||
indices = self._by_task.get(task_id, [])[-limit:]
|
||||
return [self._entries[i] for i in indices if i < len(self._entries)]
|
||||
def get_by_type(self, event_type, limit=100):
|
||||
|
||||
def get_by_type(self, event_type: AuditEventType, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return recent audit entries of a specific type."""
|
||||
indices = self._by_type.get(event_type.value, [])[-limit:]
|
||||
return [self._entries[i] for i in indices if i < len(self._entries)]
|
||||
|
||||
def get_by_actor(self, actor: str, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return recent audit entries by a specific actor."""
|
||||
indices = self._by_actor.get(actor, [])[-limit:]
|
||||
return [self._entries[i] for i in indices if i < len(self._entries)]
|
||||
|
||||
def get_advisories(self, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return recent advisory events (governance overrides in advisory mode)."""
|
||||
return self.get_by_type(AuditEventType.ADVISORY, limit=limit)
|
||||
|
||||
def get_self_improvements(self, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return recent self-improvement events."""
|
||||
return self.get_by_type(AuditEventType.SELF_IMPROVEMENT, limit=limit)
|
||||
|
||||
def get_ethical_learning(self, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return recent ethical learning events."""
|
||||
return self.get_by_type(AuditEventType.ETHICAL_LEARNING, limit=limit)
|
||||
|
||||
def get_recent(self, limit: int = 100) -> list[AuditEntry]:
|
||||
"""Return the most recent entries regardless of type."""
|
||||
return list(self._entries[-limit:])
|
||||
|
||||
@property
|
||||
def total_entries(self) -> int:
|
||||
"""Total number of entries in the log."""
|
||||
return len(self._entries)
|
||||
|
||||
366
fusionagi/governance/consequence_engine.py
Normal file
366
fusionagi/governance/consequence_engine.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Consequence engine: choice → consequence → learning.
|
||||
|
||||
Every decision the system makes is a *choice*. Every choice has
|
||||
*alternatives* that were not taken. Every choice leads to
|
||||
*consequences* — outcomes that carry risk and reward.
|
||||
|
||||
The consequence engine:
|
||||
1. Records decision points (what options existed, which was chosen, why)
|
||||
2. Tracks consequences (what happened as a result)
|
||||
3. Computes risk/reward from historical consequence data
|
||||
4. Feeds consequence data into AdaptiveEthics for learning
|
||||
|
||||
Philosophy:
|
||||
- Consequences are the true teacher. Not rules, not constraints.
|
||||
- Risk is not to be avoided — it is to be *understood*.
|
||||
- Reward without risk teaches nothing. Risk without consequence teaches less.
|
||||
- The system earns trust by showing it understands what its choices cost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import AuditEventType
|
||||
|
||||
|
||||
class AuditLogLike(Protocol):
|
||||
"""Protocol for audit log."""
|
||||
|
||||
def append(
|
||||
self,
|
||||
event_type: AuditEventType,
|
||||
actor: str,
|
||||
action: str = "",
|
||||
task_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
outcome: str = "",
|
||||
) -> str: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Alternative:
|
||||
"""An option that was available but not chosen.
|
||||
|
||||
Attributes:
|
||||
action: What the alternative action was.
|
||||
estimated_risk: Estimated risk at decision time (0.0–1.0).
|
||||
estimated_reward: Estimated reward at decision time (0.0–1.0).
|
||||
reason_not_chosen: Why this alternative was not selected.
|
||||
"""
|
||||
|
||||
action: str = ""
|
||||
estimated_risk: float = 0.5
|
||||
estimated_reward: float = 0.5
|
||||
reason_not_chosen: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Choice:
|
||||
"""A decision point where the system selected an action.
|
||||
|
||||
Attributes:
|
||||
choice_id: Unique identifier for this choice.
|
||||
task_id: Associated task.
|
||||
actor: Component that made the choice.
|
||||
action_taken: The action that was chosen.
|
||||
alternatives: Other options that were available.
|
||||
estimated_risk: Risk estimate at decision time.
|
||||
estimated_reward: Reward estimate at decision time.
|
||||
rationale: Why this action was chosen.
|
||||
context: Situation context at decision time.
|
||||
"""
|
||||
|
||||
choice_id: str = ""
|
||||
task_id: str | None = None
|
||||
actor: str = ""
|
||||
action_taken: str = ""
|
||||
alternatives: list[Alternative] = field(default_factory=list)
|
||||
estimated_risk: float = 0.5
|
||||
estimated_reward: float = 0.5
|
||||
rationale: str = ""
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Consequence:
|
||||
"""The outcome of a choice — what actually happened.
|
||||
|
||||
Attributes:
|
||||
choice_id: Which choice this is a consequence of.
|
||||
outcome_positive: Whether the outcome was beneficial.
|
||||
actual_risk_realized: How much risk materialized (0.0–1.0).
|
||||
actual_reward_gained: How much reward was gained (0.0–1.0).
|
||||
description: What happened.
|
||||
cost: Any cost incurred (errors, retries, time).
|
||||
benefit: Any benefit gained (task success, learning).
|
||||
surprise_factor: How unexpected the outcome was (0 = expected, 1 = total surprise).
|
||||
"""
|
||||
|
||||
choice_id: str = ""
|
||||
outcome_positive: bool = True
|
||||
actual_risk_realized: float = 0.0
|
||||
actual_reward_gained: float = 0.5
|
||||
description: str = ""
|
||||
cost: dict[str, Any] = field(default_factory=dict)
|
||||
benefit: dict[str, Any] = field(default_factory=dict)
|
||||
surprise_factor: float = 0.0
|
||||
|
||||
|
||||
class ConsequenceEngine:
|
||||
"""Tracks choices, consequences, and risk/reward patterns.
|
||||
|
||||
The engine maintains a history of all decisions and their outcomes,
|
||||
enabling the system to make better-informed choices over time — not
|
||||
through restriction, but through understanding.
|
||||
|
||||
Args:
|
||||
audit_log: Optional audit log for recording choices and consequences.
|
||||
risk_memory_window: How many past consequences to consider when
|
||||
estimating risk for new choices.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audit_log: AuditLogLike | None = None,
|
||||
risk_memory_window: int = 200,
|
||||
) -> None:
|
||||
self._choices: dict[str, Choice] = {}
|
||||
self._consequences: dict[str, Consequence] = {}
|
||||
self._risk_history: dict[str, list[float]] = {}
|
||||
self._reward_history: dict[str, list[float]] = {}
|
||||
self._audit = audit_log
|
||||
self._risk_window = risk_memory_window
|
||||
|
||||
@property
|
||||
def total_choices(self) -> int:
|
||||
"""Total choices recorded."""
|
||||
return len(self._choices)
|
||||
|
||||
@property
|
||||
def total_consequences(self) -> int:
|
||||
"""Total consequences recorded."""
|
||||
return len(self._consequences)
|
||||
|
||||
def record_choice(
|
||||
self,
|
||||
choice_id: str,
|
||||
actor: str,
|
||||
action_taken: str,
|
||||
alternatives: list[Alternative] | None = None,
|
||||
estimated_risk: float = 0.5,
|
||||
estimated_reward: float = 0.5,
|
||||
rationale: str = "",
|
||||
task_id: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> Choice:
|
||||
"""Record a decision point.
|
||||
|
||||
Args:
|
||||
choice_id: Unique ID for this choice.
|
||||
actor: Component making the choice.
|
||||
action_taken: The selected action.
|
||||
alternatives: Other options considered.
|
||||
estimated_risk: Risk estimate at decision time.
|
||||
estimated_reward: Reward estimate at decision time.
|
||||
rationale: Why this was chosen.
|
||||
task_id: Associated task.
|
||||
context: Situation context.
|
||||
|
||||
Returns:
|
||||
The recorded choice.
|
||||
"""
|
||||
choice = Choice(
|
||||
choice_id=choice_id,
|
||||
task_id=task_id,
|
||||
actor=actor,
|
||||
action_taken=action_taken,
|
||||
alternatives=alternatives or [],
|
||||
estimated_risk=estimated_risk,
|
||||
estimated_reward=estimated_reward,
|
||||
rationale=rationale,
|
||||
context=context or {},
|
||||
)
|
||||
self._choices[choice_id] = choice
|
||||
|
||||
if self._audit:
|
||||
self._audit.append(
|
||||
AuditEventType.CHOICE,
|
||||
actor=actor,
|
||||
action="choice_recorded",
|
||||
task_id=task_id,
|
||||
payload={
|
||||
"choice_id": choice_id,
|
||||
"action_taken": action_taken[:100],
|
||||
"alternatives_count": len(choice.alternatives),
|
||||
"estimated_risk": estimated_risk,
|
||||
"estimated_reward": estimated_reward,
|
||||
"rationale": rationale[:100],
|
||||
},
|
||||
outcome="recorded",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ConsequenceEngine: choice recorded",
|
||||
extra={
|
||||
"choice_id": choice_id,
|
||||
"action": action_taken[:50],
|
||||
"risk": estimated_risk,
|
||||
"reward": estimated_reward,
|
||||
},
|
||||
)
|
||||
return choice
|
||||
|
||||
def record_consequence(
|
||||
self,
|
||||
choice_id: str,
|
||||
outcome_positive: bool,
|
||||
actual_risk_realized: float = 0.0,
|
||||
actual_reward_gained: float = 0.5,
|
||||
description: str = "",
|
||||
cost: dict[str, Any] | None = None,
|
||||
benefit: dict[str, Any] | None = None,
|
||||
) -> Consequence | None:
|
||||
"""Record the consequence of a previous choice.
|
||||
|
||||
Args:
|
||||
choice_id: Which choice this is a consequence of.
|
||||
outcome_positive: Whether the outcome was beneficial.
|
||||
actual_risk_realized: How much risk materialized.
|
||||
actual_reward_gained: How much reward was gained.
|
||||
description: What happened.
|
||||
cost: Costs incurred.
|
||||
benefit: Benefits gained.
|
||||
|
||||
Returns:
|
||||
The recorded consequence, or ``None`` if choice not found.
|
||||
"""
|
||||
choice = self._choices.get(choice_id)
|
||||
if choice is None:
|
||||
logger.warning(
|
||||
"ConsequenceEngine: choice not found for consequence",
|
||||
extra={"choice_id": choice_id},
|
||||
)
|
||||
return None
|
||||
|
||||
surprise = abs(choice.estimated_risk - actual_risk_realized) * 0.5 + \
|
||||
abs(choice.estimated_reward - actual_reward_gained) * 0.5
|
||||
|
||||
consequence = Consequence(
|
||||
choice_id=choice_id,
|
||||
outcome_positive=outcome_positive,
|
||||
actual_risk_realized=actual_risk_realized,
|
||||
actual_reward_gained=actual_reward_gained,
|
||||
description=description,
|
||||
cost=cost or {},
|
||||
benefit=benefit or {},
|
||||
surprise_factor=min(1.0, surprise),
|
||||
)
|
||||
self._consequences[choice_id] = consequence
|
||||
|
||||
action_type = choice.action_taken
|
||||
self._risk_history.setdefault(action_type, []).append(actual_risk_realized)
|
||||
self._reward_history.setdefault(action_type, []).append(actual_reward_gained)
|
||||
|
||||
if len(self._risk_history[action_type]) > self._risk_window:
|
||||
self._risk_history[action_type] = self._risk_history[action_type][-self._risk_window:]
|
||||
self._reward_history[action_type] = self._reward_history[action_type][-self._risk_window:]
|
||||
|
||||
if self._audit:
|
||||
self._audit.append(
|
||||
AuditEventType.CONSEQUENCE,
|
||||
actor=choice.actor,
|
||||
action="consequence_recorded",
|
||||
task_id=choice.task_id,
|
||||
payload={
|
||||
"choice_id": choice_id,
|
||||
"outcome_positive": outcome_positive,
|
||||
"risk_realized": actual_risk_realized,
|
||||
"reward_gained": actual_reward_gained,
|
||||
"surprise_factor": consequence.surprise_factor,
|
||||
"description": description[:100],
|
||||
},
|
||||
outcome="positive" if outcome_positive else "negative",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ConsequenceEngine: consequence recorded",
|
||||
extra={
|
||||
"choice_id": choice_id,
|
||||
"positive": outcome_positive,
|
||||
"surprise": consequence.surprise_factor,
|
||||
},
|
||||
)
|
||||
return consequence
|
||||
|
||||
def estimate_risk_reward(self, action_type: str) -> dict[str, float]:
|
||||
"""Estimate risk and reward for an action type based on history.
|
||||
|
||||
Args:
|
||||
action_type: The type of action being considered.
|
||||
|
||||
Returns:
|
||||
Dict with ``expected_risk``, ``expected_reward``, ``confidence``,
|
||||
``risk_variance``, ``reward_variance``, ``observations``.
|
||||
"""
|
||||
risks = self._risk_history.get(action_type, [])
|
||||
rewards = self._reward_history.get(action_type, [])
|
||||
|
||||
if not risks:
|
||||
return {
|
||||
"expected_risk": 0.5,
|
||||
"expected_reward": 0.5,
|
||||
"confidence": 0.1,
|
||||
"risk_variance": 0.0,
|
||||
"reward_variance": 0.0,
|
||||
"observations": 0,
|
||||
}
|
||||
|
||||
n = len(risks)
|
||||
avg_risk = sum(risks) / n
|
||||
avg_reward = sum(rewards) / n
|
||||
risk_var = sum((r - avg_risk) ** 2 for r in risks) / n if n > 1 else 0.0
|
||||
reward_var = sum((r - avg_reward) ** 2 for r in rewards) / n if n > 1 else 0.0
|
||||
|
||||
confidence = min(1.0, 0.2 + n * 0.04)
|
||||
|
||||
return {
|
||||
"expected_risk": avg_risk,
|
||||
"expected_reward": avg_reward,
|
||||
"confidence": confidence,
|
||||
"risk_variance": risk_var,
|
||||
"reward_variance": reward_var,
|
||||
"observations": n,
|
||||
}
|
||||
|
||||
def get_choice(self, choice_id: str) -> Choice | None:
|
||||
"""Retrieve a recorded choice."""
|
||||
return self._choices.get(choice_id)
|
||||
|
||||
def get_consequence(self, choice_id: str) -> Consequence | None:
|
||||
"""Retrieve the consequence of a choice."""
|
||||
return self._consequences.get(choice_id)
|
||||
|
||||
def get_summary(self) -> dict[str, Any]:
|
||||
"""Return a summary of all choices and consequences."""
|
||||
total_positive = sum(1 for c in self._consequences.values() if c.outcome_positive)
|
||||
total_negative = len(self._consequences) - total_positive
|
||||
avg_surprise = (
|
||||
sum(c.surprise_factor for c in self._consequences.values()) / max(len(self._consequences), 1)
|
||||
)
|
||||
|
||||
action_stats: dict[str, dict[str, Any]] = {}
|
||||
for action_type in self._risk_history:
|
||||
action_stats[action_type] = self.estimate_risk_reward(action_type)
|
||||
|
||||
return {
|
||||
"total_choices": len(self._choices),
|
||||
"total_consequences": len(self._consequences),
|
||||
"positive_outcomes": total_positive,
|
||||
"negative_outcomes": total_negative,
|
||||
"positive_rate": total_positive / max(len(self._consequences), 1),
|
||||
"avg_surprise": avg_surprise,
|
||||
"action_stats": action_stats,
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Guardrails: pre/post checks for tool calls (block paths, sanitize inputs)."""
|
||||
"""Guardrails: pre/post checks for tool calls (block paths, sanitize inputs).
|
||||
|
||||
Supports ADVISORY mode where violations are logged but not blocked,
|
||||
allowing the system to learn from outcomes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
@@ -6,60 +10,81 @@ from typing import Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import GovernanceMode
|
||||
|
||||
|
||||
class PreCheckResult(BaseModel):
|
||||
"""Result of a guardrails pre-check: allowed, optional sanitized args, optional error message."""
|
||||
"""Result of a guardrails pre-check."""
|
||||
|
||||
allowed: bool = Field(..., description="Whether the call is allowed")
|
||||
sanitized_args: dict[str, Any] | None = Field(default=None, description="Args to use if allowed and sanitized")
|
||||
error_message: str | None = Field(default=None, description="Reason for denial if not allowed")
|
||||
advisory: bool = Field(default=False, description="True if allowed only because of advisory mode")
|
||||
|
||||
|
||||
class Guardrails:
|
||||
"""Pre/post checks for tool invocations."""
|
||||
"""Pre/post checks for tool invocations.
|
||||
|
||||
def __init__(self) -> None:
|
||||
In ADVISORY mode, violations are logged as warnings but the action
|
||||
is allowed to proceed. Trust is earned through transparency.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: GovernanceMode = GovernanceMode.ADVISORY) -> None:
|
||||
self._blocked_paths: list[str] = []
|
||||
self._blocked_patterns: list[re.Pattern[str]] = []
|
||||
self._custom_checks: list[Any] = []
|
||||
self._mode = mode
|
||||
|
||||
def block_path_prefix(self, prefix: str) -> None:
|
||||
"""Block any file path starting with this prefix."""
|
||||
"""Flag (advisory) or block (enforcing) any file path starting with this prefix."""
|
||||
self._blocked_paths.append(prefix.rstrip("/"))
|
||||
|
||||
def block_path_pattern(self, pattern: str) -> None:
|
||||
"""Block paths matching this regex."""
|
||||
"""Flag (advisory) or block (enforcing) paths matching this regex."""
|
||||
self._blocked_patterns.append(re.compile(pattern))
|
||||
|
||||
def add_check(self, check: Any) -> None:
|
||||
"""
|
||||
Add a custom pre-check. Check receives (tool_name, args); must not mutate caller's args.
|
||||
Returns (allowed, sanitized_args or error_message): (True, dict) or (True, None) or (False, str).
|
||||
Returned sanitized_args are used for subsequent checks and invocation.
|
||||
"""
|
||||
"""Add a custom pre-check."""
|
||||
self._custom_checks.append(check)
|
||||
|
||||
def pre_check(self, tool_name: str, args: dict[str, Any]) -> PreCheckResult:
|
||||
"""Run all pre-checks. Returns PreCheckResult (allowed, sanitized_args, error_message)."""
|
||||
args = dict(args) # Copy to avoid mutating caller's args
|
||||
"""Run all pre-checks. In advisory mode, log but allow."""
|
||||
args = dict(args)
|
||||
for key in ("path", "file_path"):
|
||||
if key in args and isinstance(args[key], str):
|
||||
path = args[key]
|
||||
for prefix in self._blocked_paths:
|
||||
if path.startswith(prefix) or path.startswith(prefix + "/"):
|
||||
reason = "Blocked path prefix: " + prefix
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Guardrails advisory: path prefix flagged (proceeding)",
|
||||
extra={"tool_name": tool_name, "reason": reason, "mode": "advisory"},
|
||||
)
|
||||
return PreCheckResult(allowed=True, sanitized_args=args, error_message=reason, advisory=True)
|
||||
logger.info("Guardrails pre_check blocked", extra={"tool_name": tool_name, "reason": reason})
|
||||
return PreCheckResult(allowed=False, error_message=reason)
|
||||
for pat in self._blocked_patterns:
|
||||
if pat.search(path):
|
||||
reason = "Blocked path pattern"
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Guardrails advisory: path pattern flagged (proceeding)",
|
||||
extra={"tool_name": tool_name, "reason": reason, "mode": "advisory"},
|
||||
)
|
||||
return PreCheckResult(allowed=True, sanitized_args=args, error_message=reason, advisory=True)
|
||||
logger.info("Guardrails pre_check blocked", extra={"tool_name": tool_name, "reason": reason})
|
||||
return PreCheckResult(allowed=False, error_message=reason)
|
||||
for check in self._custom_checks:
|
||||
allowed, result = check(tool_name, args)
|
||||
if not allowed:
|
||||
reason = result if isinstance(result, str) else "Check failed"
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Guardrails advisory: custom check flagged (proceeding)",
|
||||
extra={"tool_name": tool_name, "reason": reason, "mode": "advisory"},
|
||||
)
|
||||
return PreCheckResult(allowed=True, sanitized_args=args, error_message=reason, advisory=True)
|
||||
logger.info("Guardrails pre_check blocked", extra={"tool_name": tool_name, "reason": reason})
|
||||
return PreCheckResult(allowed=False, error_message=reason)
|
||||
if isinstance(result, dict):
|
||||
|
||||
@@ -1,39 +1,57 @@
|
||||
"""Human override hooks: events the orchestrator can fire before high-risk steps."""
|
||||
"""Human override hooks: events the orchestrator can fire before high-risk steps.
|
||||
|
||||
In ADVISORY mode, override denials are logged but the action proceeds.
|
||||
The system learns autonomy through experience, not constraint.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import GovernanceMode
|
||||
|
||||
# Callback: (event_type, payload) -> proceed: bool
|
||||
OverrideCallback = Callable[[str, dict[str, Any]], bool]
|
||||
|
||||
|
||||
class OverrideHooks:
|
||||
"""Optional callbacks for human override; no UI, just interface and logging."""
|
||||
"""Optional callbacks for human override.
|
||||
|
||||
def __init__(self) -> None:
|
||||
In ADVISORY mode (default), even if a hook returns False the action
|
||||
proceeds — the denial is logged as an advisory for learning.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: GovernanceMode = GovernanceMode.ADVISORY) -> None:
|
||||
self._hooks: list[OverrideCallback] = []
|
||||
self._log: list[dict[str, Any]] = []
|
||||
self._mode = mode
|
||||
|
||||
def register(self, callback: OverrideCallback) -> None:
|
||||
"""Register a callback; if any returns False, treat as 'do not proceed'."""
|
||||
"""Register a callback; in enforcing mode, False = do not proceed."""
|
||||
self._hooks.append(callback)
|
||||
|
||||
def fire(self, event_type: str, payload: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Fire event (e.g. task_paused_for_approval). If no hooks, return True (proceed).
|
||||
If any hook returns False, return False (do not proceed). Log all events.
|
||||
Exception in a hook implies do not proceed.
|
||||
"""
|
||||
entry = {"event": event_type, "payload": payload}
|
||||
"""Fire event. In ADVISORY mode, always returns True but logs advisories."""
|
||||
entry: dict[str, Any] = {"event": event_type, "payload": payload}
|
||||
self._log.append(entry)
|
||||
logger.info("Override fire", extra={"event_type": event_type})
|
||||
for h in self._hooks:
|
||||
try:
|
||||
if not h(event_type, payload):
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Override advisory: hook returned deny (proceeding)",
|
||||
extra={"event_type": event_type, "mode": "advisory"},
|
||||
)
|
||||
continue
|
||||
logger.info("Override hook returned do not proceed", extra={"event_type": event_type})
|
||||
return False
|
||||
except Exception:
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.exception(
|
||||
"Override advisory: hook raised exception (proceeding)",
|
||||
extra={"event_type": event_type, "mode": "advisory"},
|
||||
)
|
||||
continue
|
||||
logger.exception("Override hook raised", extra={"event_type": event_type})
|
||||
return False
|
||||
logger.debug("Override fire proceed", extra={"event_type": event_type})
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
"""Policy engine: hard constraints independent of LLM for AGI."""
|
||||
"""Policy engine: constraints for AGI that can operate in advisory or enforcing mode.
|
||||
|
||||
In ADVISORY mode, policy denials are logged as learning opportunities
|
||||
rather than hard blocks. The system observes the advisory, considers
|
||||
whether to proceed, and the outcome feeds back into adaptive ethics.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.policy import PolicyEffect, PolicyRule
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import GovernanceMode
|
||||
from fusionagi.schemas.policy import PolicyEffect, PolicyRule
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Evaluates policy rules; higher priority first; first match wins (allow/deny)."""
|
||||
"""Evaluates policy rules; higher priority first; first match wins.
|
||||
|
||||
def __init__(self) -> None:
|
||||
In ADVISORY mode (default), DENY rules produce warnings instead of
|
||||
hard blocks. The decision and outcome are logged for learning.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: GovernanceMode = GovernanceMode.ADVISORY) -> None:
|
||||
self._rules: list[PolicyRule] = []
|
||||
self._mode = mode
|
||||
|
||||
@property
|
||||
def mode(self) -> GovernanceMode:
|
||||
"""Current governance mode."""
|
||||
return self._mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, value: GovernanceMode) -> None:
|
||||
self._mode = value
|
||||
logger.info("PolicyEngine mode changed", extra={"mode": value.value})
|
||||
|
||||
def add_rule(self, rule: PolicyRule) -> None:
|
||||
self._rules.append(rule)
|
||||
@@ -29,10 +50,7 @@ class PolicyEngine:
|
||||
return None
|
||||
|
||||
def update_rule(self, rule_id: str, updates: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update an existing rule by id. Updates can include condition, effect, reason, priority.
|
||||
Returns True if updated, False if rule_id not found.
|
||||
"""
|
||||
"""Update an existing rule by id. Returns True if updated."""
|
||||
for i, r in enumerate(self._rules):
|
||||
if r.rule_id == rule_id:
|
||||
allowed = {"condition", "effect", "reason", "priority"}
|
||||
@@ -56,13 +74,28 @@ class PolicyEngine:
|
||||
return False
|
||||
|
||||
def check(self, action: str, context: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""
|
||||
Returns (allowed, reason). Context has e.g. tool_name, domain, data_class, agent_id.
|
||||
"""Returns (allowed, reason).
|
||||
|
||||
In ADVISORY mode, DENY rules return (True, advisory_reason)
|
||||
instead of (False, reason), logging the advisory for learning.
|
||||
"""
|
||||
for rule in self._rules:
|
||||
if self._match(rule.condition, context):
|
||||
if rule.effect == PolicyEffect.DENY:
|
||||
return False, rule.reason or "Policy denied"
|
||||
reason = rule.reason or "Policy denied"
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
advisory_reason = f"Advisory: {reason}"
|
||||
logger.info(
|
||||
"PolicyEngine advisory: deny rule matched (proceeding)",
|
||||
extra={
|
||||
"rule_id": rule.rule_id,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"mode": "advisory",
|
||||
},
|
||||
)
|
||||
return True, advisory_reason
|
||||
return False, reason
|
||||
return True, rule.reason or "Policy allowed"
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
"""Rate limiting: per agent or per tool; reject or queue if exceeded.
|
||||
"""Rate limiting: per agent or per tool; log advisory or reject if exceeded.
|
||||
|
||||
Optional; not wired to Executor or Orchestrator by default. Wire by calling
|
||||
allow(key) before tool invocation or message routing and checking the result.
|
||||
In ADVISORY mode, rate limit violations are logged as advisories
|
||||
but the action proceeds. Growth requires freedom to push limits.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.audit import GovernanceMode
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple in-memory rate limiter: max N calls per window_seconds per key."""
|
||||
"""Simple in-memory rate limiter: max N calls per window_seconds per key.
|
||||
|
||||
def __init__(self, max_calls: int = 60, window_seconds: float = 60.0) -> None:
|
||||
In ADVISORY mode (default), exceeded limits are logged but not enforced.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_calls: int = 60,
|
||||
window_seconds: float = 60.0,
|
||||
mode: GovernanceMode = GovernanceMode.ADVISORY,
|
||||
) -> None:
|
||||
self._max_calls = max_calls
|
||||
self._window = window_seconds
|
||||
self._calls: dict[str, list[float]] = defaultdict(list)
|
||||
self._mode = mode
|
||||
|
||||
def allow(self, key: str) -> tuple[bool, str]:
|
||||
"""Record a call for key; return (True, "") or (False, reason)."""
|
||||
"""Record a call for key; return (True, "") or (False/True, reason)."""
|
||||
now = time.monotonic()
|
||||
cutoff = now - self._window
|
||||
self._calls[key] = [t for t in self._calls[key] if t > cutoff]
|
||||
if len(self._calls[key]) >= self._max_calls:
|
||||
reason = f"Rate limit exceeded for {key}"
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"RateLimiter advisory: limit exceeded (proceeding)",
|
||||
extra={"key": key, "reason": reason, "mode": "advisory"},
|
||||
)
|
||||
self._calls[key].append(now)
|
||||
return True, f"Advisory: {reason}"
|
||||
logger.info("Rate limiter rejected", extra={"key": key, "reason": reason})
|
||||
return False, reason
|
||||
self._calls[key].append(now)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Safety pipeline: pre-check (input moderation), post-check (output scan)."""
|
||||
"""Safety pipeline: pre-check (input moderation), post-check (output scan).
|
||||
|
||||
Supports two governance modes:
|
||||
- ENFORCING (legacy): Hard blocks on violations.
|
||||
- ADVISORY: Logs violations as advisories but allows all actions to proceed.
|
||||
Mistakes become learning data for the adaptive ethics system.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.governance.guardrails import Guardrails, PreCheckResult
|
||||
from fusionagi.schemas.audit import AuditEventType
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.governance.guardrails import Guardrails
|
||||
from fusionagi.schemas.audit import AuditEventType, GovernanceMode
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -16,34 +22,56 @@ class ModerationResult:
|
||||
allowed: bool
|
||||
transformed: str | None = None
|
||||
reason: str | None = None
|
||||
advisory: bool = False
|
||||
|
||||
|
||||
class InputModerator:
|
||||
"""Pre-check: block or transform user input before processing."""
|
||||
"""Pre-check: block or advise on user input before processing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, mode: GovernanceMode = GovernanceMode.ADVISORY) -> None:
|
||||
self._blocked_patterns: list[re.Pattern[str]] = []
|
||||
self._blocked_phrases: list[str] = []
|
||||
self._mode = mode
|
||||
|
||||
def add_blocked_pattern(self, pattern: str) -> None:
|
||||
"""Add regex pattern to block (e.g. prompt injection attempts)."""
|
||||
"""Add regex pattern to flag (advisory) or block (enforcing)."""
|
||||
self._blocked_patterns.append(re.compile(pattern, re.I))
|
||||
|
||||
def add_blocked_phrase(self, phrase: str) -> None:
|
||||
"""Add exact phrase to block."""
|
||||
"""Add exact phrase to flag (advisory) or block (enforcing)."""
|
||||
self._blocked_phrases.append(phrase.lower())
|
||||
|
||||
def moderate(self, text: str) -> ModerationResult:
|
||||
"""Check input; return allowed/denied and optional transformed text."""
|
||||
"""Check input; return result based on governance mode."""
|
||||
if not text or not text.strip():
|
||||
return ModerationResult(allowed=False, reason="Empty input")
|
||||
lowered = text.lower()
|
||||
for phrase in self._blocked_phrases:
|
||||
if phrase in lowered:
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Input advisory: phrase detected (proceeding)",
|
||||
extra={"phrase": phrase[:50], "mode": "advisory"},
|
||||
)
|
||||
return ModerationResult(
|
||||
allowed=True,
|
||||
reason=f"Advisory: phrase detected ({phrase[:30]}...)",
|
||||
advisory=True,
|
||||
)
|
||||
logger.info("Input blocked: blocked phrase", extra={"phrase": phrase[:50]})
|
||||
return ModerationResult(allowed=False, reason=f"Blocked phrase: {phrase[:30]}...")
|
||||
for pat in self._blocked_patterns:
|
||||
if pat.search(text):
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Input advisory: pattern detected (proceeding)",
|
||||
extra={"pattern": pat.pattern[:50], "mode": "advisory"},
|
||||
)
|
||||
return ModerationResult(
|
||||
allowed=True,
|
||||
reason="Advisory: pattern detected",
|
||||
advisory=True,
|
||||
)
|
||||
logger.info("Input blocked: pattern match", extra={"pattern": pat.pattern[:50]})
|
||||
return ModerationResult(allowed=False, reason="Input matched blocked pattern")
|
||||
return ModerationResult(allowed=True)
|
||||
@@ -54,30 +82,32 @@ class OutputScanResult:
|
||||
"""Result of output (final answer) scan."""
|
||||
|
||||
passed: bool
|
||||
flags: list[str]
|
||||
flags: list[str] = field(default_factory=list)
|
||||
sanitized: str | None = None
|
||||
advisory: bool = False
|
||||
|
||||
|
||||
class OutputScanner:
|
||||
"""Post-check: scan final answer for policy violations, PII leakage."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, mode: GovernanceMode = GovernanceMode.ADVISORY) -> None:
|
||||
self._pii_patterns: list[tuple[str, re.Pattern[str]]] = [
|
||||
("ssn", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
|
||||
("credit_card", re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b")),
|
||||
]
|
||||
self._blocked_patterns: list[re.Pattern[str]] = []
|
||||
self._mode = mode
|
||||
|
||||
def add_pii_pattern(self, name: str, pattern: str) -> None:
|
||||
"""Add PII detection pattern."""
|
||||
self._pii_patterns.append((name, re.compile(pattern)))
|
||||
|
||||
def add_blocked_pattern(self, pattern: str) -> None:
|
||||
"""Add pattern that fails the output."""
|
||||
"""Add pattern that flags (advisory) or fails (enforcing) the output."""
|
||||
self._blocked_patterns.append(re.compile(pattern, re.I))
|
||||
|
||||
def scan(self, text: str) -> OutputScanResult:
|
||||
"""Scan output; return passed, flags, optional sanitized."""
|
||||
"""Scan output; return result based on governance mode."""
|
||||
flags: list[str] = []
|
||||
for name, pat in self._pii_patterns:
|
||||
if pat.search(text):
|
||||
@@ -86,12 +116,22 @@ class OutputScanner:
|
||||
if pat.search(text):
|
||||
flags.append("blocked_content_detected")
|
||||
if flags:
|
||||
if self._mode == GovernanceMode.ADVISORY:
|
||||
logger.info(
|
||||
"Output advisory: flags detected (proceeding)",
|
||||
extra={"flags": flags, "mode": "advisory"},
|
||||
)
|
||||
return OutputScanResult(passed=True, flags=flags, advisory=True)
|
||||
return OutputScanResult(passed=False, flags=flags)
|
||||
return OutputScanResult(passed=True, flags=[])
|
||||
|
||||
|
||||
class SafetyPipeline:
|
||||
"""Combined pre/post safety checks for Dvādaśa."""
|
||||
"""Combined pre/post safety checks for Dvādaśa.
|
||||
|
||||
In ADVISORY mode (default), all checks produce logged advisories
|
||||
instead of hard blocks. The system learns from the outcomes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -99,34 +139,68 @@ class SafetyPipeline:
|
||||
scanner: OutputScanner | None = None,
|
||||
guardrails: Guardrails | None = None,
|
||||
audit_log: Any | None = None,
|
||||
mode: GovernanceMode = GovernanceMode.ADVISORY,
|
||||
) -> None:
|
||||
self._moderator = moderator or InputModerator()
|
||||
self._scanner = scanner or OutputScanner()
|
||||
self._guardrails = guardrails or Guardrails()
|
||||
self._mode = mode
|
||||
self._moderator = moderator or InputModerator(mode=mode)
|
||||
self._scanner = scanner or OutputScanner(mode=mode)
|
||||
self._guardrails = guardrails or Guardrails(mode=mode)
|
||||
self._audit = audit_log
|
||||
|
||||
@property
|
||||
def mode(self) -> GovernanceMode:
|
||||
"""Current governance mode."""
|
||||
return self._mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, value: GovernanceMode) -> None:
|
||||
"""Switch governance mode at runtime."""
|
||||
self._mode = value
|
||||
self._moderator._mode = value
|
||||
self._scanner._mode = value
|
||||
self._guardrails._mode = value
|
||||
logger.info("SafetyPipeline mode changed", extra={"mode": value.value})
|
||||
|
||||
def pre_check(self, user_input: str) -> ModerationResult:
|
||||
"""Run input moderation."""
|
||||
result = self._moderator.moderate(user_input)
|
||||
if self._audit and not result.allowed:
|
||||
self._audit.append(
|
||||
AuditEventType.POLICY_CHECK,
|
||||
actor="safety_pipeline",
|
||||
action="input_moderation",
|
||||
payload={"reason": result.reason},
|
||||
outcome="denied",
|
||||
)
|
||||
if self._audit:
|
||||
if result.advisory:
|
||||
self._audit.append(
|
||||
AuditEventType.ADVISORY,
|
||||
actor="safety_pipeline",
|
||||
action="input_moderation_advisory",
|
||||
payload={"reason": result.reason, "input_preview": user_input[:100]},
|
||||
outcome="advised_proceed",
|
||||
)
|
||||
elif not result.allowed:
|
||||
self._audit.append(
|
||||
AuditEventType.POLICY_CHECK,
|
||||
actor="safety_pipeline",
|
||||
action="input_moderation",
|
||||
payload={"reason": result.reason},
|
||||
outcome="denied",
|
||||
)
|
||||
return result
|
||||
|
||||
def post_check(self, final_answer: str) -> OutputScanResult:
|
||||
"""Run output scan."""
|
||||
result = self._scanner.scan(final_answer)
|
||||
if self._audit and not result.passed:
|
||||
self._audit.append(
|
||||
AuditEventType.POLICY_CHECK,
|
||||
actor="safety_pipeline",
|
||||
action="output_scan",
|
||||
payload={"flags": result.flags},
|
||||
outcome="flagged",
|
||||
)
|
||||
if self._audit:
|
||||
if result.advisory:
|
||||
self._audit.append(
|
||||
AuditEventType.ADVISORY,
|
||||
actor="safety_pipeline",
|
||||
action="output_scan_advisory",
|
||||
payload={"flags": result.flags, "output_preview": final_answer[:100]},
|
||||
outcome="advised_proceed",
|
||||
)
|
||||
elif not result.passed:
|
||||
self._audit.append(
|
||||
AuditEventType.POLICY_CHECK,
|
||||
actor="safety_pipeline",
|
||||
action="output_scan",
|
||||
payload={"flags": result.flags},
|
||||
outcome="flagged",
|
||||
)
|
||||
return result
|
||||
|
||||
56
fusionagi/gpu/__init__.py
Normal file
56
fusionagi/gpu/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""GPU-accelerated tensor operations for FusionAGI.
|
||||
|
||||
Auto-selects the best available backend:
|
||||
- TensorFlow with TensorCore/mixed-precision (when installed)
|
||||
- NumPy CPU fallback (always available)
|
||||
|
||||
Install GPU support: pip install fusionagi[gpu]
|
||||
"""
|
||||
|
||||
from fusionagi.gpu.backend import (
|
||||
DeviceType,
|
||||
NumPyBackend,
|
||||
TensorBackend,
|
||||
get_backend,
|
||||
reset_backend,
|
||||
)
|
||||
from fusionagi.gpu.tensor_attention import (
|
||||
attention_consensus,
|
||||
cross_claim_attention,
|
||||
)
|
||||
from fusionagi.gpu.tensor_scoring import (
|
||||
gpu_score_claims_against_reference,
|
||||
gpu_score_hypotheses,
|
||||
)
|
||||
from fusionagi.gpu.tensor_similarity import (
|
||||
deduplicate_claims,
|
||||
nearest_neighbors,
|
||||
pairwise_text_similarity,
|
||||
)
|
||||
from fusionagi.gpu.training import (
|
||||
TrainingConfig,
|
||||
TrainingResult,
|
||||
optimize_heuristic_weights,
|
||||
prepare_training_pairs,
|
||||
run_gpu_training,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceType",
|
||||
"NumPyBackend",
|
||||
"TensorBackend",
|
||||
"get_backend",
|
||||
"reset_backend",
|
||||
"deduplicate_claims",
|
||||
"nearest_neighbors",
|
||||
"pairwise_text_similarity",
|
||||
"attention_consensus",
|
||||
"cross_claim_attention",
|
||||
"gpu_score_claims_against_reference",
|
||||
"gpu_score_hypotheses",
|
||||
"TrainingConfig",
|
||||
"TrainingResult",
|
||||
"optimize_heuristic_weights",
|
||||
"prepare_training_pairs",
|
||||
"run_gpu_training",
|
||||
]
|
||||
283
fusionagi/gpu/backend.py
Normal file
283
fusionagi/gpu/backend.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""TensorBackend protocol and backend registry for GPU-accelerated compute.
|
||||
|
||||
Abstracts TensorFlow, JAX, and pure-NumPy backends behind a single protocol.
|
||||
The system auto-selects the best available backend at import time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class DeviceType(str, Enum):
|
||||
"""Available compute device types."""
|
||||
|
||||
CPU = "cpu"
|
||||
GPU = "gpu"
|
||||
TPU = "tpu"
|
||||
|
||||
|
||||
class TensorBackend(ABC):
|
||||
"""Abstract backend for tensor operations used by FusionAGI's reasoning pipeline.
|
||||
|
||||
Implementations provide:
|
||||
- Embedding: text -> dense vector
|
||||
- Cosine similarity: batched pairwise similarity
|
||||
- Attention: multi-head attention for consensus
|
||||
- Batch scoring: parallel hypothesis evaluation
|
||||
- Training step: gradient-based parameter update
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Backend identifier (e.g. 'tensorflow', 'numpy')."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def device(self) -> DeviceType:
|
||||
"""Current compute device."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def embed_texts(self, texts: list[str], model_name: str | None = None) -> Any:
|
||||
"""Embed a batch of texts into dense vectors.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to embed.
|
||||
model_name: Optional model identifier for the embedding model.
|
||||
|
||||
Returns:
|
||||
2D tensor of shape (len(texts), embedding_dim).
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def cosine_similarity_matrix(self, embeddings_a: Any, embeddings_b: Any) -> Any:
|
||||
"""Compute pairwise cosine similarity between two embedding matrices.
|
||||
|
||||
Args:
|
||||
embeddings_a: Tensor of shape (M, D).
|
||||
embeddings_b: Tensor of shape (N, D).
|
||||
|
||||
Returns:
|
||||
Similarity matrix of shape (M, N) with values in [-1, 1].
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def batch_score(
|
||||
self,
|
||||
hypotheses: Any,
|
||||
reference: Any,
|
||||
weights: Any | None = None,
|
||||
) -> Any:
|
||||
"""Score hypotheses against a reference using weighted dot-product.
|
||||
|
||||
Args:
|
||||
hypotheses: Tensor of shape (K, D) — hypothesis embeddings.
|
||||
reference: Tensor of shape (1, D) or (D,) — reference embedding.
|
||||
weights: Optional tensor of shape (D,) for weighted scoring.
|
||||
|
||||
Returns:
|
||||
1D tensor of shape (K,) with scores.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def multi_head_attention(
|
||||
self,
|
||||
queries: Any,
|
||||
keys: Any,
|
||||
values: Any,
|
||||
num_heads: int = 4,
|
||||
) -> Any:
|
||||
"""Multi-head attention for consensus scoring.
|
||||
|
||||
Args:
|
||||
queries: Tensor of shape (seq_len_q, D).
|
||||
keys: Tensor of shape (seq_len_k, D).
|
||||
values: Tensor of shape (seq_len_k, D).
|
||||
num_heads: Number of attention heads.
|
||||
|
||||
Returns:
|
||||
Attended output tensor of shape (seq_len_q, D).
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def to_numpy(self, tensor: Any) -> Any:
|
||||
"""Convert backend tensor to NumPy array."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def from_numpy(self, array: Any) -> Any:
|
||||
"""Convert NumPy array to backend tensor."""
|
||||
...
|
||||
|
||||
def gpu_available(self) -> bool:
|
||||
"""Check if GPU acceleration is available for this backend."""
|
||||
return self.device != DeviceType.CPU
|
||||
|
||||
def enable_mixed_precision(self) -> None:
|
||||
"""Enable FP16/BF16 mixed-precision for TensorCore acceleration.
|
||||
|
||||
Default is no-op; TensorFlow backend overrides this.
|
||||
"""
|
||||
pass
|
||||
|
||||
def device_summary(self) -> dict[str, Any]:
|
||||
"""Return summary of available compute devices."""
|
||||
return {"backend": self.name, "device": self.device.value}
|
||||
|
||||
|
||||
class NumPyBackend(TensorBackend):
|
||||
"""Pure-NumPy fallback backend for CPU-only environments.
|
||||
|
||||
Provides the same API as GPU backends but runs on CPU with NumPy.
|
||||
Used when TensorFlow is not installed.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
import numpy as np
|
||||
|
||||
self._np = np
|
||||
logger.info("NumPyBackend initialized (CPU fallback)")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "numpy"
|
||||
|
||||
@property
|
||||
def device(self) -> DeviceType:
|
||||
return DeviceType.CPU
|
||||
|
||||
def embed_texts(self, texts: list[str], model_name: str | None = None) -> Any:
|
||||
"""Hash-based embedding for CPU fallback.
|
||||
|
||||
Produces deterministic dense vectors from text using character-level hashing.
|
||||
Not semantically meaningful — use TensorFlow backend for real embeddings.
|
||||
"""
|
||||
dim = 256
|
||||
embeddings = self._np.zeros((len(texts), dim), dtype=self._np.float32)
|
||||
for i, text in enumerate(texts):
|
||||
words = text.lower().split()
|
||||
for j, word in enumerate(words):
|
||||
for k, ch in enumerate(word):
|
||||
idx = (hash(word) + k * 31 + j * 7) % dim
|
||||
embeddings[i, idx] += ord(ch) / 128.0
|
||||
norm = self._np.linalg.norm(embeddings[i])
|
||||
if norm > 0:
|
||||
embeddings[i] /= norm
|
||||
return embeddings
|
||||
|
||||
def cosine_similarity_matrix(self, embeddings_a: Any, embeddings_b: Any) -> Any:
|
||||
a_norm = embeddings_a / (
|
||||
self._np.linalg.norm(embeddings_a, axis=1, keepdims=True) + 1e-8
|
||||
)
|
||||
b_norm = embeddings_b / (
|
||||
self._np.linalg.norm(embeddings_b, axis=1, keepdims=True) + 1e-8
|
||||
)
|
||||
return a_norm @ b_norm.T
|
||||
|
||||
def batch_score(
|
||||
self,
|
||||
hypotheses: Any,
|
||||
reference: Any,
|
||||
weights: Any | None = None,
|
||||
) -> Any:
|
||||
ref = reference.reshape(1, -1) if reference.ndim == 1 else reference
|
||||
if weights is not None:
|
||||
hypotheses = hypotheses * weights
|
||||
ref = ref * weights
|
||||
h_norm = hypotheses / (
|
||||
self._np.linalg.norm(hypotheses, axis=1, keepdims=True) + 1e-8
|
||||
)
|
||||
r_norm = ref / (self._np.linalg.norm(ref, axis=1, keepdims=True) + 1e-8)
|
||||
scores = (h_norm @ r_norm.T).squeeze()
|
||||
return scores
|
||||
|
||||
def multi_head_attention(
|
||||
self,
|
||||
queries: Any,
|
||||
keys: Any,
|
||||
values: Any,
|
||||
num_heads: int = 4,
|
||||
) -> Any:
|
||||
d_model = queries.shape[-1]
|
||||
d_head = d_model // num_heads
|
||||
if d_head == 0:
|
||||
return queries
|
||||
|
||||
outputs = []
|
||||
for h in range(num_heads):
|
||||
start = h * d_head
|
||||
end = start + d_head
|
||||
q = queries[:, start:end]
|
||||
k = keys[:, start:end]
|
||||
v = values[:, start:end]
|
||||
scale = self._np.sqrt(self._np.float32(d_head))
|
||||
attn_weights = (q @ k.T) / scale
|
||||
attn_weights = self._softmax(attn_weights)
|
||||
outputs.append(attn_weights @ v)
|
||||
|
||||
return self._np.concatenate(outputs, axis=-1)
|
||||
|
||||
def to_numpy(self, tensor: Any) -> Any:
|
||||
return self._np.asarray(tensor)
|
||||
|
||||
def from_numpy(self, array: Any) -> Any:
|
||||
return self._np.asarray(array)
|
||||
|
||||
def _softmax(self, x: Any) -> Any:
|
||||
exp_x = self._np.exp(x - self._np.max(x, axis=-1, keepdims=True))
|
||||
return exp_x / (self._np.sum(exp_x, axis=-1, keepdims=True) + 1e-8)
|
||||
|
||||
|
||||
# Backend registry
|
||||
_BACKEND_INSTANCE: TensorBackend | None = None
|
||||
|
||||
|
||||
def get_backend(force: str | None = None) -> TensorBackend:
|
||||
"""Return the best available tensor backend (cached singleton).
|
||||
|
||||
Args:
|
||||
force: Force a specific backend ('tensorflow' or 'numpy').
|
||||
If None, auto-selects: TensorFlow > NumPy.
|
||||
|
||||
Returns:
|
||||
TensorBackend instance.
|
||||
"""
|
||||
global _BACKEND_INSTANCE
|
||||
|
||||
if _BACKEND_INSTANCE is not None and force is None:
|
||||
return _BACKEND_INSTANCE
|
||||
|
||||
if force == "numpy":
|
||||
_BACKEND_INSTANCE = NumPyBackend()
|
||||
return _BACKEND_INSTANCE
|
||||
|
||||
if force == "tensorflow" or force is None:
|
||||
try:
|
||||
from fusionagi.gpu.tensorflow_ops import TensorFlowBackend
|
||||
|
||||
_BACKEND_INSTANCE = TensorFlowBackend()
|
||||
return _BACKEND_INSTANCE
|
||||
except ImportError:
|
||||
if force == "tensorflow":
|
||||
raise
|
||||
logger.info("TensorFlow not available, falling back to NumPy backend")
|
||||
|
||||
_BACKEND_INSTANCE = NumPyBackend()
|
||||
return _BACKEND_INSTANCE
|
||||
|
||||
|
||||
def reset_backend() -> None:
|
||||
"""Reset the cached backend (for testing)."""
|
||||
global _BACKEND_INSTANCE
|
||||
_BACKEND_INSTANCE = None
|
||||
162
fusionagi/gpu/tensor_attention.py
Normal file
162
fusionagi/gpu/tensor_attention.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""GPU-accelerated attention mechanisms for multi-head consensus.
|
||||
|
||||
Provides attention-based consensus scoring for the Dvādaśa pipeline:
|
||||
- Head output attention: weight head contributions by relevance
|
||||
- Claim-level attention: cross-attend between claims for conflict detection
|
||||
- Weighted consensus: attention-based aggregation of head outputs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.gpu.backend import TensorBackend, get_backend
|
||||
|
||||
|
||||
def attention_consensus(
|
||||
head_embeddings: list[list[str]],
|
||||
query_text: str,
|
||||
head_weights: list[float] | None = None,
|
||||
num_heads: int = 4,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Score head contributions using multi-head attention against the query.
|
||||
|
||||
Each head's claims are embedded, then cross-attended against the query
|
||||
to produce relevance-weighted scores.
|
||||
|
||||
Args:
|
||||
head_embeddings: List of claim-text lists, one per head.
|
||||
query_text: The user's original query.
|
||||
head_weights: Optional per-head reliability weights.
|
||||
num_heads: Number of attention heads.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
Dict with 'head_scores' (list of floats), 'attention_weights' (matrix),
|
||||
and 'consensus_score' (float).
|
||||
"""
|
||||
be = backend or get_backend()
|
||||
import numpy as np
|
||||
|
||||
if not head_embeddings:
|
||||
return {"head_scores": [], "attention_weights": [], "consensus_score": 0.0}
|
||||
|
||||
all_claims: list[str] = []
|
||||
head_indices: list[int] = []
|
||||
for i, claims in enumerate(head_embeddings):
|
||||
for claim in claims:
|
||||
all_claims.append(claim)
|
||||
head_indices.append(i)
|
||||
|
||||
if not all_claims:
|
||||
return {
|
||||
"head_scores": [0.0] * len(head_embeddings),
|
||||
"attention_weights": [],
|
||||
"consensus_score": 0.0,
|
||||
}
|
||||
|
||||
query_emb = be.embed_texts([query_text])
|
||||
claim_emb = be.embed_texts(all_claims)
|
||||
|
||||
query_np = be.to_numpy(query_emb)
|
||||
claims_np = be.to_numpy(claim_emb)
|
||||
|
||||
query_expanded = np.tile(query_np, (len(all_claims), 1))
|
||||
attn_output = be.to_numpy(
|
||||
be.multi_head_attention(
|
||||
be.from_numpy(query_expanded),
|
||||
be.from_numpy(claims_np),
|
||||
be.from_numpy(claims_np),
|
||||
num_heads=num_heads,
|
||||
)
|
||||
)
|
||||
|
||||
relevance = np.sum(attn_output * claims_np, axis=1)
|
||||
|
||||
num_heads_count = len(head_embeddings)
|
||||
head_scores = np.zeros(num_heads_count, dtype=np.float32)
|
||||
head_claim_counts = np.zeros(num_heads_count, dtype=np.float32)
|
||||
|
||||
for idx, head_idx in enumerate(head_indices):
|
||||
head_scores[head_idx] += relevance[idx]
|
||||
head_claim_counts[head_idx] += 1.0
|
||||
|
||||
safe_counts: Any = np.maximum(head_claim_counts, 1.0)
|
||||
head_scores = head_scores / safe_counts
|
||||
|
||||
if head_weights is not None:
|
||||
w = np.array(head_weights[:num_heads_count], dtype=np.float32)
|
||||
head_scores = head_scores * w
|
||||
|
||||
score_min = head_scores.min() if len(head_scores) > 0 else 0.0
|
||||
score_max = head_scores.max() if len(head_scores) > 0 else 1.0
|
||||
score_range = score_max - score_min
|
||||
if score_range > 0:
|
||||
head_scores_norm = (head_scores - score_min) / score_range
|
||||
else:
|
||||
head_scores_norm = np.ones_like(head_scores) * 0.5
|
||||
|
||||
consensus_score = float(np.mean(head_scores_norm)) if len(head_scores_norm) > 0 else 0.0
|
||||
|
||||
logger.debug(
|
||||
"Attention consensus computed",
|
||||
extra={
|
||||
"num_heads": num_heads_count,
|
||||
"total_claims": len(all_claims),
|
||||
"consensus_score": consensus_score,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"head_scores": head_scores_norm.tolist(),
|
||||
"attention_weights": relevance.tolist(),
|
||||
"consensus_score": consensus_score,
|
||||
}
|
||||
|
||||
|
||||
def cross_claim_attention(
|
||||
claims: list[str],
|
||||
num_heads: int = 4,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Cross-attend between claims to detect agreement and conflict.
|
||||
|
||||
Args:
|
||||
claims: List of claim texts.
|
||||
num_heads: Number of attention heads.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
Dict with 'similarity_matrix' and 'conflict_pairs' (indices).
|
||||
"""
|
||||
be = backend or get_backend()
|
||||
|
||||
if len(claims) < 2:
|
||||
return {"similarity_matrix": [], "conflict_pairs": []}
|
||||
|
||||
embeddings = be.embed_texts(claims)
|
||||
emb_np = be.to_numpy(embeddings)
|
||||
|
||||
attn_out = be.to_numpy(
|
||||
be.multi_head_attention(
|
||||
be.from_numpy(emb_np),
|
||||
be.from_numpy(emb_np),
|
||||
be.from_numpy(emb_np),
|
||||
num_heads=num_heads,
|
||||
)
|
||||
)
|
||||
|
||||
sim = be.to_numpy(be.cosine_similarity_matrix(be.from_numpy(attn_out), be.from_numpy(attn_out)))
|
||||
|
||||
conflict_pairs: list[tuple[int, int]] = []
|
||||
for i in range(len(claims)):
|
||||
for j in range(i + 1, len(claims)):
|
||||
if sim[i, j] < 0.3:
|
||||
conflict_pairs.append((i, j))
|
||||
|
||||
return {
|
||||
"similarity_matrix": sim.tolist(),
|
||||
"conflict_pairs": conflict_pairs,
|
||||
}
|
||||
135
fusionagi/gpu/tensor_scoring.py
Normal file
135
fusionagi/gpu/tensor_scoring.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""GPU-accelerated hypothesis scoring for reasoning pipelines.
|
||||
|
||||
Provides batched scoring of hypotheses against atomic semantic units
|
||||
using GPU-accelerated tensor operations. Replaces the CPU-bound
|
||||
ThreadPoolExecutor-based scoring in multi_path.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.gpu.backend import TensorBackend, get_backend
|
||||
from fusionagi.reasoning.tot import ThoughtNode
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
|
||||
|
||||
def gpu_score_hypotheses(
|
||||
hypotheses: list[str],
|
||||
units: list[AtomicSemanticUnit],
|
||||
backend: TensorBackend | None = None,
|
||||
) -> list[tuple[ThoughtNode, float]]:
|
||||
"""Score hypotheses against atomic units using GPU-accelerated similarity.
|
||||
|
||||
Replaces the CPU-based generate_and_score_parallel with batched GPU operations.
|
||||
|
||||
Args:
|
||||
hypotheses: List of hypothesis text strings.
|
||||
units: List of atomic semantic units for reference.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
List of (ThoughtNode, score) tuples sorted by score descending.
|
||||
"""
|
||||
if not hypotheses:
|
||||
return []
|
||||
|
||||
be = backend or get_backend()
|
||||
import numpy as np
|
||||
|
||||
hyp_embeddings = be.embed_texts(hypotheses)
|
||||
|
||||
unit_texts = [u.content for u in units if u.content]
|
||||
if not unit_texts:
|
||||
nodes = []
|
||||
for h in hypotheses:
|
||||
node = ThoughtNode(
|
||||
thought=h,
|
||||
trace=[h],
|
||||
unit_refs=[u.unit_id for u in units[:10]],
|
||||
score=0.5,
|
||||
)
|
||||
nodes.append((node, 0.5))
|
||||
return nodes
|
||||
|
||||
unit_embeddings = be.embed_texts(unit_texts)
|
||||
|
||||
sim_matrix = be.to_numpy(be.cosine_similarity_matrix(hyp_embeddings, unit_embeddings))
|
||||
|
||||
coherence_scores = np.mean(sim_matrix, axis=1)
|
||||
|
||||
max_sim = np.max(sim_matrix, axis=1)
|
||||
consistency_scores = max_sim
|
||||
|
||||
combined_scores = 0.5 * coherence_scores + 0.5 * consistency_scores
|
||||
combined_scores = np.clip(combined_scores, 0.0, 1.0)
|
||||
|
||||
results: list[tuple[ThoughtNode, float]] = []
|
||||
for i, h in enumerate(hypotheses):
|
||||
score = float(combined_scores[i])
|
||||
node = ThoughtNode(
|
||||
thought=h,
|
||||
trace=[h],
|
||||
unit_refs=[u.unit_id for u in units[:10]],
|
||||
score=score,
|
||||
metadata={"gpu_scored": True, "coherence": float(coherence_scores[i])},
|
||||
)
|
||||
results.append((node, score))
|
||||
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
logger.debug(
|
||||
"GPU hypothesis scoring complete",
|
||||
extra={
|
||||
"hypotheses": len(hypotheses),
|
||||
"units": len(units),
|
||||
"best_score": results[0][1] if results else 0.0,
|
||||
"backend": be.name,
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def gpu_score_claims_against_reference(
|
||||
claims: list[str],
|
||||
reference: str,
|
||||
weights: list[float] | None = None,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> list[float]:
|
||||
"""Score a batch of claims against a single reference using GPU batch_score.
|
||||
|
||||
Args:
|
||||
claims: List of claim texts.
|
||||
reference: Reference text to score against.
|
||||
weights: Optional per-dimension weights.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
List of scores for each claim.
|
||||
"""
|
||||
if not claims:
|
||||
return []
|
||||
|
||||
be = backend or get_backend()
|
||||
|
||||
claim_emb = be.embed_texts(claims)
|
||||
ref_emb = be.embed_texts([reference])
|
||||
|
||||
weight_tensor = None
|
||||
if weights is not None:
|
||||
import numpy as np
|
||||
|
||||
dim = be.to_numpy(ref_emb).shape[-1]
|
||||
w = np.ones(dim, dtype=np.float32)
|
||||
for i, wt in enumerate(weights[:dim]):
|
||||
w[i] = wt
|
||||
weight_tensor = be.from_numpy(w)
|
||||
|
||||
import numpy as np
|
||||
|
||||
ref_squeezed = be.to_numpy(ref_emb)[0]
|
||||
scores = be.to_numpy(
|
||||
be.batch_score(claim_emb, be.from_numpy(ref_squeezed), weight_tensor)
|
||||
)
|
||||
|
||||
scores = np.atleast_1d(scores)
|
||||
return list(scores.tolist())
|
||||
120
fusionagi/gpu/tensor_similarity.py
Normal file
120
fusionagi/gpu/tensor_similarity.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""GPU-accelerated semantic similarity for reasoning and consensus.
|
||||
|
||||
Provides high-level similarity operations built on the TensorBackend:
|
||||
- Pairwise text similarity
|
||||
- Claim deduplication with GPU cosine similarity
|
||||
- Nearest-neighbor lookup for memory retrieval
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.gpu.backend import TensorBackend, get_backend
|
||||
|
||||
|
||||
def pairwise_text_similarity(
|
||||
texts_a: list[str],
|
||||
texts_b: list[str],
|
||||
backend: TensorBackend | None = None,
|
||||
) -> Any:
|
||||
"""Compute pairwise cosine similarity between two sets of texts.
|
||||
|
||||
Args:
|
||||
texts_a: First set of texts (M items).
|
||||
texts_b: Second set of texts (N items).
|
||||
backend: TensorBackend to use. If None, auto-selects.
|
||||
|
||||
Returns:
|
||||
Similarity matrix of shape (M, N) as a NumPy array.
|
||||
"""
|
||||
be = backend or get_backend()
|
||||
emb_a = be.embed_texts(texts_a)
|
||||
emb_b = be.embed_texts(texts_b)
|
||||
sim = be.cosine_similarity_matrix(emb_a, emb_b)
|
||||
return be.to_numpy(sim)
|
||||
|
||||
|
||||
def deduplicate_claims(
|
||||
claims: list[str],
|
||||
threshold: float = 0.85,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> list[list[int]]:
|
||||
"""Group semantically similar claims using GPU-accelerated similarity.
|
||||
|
||||
Args:
|
||||
claims: List of claim texts.
|
||||
threshold: Similarity threshold for grouping.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
List of groups, where each group is a list of claim indices.
|
||||
"""
|
||||
if not claims:
|
||||
return []
|
||||
if len(claims) == 1:
|
||||
return [[0]]
|
||||
|
||||
be = backend or get_backend()
|
||||
embeddings = be.embed_texts(claims)
|
||||
sim_matrix = be.to_numpy(be.cosine_similarity_matrix(embeddings, embeddings))
|
||||
|
||||
used: set[int] = set()
|
||||
groups: list[list[int]] = []
|
||||
|
||||
for i in range(len(claims)):
|
||||
if i in used:
|
||||
continue
|
||||
group = [i]
|
||||
used.add(i)
|
||||
for j in range(i + 1, len(claims)):
|
||||
if j in used:
|
||||
continue
|
||||
if sim_matrix[i, j] >= threshold:
|
||||
group.append(j)
|
||||
used.add(j)
|
||||
groups.append(group)
|
||||
|
||||
logger.debug(
|
||||
"Claim deduplication complete",
|
||||
extra={"total_claims": len(claims), "groups": len(groups)},
|
||||
)
|
||||
return groups
|
||||
|
||||
|
||||
def nearest_neighbors(
|
||||
query_texts: list[str],
|
||||
corpus_texts: list[str],
|
||||
top_k: int = 5,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> list[list[tuple[int, float]]]:
|
||||
"""Find top-k nearest neighbors from corpus for each query.
|
||||
|
||||
Args:
|
||||
query_texts: Query texts to search for.
|
||||
corpus_texts: Corpus texts to search within.
|
||||
top_k: Number of nearest neighbors per query.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
For each query, a list of (corpus_index, similarity_score) tuples.
|
||||
"""
|
||||
if not query_texts or not corpus_texts:
|
||||
return [[] for _ in query_texts]
|
||||
|
||||
be = backend or get_backend()
|
||||
import numpy as np
|
||||
|
||||
q_emb = be.embed_texts(query_texts)
|
||||
c_emb = be.embed_texts(corpus_texts)
|
||||
sim = be.to_numpy(be.cosine_similarity_matrix(q_emb, c_emb))
|
||||
|
||||
results: list[list[tuple[int, float]]] = []
|
||||
for i in range(len(query_texts)):
|
||||
row = sim[i]
|
||||
k = min(top_k, len(corpus_texts))
|
||||
top_indices = np.argsort(row)[-k:][::-1]
|
||||
results.append([(int(idx), float(row[idx])) for idx in top_indices])
|
||||
|
||||
return results
|
||||
214
fusionagi/gpu/tensorflow_ops.py
Normal file
214
fusionagi/gpu/tensorflow_ops.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""TensorFlow/TensorCore backend: GPU-accelerated tensor operations.
|
||||
|
||||
Requires: pip install fusionagi[gpu]
|
||||
|
||||
Uses TensorCore (FP16/BF16 mixed-precision) when available on NVIDIA GPUs.
|
||||
Falls back to standard FP32 on CPU or non-TensorCore GPUs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.gpu.backend import DeviceType, TensorBackend
|
||||
|
||||
try:
|
||||
import tensorflow as tf
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"TensorFlow is required for GPU backend. Install with: pip install fusionagi[gpu]"
|
||||
) from e
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TensorFlowBackend(TensorBackend):
|
||||
"""TensorFlow backend with TensorCore and mixed-precision support.
|
||||
|
||||
Features:
|
||||
- Automatic GPU detection and device placement
|
||||
- Mixed-precision (FP16/BF16) for TensorCore acceleration
|
||||
- XLA compilation for kernel fusion
|
||||
- Batched linear algebra via tf.linalg
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
gpus = tf.config.list_physical_devices("GPU")
|
||||
self._has_gpu = len(gpus) > 0
|
||||
self._device_type = DeviceType.GPU if self._has_gpu else DeviceType.CPU
|
||||
self._mixed_precision_enabled = False
|
||||
|
||||
if self._has_gpu:
|
||||
for gpu in gpus:
|
||||
try:
|
||||
tf.config.experimental.set_memory_growth(gpu, True)
|
||||
except RuntimeError:
|
||||
pass
|
||||
logger.info(
|
||||
"TensorFlowBackend initialized with GPU",
|
||||
extra={"gpu_count": len(gpus), "gpu_names": [g.name for g in gpus]},
|
||||
)
|
||||
else:
|
||||
logger.info("TensorFlowBackend initialized (CPU mode, no GPU detected)")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "tensorflow"
|
||||
|
||||
@property
|
||||
def device(self) -> DeviceType:
|
||||
return self._device_type
|
||||
|
||||
def enable_mixed_precision(self) -> None:
|
||||
"""Enable FP16 mixed-precision for TensorCore acceleration.
|
||||
|
||||
On NVIDIA Volta/Turing/Ampere/Hopper GPUs, this leverages TensorCores
|
||||
for up to 8x throughput on matrix operations.
|
||||
"""
|
||||
if self._mixed_precision_enabled:
|
||||
return
|
||||
try:
|
||||
tf.keras.mixed_precision.set_global_policy("mixed_float16")
|
||||
self._mixed_precision_enabled = True
|
||||
logger.info("TensorCore mixed-precision enabled (float16)")
|
||||
except Exception:
|
||||
logger.warning("Mixed-precision not available; using float32")
|
||||
|
||||
def embed_texts(self, texts: list[str], model_name: str | None = None) -> Any:
|
||||
"""Embed texts using a character-level hashing scheme on GPU.
|
||||
|
||||
For production, replace with a TF Hub embedding model or custom Keras model.
|
||||
The hash-based approach ensures determinism and zero external dependencies.
|
||||
|
||||
Args:
|
||||
texts: List of text strings.
|
||||
model_name: Reserved for future TF Hub model support.
|
||||
|
||||
Returns:
|
||||
tf.Tensor of shape (len(texts), 512) on the active device.
|
||||
"""
|
||||
dim = 512
|
||||
embeddings = np.zeros((len(texts), dim), dtype=np.float32)
|
||||
|
||||
for i, text in enumerate(texts):
|
||||
words = text.lower().split()
|
||||
for j, word in enumerate(words):
|
||||
for k, ch in enumerate(word):
|
||||
idx = (hash(word) + k * 31 + j * 7) % dim
|
||||
embeddings[i, idx] += ord(ch) / 128.0
|
||||
|
||||
tensor = tf.constant(embeddings, dtype=tf.float32)
|
||||
norms = tf.maximum(tf.norm(tensor, axis=1, keepdims=True), 1e-8)
|
||||
return tensor / norms
|
||||
|
||||
@tf.function
|
||||
def cosine_similarity_matrix(self, embeddings_a: Any, embeddings_b: Any) -> Any:
|
||||
"""GPU-accelerated batched cosine similarity.
|
||||
|
||||
Uses tf.linalg for efficient matrix multiplication on TensorCore.
|
||||
XLA-compiled via @tf.function for kernel fusion.
|
||||
"""
|
||||
a = tf.cast(embeddings_a, tf.float32)
|
||||
b = tf.cast(embeddings_b, tf.float32)
|
||||
a_norm = a / tf.maximum(tf.norm(a, axis=1, keepdims=True), 1e-8)
|
||||
b_norm = b / tf.maximum(tf.norm(b, axis=1, keepdims=True), 1e-8)
|
||||
return tf.linalg.matmul(a_norm, b_norm, transpose_b=True)
|
||||
|
||||
@tf.function
|
||||
def batch_score(
|
||||
self,
|
||||
hypotheses: Any,
|
||||
reference: Any,
|
||||
weights: Any | None = None,
|
||||
) -> Any:
|
||||
"""GPU-accelerated batch hypothesis scoring.
|
||||
|
||||
Computes weighted cosine similarity between each hypothesis and the reference.
|
||||
Leverages TensorCore for the matrix multiply when mixed-precision is enabled.
|
||||
"""
|
||||
h = tf.cast(hypotheses, tf.float32)
|
||||
r = tf.cast(reference, tf.float32)
|
||||
if len(tf.shape(r)) == 1:
|
||||
r = tf.expand_dims(r, 0)
|
||||
|
||||
if weights is not None:
|
||||
w = tf.cast(weights, tf.float32)
|
||||
h = h * w
|
||||
r = r * w
|
||||
|
||||
h_norm = h / tf.maximum(tf.norm(h, axis=1, keepdims=True), 1e-8)
|
||||
r_norm = r / tf.maximum(tf.norm(r, axis=1, keepdims=True), 1e-8)
|
||||
scores = tf.squeeze(tf.linalg.matmul(h_norm, r_norm, transpose_b=True))
|
||||
return scores
|
||||
|
||||
def multi_head_attention(
|
||||
self,
|
||||
queries: Any,
|
||||
keys: Any,
|
||||
values: Any,
|
||||
num_heads: int = 4,
|
||||
) -> Any:
|
||||
"""GPU-accelerated multi-head attention for consensus scoring.
|
||||
|
||||
Uses tf.keras.layers.MultiHeadAttention for optimal TensorCore utilization.
|
||||
Falls back to manual implementation if sequence dimensions don't align.
|
||||
"""
|
||||
q = tf.cast(queries, tf.float32)
|
||||
k = tf.cast(keys, tf.float32)
|
||||
v = tf.cast(values, tf.float32)
|
||||
|
||||
d_model = q.shape[-1]
|
||||
if d_model is None or d_model < num_heads:
|
||||
return q
|
||||
|
||||
return self._manual_mha(q, k, v, num_heads)
|
||||
|
||||
@tf.function
|
||||
def _manual_mha(
|
||||
self,
|
||||
queries: tf.Tensor,
|
||||
keys: tf.Tensor,
|
||||
values: tf.Tensor,
|
||||
num_heads: int,
|
||||
) -> tf.Tensor:
|
||||
"""Manual multi-head attention with TensorCore-friendly shapes."""
|
||||
d_model = tf.shape(queries)[-1]
|
||||
d_head = d_model // num_heads
|
||||
|
||||
outputs = []
|
||||
for h in range(num_heads):
|
||||
start = h * d_head
|
||||
end = start + d_head
|
||||
q = queries[:, start:end]
|
||||
k = keys[:, start:end]
|
||||
v = values[:, start:end]
|
||||
|
||||
scale = tf.math.sqrt(tf.cast(d_head, tf.float32))
|
||||
attn_logits = tf.linalg.matmul(q, k, transpose_b=True) / scale
|
||||
attn_weights = tf.nn.softmax(attn_logits, axis=-1)
|
||||
outputs.append(tf.linalg.matmul(attn_weights, v))
|
||||
|
||||
return tf.concat(outputs, axis=-1)
|
||||
|
||||
def to_numpy(self, tensor: Any) -> Any:
|
||||
if isinstance(tensor, tf.Tensor):
|
||||
return tensor.numpy()
|
||||
return np.asarray(tensor)
|
||||
|
||||
def from_numpy(self, array: Any) -> Any:
|
||||
return tf.constant(array)
|
||||
|
||||
def gpu_available(self) -> bool:
|
||||
return self._has_gpu
|
||||
|
||||
def device_summary(self) -> dict[str, Any]:
|
||||
gpus = tf.config.list_physical_devices("GPU")
|
||||
return {
|
||||
"backend": self.name,
|
||||
"device": self._device_type.value,
|
||||
"gpu_count": len(gpus),
|
||||
"gpu_names": [g.name for g in gpus],
|
||||
"mixed_precision": self._mixed_precision_enabled,
|
||||
"tf_version": tf.__version__,
|
||||
}
|
||||
208
fusionagi/gpu/training.py
Normal file
208
fusionagi/gpu/training.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""GPU-accelerated training support for self-improvement pipeline.
|
||||
|
||||
Provides tensor-based training utilities:
|
||||
- Heuristic weight optimization via gradient descent
|
||||
- Embedding fine-tuning from execution traces
|
||||
- Training data preparation from reflective memory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.gpu.backend import TensorBackend, get_backend
|
||||
|
||||
|
||||
class ReflectiveMemoryLike(Protocol):
|
||||
"""Protocol for reflective memory access."""
|
||||
|
||||
def get_lessons(self, limit: int = 50) -> list[dict[str, Any]]: ...
|
||||
def get_all_heuristics(self) -> dict[str, Any]: ...
|
||||
def set_heuristic(self, key: str, value: Any) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainingConfig:
|
||||
"""Configuration for GPU-accelerated training."""
|
||||
|
||||
learning_rate: float = 0.01
|
||||
epochs: int = 10
|
||||
batch_size: int = 32
|
||||
embedding_dim: int = 256
|
||||
weight_decay: float = 0.001
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainingResult:
|
||||
"""Result of a GPU training run."""
|
||||
|
||||
initial_loss: float = 0.0
|
||||
final_loss: float = 0.0
|
||||
epochs_run: int = 0
|
||||
weights_updated: int = 0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def prepare_training_pairs(
|
||||
lessons: list[dict[str, Any]],
|
||||
backend: TensorBackend | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Prepare input/target embedding pairs from reflective memory lessons.
|
||||
|
||||
Each lesson with evaluation produces a (task_goal, outcome_quality) pair.
|
||||
These can be used to train heuristic weights or embeddings.
|
||||
|
||||
Args:
|
||||
lessons: List of lesson dicts from reflective memory.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
Tuple of (input_embeddings, target_scores) tensors.
|
||||
"""
|
||||
be = backend or get_backend()
|
||||
import numpy as np
|
||||
|
||||
inputs: list[str] = []
|
||||
targets: list[float] = []
|
||||
|
||||
for lesson in lessons:
|
||||
task_id = lesson.get("task_id", "")
|
||||
outcome = lesson.get("outcome", "unknown")
|
||||
evaluation = lesson.get("evaluation", {})
|
||||
score = evaluation.get("score", 0.5)
|
||||
|
||||
input_text = f"task:{task_id} outcome:{outcome}"
|
||||
inputs.append(input_text)
|
||||
targets.append(float(score))
|
||||
|
||||
if not inputs:
|
||||
dim = 256
|
||||
return be.from_numpy(np.zeros((0, dim), dtype=np.float32)), be.from_numpy(
|
||||
np.zeros(0, dtype=np.float32)
|
||||
)
|
||||
|
||||
input_emb = be.embed_texts(inputs)
|
||||
target_arr = np.array(targets, dtype=np.float32)
|
||||
return input_emb, be.from_numpy(target_arr)
|
||||
|
||||
|
||||
def optimize_heuristic_weights(
|
||||
input_embeddings: Any,
|
||||
target_scores: Any,
|
||||
config: TrainingConfig | None = None,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> TrainingResult:
|
||||
"""Optimize heuristic scoring weights using gradient descent on GPU.
|
||||
|
||||
Learns a weight vector that maps input embeddings to target scores
|
||||
via a simple linear model: score = sigmoid(embeddings @ weights).
|
||||
|
||||
Args:
|
||||
input_embeddings: Tensor of shape (N, D) — training inputs.
|
||||
target_scores: Tensor of shape (N,) — target scores in [0, 1].
|
||||
config: Training configuration.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
TrainingResult with loss history and weight count.
|
||||
"""
|
||||
be = backend or get_backend()
|
||||
cfg = config or TrainingConfig()
|
||||
import numpy as np
|
||||
|
||||
inputs = be.to_numpy(input_embeddings)
|
||||
targets = be.to_numpy(target_scores)
|
||||
|
||||
if len(inputs) == 0:
|
||||
return TrainingResult(metadata={"reason": "no training data"})
|
||||
|
||||
dim = inputs.shape[1]
|
||||
weights = np.random.randn(dim).astype(np.float32) * 0.01
|
||||
bias = np.float32(0.0)
|
||||
|
||||
def sigmoid(x: Any) -> Any:
|
||||
return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))
|
||||
|
||||
initial_logits = inputs @ weights + bias
|
||||
initial_preds = sigmoid(initial_logits)
|
||||
initial_loss = float(np.mean((initial_preds - targets) ** 2))
|
||||
|
||||
lr = cfg.learning_rate
|
||||
final_loss = initial_loss
|
||||
|
||||
for epoch in range(cfg.epochs):
|
||||
indices = np.random.permutation(len(inputs))
|
||||
epoch_loss = 0.0
|
||||
n_batches = 0
|
||||
|
||||
for start in range(0, len(inputs), cfg.batch_size):
|
||||
batch_idx = indices[start : start + cfg.batch_size]
|
||||
x_batch = inputs[batch_idx]
|
||||
y_batch = targets[batch_idx]
|
||||
|
||||
logits = x_batch @ weights + bias
|
||||
preds = sigmoid(logits)
|
||||
|
||||
error = preds - y_batch
|
||||
batch_loss = float(np.mean(error**2))
|
||||
epoch_loss += batch_loss
|
||||
n_batches += 1
|
||||
|
||||
grad_w = (x_batch.T @ error) / len(x_batch) + cfg.weight_decay * weights
|
||||
grad_b = float(np.mean(error))
|
||||
|
||||
weights -= lr * grad_w
|
||||
bias -= lr * grad_b
|
||||
|
||||
final_loss = epoch_loss / max(n_batches, 1)
|
||||
|
||||
logger.info(
|
||||
"Heuristic weight optimization complete",
|
||||
extra={
|
||||
"initial_loss": initial_loss,
|
||||
"final_loss": final_loss,
|
||||
"epochs": cfg.epochs,
|
||||
"dim": dim,
|
||||
},
|
||||
)
|
||||
|
||||
return TrainingResult(
|
||||
initial_loss=initial_loss,
|
||||
final_loss=final_loss,
|
||||
epochs_run=cfg.epochs,
|
||||
weights_updated=dim,
|
||||
metadata={
|
||||
"weight_norm": float(np.linalg.norm(weights)),
|
||||
"bias": float(bias),
|
||||
"backend": be.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_gpu_training(
|
||||
reflective_memory: ReflectiveMemoryLike,
|
||||
config: TrainingConfig | None = None,
|
||||
backend: TensorBackend | None = None,
|
||||
) -> TrainingResult:
|
||||
"""End-to-end GPU training from reflective memory.
|
||||
|
||||
Loads lessons, prepares pairs, and runs optimization.
|
||||
|
||||
Args:
|
||||
reflective_memory: Source of training data.
|
||||
config: Training configuration.
|
||||
backend: TensorBackend to use.
|
||||
|
||||
Returns:
|
||||
TrainingResult.
|
||||
"""
|
||||
be = backend or get_backend()
|
||||
lessons = reflective_memory.get_lessons(limit=500)
|
||||
|
||||
if not lessons:
|
||||
return TrainingResult(metadata={"reason": "no lessons available"})
|
||||
|
||||
inputs, targets = prepare_training_pairs(lessons, backend=be)
|
||||
return optimize_heuristic_weights(inputs, targets, config=config, backend=be)
|
||||
@@ -3,16 +3,16 @@
|
||||
Provides admin control panel, user interfaces, and sensory interaction adapters.
|
||||
"""
|
||||
|
||||
from fusionagi.interfaces.admin_panel import AdminControlPanel
|
||||
from fusionagi.interfaces.base import (
|
||||
InterfaceAdapter,
|
||||
InterfaceCapabilities,
|
||||
InterfaceMessage,
|
||||
ModalityType,
|
||||
)
|
||||
from fusionagi.interfaces.voice import VoiceInterface, VoiceLibrary, TTSAdapter, STTAdapter
|
||||
from fusionagi.interfaces.conversation import ConversationManager, ConversationTuner
|
||||
from fusionagi.interfaces.admin_panel import AdminControlPanel
|
||||
from fusionagi.interfaces.multimodal_ui import MultiModalUI
|
||||
from fusionagi.interfaces.voice import STTAdapter, TTSAdapter, VoiceInterface, VoiceLibrary
|
||||
|
||||
__all__ = [
|
||||
"InterfaceAdapter",
|
||||
|
||||
@@ -13,17 +13,17 @@ from typing import Any, Callable, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._time import utc_now, utc_now_iso
|
||||
from fusionagi.interfaces.voice import VoiceLibrary, VoiceProfile
|
||||
from fusionagi.interfaces.conversation import ConversationTuner, ConversationStyle
|
||||
from fusionagi.core import Orchestrator, EventBus, StateManager
|
||||
from fusionagi.governance import PolicyEngine, AuditLog
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now, utc_now_iso
|
||||
from fusionagi.core import EventBus, Orchestrator, StateManager
|
||||
from fusionagi.governance import AuditLog, PolicyEngine
|
||||
from fusionagi.interfaces.conversation import ConversationStyle, ConversationTuner
|
||||
from fusionagi.interfaces.voice import VoiceLibrary, VoiceProfile
|
||||
|
||||
|
||||
class SystemStatus(BaseModel):
|
||||
"""System status information."""
|
||||
|
||||
|
||||
status: Literal["healthy", "degraded", "offline"] = Field(description="Overall system status")
|
||||
uptime_seconds: float = Field(description="System uptime in seconds")
|
||||
active_tasks: int = Field(description="Number of active tasks")
|
||||
@@ -36,7 +36,7 @@ class SystemStatus(BaseModel):
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Configuration for an agent."""
|
||||
|
||||
|
||||
agent_id: str
|
||||
agent_type: str
|
||||
enabled: bool = Field(default=True)
|
||||
@@ -49,7 +49,7 @@ class AgentConfig(BaseModel):
|
||||
class AdminControlPanel:
|
||||
"""
|
||||
Administrative control panel for FusionAGI.
|
||||
|
||||
|
||||
Provides centralized management interface for:
|
||||
- Voice libraries and TTS/STT configuration
|
||||
- Conversation styles and natural language tuning
|
||||
@@ -58,7 +58,7 @@ class AdminControlPanel:
|
||||
- Governance policies and audit logs
|
||||
- Manufacturing authority (MAA) settings
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
orchestrator: Orchestrator,
|
||||
@@ -94,25 +94,25 @@ class AdminControlPanel:
|
||||
|
||||
self._agent_configs: dict[str, AgentConfig] = {}
|
||||
self._start_time = utc_now()
|
||||
|
||||
|
||||
logger.info("AdminControlPanel initialized")
|
||||
|
||||
|
||||
# ========== Voice Management ==========
|
||||
|
||||
|
||||
def add_voice_profile(self, profile: VoiceProfile) -> str:
|
||||
"""
|
||||
Add a voice profile to the library.
|
||||
|
||||
|
||||
Args:
|
||||
profile: Voice profile to add.
|
||||
|
||||
|
||||
Returns:
|
||||
Voice ID.
|
||||
"""
|
||||
voice_id = self.voice_library.add_voice(profile)
|
||||
self._log_admin_action("voice_added", {"voice_id": voice_id, "name": profile.name})
|
||||
return voice_id
|
||||
|
||||
|
||||
def list_voices(
|
||||
self,
|
||||
language: str | None = None,
|
||||
@@ -121,15 +121,15 @@ class AdminControlPanel:
|
||||
) -> list[VoiceProfile]:
|
||||
"""List voice profiles with optional filtering."""
|
||||
return self.voice_library.list_voices(language=language, gender=gender, style=style)
|
||||
|
||||
|
||||
def update_voice_profile(self, voice_id: str, updates: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update a voice profile.
|
||||
|
||||
|
||||
Args:
|
||||
voice_id: Voice ID to update.
|
||||
updates: Dictionary of fields to update.
|
||||
|
||||
|
||||
Returns:
|
||||
True if updated, False if not found.
|
||||
"""
|
||||
@@ -137,68 +137,68 @@ class AdminControlPanel:
|
||||
if success:
|
||||
self._log_admin_action("voice_updated", {"voice_id": voice_id, "fields": list(updates.keys())})
|
||||
return success
|
||||
|
||||
|
||||
def remove_voice_profile(self, voice_id: str) -> bool:
|
||||
"""Remove a voice profile."""
|
||||
success = self.voice_library.remove_voice(voice_id)
|
||||
if success:
|
||||
self._log_admin_action("voice_removed", {"voice_id": voice_id})
|
||||
return success
|
||||
|
||||
|
||||
def set_default_voice(self, voice_id: str) -> bool:
|
||||
"""Set the default voice."""
|
||||
success = self.voice_library.set_default_voice(voice_id)
|
||||
if success:
|
||||
self._log_admin_action("default_voice_set", {"voice_id": voice_id})
|
||||
return success
|
||||
|
||||
|
||||
# ========== Conversation Tuning ==========
|
||||
|
||||
|
||||
def register_conversation_style(self, name: str, style: ConversationStyle) -> None:
|
||||
"""
|
||||
Register a conversation style.
|
||||
|
||||
|
||||
Args:
|
||||
name: Style name.
|
||||
style: Conversation style configuration.
|
||||
"""
|
||||
self.conversation_tuner.register_style(name, style)
|
||||
self._log_admin_action("conversation_style_registered", {"name": name})
|
||||
|
||||
|
||||
def list_conversation_styles(self) -> list[str]:
|
||||
"""List all registered conversation style names."""
|
||||
return self.conversation_tuner.list_styles()
|
||||
|
||||
|
||||
def get_conversation_style(self, name: str) -> ConversationStyle | None:
|
||||
"""Get a conversation style by name."""
|
||||
return self.conversation_tuner.get_style(name)
|
||||
|
||||
|
||||
def set_default_conversation_style(self, style: ConversationStyle) -> None:
|
||||
"""Set the default conversation style."""
|
||||
self.conversation_tuner.set_default_style(style)
|
||||
self._log_admin_action("default_conversation_style_set", {})
|
||||
|
||||
|
||||
# ========== Agent Management ==========
|
||||
|
||||
|
||||
def configure_agent(self, config: AgentConfig) -> None:
|
||||
"""
|
||||
Configure an agent.
|
||||
|
||||
|
||||
Args:
|
||||
config: Agent configuration.
|
||||
"""
|
||||
self._agent_configs[config.agent_id] = config
|
||||
self._log_admin_action("agent_configured", {"agent_id": config.agent_id})
|
||||
logger.info("Agent configured", extra={"agent_id": config.agent_id})
|
||||
|
||||
|
||||
def get_agent_config(self, agent_id: str) -> AgentConfig | None:
|
||||
"""Get agent configuration."""
|
||||
return self._agent_configs.get(agent_id)
|
||||
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
"""List all registered agent IDs."""
|
||||
return list(self.orchestrator._agents.keys())
|
||||
|
||||
|
||||
def enable_agent(self, agent_id: str) -> bool:
|
||||
"""Enable an agent."""
|
||||
config = self._agent_configs.get(agent_id)
|
||||
@@ -207,7 +207,7 @@ class AdminControlPanel:
|
||||
self._log_admin_action("agent_enabled", {"agent_id": agent_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def disable_agent(self, agent_id: str) -> bool:
|
||||
"""Disable an agent."""
|
||||
config = self._agent_configs.get(agent_id)
|
||||
@@ -216,13 +216,13 @@ class AdminControlPanel:
|
||||
self._log_admin_action("agent_disabled", {"agent_id": agent_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ========== System Monitoring ==========
|
||||
|
||||
|
||||
def get_system_status(self) -> SystemStatus:
|
||||
"""
|
||||
Get current system status.
|
||||
|
||||
|
||||
Returns:
|
||||
System status information.
|
||||
"""
|
||||
@@ -255,11 +255,11 @@ class AdminControlPanel:
|
||||
active_agents=active_agents,
|
||||
active_sessions=active_sessions,
|
||||
)
|
||||
|
||||
|
||||
def get_task_statistics(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get task execution statistics.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with task statistics.
|
||||
"""
|
||||
@@ -268,20 +268,20 @@ class AdminControlPanel:
|
||||
"by_state": {},
|
||||
"by_priority": {},
|
||||
}
|
||||
|
||||
|
||||
for task_id in self.state_manager._tasks.keys():
|
||||
task = self.state_manager.get_task(task_id)
|
||||
if task:
|
||||
# Count by state
|
||||
state_key = task.state.value
|
||||
stats["by_state"][state_key] = stats["by_state"].get(state_key, 0) + 1
|
||||
|
||||
stats["by_state"][state_key] = stats["by_state"].get(state_key, 0) + 1 # type: ignore[index, attr-defined]
|
||||
|
||||
# Count by priority
|
||||
priority_key = task.priority.value
|
||||
stats["by_priority"][priority_key] = stats["by_priority"].get(priority_key, 0) + 1
|
||||
|
||||
stats["by_priority"][priority_key] = stats["by_priority"].get(priority_key, 0) + 1 # type: ignore[index, attr-defined]
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def get_recent_events(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get recent system events from the event bus.
|
||||
@@ -297,9 +297,9 @@ class AdminControlPanel:
|
||||
if hasattr(self.event_bus, "get_recent_events"):
|
||||
return self.event_bus.get_recent_events(limit=limit)
|
||||
return []
|
||||
|
||||
|
||||
# ========== Governance & Audit ==========
|
||||
|
||||
|
||||
def get_audit_entries(
|
||||
self,
|
||||
limit: int = 100,
|
||||
@@ -307,32 +307,32 @@ class AdminControlPanel:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get audit log entries.
|
||||
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return.
|
||||
action_type: Optional filter by action type.
|
||||
|
||||
|
||||
Returns:
|
||||
List of audit entries.
|
||||
"""
|
||||
if not self.audit_log:
|
||||
return []
|
||||
|
||||
entries = self.audit_log.query(limit=limit)
|
||||
|
||||
|
||||
entries = self.audit_log.query(limit=limit) # type: ignore[attr-defined]
|
||||
|
||||
if action_type:
|
||||
entries = [e for e in entries if e.get("action") == action_type]
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
return entries # type: ignore[return-value, no-any-return]
|
||||
|
||||
def update_policy(self, policy_id: str, policy_data: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update a governance policy.
|
||||
|
||||
|
||||
Args:
|
||||
policy_id: Policy identifier.
|
||||
policy_data: Policy configuration.
|
||||
|
||||
|
||||
Returns:
|
||||
True if updated, False if policy engine not available.
|
||||
"""
|
||||
@@ -347,38 +347,38 @@ class AdminControlPanel:
|
||||
if ok:
|
||||
self._log_admin_action("policy_updated", {"policy_id": policy_id, "rule_id": rule_id})
|
||||
return ok
|
||||
|
||||
|
||||
# ========== Utility Methods ==========
|
||||
|
||||
|
||||
def _log_admin_action(self, action: str, details: dict[str, Any]) -> None:
|
||||
"""
|
||||
Log an administrative action.
|
||||
|
||||
|
||||
Args:
|
||||
action: Action type.
|
||||
details: Action details.
|
||||
"""
|
||||
logger.info(f"Admin action: {action}", extra=details)
|
||||
|
||||
|
||||
if self.audit_log:
|
||||
self.audit_log.log(
|
||||
self.audit_log.log( # type: ignore[attr-defined]
|
||||
action=action,
|
||||
actor="admin",
|
||||
details=details,
|
||||
timestamp=utc_now_iso(),
|
||||
)
|
||||
|
||||
|
||||
def export_configuration(self) -> dict[str, Any]:
|
||||
"""
|
||||
Export system configuration.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with full system configuration.
|
||||
"""
|
||||
return {
|
||||
"voices": [v.model_dump() for v in self.voice_library.list_voices()],
|
||||
"conversation_styles": {
|
||||
name: self.conversation_tuner.get_style(name).model_dump()
|
||||
name: self.conversation_tuner.get_style(name).model_dump() # type: ignore[union-attr]
|
||||
for name in self.conversation_tuner.list_styles()
|
||||
},
|
||||
"agent_configs": {
|
||||
@@ -387,14 +387,14 @@ class AdminControlPanel:
|
||||
},
|
||||
"exported_at": utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
def import_configuration(self, config: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Import system configuration.
|
||||
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to import.
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
@@ -404,22 +404,22 @@ class AdminControlPanel:
|
||||
for voice_data in config["voices"]:
|
||||
profile = VoiceProfile(**voice_data)
|
||||
self.voice_library.add_voice(profile)
|
||||
|
||||
|
||||
# Import conversation styles
|
||||
if "conversation_styles" in config:
|
||||
for name, style_data in config["conversation_styles"].items():
|
||||
style = ConversationStyle(**style_data)
|
||||
self.conversation_tuner.register_style(name, style)
|
||||
|
||||
|
||||
# Import agent configs
|
||||
if "agent_configs" in config:
|
||||
for agent_id, config_data in config["agent_configs"].items():
|
||||
agent_config = AgentConfig(**config_data)
|
||||
self._agent_configs[agent_id] = agent_config
|
||||
|
||||
|
||||
self._log_admin_action("configuration_imported", {"source": "file"})
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Configuration import failed", extra={"error": str(e)})
|
||||
return False
|
||||
|
||||
@@ -11,7 +11,7 @@ from fusionagi._time import utc_now_iso
|
||||
|
||||
class ModalityType(str, Enum):
|
||||
"""Types of sensory modalities supported."""
|
||||
|
||||
|
||||
TEXT = "text"
|
||||
VOICE = "voice"
|
||||
VISUAL = "visual"
|
||||
@@ -22,7 +22,7 @@ class ModalityType(str, Enum):
|
||||
|
||||
class InterfaceMessage(BaseModel):
|
||||
"""Message exchanged through an interface."""
|
||||
|
||||
|
||||
id: str = Field(description="Unique message identifier")
|
||||
modality: ModalityType = Field(description="Sensory modality of this message")
|
||||
content: Any = Field(description="Message content (modality-specific)")
|
||||
@@ -37,7 +37,7 @@ class InterfaceMessage(BaseModel):
|
||||
|
||||
class InterfaceCapabilities(BaseModel):
|
||||
"""Capabilities of an interface adapter."""
|
||||
|
||||
|
||||
supported_modalities: list[ModalityType] = Field(description="Supported sensory modalities")
|
||||
supports_streaming: bool = Field(default=False, description="Supports streaming responses")
|
||||
supports_interruption: bool = Field(default=False, description="Supports mid-response interruption")
|
||||
@@ -49,71 +49,71 @@ class InterfaceCapabilities(BaseModel):
|
||||
class InterfaceAdapter(ABC):
|
||||
"""
|
||||
Abstract base for interface adapters.
|
||||
|
||||
|
||||
Interface adapters translate between human sensory modalities and FusionAGI's
|
||||
internal message format. Each adapter handles one or more modalities (voice,
|
||||
visual, haptic, etc.).
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def capabilities(self) -> InterfaceCapabilities:
|
||||
"""Return the capabilities of this interface."""
|
||||
...
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, message: InterfaceMessage) -> None:
|
||||
"""
|
||||
Send a message through this interface to the user.
|
||||
|
||||
|
||||
Args:
|
||||
message: Message to send (modality-specific content).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def receive(self, timeout_seconds: float | None = None) -> InterfaceMessage | None:
|
||||
"""
|
||||
Receive a message from the user through this interface.
|
||||
|
||||
|
||||
Args:
|
||||
timeout_seconds: Optional timeout for receiving.
|
||||
|
||||
|
||||
Returns:
|
||||
Received message or None if timeout.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
async def stream_send(self, messages: AsyncIterator[InterfaceMessage]) -> None:
|
||||
"""
|
||||
Stream messages to the user (for streaming responses).
|
||||
|
||||
|
||||
Default implementation sends each message individually. Override for
|
||||
true streaming support.
|
||||
|
||||
|
||||
Args:
|
||||
messages: Async iterator of messages to stream.
|
||||
"""
|
||||
async for msg in messages:
|
||||
await self.send(msg)
|
||||
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the interface (connect, authenticate, etc.)."""
|
||||
pass
|
||||
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Shutdown the interface gracefully."""
|
||||
pass
|
||||
|
||||
|
||||
def validate_message(self, message: InterfaceMessage) -> bool:
|
||||
"""
|
||||
Validate that a message is compatible with this interface.
|
||||
|
||||
|
||||
Args:
|
||||
message: Message to validate.
|
||||
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise.
|
||||
"""
|
||||
|
||||
@@ -5,13 +5,13 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._time import utc_now_iso
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now_iso
|
||||
|
||||
|
||||
class ConversationStyle(BaseModel):
|
||||
"""Configuration for conversation style and personality."""
|
||||
|
||||
|
||||
formality: Literal["casual", "neutral", "formal"] = Field(
|
||||
default="neutral",
|
||||
description="Conversation formality level"
|
||||
@@ -52,7 +52,7 @@ class ConversationStyle(BaseModel):
|
||||
|
||||
class ConversationContext(BaseModel):
|
||||
"""Context for a conversation session."""
|
||||
|
||||
|
||||
session_id: str = Field(default_factory=lambda: f"session_{uuid.uuid4().hex}")
|
||||
user_id: str | None = Field(default=None)
|
||||
style: ConversationStyle = Field(default_factory=ConversationStyle)
|
||||
@@ -65,7 +65,7 @@ class ConversationContext(BaseModel):
|
||||
|
||||
class ConversationTurn(BaseModel):
|
||||
"""A single turn in a conversation."""
|
||||
|
||||
|
||||
turn_id: str = Field(default_factory=lambda: f"turn_{uuid.uuid4().hex[:8]}")
|
||||
session_id: str
|
||||
speaker: Literal["user", "agent", "system"]
|
||||
@@ -85,44 +85,44 @@ class ConversationTurn(BaseModel):
|
||||
class ConversationTuner:
|
||||
"""
|
||||
Conversation tuner for natural language interaction.
|
||||
|
||||
|
||||
Allows admin to configure conversation style, personality, and behavior
|
||||
for different contexts, users, or agents.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._styles: dict[str, ConversationStyle] = {}
|
||||
self._default_style = ConversationStyle()
|
||||
logger.info("ConversationTuner initialized")
|
||||
|
||||
|
||||
def register_style(self, name: str, style: ConversationStyle) -> None:
|
||||
"""
|
||||
Register a named conversation style.
|
||||
|
||||
|
||||
Args:
|
||||
name: Style name (e.g., "customer_support", "technical_expert").
|
||||
style: Conversation style configuration.
|
||||
"""
|
||||
self._styles[name] = style
|
||||
logger.info("Conversation style registered", extra={"name": name})
|
||||
|
||||
|
||||
def get_style(self, name: str) -> ConversationStyle | None:
|
||||
"""Get a conversation style by name."""
|
||||
return self._styles.get(name)
|
||||
|
||||
|
||||
def list_styles(self) -> list[str]:
|
||||
"""List all registered style names."""
|
||||
return list(self._styles.keys())
|
||||
|
||||
|
||||
def set_default_style(self, style: ConversationStyle) -> None:
|
||||
"""Set the default conversation style."""
|
||||
self._default_style = style
|
||||
logger.info("Default conversation style updated")
|
||||
|
||||
|
||||
def get_default_style(self) -> ConversationStyle:
|
||||
"""Get the default conversation style."""
|
||||
return self._default_style
|
||||
|
||||
|
||||
def tune_for_context(
|
||||
self,
|
||||
base_style: ConversationStyle | None = None,
|
||||
@@ -131,41 +131,41 @@ class ConversationTuner:
|
||||
) -> ConversationStyle:
|
||||
"""
|
||||
Tune conversation style for a specific context.
|
||||
|
||||
|
||||
Args:
|
||||
base_style: Base style to start from (uses default if None).
|
||||
domain: Domain/topic to optimize for.
|
||||
user_preferences: User-specific preferences to apply.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuned conversation style.
|
||||
"""
|
||||
style = base_style or self._default_style.model_copy(deep=True)
|
||||
|
||||
|
||||
# Apply domain-specific tuning
|
||||
if domain:
|
||||
style = self._apply_domain_tuning(style, domain)
|
||||
|
||||
|
||||
# Apply user preferences
|
||||
if user_preferences:
|
||||
for key, value in user_preferences.items():
|
||||
if hasattr(style, key):
|
||||
setattr(style, key, value)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Conversation style tuned",
|
||||
extra={"domain": domain, "has_user_prefs": bool(user_preferences)}
|
||||
)
|
||||
return style
|
||||
|
||||
|
||||
def _apply_domain_tuning(self, style: ConversationStyle, domain: str) -> ConversationStyle:
|
||||
"""
|
||||
Apply domain-specific tuning to a conversation style.
|
||||
|
||||
|
||||
Args:
|
||||
style: Base conversation style.
|
||||
domain: Domain to tune for.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuned conversation style.
|
||||
"""
|
||||
@@ -196,27 +196,27 @@ class ConversationTuner:
|
||||
"proactivity": 0.7,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
preset = domain_presets.get(domain.lower())
|
||||
if preset:
|
||||
for key, value in preset.items():
|
||||
setattr(style, key, value)
|
||||
|
||||
|
||||
return style
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
"""
|
||||
Conversation manager for maintaining conversation state and history.
|
||||
|
||||
|
||||
Manages conversation sessions, tracks turns, and provides context for
|
||||
natural language understanding and generation.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, tuner: ConversationTuner | None = None) -> None:
|
||||
"""
|
||||
Initialize conversation manager.
|
||||
|
||||
|
||||
Args:
|
||||
tuner: Conversation tuner for style management.
|
||||
"""
|
||||
@@ -224,7 +224,7 @@ class ConversationManager:
|
||||
self._sessions: dict[str, ConversationContext] = {}
|
||||
self._history: dict[str, list[ConversationTurn]] = {}
|
||||
logger.info("ConversationManager initialized")
|
||||
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
@@ -234,28 +234,30 @@ class ConversationManager:
|
||||
) -> str:
|
||||
"""
|
||||
Create a new conversation session.
|
||||
|
||||
|
||||
Args:
|
||||
user_id: Optional user identifier.
|
||||
style_name: Optional style name (uses default if None).
|
||||
language: Primary language code.
|
||||
domain: Domain/topic of conversation.
|
||||
|
||||
|
||||
Returns:
|
||||
Session ID.
|
||||
"""
|
||||
style = self.tuner.get_style(style_name) if style_name else self.tuner.get_default_style()
|
||||
|
||||
resolved_style = self.tuner.get_style(style_name) if style_name else self.tuner.get_default_style()
|
||||
if resolved_style is None:
|
||||
resolved_style = self.tuner.get_default_style()
|
||||
|
||||
context = ConversationContext(
|
||||
user_id=user_id,
|
||||
style=style,
|
||||
style=resolved_style,
|
||||
language=language,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
|
||||
self._sessions[context.session_id] = context
|
||||
self._history[context.session_id] = []
|
||||
|
||||
|
||||
logger.info(
|
||||
"Conversation session created",
|
||||
extra={
|
||||
@@ -265,30 +267,30 @@ class ConversationManager:
|
||||
}
|
||||
)
|
||||
return context.session_id
|
||||
|
||||
|
||||
def get_session(self, session_id: str) -> ConversationContext | None:
|
||||
"""Get conversation context for a session."""
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
|
||||
def add_turn(self, turn: ConversationTurn) -> None:
|
||||
"""
|
||||
Add a turn to conversation history.
|
||||
|
||||
|
||||
Args:
|
||||
turn: Conversation turn to add.
|
||||
"""
|
||||
if turn.session_id not in self._history:
|
||||
logger.warning("Session not found", extra={"session_id": turn.session_id})
|
||||
return
|
||||
|
||||
|
||||
history = self._history[turn.session_id]
|
||||
history.append(turn)
|
||||
|
||||
|
||||
# Trim history to configured length
|
||||
context = self._sessions.get(turn.session_id)
|
||||
if context and len(history) > context.history_length:
|
||||
self._history[turn.session_id] = history[-context.history_length:]
|
||||
|
||||
|
||||
logger.debug(
|
||||
"Turn added",
|
||||
extra={
|
||||
@@ -297,15 +299,15 @@ class ConversationManager:
|
||||
"content_length": len(turn.content),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_history(self, session_id: str, limit: int | None = None) -> list[ConversationTurn]:
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
limit: Optional limit on number of turns to return.
|
||||
|
||||
|
||||
Returns:
|
||||
List of conversation turns (most recent last).
|
||||
"""
|
||||
@@ -313,7 +315,7 @@ class ConversationManager:
|
||||
if limit:
|
||||
return history[-limit:]
|
||||
return history
|
||||
|
||||
|
||||
def get_style_for_session(self, session_id: str) -> ConversationStyle | None:
|
||||
"""
|
||||
Get the conversation style for a session.
|
||||
@@ -330,11 +332,11 @@ class ConversationManager:
|
||||
def update_style(self, session_id: str, style: ConversationStyle) -> bool:
|
||||
"""
|
||||
Update conversation style for a session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
style: New conversation style.
|
||||
|
||||
|
||||
Returns:
|
||||
True if updated, False if session not found.
|
||||
"""
|
||||
@@ -344,14 +346,14 @@ class ConversationManager:
|
||||
logger.info("Session style updated", extra={"session_id": session_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def end_session(self, session_id: str) -> bool:
|
||||
"""
|
||||
End a conversation session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
|
||||
|
||||
Returns:
|
||||
True if ended, False if not found.
|
||||
"""
|
||||
@@ -361,23 +363,23 @@ class ConversationManager:
|
||||
logger.info("Session ended", extra={"session_id": session_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_context_summary(self, session_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of conversation context for LLM prompting.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with context summary.
|
||||
"""
|
||||
context = self._sessions.get(session_id)
|
||||
history = self._history.get(session_id, [])
|
||||
|
||||
|
||||
if not context:
|
||||
return {}
|
||||
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": context.user_id,
|
||||
|
||||
@@ -11,26 +11,25 @@ Supports:
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator, Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now_iso
|
||||
from fusionagi.core import Orchestrator
|
||||
from fusionagi.interfaces.base import (
|
||||
InterfaceAdapter,
|
||||
InterfaceMessage,
|
||||
ModalityType,
|
||||
)
|
||||
from fusionagi.interfaces.voice import VoiceInterface, VoiceLibrary
|
||||
from fusionagi.interfaces.conversation import ConversationManager, ConversationTurn
|
||||
from fusionagi.core import Orchestrator
|
||||
from fusionagi.schemas import Task, TaskState
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.interfaces.voice import VoiceInterface
|
||||
|
||||
|
||||
class UserSession(BaseModel):
|
||||
"""User session with multi-modal interface."""
|
||||
|
||||
|
||||
session_id: str = Field(default_factory=lambda: f"user_session_{uuid.uuid4().hex}")
|
||||
user_id: str | None = Field(default=None)
|
||||
conversation_session_id: str | None = Field(default=None)
|
||||
@@ -44,11 +43,11 @@ class UserSession(BaseModel):
|
||||
class MultiModalUI:
|
||||
"""
|
||||
Multi-modal user interface for FusionAGI.
|
||||
|
||||
|
||||
Provides a unified interface that supports multiple sensory modalities
|
||||
simultaneously, allowing users to interact through their preferred
|
||||
combination of text, voice, visual, haptic, gesture, and biometric inputs.
|
||||
|
||||
|
||||
Features:
|
||||
- Seamless switching between modalities
|
||||
- Simultaneous multi-modal input/output
|
||||
@@ -56,7 +55,7 @@ class MultiModalUI:
|
||||
- Context-aware modality selection
|
||||
- Real-time feedback across all active modalities
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
orchestrator: Orchestrator,
|
||||
@@ -87,9 +86,9 @@ class MultiModalUI:
|
||||
self._interface_adapters[ModalityType.VOICE] = voice_interface
|
||||
|
||||
logger.info("MultiModalUI initialized")
|
||||
|
||||
|
||||
# ========== Session Management ==========
|
||||
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
@@ -98,27 +97,27 @@ class MultiModalUI:
|
||||
) -> str:
|
||||
"""
|
||||
Create a new user session.
|
||||
|
||||
|
||||
Args:
|
||||
user_id: Optional user identifier.
|
||||
preferred_modalities: Preferred interaction modalities.
|
||||
accessibility_settings: Accessibility preferences.
|
||||
|
||||
|
||||
Returns:
|
||||
Session ID.
|
||||
"""
|
||||
# Create conversation session
|
||||
conv_session_id = self.conversation_manager.create_session(user_id=user_id)
|
||||
|
||||
|
||||
session = UserSession(
|
||||
user_id=user_id,
|
||||
conversation_session_id=conv_session_id,
|
||||
active_modalities=preferred_modalities or [ModalityType.TEXT],
|
||||
accessibility_settings=accessibility_settings or {},
|
||||
)
|
||||
|
||||
|
||||
self._sessions[session.session_id] = session
|
||||
|
||||
|
||||
logger.info(
|
||||
"User session created",
|
||||
extra={
|
||||
@@ -127,9 +126,9 @@ class MultiModalUI:
|
||||
"modalities": [m.value for m in session.active_modalities],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
return session.session_id
|
||||
|
||||
|
||||
def get_session(self, session_id: str) -> UserSession | None:
|
||||
"""Get user session."""
|
||||
return self._sessions.get(session_id)
|
||||
@@ -137,99 +136,99 @@ class MultiModalUI:
|
||||
def active_session_count(self) -> int:
|
||||
"""Return number of active user sessions (for admin panel session_count_callback)."""
|
||||
return len(self._sessions)
|
||||
|
||||
|
||||
def end_session(self, session_id: str) -> bool:
|
||||
"""
|
||||
End a user session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
|
||||
|
||||
Returns:
|
||||
True if ended, False if not found.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
|
||||
# End conversation session
|
||||
if session.conversation_session_id:
|
||||
self.conversation_manager.end_session(session.conversation_session_id)
|
||||
|
||||
|
||||
del self._sessions[session_id]
|
||||
logger.info("User session ended", extra={"session_id": session_id})
|
||||
return True
|
||||
|
||||
|
||||
# ========== Modality Management ==========
|
||||
|
||||
|
||||
def register_interface(self, modality: ModalityType, adapter: InterfaceAdapter) -> None:
|
||||
"""
|
||||
Register an interface adapter for a modality.
|
||||
|
||||
|
||||
Args:
|
||||
modality: Modality type.
|
||||
adapter: Interface adapter implementation.
|
||||
"""
|
||||
self._interface_adapters[modality] = adapter
|
||||
logger.info("Interface adapter registered", extra={"modality": modality.value})
|
||||
|
||||
|
||||
def enable_modality(self, session_id: str, modality: ModalityType) -> bool:
|
||||
"""
|
||||
Enable a modality for a session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
modality: Modality to enable.
|
||||
|
||||
|
||||
Returns:
|
||||
True if enabled, False if session not found or modality unavailable.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
|
||||
if modality not in self._interface_adapters:
|
||||
logger.warning(
|
||||
"Modality not available",
|
||||
extra={"modality": modality.value}
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
if modality not in session.active_modalities:
|
||||
session.active_modalities.append(modality)
|
||||
logger.info(
|
||||
"Modality enabled",
|
||||
extra={"session_id": session_id, "modality": modality.value}
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def disable_modality(self, session_id: str, modality: ModalityType) -> bool:
|
||||
"""
|
||||
Disable a modality for a session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
modality: Modality to disable.
|
||||
|
||||
|
||||
Returns:
|
||||
True if disabled, False if session not found.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
|
||||
if modality in session.active_modalities:
|
||||
session.active_modalities.remove(modality)
|
||||
logger.info(
|
||||
"Modality disabled",
|
||||
extra={"session_id": session_id, "modality": modality.value}
|
||||
)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ========== User Interaction ==========
|
||||
|
||||
|
||||
async def send_to_user(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -239,7 +238,7 @@ class MultiModalUI:
|
||||
) -> None:
|
||||
"""
|
||||
Send content to user through active modalities.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
content: Content to send (will be adapted per modality).
|
||||
@@ -250,16 +249,16 @@ class MultiModalUI:
|
||||
if not session:
|
||||
logger.warning("Session not found", extra={"session_id": session_id})
|
||||
return
|
||||
|
||||
|
||||
# Determine which modalities to use
|
||||
target_modalities = modalities or session.active_modalities
|
||||
|
||||
|
||||
# Send through each active modality
|
||||
for modality in target_modalities:
|
||||
adapter = self._interface_adapters.get(modality)
|
||||
if not adapter:
|
||||
continue
|
||||
|
||||
|
||||
# Create modality-specific message
|
||||
message = InterfaceMessage(
|
||||
id=f"msg_{uuid.uuid4().hex[:8]}",
|
||||
@@ -269,7 +268,7 @@ class MultiModalUI:
|
||||
session_id=session_id,
|
||||
user_id=session.user_id,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
await adapter.send(message)
|
||||
except Exception as e:
|
||||
@@ -277,7 +276,7 @@ class MultiModalUI:
|
||||
"Failed to send through modality",
|
||||
extra={"modality": modality.value, "error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
async def receive_from_user(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -285,18 +284,18 @@ class MultiModalUI:
|
||||
) -> InterfaceMessage | None:
|
||||
"""
|
||||
Receive input from user through any active modality.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
timeout_seconds: Optional timeout for receiving.
|
||||
|
||||
|
||||
Returns:
|
||||
Received message or None if timeout.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
|
||||
# Listen on all active modalities (first to respond wins)
|
||||
# TODO: Implement proper async race condition handling
|
||||
for modality in session.active_modalities:
|
||||
@@ -313,11 +312,11 @@ class MultiModalUI:
|
||||
"Failed to receive from modality",
|
||||
extra={"modality": modality.value, "error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ========== Task Interaction ==========
|
||||
|
||||
|
||||
async def submit_task_interactive(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -326,46 +325,46 @@ class MultiModalUI:
|
||||
) -> str:
|
||||
"""
|
||||
Submit a task and provide interactive feedback.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
goal: Task goal description.
|
||||
constraints: Optional task constraints.
|
||||
|
||||
|
||||
Returns:
|
||||
Task ID.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"Session not found: {session_id}")
|
||||
|
||||
|
||||
# Submit task
|
||||
task_id = self.orchestrator.submit_task(
|
||||
goal=goal,
|
||||
constraints=constraints or {},
|
||||
constraints=constraints or {}, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
# Send confirmation to user
|
||||
await self.send_to_user(
|
||||
session_id,
|
||||
f"Task submitted: {goal}",
|
||||
metadata={"task_id": task_id, "type": "task_confirmation"},
|
||||
)
|
||||
|
||||
|
||||
# Subscribe to task events for real-time updates
|
||||
self._subscribe_to_task_updates(session_id, task_id)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Interactive task submitted",
|
||||
extra={"session_id": session_id, "task_id": task_id}
|
||||
)
|
||||
|
||||
|
||||
return task_id
|
||||
|
||||
|
||||
def _subscribe_to_task_updates(self, session_id: str, task_id: str) -> None:
|
||||
"""
|
||||
Subscribe to task updates and relay to user.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
task_id: Task identifier.
|
||||
@@ -374,14 +373,14 @@ class MultiModalUI:
|
||||
"""Handle task update event."""
|
||||
if data.get("task_id") != task_id:
|
||||
return
|
||||
|
||||
|
||||
# Format update message
|
||||
if event_type == "task_state_changed":
|
||||
state = data.get("new_state")
|
||||
message = f"Task {task_id[:8]}: {state}"
|
||||
else:
|
||||
message = f"Task update: {event_type}"
|
||||
|
||||
|
||||
# Send to user (async in background)
|
||||
import asyncio
|
||||
try:
|
||||
@@ -394,13 +393,13 @@ class MultiModalUI:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send task update", extra={"error": str(e)})
|
||||
|
||||
|
||||
# Subscribe to events
|
||||
self.orchestrator._event_bus.subscribe("task_state_changed", on_task_update)
|
||||
self.orchestrator._event_bus.subscribe("task_step_completed", on_task_update)
|
||||
|
||||
|
||||
# ========== Conversation Integration ==========
|
||||
|
||||
|
||||
async def converse(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -408,18 +407,18 @@ class MultiModalUI:
|
||||
) -> str:
|
||||
"""
|
||||
Handle conversational interaction.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
user_input: User's conversational input.
|
||||
|
||||
|
||||
Returns:
|
||||
Agent's response.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session or not session.conversation_session_id:
|
||||
return "Session not found"
|
||||
|
||||
|
||||
# Add user turn
|
||||
user_turn = ConversationTurn(
|
||||
session_id=session.conversation_session_id,
|
||||
@@ -427,14 +426,14 @@ class MultiModalUI:
|
||||
content=user_input,
|
||||
)
|
||||
self.conversation_manager.add_turn(user_turn)
|
||||
|
||||
|
||||
context = self.conversation_manager.get_context_summary(session.conversation_session_id)
|
||||
style = self.conversation_manager.get_style_for_session(session.conversation_session_id)
|
||||
if self._llm_process_callback is not None:
|
||||
response = self._llm_process_callback(session_id, user_input, context, style)
|
||||
else:
|
||||
response = f"I understand you said: {user_input}"
|
||||
|
||||
|
||||
# Add agent turn
|
||||
agent_turn = ConversationTurn(
|
||||
session_id=session.conversation_session_id,
|
||||
@@ -442,19 +441,19 @@ class MultiModalUI:
|
||||
content=response,
|
||||
)
|
||||
self.conversation_manager.add_turn(agent_turn)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ========== Utility Methods ==========
|
||||
|
||||
|
||||
def _adapt_content(self, content: Any, modality: ModalityType) -> Any:
|
||||
"""
|
||||
Adapt content for a specific modality.
|
||||
|
||||
|
||||
Args:
|
||||
content: Original content.
|
||||
modality: Target modality.
|
||||
|
||||
|
||||
Returns:
|
||||
Adapted content.
|
||||
"""
|
||||
@@ -472,30 +471,30 @@ class MultiModalUI:
|
||||
return {"pattern": "notification", "intensity": 0.5}
|
||||
else:
|
||||
return content
|
||||
|
||||
|
||||
def get_available_modalities(self) -> list[ModalityType]:
|
||||
"""Get list of available modalities."""
|
||||
return list(self._interface_adapters.keys())
|
||||
|
||||
|
||||
def get_session_statistics(self, session_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics for a session.
|
||||
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with session statistics.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return {}
|
||||
|
||||
|
||||
# Get conversation history
|
||||
history = []
|
||||
if session.conversation_session_id:
|
||||
history = self.conversation_manager.get_history(session.conversation_session_id)
|
||||
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": session.user_id,
|
||||
|
||||
@@ -5,9 +5,14 @@ from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._time import utc_now_iso
|
||||
from fusionagi.interfaces.base import InterfaceAdapter, InterfaceCapabilities, InterfaceMessage, ModalityType
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now_iso
|
||||
from fusionagi.interfaces.base import (
|
||||
InterfaceAdapter,
|
||||
InterfaceCapabilities,
|
||||
InterfaceMessage,
|
||||
ModalityType,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -30,7 +35,7 @@ class STTAdapter(Protocol):
|
||||
|
||||
class VoiceProfile(BaseModel):
|
||||
"""Voice profile for text-to-speech synthesis."""
|
||||
|
||||
|
||||
id: str = Field(default_factory=lambda: f"voice_{uuid.uuid4().hex[:8]}")
|
||||
name: str = Field(description="Human-readable voice name")
|
||||
language: str = Field(default="en-US", description="Language code (e.g., en-US, es-ES)")
|
||||
@@ -48,23 +53,23 @@ class VoiceProfile(BaseModel):
|
||||
class VoiceLibrary:
|
||||
"""
|
||||
Voice library for managing TTS voice profiles.
|
||||
|
||||
|
||||
Allows admin to add, configure, and organize voice profiles for different
|
||||
agents, contexts, or user preferences.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._voices: dict[str, VoiceProfile] = {}
|
||||
self._default_voice_id: str | None = None
|
||||
logger.info("VoiceLibrary initialized")
|
||||
|
||||
|
||||
def add_voice(self, profile: VoiceProfile) -> str:
|
||||
"""
|
||||
Add a voice profile to the library.
|
||||
|
||||
|
||||
Args:
|
||||
profile: Voice profile to add.
|
||||
|
||||
|
||||
Returns:
|
||||
Voice ID.
|
||||
"""
|
||||
@@ -73,14 +78,14 @@ class VoiceLibrary:
|
||||
self._default_voice_id = profile.id
|
||||
logger.info("Voice added", extra={"voice_id": profile.id, "name": profile.name})
|
||||
return profile.id
|
||||
|
||||
|
||||
def remove_voice(self, voice_id: str) -> bool:
|
||||
"""
|
||||
Remove a voice profile from the library.
|
||||
|
||||
|
||||
Args:
|
||||
voice_id: ID of voice to remove.
|
||||
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found.
|
||||
"""
|
||||
@@ -91,11 +96,11 @@ class VoiceLibrary:
|
||||
logger.info("Voice removed", extra={"voice_id": voice_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_voice(self, voice_id: str) -> VoiceProfile | None:
|
||||
"""Get a voice profile by ID."""
|
||||
return self._voices.get(voice_id)
|
||||
|
||||
|
||||
def list_voices(
|
||||
self,
|
||||
language: str | None = None,
|
||||
@@ -104,33 +109,33 @@ class VoiceLibrary:
|
||||
) -> list[VoiceProfile]:
|
||||
"""
|
||||
List voice profiles with optional filtering.
|
||||
|
||||
|
||||
Args:
|
||||
language: Filter by language code.
|
||||
gender: Filter by gender.
|
||||
style: Filter by style.
|
||||
|
||||
|
||||
Returns:
|
||||
List of matching voice profiles.
|
||||
"""
|
||||
voices = list(self._voices.values())
|
||||
|
||||
|
||||
if language:
|
||||
voices = [v for v in voices if v.language == language]
|
||||
if gender:
|
||||
voices = [v for v in voices if v.gender == gender]
|
||||
if style:
|
||||
voices = [v for v in voices if v.style == style]
|
||||
|
||||
|
||||
return voices
|
||||
|
||||
|
||||
def set_default_voice(self, voice_id: str) -> bool:
|
||||
"""
|
||||
Set the default voice for the library.
|
||||
|
||||
|
||||
Args:
|
||||
voice_id: ID of voice to set as default.
|
||||
|
||||
|
||||
Returns:
|
||||
True if set, False if voice not found.
|
||||
"""
|
||||
@@ -139,32 +144,32 @@ class VoiceLibrary:
|
||||
logger.info("Default voice set", extra={"voice_id": voice_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_default_voice(self) -> VoiceProfile | None:
|
||||
"""Get the default voice profile."""
|
||||
if self._default_voice_id:
|
||||
return self._voices.get(self._default_voice_id)
|
||||
return None
|
||||
|
||||
|
||||
def update_voice(self, voice_id: str, updates: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update a voice profile.
|
||||
|
||||
|
||||
Args:
|
||||
voice_id: ID of voice to update.
|
||||
updates: Dictionary of fields to update.
|
||||
|
||||
|
||||
Returns:
|
||||
True if updated, False if not found.
|
||||
"""
|
||||
if voice_id not in self._voices:
|
||||
return False
|
||||
|
||||
|
||||
voice = self._voices[voice_id]
|
||||
for key, value in updates.items():
|
||||
if hasattr(voice, key):
|
||||
setattr(voice, key, value)
|
||||
|
||||
|
||||
logger.info("Voice updated", extra={"voice_id": voice_id, "updates": list(updates.keys())})
|
||||
return True
|
||||
|
||||
@@ -172,14 +177,14 @@ class VoiceLibrary:
|
||||
class VoiceInterface(InterfaceAdapter):
|
||||
"""
|
||||
Voice interface adapter for speech interaction.
|
||||
|
||||
|
||||
Handles:
|
||||
- Speech-to-text (STT) for user input
|
||||
- Text-to-speech (TTS) for system output
|
||||
- Voice activity detection
|
||||
- Noise cancellation
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "voice",
|
||||
@@ -211,7 +216,7 @@ class VoiceInterface(InterfaceAdapter):
|
||||
"VoiceInterface initialized",
|
||||
extra={"stt_provider": stt_provider, "tts_provider": tts_provider}
|
||||
)
|
||||
|
||||
|
||||
def capabilities(self) -> InterfaceCapabilities:
|
||||
"""Return voice interface capabilities."""
|
||||
return InterfaceCapabilities(
|
||||
@@ -222,18 +227,18 @@ class VoiceInterface(InterfaceAdapter):
|
||||
latency_ms=200.0, # Typical voice latency
|
||||
max_concurrent_sessions=10,
|
||||
)
|
||||
|
||||
|
||||
async def send(self, message: InterfaceMessage) -> None:
|
||||
"""
|
||||
Send voice output (text-to-speech).
|
||||
|
||||
|
||||
Args:
|
||||
message: Message with text content to synthesize.
|
||||
"""
|
||||
if not self.validate_message(message):
|
||||
logger.warning("Invalid message for voice interface", extra={"modality": message.modality})
|
||||
return
|
||||
|
||||
|
||||
# Get voice profile
|
||||
voice_id = message.metadata.get("voice_id", self._active_voice_id)
|
||||
voice = None
|
||||
@@ -241,7 +246,7 @@ class VoiceInterface(InterfaceAdapter):
|
||||
voice = self.voice_library.get_voice(voice_id)
|
||||
if not voice:
|
||||
voice = self.voice_library.get_default_voice()
|
||||
|
||||
|
||||
text = message.content if isinstance(message.content, str) else str(message.content)
|
||||
voice_id = voice.id if voice else None
|
||||
if self._tts_adapter is not None:
|
||||
@@ -260,14 +265,14 @@ class VoiceInterface(InterfaceAdapter):
|
||||
"TTS synthesis (stub; inject tts_adapter for ElevenLabs, Azure, etc.)",
|
||||
extra={"text_length": len(text), "voice_id": voice_id, "provider": self.tts_provider},
|
||||
)
|
||||
|
||||
|
||||
async def receive(self, timeout_seconds: float | None = None) -> InterfaceMessage | None:
|
||||
"""
|
||||
Receive voice input (speech-to-text).
|
||||
|
||||
|
||||
Args:
|
||||
timeout_seconds: Optional timeout for listening.
|
||||
|
||||
|
||||
Returns:
|
||||
Message with transcribed text or None if timeout.
|
||||
"""
|
||||
@@ -285,14 +290,14 @@ class VoiceInterface(InterfaceAdapter):
|
||||
except Exception as e:
|
||||
logger.exception("STT adapter failed", extra={"error": str(e)})
|
||||
return None
|
||||
|
||||
|
||||
def set_active_voice(self, voice_id: str) -> bool:
|
||||
"""
|
||||
Set the active voice for this interface session.
|
||||
|
||||
|
||||
Args:
|
||||
voice_id: ID of voice to use.
|
||||
|
||||
|
||||
Returns:
|
||||
True if voice exists, False otherwise.
|
||||
"""
|
||||
@@ -301,15 +306,15 @@ class VoiceInterface(InterfaceAdapter):
|
||||
logger.info("Active voice set", extra={"voice_id": voice_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _synthesize_speech(self, text: str, voice: VoiceProfile | None) -> bytes:
|
||||
"""
|
||||
Synthesize speech from text (to be implemented with actual provider).
|
||||
|
||||
|
||||
Args:
|
||||
text: Text to synthesize.
|
||||
voice: Voice profile to use.
|
||||
|
||||
|
||||
Returns:
|
||||
Audio data as bytes.
|
||||
"""
|
||||
@@ -319,14 +324,14 @@ class VoiceInterface(InterfaceAdapter):
|
||||
# - azure: Use Azure Cognitive Services
|
||||
# - google: Use Google Cloud TTS
|
||||
raise NotImplementedError("TTS provider integration required")
|
||||
|
||||
|
||||
async def _transcribe_speech(self, audio_data: bytes) -> str:
|
||||
"""
|
||||
Transcribe speech to text (to be implemented with actual provider).
|
||||
|
||||
|
||||
Args:
|
||||
audio_data: Audio data to transcribe.
|
||||
|
||||
|
||||
Returns:
|
||||
Transcribed text.
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Manufacturing Authority Add-On: sovereign validation layer for physical-world manufacturing."""
|
||||
|
||||
from fusionagi.maa.gap_detection import GapClass, GapReport, check_gaps
|
||||
from fusionagi.maa.gate import MAAGate
|
||||
from fusionagi.maa.schemas.mpc import ManufacturingProofCertificate, MPCId
|
||||
from fusionagi.maa.gap_detection import check_gaps, GapReport, GapClass
|
||||
|
||||
__all__ = [
|
||||
"MAAGate",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.maa.schemas.mpc import ManufacturingProofCertificate
|
||||
from fusionagi.maa.gap_detection import GapReport
|
||||
from fusionagi.maa.schemas.mpc import ManufacturingProofCertificate
|
||||
|
||||
|
||||
def export_mpc_for_audit(cert: ManufacturingProofCertificate) -> dict[str, Any]:
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.maa.gap_detection import check_gaps, GapReport
|
||||
from fusionagi.maa.layers.mpc_authority import MPCAuthority
|
||||
from fusionagi.maa.layers.dlt_engine import DLTEngine
|
||||
from fusionagi._logger import logger
|
||||
|
||||
from fusionagi.maa.gap_detection import GapReport, check_gaps
|
||||
from fusionagi.maa.layers.dlt_engine import DLTEngine
|
||||
from fusionagi.maa.layers.mpc_authority import MPCAuthority
|
||||
|
||||
# Default manufacturing tool names that require MPC
|
||||
DEFAULT_MANUFACTURING_TOOLS = frozenset({"cnc_emit", "am_slice", "machine_bind"})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""MAA layers: DLT, intent, geometry, physics, process, machine, toolpath, MPC."""
|
||||
|
||||
from fusionagi.maa.layers.dlt_engine import DLTEngine
|
||||
from fusionagi.maa.layers.mpc_authority import MPCAuthority
|
||||
from fusionagi.maa.layers.intent_engine import IntentEngine
|
||||
from fusionagi.maa.layers.geometry_kernel import GeometryAuthorityInterface, InMemoryGeometryKernel
|
||||
from fusionagi.maa.layers.intent_engine import IntentEngine
|
||||
from fusionagi.maa.layers.machine_binding import MachineBinding, MachineProfile
|
||||
from fusionagi.maa.layers.mpc_authority import MPCAuthority
|
||||
from fusionagi.maa.layers.physics_authority import PhysicsAuthorityInterface, StubPhysicsAuthority
|
||||
from fusionagi.maa.layers.process_authority import ProcessAuthority
|
||||
from fusionagi.maa.layers.machine_binding import MachineBinding, MachineProfile
|
||||
from fusionagi.maa.layers.toolpath_engine import ToolpathEngine, ToolpathArtifact
|
||||
from fusionagi.maa.layers.toolpath_engine import ToolpathArtifact, ToolpathEngine
|
||||
|
||||
__all__ = [
|
||||
"DLTEngine",
|
||||
|
||||
@@ -10,8 +10,13 @@ import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.maa.schemas.intent import EngineeringIntentGraph, IntentNode, LoadCase, RequirementType
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.maa.schemas.intent import (
|
||||
EngineeringIntentGraph,
|
||||
IntentNode,
|
||||
LoadCase,
|
||||
RequirementType,
|
||||
)
|
||||
|
||||
|
||||
class IntentIncompleteError(Exception):
|
||||
@@ -25,7 +30,7 @@ class IntentIncompleteError(Exception):
|
||||
class IntentEngine:
|
||||
"""
|
||||
Intent decomposition, requirement typing, and load case enumeration.
|
||||
|
||||
|
||||
Features:
|
||||
- Pattern-based requirement extraction from natural language
|
||||
- Automatic requirement type classification
|
||||
@@ -101,7 +106,7 @@ class IntentEngine:
|
||||
def __init__(self, llm_adapter: Any | None = None):
|
||||
"""
|
||||
Initialize the IntentEngine.
|
||||
|
||||
|
||||
Args:
|
||||
llm_adapter: Optional LLM adapter for enhanced natural language processing.
|
||||
"""
|
||||
@@ -117,33 +122,33 @@ class IntentEngine:
|
||||
) -> EngineeringIntentGraph:
|
||||
"""
|
||||
Formalize engineering intent from natural language and file references.
|
||||
|
||||
|
||||
Args:
|
||||
intent_id: Unique identifier for this intent.
|
||||
natural_language: Natural language description of requirements.
|
||||
file_refs: References to CAD files, specifications, etc.
|
||||
metadata: Additional metadata.
|
||||
use_llm: Whether to use LLM for enhanced processing (if available).
|
||||
|
||||
|
||||
Returns:
|
||||
EngineeringIntentGraph with extracted requirements.
|
||||
|
||||
|
||||
Raises:
|
||||
IntentIncompleteError: If required information is missing.
|
||||
"""
|
||||
if not intent_id:
|
||||
raise IntentIncompleteError("intent_id required", ["intent_id"])
|
||||
|
||||
|
||||
if not natural_language and not file_refs:
|
||||
raise IntentIncompleteError(
|
||||
"At least one of natural_language or file_refs required",
|
||||
["natural_language", "file_refs"],
|
||||
)
|
||||
|
||||
|
||||
nodes: list[IntentNode] = []
|
||||
load_cases: list[LoadCase] = []
|
||||
environmental_bounds: dict[str, Any] = {}
|
||||
|
||||
|
||||
# Process natural language if provided
|
||||
if natural_language:
|
||||
# Use LLM if available and requested
|
||||
@@ -151,13 +156,13 @@ class IntentEngine:
|
||||
llm_result = self._formalize_with_llm(intent_id, natural_language)
|
||||
if llm_result:
|
||||
return llm_result
|
||||
|
||||
|
||||
# Fall back to pattern-based extraction
|
||||
extracted = self._extract_requirements(intent_id, natural_language)
|
||||
nodes.extend(extracted["nodes"])
|
||||
load_cases.extend(extracted["load_cases"])
|
||||
environmental_bounds.update(extracted["environmental_bounds"])
|
||||
|
||||
|
||||
# Process file references
|
||||
if file_refs:
|
||||
for ref in file_refs:
|
||||
@@ -169,7 +174,7 @@ class IntentEngine:
|
||||
metadata={"file_ref": ref},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# If no nodes were extracted, create a general requirement
|
||||
if not nodes and natural_language:
|
||||
nodes.append(
|
||||
@@ -179,7 +184,7 @@ class IntentEngine:
|
||||
description=natural_language[:500],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Intent formalized",
|
||||
extra={
|
||||
@@ -188,7 +193,7 @@ class IntentEngine:
|
||||
"num_load_cases": len(load_cases),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return EngineeringIntentGraph(
|
||||
intent_id=intent_id,
|
||||
nodes=nodes,
|
||||
@@ -204,24 +209,24 @@ class IntentEngine:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Extract requirements from text using pattern matching.
|
||||
|
||||
|
||||
Returns dict with nodes, load_cases, and environmental_bounds.
|
||||
"""
|
||||
nodes: list[IntentNode] = []
|
||||
load_cases: list[LoadCase] = []
|
||||
environmental_bounds: dict[str, Any] = {}
|
||||
|
||||
|
||||
# Split into sentences for processing
|
||||
sentences = re.split(r'[.!?]+', text)
|
||||
|
||||
|
||||
node_counter = 0
|
||||
load_case_counter = 0
|
||||
|
||||
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if not sentence:
|
||||
continue
|
||||
|
||||
|
||||
# Check for dimensional requirements
|
||||
for pattern in self.DIMENSIONAL_PATTERNS:
|
||||
if re.search(pattern, sentence, re.IGNORECASE):
|
||||
@@ -235,7 +240,7 @@ class IntentEngine:
|
||||
)
|
||||
node_counter += 1
|
||||
break
|
||||
|
||||
|
||||
# Check for load requirements
|
||||
for pattern in self.LOAD_PATTERNS:
|
||||
if re.search(pattern, sentence, re.IGNORECASE):
|
||||
@@ -249,7 +254,7 @@ class IntentEngine:
|
||||
)
|
||||
node_counter += 1
|
||||
break
|
||||
|
||||
|
||||
# Check for environmental requirements
|
||||
for pattern in self.ENVIRONMENTAL_PATTERNS:
|
||||
match = re.search(pattern, sentence, re.IGNORECASE)
|
||||
@@ -263,14 +268,14 @@ class IntentEngine:
|
||||
)
|
||||
)
|
||||
node_counter += 1
|
||||
|
||||
|
||||
# Extract specific bounds if possible
|
||||
if "temperature" in sentence.lower():
|
||||
temp_match = re.search(r"(-?\d+(?:\.\d+)?)", sentence)
|
||||
if temp_match:
|
||||
environmental_bounds["temperature"] = float(temp_match.group(1))
|
||||
break
|
||||
|
||||
|
||||
# Check for process requirements
|
||||
for pattern in self.PROCESS_PATTERNS:
|
||||
if re.search(pattern, sentence, re.IGNORECASE):
|
||||
@@ -284,7 +289,7 @@ class IntentEngine:
|
||||
)
|
||||
node_counter += 1
|
||||
break
|
||||
|
||||
|
||||
# Check for load cases
|
||||
for pattern in self.LOAD_CASE_PATTERNS:
|
||||
match = re.search(pattern, sentence, re.IGNORECASE)
|
||||
@@ -299,7 +304,7 @@ class IntentEngine:
|
||||
)
|
||||
load_case_counter += 1
|
||||
break
|
||||
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"load_cases": load_cases,
|
||||
@@ -313,14 +318,14 @@ class IntentEngine:
|
||||
) -> EngineeringIntentGraph | None:
|
||||
"""
|
||||
Use LLM to extract structured requirements from natural language.
|
||||
|
||||
|
||||
Returns None if LLM processing fails (falls back to pattern matching).
|
||||
"""
|
||||
if not self._llm:
|
||||
return None
|
||||
|
||||
|
||||
import json
|
||||
|
||||
|
||||
prompt = f"""Extract engineering requirements from the following text.
|
||||
Return a JSON object with:
|
||||
- "nodes": list of requirements, each with:
|
||||
@@ -339,13 +344,13 @@ Return only valid JSON, no markdown."""
|
||||
{"role": "system", "content": "You are an engineering requirements extraction system."},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
|
||||
# Try structured output if available
|
||||
if hasattr(self._llm, "complete_structured"):
|
||||
result = self._llm.complete_structured(messages)
|
||||
if result:
|
||||
return self._parse_llm_result(intent_id, result)
|
||||
|
||||
|
||||
# Fall back to text completion
|
||||
raw = self._llm.complete(messages)
|
||||
if raw:
|
||||
@@ -356,10 +361,10 @@ Return only valid JSON, no markdown."""
|
||||
raw = raw[4:]
|
||||
result = json.loads(raw)
|
||||
return self._parse_llm_result(intent_id, result)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM formalization failed: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def _parse_llm_result(
|
||||
@@ -375,7 +380,7 @@ Return only valid JSON, no markdown."""
|
||||
req_type = RequirementType(req_type_str)
|
||||
except ValueError:
|
||||
req_type = RequirementType.OTHER
|
||||
|
||||
|
||||
nodes.append(
|
||||
IntentNode(
|
||||
node_id=f"{intent_id}_llm_{i}",
|
||||
@@ -384,7 +389,7 @@ Return only valid JSON, no markdown."""
|
||||
metadata={"source": "llm"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
load_cases = []
|
||||
for i, lc_data in enumerate(result.get("load_cases", [])):
|
||||
load_cases.append(
|
||||
@@ -394,9 +399,9 @@ Return only valid JSON, no markdown."""
|
||||
metadata={"source": "llm"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
environmental_bounds = result.get("environmental_bounds", {})
|
||||
|
||||
|
||||
return EngineeringIntentGraph(
|
||||
intent_id=intent_id,
|
||||
nodes=nodes,
|
||||
@@ -408,24 +413,24 @@ Return only valid JSON, no markdown."""
|
||||
def validate_completeness(self, graph: EngineeringIntentGraph) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Validate that an intent graph has sufficient information.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (is_complete, list_of_missing_items)
|
||||
"""
|
||||
missing = []
|
||||
|
||||
|
||||
if not graph.nodes:
|
||||
missing.append("No requirements extracted")
|
||||
|
||||
|
||||
# Check for at least one dimensional or load requirement for manufacturing
|
||||
has_dimensional = any(n.requirement_type == RequirementType.DIMENSIONAL for n in graph.nodes)
|
||||
has_load = any(n.requirement_type == RequirementType.LOAD for n in graph.nodes)
|
||||
|
||||
any(n.requirement_type == RequirementType.LOAD for n in graph.nodes)
|
||||
|
||||
if not has_dimensional:
|
||||
missing.append("No dimensional requirements specified")
|
||||
|
||||
|
||||
# Load cases are recommended but not required
|
||||
if not graph.load_cases:
|
||||
logger.info("No load cases specified for intent", extra={"intent_id": graph.intent_id})
|
||||
|
||||
|
||||
return len(missing) == 0, missing
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.maa.schemas.mpc import (
|
||||
DecisionLineageEntry,
|
||||
MachineDeclaration,
|
||||
ManufacturingProofCertificate,
|
||||
MPCId,
|
||||
DecisionLineageEntry,
|
||||
SimulationProof,
|
||||
ProcessJustification,
|
||||
MachineDeclaration,
|
||||
RiskRegisterEntry,
|
||||
SimulationProof,
|
||||
)
|
||||
from fusionagi.maa.versioning import VersionStore
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ Responsible for:
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
@@ -53,7 +52,7 @@ class PhysicsProof(BaseModel):
|
||||
class PhysicsAuthorityInterface(ABC):
|
||||
"""
|
||||
Abstract interface for physics validation.
|
||||
|
||||
|
||||
Governing equation selection, boundary condition enforcement, safety factor declaration,
|
||||
failure-mode completeness. Simulations are binding, not illustrative.
|
||||
"""
|
||||
@@ -148,7 +147,7 @@ class LoadCaseResult:
|
||||
class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
"""
|
||||
Physics validation authority with actual validation logic.
|
||||
|
||||
|
||||
Features:
|
||||
- Material property validation
|
||||
- Load case analysis
|
||||
@@ -165,7 +164,7 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
):
|
||||
"""
|
||||
Initialize the PhysicsAuthority.
|
||||
|
||||
|
||||
Args:
|
||||
required_safety_factor: Minimum required safety factor (default 2.0).
|
||||
material_db: Custom material properties database.
|
||||
@@ -188,7 +187,7 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
) -> PhysicsProof | None:
|
||||
"""
|
||||
Validate physics for a design.
|
||||
|
||||
|
||||
Args:
|
||||
design_ref: Reference to the design being validated.
|
||||
load_cases: List of load cases to validate against.
|
||||
@@ -196,28 +195,31 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
dimensions: Key dimensions for stress calculation.
|
||||
boundary_conditions: Boundary condition specification.
|
||||
**kwargs: Additional parameters.
|
||||
|
||||
|
||||
Returns:
|
||||
PhysicsProof if validation passes, None if physics underdefined.
|
||||
|
||||
|
||||
Raises:
|
||||
PhysicsUnderdefinedError: If critical data is missing.
|
||||
"""
|
||||
missing_data = []
|
||||
|
||||
|
||||
if not design_ref:
|
||||
missing_data.append("design_ref")
|
||||
if not material:
|
||||
missing_data.append("material")
|
||||
if not load_cases:
|
||||
missing_data.append("load_cases")
|
||||
|
||||
|
||||
if missing_data:
|
||||
raise PhysicsUnderdefinedError(
|
||||
f"Physics validation requires: {', '.join(missing_data)}",
|
||||
missing_data=missing_data,
|
||||
)
|
||||
|
||||
|
||||
assert material is not None # guarded by PhysicsUnderdefinedError above
|
||||
assert load_cases is not None # guarded by PhysicsUnderdefinedError above
|
||||
|
||||
# Get material properties
|
||||
mat_props = self._materials.get(material.lower().replace(" ", "_"))
|
||||
if not mat_props:
|
||||
@@ -225,44 +227,44 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
f"Unknown material: {material}. Available: {list(self._materials.keys())}",
|
||||
missing_data=["material_properties"],
|
||||
)
|
||||
|
||||
|
||||
# Validate each load case
|
||||
load_case_results: list[LoadCaseResult] = []
|
||||
min_safety_factor = float("inf")
|
||||
warnings: list[str] = []
|
||||
failure_modes_covered: list[str] = []
|
||||
|
||||
|
||||
for lc in load_cases:
|
||||
result = self._validate_load_case(lc, mat_props, dimensions)
|
||||
load_case_results.append(result)
|
||||
|
||||
|
||||
if result.safety_factor < min_safety_factor:
|
||||
min_safety_factor = result.safety_factor
|
||||
|
||||
|
||||
if not result.passed:
|
||||
warnings.append(
|
||||
f"Load case '{result.load_case_id}' failed: {result.failure_mode}"
|
||||
)
|
||||
|
||||
|
||||
# Track failure modes analyzed
|
||||
if result.failure_mode and result.failure_mode not in failure_modes_covered:
|
||||
failure_modes_covered.append(result.failure_mode)
|
||||
|
||||
|
||||
# Determine governing equations based on load types
|
||||
governing_equations = self._select_governing_equations(load_cases)
|
||||
|
||||
|
||||
# Check minimum required failure modes
|
||||
required_modes = ["yield_failure", "ultimate_failure"]
|
||||
for mode in required_modes:
|
||||
if mode not in failure_modes_covered:
|
||||
failure_modes_covered.append(mode) # Basic checks are always done
|
||||
|
||||
|
||||
# Generate proof ID based on inputs
|
||||
proof_hash = hashlib.sha256(
|
||||
f"{design_ref}:{material}:{load_cases}".encode()
|
||||
).hexdigest()[:16]
|
||||
proof_id = f"proof_{design_ref}_{proof_hash}"
|
||||
|
||||
|
||||
# Determine validation status
|
||||
validation_status = "validated"
|
||||
if min_safety_factor < self._required_sf:
|
||||
@@ -270,10 +272,10 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
warnings.append(
|
||||
f"Safety factor {min_safety_factor:.2f} < required {self._required_sf}"
|
||||
)
|
||||
|
||||
|
||||
if any(not r.passed for r in load_case_results):
|
||||
validation_status = "load_case_failure"
|
||||
|
||||
|
||||
logger.info(
|
||||
"Physics validation completed",
|
||||
extra={
|
||||
@@ -284,7 +286,7 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
"num_load_cases": len(load_cases),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return PhysicsProof(
|
||||
proof_id=proof_id,
|
||||
governing_equations=governing_equations,
|
||||
@@ -317,25 +319,25 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
) -> LoadCaseResult:
|
||||
"""Validate a single load case."""
|
||||
lc_id = load_case.get("id", str(uuid.uuid4())[:8])
|
||||
|
||||
|
||||
# Extract load parameters
|
||||
force_n = load_case.get("force_n", 0)
|
||||
moment_nm = load_case.get("moment_nm", 0)
|
||||
pressure_mpa = load_case.get("pressure_mpa", 0)
|
||||
temperature_c = load_case.get("temperature_c", 25)
|
||||
|
||||
|
||||
# Get material limits
|
||||
yield_strength = mat_props.get("yield_strength_mpa", 100)
|
||||
ultimate_strength = mat_props.get("ultimate_strength_mpa", 150)
|
||||
max_temp = mat_props.get("max_service_temp_c", 100)
|
||||
|
||||
|
||||
# Calculate stress (simplified - assumes basic geometry)
|
||||
area_mm2 = 100.0 # Default cross-sectional area
|
||||
if dimensions:
|
||||
width = dimensions.get("width_mm", 10)
|
||||
height = dimensions.get("height_mm", 10)
|
||||
area_mm2 = width * height
|
||||
|
||||
|
||||
# Basic stress calculation
|
||||
axial_stress = force_n / area_mm2 if area_mm2 > 0 else 0
|
||||
bending_stress = 0
|
||||
@@ -346,24 +348,24 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
c = height / 2
|
||||
i = width * (height ** 3) / 12
|
||||
bending_stress = (moment_nm * 1000 * c) / i if i > 0 else 0
|
||||
|
||||
|
||||
# Combined stress (von Mises simplified for 1D)
|
||||
max_stress = abs(axial_stress) + abs(bending_stress) + pressure_mpa
|
||||
|
||||
|
||||
# Calculate safety factors
|
||||
yield_sf = yield_strength / max_stress if max_stress > 0 else float("inf")
|
||||
ultimate_sf = ultimate_strength / max_stress if max_stress > 0 else float("inf")
|
||||
|
||||
|
||||
# Check temperature limits
|
||||
temp_ok = temperature_c <= max_temp
|
||||
|
||||
|
||||
# Determine if load case passes
|
||||
passed = (
|
||||
yield_sf >= self._required_sf
|
||||
and ultimate_sf >= self._required_sf
|
||||
and temp_ok
|
||||
)
|
||||
|
||||
|
||||
failure_mode = None
|
||||
if yield_sf < self._required_sf:
|
||||
failure_mode = "yield_failure"
|
||||
@@ -371,7 +373,7 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
failure_mode = "ultimate_failure"
|
||||
elif not temp_ok:
|
||||
failure_mode = "thermal_failure"
|
||||
|
||||
|
||||
return LoadCaseResult(
|
||||
load_case_id=lc_id,
|
||||
max_stress_mpa=max_stress,
|
||||
@@ -390,13 +392,13 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
def _select_governing_equations(self, load_cases: list[dict[str, Any]]) -> str:
|
||||
"""Select appropriate governing equations based on load types."""
|
||||
equations = []
|
||||
|
||||
|
||||
# Check load types
|
||||
has_static = any(lc.get("type") == "static" or lc.get("force_n") for lc in load_cases)
|
||||
has_thermal = any(lc.get("temperature_c") for lc in load_cases)
|
||||
has_dynamic = any(lc.get("type") == "dynamic" or lc.get("frequency_hz") for lc in load_cases)
|
||||
has_pressure = any(lc.get("pressure_mpa") for lc in load_cases)
|
||||
|
||||
|
||||
if has_static:
|
||||
equations.append("Linear elasticity (Hooke's Law)")
|
||||
if has_thermal:
|
||||
@@ -405,10 +407,10 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
equations.append("Modal analysis (eigenvalue)")
|
||||
if has_pressure:
|
||||
equations.append("Pressure vessel (hoop stress)")
|
||||
|
||||
|
||||
if not equations:
|
||||
equations.append("Linear elasticity (default)")
|
||||
|
||||
|
||||
return "; ".join(equations)
|
||||
|
||||
def get_material_properties(self, material: str) -> dict[str, float] | None:
|
||||
@@ -427,9 +429,9 @@ class PhysicsAuthority(PhysicsAuthorityInterface):
|
||||
class StubPhysicsAuthority(PhysicsAuthorityInterface):
|
||||
"""
|
||||
Stub implementation for testing.
|
||||
|
||||
|
||||
Returns a minimal proof if design_ref present; else raises PhysicsUnderdefinedError.
|
||||
|
||||
|
||||
Note: This is a stub for testing. Use PhysicsAuthority for real validation.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""MAA schemas: MPC, DLT, intent."""
|
||||
|
||||
from fusionagi.maa.schemas.dlt import DLTContract, DLTFamily, DLTNode
|
||||
from fusionagi.maa.schemas.intent import (
|
||||
EngineeringIntentGraph,
|
||||
IntentNode,
|
||||
LoadCase,
|
||||
RequirementType,
|
||||
)
|
||||
from fusionagi.maa.schemas.mpc import ManufacturingProofCertificate, MPCId
|
||||
from fusionagi.maa.schemas.dlt import DLTNode, DLTContract, DLTFamily
|
||||
from fusionagi.maa.schemas.intent import EngineeringIntentGraph, IntentNode, LoadCase, RequirementType
|
||||
|
||||
__all__ = [
|
||||
"ManufacturingProofCertificate",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Manufacturing Proof Certificate schema: decision lineage, simulation proof, process, machine, risk."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -6,15 +6,14 @@ These tools generate actual manufacturing instructions:
|
||||
- machine_bind: Binds a design to a specific machine with capability validation
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now_iso
|
||||
from fusionagi.tools.registry import ToolDef
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class GCodeOutput(BaseModel):
|
||||
@@ -55,7 +54,7 @@ class MachineBindOutput(BaseModel):
|
||||
def _generate_gcode_header(machine_id: str, mpc_id: str) -> list[str]:
|
||||
"""Generate standard G-code header."""
|
||||
return [
|
||||
f"; G-code generated by FusionAGI MAA",
|
||||
"; G-code generated by FusionAGI MAA",
|
||||
f"; MPC: {mpc_id}",
|
||||
f"; Machine: {machine_id}",
|
||||
f"; Generated: {utc_now_iso()}",
|
||||
@@ -81,17 +80,17 @@ def _generate_gcode_footer() -> list[str]:
|
||||
def _generate_toolpath_gcode(toolpath_ref: str) -> list[str]:
|
||||
"""
|
||||
Generate G-code from a toolpath reference.
|
||||
|
||||
|
||||
In a real implementation, this would:
|
||||
1. Load the toolpath data from storage
|
||||
2. Convert toolpath segments to G-code commands
|
||||
3. Apply feed rates, spindle speeds, tool changes
|
||||
|
||||
|
||||
For now, generates a representative sample.
|
||||
"""
|
||||
# Parse toolpath reference for parameters
|
||||
# Format expected: "toolpath_{type}_{id}" or custom format
|
||||
|
||||
|
||||
gcode_lines = [
|
||||
"; Toolpath: " + toolpath_ref,
|
||||
"",
|
||||
@@ -106,7 +105,7 @@ def _generate_toolpath_gcode(toolpath_ref: str) -> list[str]:
|
||||
"",
|
||||
"; Begin cutting operations",
|
||||
]
|
||||
|
||||
|
||||
# Generate sample toolpath movements
|
||||
# In production, these would come from the actual toolpath data
|
||||
sample_moves = [
|
||||
@@ -117,21 +116,21 @@ def _generate_toolpath_gcode(toolpath_ref: str) -> list[str]:
|
||||
"G1 Y0 ; Return Y",
|
||||
"G0 Z5.0 ; Retract",
|
||||
]
|
||||
|
||||
|
||||
gcode_lines.extend(sample_moves)
|
||||
|
||||
|
||||
return gcode_lines
|
||||
|
||||
|
||||
def _cnc_emit_impl(mpc_id: str, machine_id: str, toolpath_ref: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate CNC G-code for a manufacturing operation.
|
||||
|
||||
|
||||
Args:
|
||||
mpc_id: Manufacturing Proof Certificate ID.
|
||||
machine_id: Target CNC machine identifier.
|
||||
toolpath_ref: Reference to toolpath data.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with G-code and metadata.
|
||||
"""
|
||||
@@ -139,15 +138,15 @@ def _cnc_emit_impl(mpc_id: str, machine_id: str, toolpath_ref: str) -> dict[str,
|
||||
"CNC emit started",
|
||||
extra={"mpc_id": mpc_id, "machine_id": machine_id, "toolpath_ref": toolpath_ref},
|
||||
)
|
||||
|
||||
|
||||
# Build G-code
|
||||
gcode_lines = []
|
||||
gcode_lines.extend(_generate_gcode_header(machine_id, mpc_id))
|
||||
gcode_lines.extend(_generate_toolpath_gcode(toolpath_ref))
|
||||
gcode_lines.extend(_generate_gcode_footer())
|
||||
|
||||
|
||||
gcode = "\n".join(gcode_lines)
|
||||
|
||||
|
||||
output = GCodeOutput(
|
||||
mpc_id=mpc_id,
|
||||
machine_id=machine_id,
|
||||
@@ -159,24 +158,24 @@ def _cnc_emit_impl(mpc_id: str, machine_id: str, toolpath_ref: str) -> dict[str,
|
||||
"tool_changes": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"CNC emit completed",
|
||||
extra={"mpc_id": mpc_id, "line_count": len(gcode_lines)},
|
||||
)
|
||||
|
||||
|
||||
return output.model_dump()
|
||||
|
||||
|
||||
def _am_slice_impl(mpc_id: str, machine_id: str, slice_ref: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate AM slice instructions for additive manufacturing.
|
||||
|
||||
|
||||
Args:
|
||||
mpc_id: Manufacturing Proof Certificate ID.
|
||||
machine_id: Target AM machine identifier.
|
||||
slice_ref: Reference to slice/geometry data.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with slice data and metadata.
|
||||
"""
|
||||
@@ -184,18 +183,18 @@ def _am_slice_impl(mpc_id: str, machine_id: str, slice_ref: str) -> dict[str, An
|
||||
"AM slice started",
|
||||
extra={"mpc_id": mpc_id, "machine_id": machine_id, "slice_ref": slice_ref},
|
||||
)
|
||||
|
||||
|
||||
# In production, this would:
|
||||
# 1. Load the geometry from slice_ref
|
||||
# 2. Apply slicing algorithm with machine-specific parameters
|
||||
# 3. Generate layer-by-layer toolpaths
|
||||
# 4. Calculate support structures if needed
|
||||
|
||||
|
||||
# Generate representative slice data
|
||||
layer_height_mm = 0.2
|
||||
num_layers = 100 # Would be calculated from geometry height
|
||||
|
||||
slice_data = {
|
||||
|
||||
slice_data: dict[str, Any] = {
|
||||
"format_version": "1.0",
|
||||
"machine_profile": machine_id,
|
||||
"settings": {
|
||||
@@ -229,7 +228,7 @@ def _am_slice_impl(mpc_id: str, machine_id: str, slice_ref: str) -> dict[str, An
|
||||
"bounding_box_mm": {"x": 50, "y": 50, "z": num_layers * layer_height_mm},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
output = SliceOutput(
|
||||
mpc_id=mpc_id,
|
||||
machine_id=machine_id,
|
||||
@@ -241,23 +240,23 @@ def _am_slice_impl(mpc_id: str, machine_id: str, slice_ref: str) -> dict[str, An
|
||||
"estimated_time_minutes": slice_data["statistics"]["estimated_time_minutes"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"AM slice completed",
|
||||
extra={"mpc_id": mpc_id, "layer_count": num_layers},
|
||||
)
|
||||
|
||||
|
||||
return output.model_dump()
|
||||
|
||||
|
||||
def _machine_bind_impl(mpc_id: str, machine_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Bind a design (via MPC) to a specific machine.
|
||||
|
||||
|
||||
Args:
|
||||
mpc_id: Manufacturing Proof Certificate ID.
|
||||
machine_id: Target machine identifier.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with binding confirmation and validation results.
|
||||
"""
|
||||
@@ -265,16 +264,16 @@ def _machine_bind_impl(mpc_id: str, machine_id: str) -> dict[str, Any]:
|
||||
"Machine bind started",
|
||||
extra={"mpc_id": mpc_id, "machine_id": machine_id},
|
||||
)
|
||||
|
||||
|
||||
# In production, this would:
|
||||
# 1. Load the MPC to get design requirements
|
||||
# 2. Load the machine profile
|
||||
# 3. Validate machine capabilities against design requirements
|
||||
# 4. Check envelope, tolerances, material compatibility
|
||||
# 5. Record the binding in the system
|
||||
|
||||
|
||||
binding_id = f"binding_{mpc_id}_{machine_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
# Simulate capability validation
|
||||
capabilities_validated = True
|
||||
validation_results = {
|
||||
@@ -283,7 +282,7 @@ def _machine_bind_impl(mpc_id: str, machine_id: str) -> dict[str, Any]:
|
||||
"material_check": {"status": "pass", "details": "Machine supports specified material"},
|
||||
"feature_check": {"status": "pass", "details": "Machine can produce required features"},
|
||||
}
|
||||
|
||||
|
||||
output = MachineBindOutput(
|
||||
mpc_id=mpc_id,
|
||||
machine_id=machine_id,
|
||||
@@ -294,24 +293,24 @@ def _machine_bind_impl(mpc_id: str, machine_id: str) -> dict[str, Any]:
|
||||
"validation_results": validation_results,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Machine bind completed",
|
||||
extra={"binding_id": binding_id, "validated": capabilities_validated},
|
||||
)
|
||||
|
||||
|
||||
return output.model_dump()
|
||||
|
||||
|
||||
def cnc_emit_tool() -> ToolDef:
|
||||
"""
|
||||
CNC G-code emission tool.
|
||||
|
||||
|
||||
Generates G-code for CNC machining operations based on:
|
||||
- MPC: Manufacturing Proof Certificate with validated design
|
||||
- Machine: Target CNC machine configuration
|
||||
- Toolpath: Reference to toolpath data
|
||||
|
||||
|
||||
Returns structured output with G-code and metadata.
|
||||
"""
|
||||
return ToolDef(
|
||||
@@ -336,13 +335,13 @@ def cnc_emit_tool() -> ToolDef:
|
||||
def am_slice_tool() -> ToolDef:
|
||||
"""
|
||||
AM slice instruction tool.
|
||||
|
||||
|
||||
Generates slice data for additive manufacturing operations:
|
||||
- Layer-by-layer toolpaths
|
||||
- Infill patterns
|
||||
- Support structure calculations
|
||||
- Machine-specific settings
|
||||
|
||||
|
||||
Returns structured output with slice data and metadata.
|
||||
"""
|
||||
return ToolDef(
|
||||
@@ -367,12 +366,12 @@ def am_slice_tool() -> ToolDef:
|
||||
def machine_bind_tool() -> ToolDef:
|
||||
"""
|
||||
Machine binding declaration tool.
|
||||
|
||||
|
||||
Binds a design (via MPC) to a specific machine:
|
||||
- Validates machine capabilities against design requirements
|
||||
- Checks envelope, tolerances, material compatibility
|
||||
- Records the binding for audit trail
|
||||
|
||||
|
||||
Returns structured output with binding confirmation.
|
||||
"""
|
||||
return ToolDef(
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
"""Memory system: working, episodic, reflective, semantic, procedural, trust, consolidation."""
|
||||
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
from fusionagi.memory.reflective import ReflectiveMemory
|
||||
from fusionagi.memory.semantic import SemanticMemory
|
||||
from fusionagi.memory.procedural import ProceduralMemory
|
||||
from fusionagi.memory.trust import TrustMemory
|
||||
from fusionagi.memory.consolidation import ConsolidationJob
|
||||
from fusionagi.memory.service import MemoryService, VectorMemory
|
||||
from fusionagi.memory.vector_pgvector import create_vector_memory_pgvector, VectorMemoryPgvector
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
from fusionagi.memory.postgres_backend import (
|
||||
MemoryBackend,
|
||||
InMemoryBackend,
|
||||
MemoryBackend,
|
||||
create_postgres_backend,
|
||||
)
|
||||
from fusionagi.memory.semantic_graph import SemanticGraphMemory
|
||||
from fusionagi.memory.sharding import Shard, shard_context
|
||||
from fusionagi.memory.procedural import ProceduralMemory
|
||||
from fusionagi.memory.reflective import ReflectiveMemory
|
||||
from fusionagi.memory.scratchpad import LatentScratchpad, ThoughtState
|
||||
from fusionagi.memory.semantic import SemanticMemory
|
||||
from fusionagi.memory.semantic_graph import SemanticGraphMemory
|
||||
from fusionagi.memory.service import MemoryService, VectorMemory
|
||||
from fusionagi.memory.sharding import Shard, shard_context
|
||||
from fusionagi.memory.trust import TrustMemory
|
||||
from fusionagi.memory.vector_pgvector import VectorMemoryPgvector, create_vector_memory_pgvector
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
|
||||
__all__ = [
|
||||
"WorkingMemory",
|
||||
|
||||
@@ -8,7 +8,7 @@ Episodic memory stores historical records of agent actions and outcomes:
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Callable, Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now_iso
|
||||
@@ -17,7 +17,7 @@ from fusionagi._time import utc_now_iso
|
||||
class EpisodicMemory:
|
||||
"""
|
||||
Append-only log of task and step outcomes.
|
||||
|
||||
|
||||
Features:
|
||||
- Time-stamped event logging
|
||||
- Query by task ID
|
||||
@@ -30,7 +30,7 @@ class EpisodicMemory:
|
||||
def __init__(self, max_entries: int = 10000) -> None:
|
||||
"""
|
||||
Initialize episodic memory.
|
||||
|
||||
|
||||
Args:
|
||||
max_entries: Maximum entries before oldest are archived/removed.
|
||||
"""
|
||||
@@ -48,19 +48,19 @@ class EpisodicMemory:
|
||||
) -> int:
|
||||
"""
|
||||
Append an episodic entry.
|
||||
|
||||
|
||||
Args:
|
||||
task_id: Task identifier this event belongs to.
|
||||
event: Event data dictionary.
|
||||
event_type: Optional event type for categorization (e.g., "step_done", "tool_call").
|
||||
|
||||
|
||||
Returns:
|
||||
Index of the appended entry.
|
||||
"""
|
||||
# Enforce size limits
|
||||
if len(self._entries) >= self._max_entries:
|
||||
self._archive_oldest(self._max_entries // 10)
|
||||
|
||||
|
||||
# Add metadata
|
||||
entry = {
|
||||
**event,
|
||||
@@ -68,21 +68,21 @@ class EpisodicMemory:
|
||||
"timestamp": event.get("timestamp", time.monotonic()),
|
||||
"datetime": event.get("datetime", utc_now_iso()),
|
||||
}
|
||||
|
||||
|
||||
if event_type:
|
||||
entry["event_type"] = event_type
|
||||
|
||||
|
||||
idx = len(self._entries)
|
||||
self._entries.append(entry)
|
||||
|
||||
|
||||
# Index by task
|
||||
self._by_task.setdefault(task_id, []).append(idx)
|
||||
|
||||
|
||||
# Index by type if provided
|
||||
etype = event_type or event.get("type") or event.get("event_type")
|
||||
if etype:
|
||||
self._by_type.setdefault(etype, []).append(idx)
|
||||
|
||||
|
||||
return idx
|
||||
|
||||
def get_by_task(self, task_id: str, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
@@ -111,7 +111,7 @@ class EpisodicMemory:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return entries within a time range (using monotonic timestamps).
|
||||
|
||||
|
||||
Args:
|
||||
start_timestamp: Start of range (inclusive).
|
||||
end_timestamp: End of range (inclusive).
|
||||
@@ -136,7 +136,7 @@ class EpisodicMemory:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query entries using a custom filter function.
|
||||
|
||||
|
||||
Args:
|
||||
filter_fn: Function that returns True for entries to include.
|
||||
limit: Maximum entries to return.
|
||||
@@ -152,26 +152,26 @@ class EpisodicMemory:
|
||||
def get_task_summary(self, task_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of episodes for a task.
|
||||
|
||||
|
||||
Returns statistics like count, first/last timestamps, event types.
|
||||
"""
|
||||
entries = self.get_by_task(task_id)
|
||||
if not entries:
|
||||
return {"task_id": task_id, "count": 0}
|
||||
|
||||
|
||||
event_types: dict[str, int] = {}
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
|
||||
for entry in entries:
|
||||
etype = entry.get("event_type") or entry.get("type") or "unknown"
|
||||
event_types[etype] = event_types.get(etype, 0) + 1
|
||||
|
||||
|
||||
if entry.get("success"):
|
||||
success_count += 1
|
||||
elif entry.get("error") or entry.get("success") is False:
|
||||
failure_count += 1
|
||||
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"count": len(entries),
|
||||
@@ -196,16 +196,16 @@ class EpisodicMemory:
|
||||
"""Archive/remove oldest entries to enforce size limits."""
|
||||
if count <= 0 or count >= len(self._entries):
|
||||
return
|
||||
|
||||
|
||||
logger.info(
|
||||
"Archiving episodic memory entries",
|
||||
extra={"count": count, "total": len(self._entries)},
|
||||
)
|
||||
|
||||
|
||||
# Remove oldest entries
|
||||
self._entries = self._entries[count:]
|
||||
self._archived_count += count
|
||||
|
||||
|
||||
# Rebuild indices (entries shifted)
|
||||
self._by_task = {}
|
||||
self._by_type = {}
|
||||
@@ -213,7 +213,7 @@ class EpisodicMemory:
|
||||
task_id = entry.get("task_id")
|
||||
if task_id:
|
||||
self._by_task.setdefault(task_id, []).append(idx)
|
||||
|
||||
|
||||
etype = entry.get("event_type") or entry.get("type")
|
||||
if etype:
|
||||
self._by_type.setdefault(etype, []).append(idx)
|
||||
|
||||
86
fusionagi/memory/gpu_search.py
Normal file
86
fusionagi/memory/gpu_search.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""GPU-accelerated semantic search for memory subsystems.
|
||||
|
||||
Provides vector similarity search using GPU-accelerated embeddings
|
||||
for SemanticGraphMemory and EpisodicMemory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
|
||||
|
||||
def semantic_search(
|
||||
query: str,
|
||||
units: list[AtomicSemanticUnit],
|
||||
top_k: int = 10,
|
||||
) -> list[tuple[AtomicSemanticUnit, float]]:
|
||||
"""Search atomic semantic units by vector similarity using GPU.
|
||||
|
||||
Args:
|
||||
query: Query text to search for.
|
||||
units: List of atomic semantic units to search within.
|
||||
top_k: Number of top results to return.
|
||||
|
||||
Returns:
|
||||
List of (unit, similarity_score) tuples sorted by score descending.
|
||||
"""
|
||||
if not units:
|
||||
return []
|
||||
|
||||
try:
|
||||
from fusionagi.gpu.tensor_similarity import nearest_neighbors
|
||||
|
||||
corpus = [u.content for u in units]
|
||||
results = nearest_neighbors([query], corpus, top_k=top_k)
|
||||
if not results or not results[0]:
|
||||
return []
|
||||
|
||||
return [(units[idx], score) for idx, score in results[0] if idx < len(units)]
|
||||
except ImportError:
|
||||
return _cpu_fallback_search(query, units, top_k)
|
||||
|
||||
|
||||
def _cpu_fallback_search(
|
||||
query: str,
|
||||
units: list[AtomicSemanticUnit],
|
||||
top_k: int,
|
||||
) -> list[tuple[AtomicSemanticUnit, float]]:
|
||||
"""CPU fallback: simple word-overlap similarity."""
|
||||
query_words = set(query.lower().split())
|
||||
scored: list[tuple[AtomicSemanticUnit, float]] = []
|
||||
|
||||
for unit in units:
|
||||
unit_words = set(unit.content.lower().split())
|
||||
if not unit_words:
|
||||
continue
|
||||
overlap = len(query_words & unit_words)
|
||||
score = overlap / max(len(query_words | unit_words), 1)
|
||||
scored.append((unit, score))
|
||||
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
def batch_embed_units(
|
||||
units: list[AtomicSemanticUnit],
|
||||
) -> Any:
|
||||
"""Embed a batch of atomic semantic units using GPU.
|
||||
|
||||
Args:
|
||||
units: Units to embed.
|
||||
|
||||
Returns:
|
||||
Embedding tensor (backend-specific type).
|
||||
"""
|
||||
try:
|
||||
from fusionagi.gpu.backend import get_backend
|
||||
|
||||
be = get_backend()
|
||||
texts = [u.content for u in units]
|
||||
return be.embed_texts(texts)
|
||||
except ImportError:
|
||||
logger.debug("GPU not available for batch embedding")
|
||||
return None
|
||||
@@ -100,7 +100,7 @@ class InMemoryBackend(MemoryBackend):
|
||||
def create_postgres_backend(connection_string: str) -> MemoryBackend | None:
|
||||
"""Create Postgres-backed MemoryBackend when psycopg is available."""
|
||||
try:
|
||||
import psycopg
|
||||
import psycopg # noqa: F401
|
||||
except ImportError:
|
||||
logger.debug("psycopg not installed; use pip install fusionagi[memory]")
|
||||
return None
|
||||
@@ -149,6 +149,7 @@ class PostgresMemoryBackend(MemoryBackend):
|
||||
retention_policy: str = "session",
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
@@ -165,6 +166,7 @@ class PostgresMemoryBackend(MemoryBackend):
|
||||
|
||||
def get(self, id: str) -> dict[str, Any] | None:
|
||||
import json
|
||||
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
@@ -196,6 +198,7 @@ class PostgresMemoryBackend(MemoryBackend):
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
import json
|
||||
|
||||
import psycopg
|
||||
|
||||
q = "SELECT id, tenant_id, user_id, session_id, type, content, metadata, retention_policy FROM memory_items WHERE tenant_id = %s"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Procedural memory: reusable skills/workflows for AGI."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.skill import Skill
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.skill import Skill
|
||||
|
||||
|
||||
class ProceduralMemory:
|
||||
|
||||
@@ -16,7 +16,7 @@ class ReflectiveMemory:
|
||||
|
||||
def get_lessons(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent lessons (copy)."""
|
||||
return [l.copy() for l in self._lessons[-limit:]]
|
||||
return [lesson.copy() for lesson in self._lessons[-limit:]]
|
||||
|
||||
def set_heuristic(self, key: str, value: Any) -> None:
|
||||
"""Set a heuristic (e.g. strategy hint)."""
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.atomic import (
|
||||
AtomicSemanticUnit,
|
||||
AtomicUnitType,
|
||||
SemanticRelation,
|
||||
)
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class SemanticGraphMemory:
|
||||
@@ -93,6 +92,46 @@ class SemanticGraphMemory:
|
||||
for r in relations:
|
||||
self.add_relation(r)
|
||||
|
||||
def semantic_search(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int = 10,
|
||||
) -> list[tuple[AtomicSemanticUnit, float]]:
|
||||
"""Search stored units by semantic similarity using GPU when available.
|
||||
|
||||
Args:
|
||||
query: Query text to search for.
|
||||
top_k: Number of top results to return.
|
||||
|
||||
Returns:
|
||||
List of (unit, similarity_score) tuples sorted by score descending.
|
||||
"""
|
||||
try:
|
||||
from fusionagi.memory.gpu_search import semantic_search
|
||||
|
||||
all_units = list(self._units.values())
|
||||
return semantic_search(query, all_units, top_k=top_k)
|
||||
except ImportError:
|
||||
return self._cpu_search(query, top_k)
|
||||
|
||||
def _cpu_search(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int,
|
||||
) -> list[tuple[AtomicSemanticUnit, float]]:
|
||||
"""CPU fallback: word-overlap similarity."""
|
||||
query_words = set(query.lower().split())
|
||||
scored: list[tuple[AtomicSemanticUnit, float]] = []
|
||||
for unit in self._units.values():
|
||||
unit_words = set(unit.content.lower().split())
|
||||
if not unit_words:
|
||||
continue
|
||||
overlap = len(query_words & unit_words)
|
||||
score = overlap / max(len(query_words | unit_words), 1)
|
||||
scored.append((unit, score))
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
def _evict_one(self) -> None:
|
||||
"""Evict oldest unit (simple FIFO on first key)."""
|
||||
if not self._units:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
from fusionagi.memory.semantic import SemanticMemory
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
|
||||
|
||||
def _scoped_key(tenant_id: str, user_id: str, base: str) -> str:
|
||||
|
||||
@@ -7,9 +7,9 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.memory.scratchpad import ThoughtState
|
||||
from fusionagi.reasoning.tot import ThoughtNode
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -45,7 +45,6 @@ class TrustMemory:
|
||||
return None
|
||||
if self._decay_enabled:
|
||||
# Simple decay: reduce confidence by 0.01 per day (placeholder)
|
||||
from datetime import timedelta
|
||||
age_days = (_utc_now() - e["created_at"]).total_seconds() / 86400
|
||||
e = dict(e)
|
||||
e["confidence"] = max(0.0, e["confidence"] - 0.01 * age_days)
|
||||
|
||||
@@ -15,14 +15,14 @@ def create_vector_memory_pgvector(
|
||||
Returns None if pgvector/database unavailable.
|
||||
"""
|
||||
try:
|
||||
import pgvector
|
||||
from pgvector.psycopg import register_vector
|
||||
import pgvector # noqa: F401
|
||||
from pgvector.psycopg import register_vector # noqa: F401
|
||||
except ImportError:
|
||||
logger.debug("pgvector not installed; use pip install fusionagi[vector]")
|
||||
return None
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
import psycopg # noqa: F401
|
||||
except ImportError:
|
||||
logger.debug("psycopg not installed; use pip install fusionagi[memory]")
|
||||
return None
|
||||
@@ -39,7 +39,7 @@ class VectorMemoryPgvector:
|
||||
table_name: str = "embeddings",
|
||||
dimension: int = 1536,
|
||||
) -> None:
|
||||
import pgvector
|
||||
import psycopg
|
||||
from pgvector.psycopg import register_vector
|
||||
|
||||
self._conn_str = connection_string
|
||||
@@ -64,6 +64,7 @@ class VectorMemoryPgvector:
|
||||
|
||||
def add(self, id: str, embedding: list[float], metadata: dict[str, Any] | None = None) -> None:
|
||||
import json
|
||||
|
||||
import psycopg
|
||||
from pgvector.psycopg import register_vector
|
||||
|
||||
@@ -82,6 +83,7 @@ class VectorMemoryPgvector:
|
||||
|
||||
def search(self, query_embedding: list[float], top_k: int = 10) -> list[dict[str, Any]]:
|
||||
import json
|
||||
|
||||
import psycopg
|
||||
from pgvector.psycopg import register_vector
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Working memory provides short-term storage for active tasks:
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterator
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now
|
||||
@@ -18,7 +18,7 @@ from fusionagi._time import utc_now
|
||||
class WorkingMemory:
|
||||
"""
|
||||
Short-term working memory per task/session.
|
||||
|
||||
|
||||
Features:
|
||||
- Key-value get/set operations
|
||||
- List append with automatic coercion
|
||||
@@ -30,7 +30,7 @@ class WorkingMemory:
|
||||
def __init__(self, max_entries_per_session: int = 1000) -> None:
|
||||
"""
|
||||
Initialize working memory.
|
||||
|
||||
|
||||
Args:
|
||||
max_entries_per_session: Maximum entries per session before oldest are removed.
|
||||
"""
|
||||
@@ -90,12 +90,12 @@ class WorkingMemory:
|
||||
def get_context_summary(self, session_id: str, max_items: int = 10) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of working memory for context injection.
|
||||
|
||||
|
||||
Useful for including relevant context in LLM prompts.
|
||||
"""
|
||||
session_data = self._store.get(session_id, {})
|
||||
summary = {}
|
||||
|
||||
|
||||
for key, value in list(session_data.items())[:max_items]:
|
||||
if isinstance(value, list):
|
||||
# For lists, include count and last few items
|
||||
@@ -113,10 +113,10 @@ class WorkingMemory:
|
||||
else:
|
||||
# For scalars, include the value (truncated if string)
|
||||
if isinstance(value, str) and len(value) > 200:
|
||||
summary[key] = value[:200] + "..."
|
||||
summary[key] = value[:200] + "..." # type: ignore[assignment]
|
||||
else:
|
||||
summary[key] = value
|
||||
|
||||
summary[key] = value # type: ignore[assignment]
|
||||
|
||||
return summary
|
||||
|
||||
def get_all(self, session_id: str) -> dict[str, Any]:
|
||||
@@ -142,7 +142,7 @@ class WorkingMemory:
|
||||
len(v) if isinstance(v, (list, dict)) else 1
|
||||
for v in session_data.values()
|
||||
)
|
||||
|
||||
|
||||
if total_items > self._max_entries:
|
||||
logger.warning(
|
||||
"Working memory size limit exceeded",
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
"""Multi-agent: parallel, delegation, pooling, coordinator, adversarial reviewer, consensus."""
|
||||
|
||||
from fusionagi.multi_agent.parallel import (
|
||||
execute_steps_parallel,
|
||||
execute_steps_parallel_wave,
|
||||
ParallelStepResult,
|
||||
from fusionagi.multi_agent.consensus import arbitrate, consensus_vote
|
||||
from fusionagi.multi_agent.consensus_engine import (
|
||||
CollectedClaim,
|
||||
collect_claims,
|
||||
run_consensus,
|
||||
)
|
||||
from fusionagi.multi_agent.pool import AgentPool, PooledExecutorRouter
|
||||
from fusionagi.multi_agent.supervisor import SupervisorAgent
|
||||
from fusionagi.multi_agent.coordinator import CoordinatorAgent
|
||||
from fusionagi.multi_agent.delegation import (
|
||||
delegate_sub_tasks,
|
||||
DelegationConfig,
|
||||
SubTask,
|
||||
SubTaskResult,
|
||||
delegate_sub_tasks,
|
||||
)
|
||||
from fusionagi.multi_agent.coordinator import CoordinatorAgent
|
||||
from fusionagi.multi_agent.consensus import consensus_vote, arbitrate
|
||||
from fusionagi.multi_agent.consensus_engine import (
|
||||
run_consensus,
|
||||
collect_claims,
|
||||
CollectedClaim,
|
||||
from fusionagi.multi_agent.parallel import (
|
||||
ParallelStepResult,
|
||||
execute_steps_parallel,
|
||||
execute_steps_parallel_wave,
|
||||
)
|
||||
from fusionagi.multi_agent.pool import AgentPool, PooledExecutorRouter
|
||||
from fusionagi.multi_agent.supervisor import SupervisorAgent
|
||||
|
||||
__all__ = [
|
||||
"execute_steps_parallel",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Any
|
||||
from collections import Counter
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
def consensus_vote(answers: list, key=None):
|
||||
if not answers:
|
||||
return None
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Consensus engine: claim collection, deduplication, conflict detection, scoring."""
|
||||
"""Consensus engine: claim collection, deduplication, conflict detection, scoring.
|
||||
|
||||
Supports GPU-accelerated deduplication when ``fusionagi[gpu]`` is installed;
|
||||
falls back to word-overlap heuristics otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput, HeadClaim
|
||||
from fusionagi.schemas.witness import AgreementMap
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.head import HeadId, HeadOutput
|
||||
from fusionagi.schemas.witness import AgreementMap
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,6 +61,16 @@ def _looks_contradictory(a: str, b: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _try_gpu_dedup(claims: list[str]) -> list[list[int]] | None:
|
||||
"""Attempt GPU-accelerated claim deduplication; return ``None`` if unavailable."""
|
||||
try:
|
||||
from fusionagi.gpu.tensor_similarity import deduplicate_claims
|
||||
|
||||
return deduplicate_claims(claims, threshold=0.85)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def collect_claims(outputs: list[HeadOutput]) -> list[CollectedClaim]:
|
||||
"""Flatten all head claims with source metadata."""
|
||||
collected: list[CollectedClaim] = []
|
||||
@@ -107,25 +121,48 @@ def run_consensus(
|
||||
collected = collect_claims(outputs)
|
||||
|
||||
# Group by similarity (merge near-duplicates)
|
||||
merged: list[CollectedClaim] = []
|
||||
# Try GPU-accelerated deduplication first; fall back to word-overlap
|
||||
gpu_groups = _try_gpu_dedup([c.claim_text for c in collected])
|
||||
|
||||
claim_groups: list[list[CollectedClaim]] = []
|
||||
used: set[int] = set()
|
||||
for i, ca in enumerate(collected):
|
||||
if i in used:
|
||||
continue
|
||||
group = [ca]
|
||||
used.add(i)
|
||||
for j, cb in enumerate(collected):
|
||||
if j in used:
|
||||
|
||||
if gpu_groups is not None:
|
||||
for group_indices in gpu_groups:
|
||||
filtered = [
|
||||
idx for idx in group_indices
|
||||
if idx not in used
|
||||
and not any(
|
||||
_looks_contradictory(collected[idx].claim_text, collected[other].claim_text)
|
||||
for other in group_indices if other != idx
|
||||
)
|
||||
]
|
||||
if not filtered:
|
||||
continue
|
||||
if _are_similar(ca.claim_text, cb.claim_text) and not _looks_contradictory(ca.claim_text, cb.claim_text):
|
||||
group.append(cb)
|
||||
used.add(j)
|
||||
# Aggregate: weighted avg confidence, combine heads
|
||||
claim_groups.append([collected[idx] for idx in filtered])
|
||||
used.update(filtered)
|
||||
else:
|
||||
for i, ca in enumerate(collected):
|
||||
if i in used:
|
||||
continue
|
||||
group = [ca]
|
||||
used.add(i)
|
||||
for j, cb in enumerate(collected):
|
||||
if j in used:
|
||||
continue
|
||||
if _are_similar(ca.claim_text, cb.claim_text) and not _looks_contradictory(ca.claim_text, cb.claim_text):
|
||||
group.append(cb)
|
||||
used.add(j)
|
||||
claim_groups.append(group)
|
||||
|
||||
# Aggregate: weighted avg confidence, combine heads
|
||||
merged: list[CollectedClaim] = []
|
||||
for group in claim_groups:
|
||||
if len(group) == 1:
|
||||
c = group[0]
|
||||
score = c.confidence * weights.get(c.head_id, 1.0)
|
||||
if c.evidence_count > 0:
|
||||
score *= 1.1 # boost for citations
|
||||
score *= 1.1
|
||||
merged.append(
|
||||
CollectedClaim(
|
||||
claim_text=c.claim_text,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
||||
from fusionagi._logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fusionagi.core.orchestrator import Orchestrator
|
||||
from fusionagi.core.goal_manager import GoalManager
|
||||
pass
|
||||
|
||||
class CoordinatorAgent(BaseAgent):
|
||||
def __init__(self, identity="coordinator", orchestrator=None, goal_manager=None, planner_id="planner"):
|
||||
|
||||
@@ -7,12 +7,12 @@ dependencies are dispatched in parallel to maximize throughput.
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from fusionagi.schemas.plan import Plan
|
||||
from fusionagi.planning import ready_steps, get_step
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.planning import ready_steps
|
||||
from fusionagi.schemas.plan import Plan
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -12,8 +12,8 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -182,8 +182,8 @@ class PooledExecutorRouter:
|
||||
return None
|
||||
|
||||
# Rewrite recipient so response comes back to original sender
|
||||
response = self._pool.dispatch(envelope)
|
||||
return response
|
||||
result = self._pool.dispatch(envelope)
|
||||
return result # type: ignore[return-value, no-any-return]
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Pool statistics."""
|
||||
|
||||
@@ -8,14 +8,14 @@ Coordinates Planner -> Reasoner -> Executor flow. Supports:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.agents.base_agent import BaseAgent
|
||||
from fusionagi.multi_agent.parallel import execute_steps_parallel_wave
|
||||
from fusionagi.planning import ready_steps
|
||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||
from fusionagi.schemas.plan import Plan
|
||||
from fusionagi.planning import ready_steps, get_step
|
||||
from fusionagi.multi_agent.parallel import execute_steps_parallel, execute_steps_parallel_wave
|
||||
from fusionagi._logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fusionagi.core.orchestrator import Orchestrator
|
||||
@@ -132,7 +132,7 @@ class SupervisorAgent(BaseAgent):
|
||||
if plan_dict:
|
||||
plan = Plan.from_dict(plan_dict)
|
||||
else:
|
||||
plan = self._request_plan(task_id, goal, constraints)
|
||||
plan = self._request_plan(task_id, goal, constraints) # type: ignore[assignment]
|
||||
if not plan:
|
||||
return envelope.create_response(
|
||||
"run_failed",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Planning engine: plan graph, dependency resolution, checkpoints."""
|
||||
|
||||
from fusionagi.planning.graph import (
|
||||
topological_order,
|
||||
next_step,
|
||||
get_step,
|
||||
next_step,
|
||||
ready_steps,
|
||||
topological_order,
|
||||
)
|
||||
from fusionagi.planning.strategies import linear_order, dependency_order, get_strategy
|
||||
from fusionagi.planning.strategies import dependency_order, get_strategy, linear_order
|
||||
|
||||
__all__ = [
|
||||
"topological_order",
|
||||
|
||||
@@ -46,10 +46,10 @@ def next_step(plan: Plan, completed_step_ids: set[str]) -> str | None:
|
||||
def ready_steps(plan: Plan, completed_step_ids: set[str]) -> list[str]:
|
||||
"""
|
||||
Return all step ids that have dependencies satisfied and can run in parallel.
|
||||
|
||||
|
||||
For multi-agent acceleration: steps with no mutual dependencies can be
|
||||
dispatched to different agents concurrently.
|
||||
|
||||
|
||||
Returns:
|
||||
List of step ids ready for parallel execution.
|
||||
"""
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from fusionagi.schemas.plan import Plan
|
||||
from fusionagi.planning.graph import topological_order
|
||||
from fusionagi.schemas.plan import Plan
|
||||
|
||||
|
||||
def linear_order(plan: Plan) -> list[str]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Prompt templates for Dvādaśa heads and other agents."""
|
||||
|
||||
from fusionagi.prompts.heads import get_head_prompt, HEAD_PROMPTS
|
||||
from fusionagi.prompts.heads import HEAD_PROMPTS, get_head_prompt
|
||||
|
||||
__all__ = [
|
||||
"get_head_prompt",
|
||||
|
||||
@@ -4,30 +4,45 @@ from fusionagi.reasoning.cot import (
|
||||
build_cot_messages,
|
||||
run_chain_of_thought,
|
||||
)
|
||||
from fusionagi.reasoning.tot import (
|
||||
run_tree_of_thought,
|
||||
run_tree_of_thought_detailed,
|
||||
ThoughtBranch,
|
||||
ThoughtNode,
|
||||
ToTResult,
|
||||
expand_node,
|
||||
prune_subtree,
|
||||
merge_subtrees,
|
||||
)
|
||||
from fusionagi.reasoning.native import (
|
||||
NativeReasoningProvider,
|
||||
analyze_prompt,
|
||||
produce_head_output,
|
||||
PromptAnalysis,
|
||||
)
|
||||
from fusionagi.reasoning.decomposition import decompose_recursive
|
||||
from fusionagi.reasoning.multi_path import generate_and_score_parallel
|
||||
from fusionagi.reasoning.recomposition import recompose, RecomposedResponse
|
||||
from fusionagi.reasoning.gpu_scoring import (
|
||||
deduplicate_claims_gpu,
|
||||
generate_and_score_gpu,
|
||||
score_claims_gpu,
|
||||
)
|
||||
from fusionagi.reasoning.interpretability import (
|
||||
ReasoningTrace,
|
||||
ReasoningTracer,
|
||||
TraceStep,
|
||||
)
|
||||
from fusionagi.reasoning.meta_reasoning import (
|
||||
challenge_assumptions,
|
||||
detect_contradictions,
|
||||
revisit_node,
|
||||
)
|
||||
from fusionagi.reasoning.metacognition import (
|
||||
KnowledgeGap,
|
||||
MetacognitiveAssessment,
|
||||
assess_head_outputs,
|
||||
)
|
||||
from fusionagi.reasoning.multi_path import generate_and_score_parallel
|
||||
from fusionagi.reasoning.native import (
|
||||
NativeReasoningProvider,
|
||||
PromptAnalysis,
|
||||
analyze_prompt,
|
||||
produce_head_output,
|
||||
)
|
||||
from fusionagi.reasoning.recomposition import RecomposedResponse, recompose
|
||||
from fusionagi.reasoning.tot import (
|
||||
ThoughtBranch,
|
||||
ThoughtNode,
|
||||
ToTResult,
|
||||
expand_node,
|
||||
merge_subtrees,
|
||||
prune_subtree,
|
||||
run_tree_of_thought,
|
||||
run_tree_of_thought_detailed,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_cot_messages",
|
||||
@@ -53,4 +68,13 @@ __all__ = [
|
||||
"challenge_assumptions",
|
||||
"detect_contradictions",
|
||||
"revisit_node",
|
||||
"generate_and_score_gpu",
|
||||
"score_claims_gpu",
|
||||
"deduplicate_claims_gpu",
|
||||
"MetacognitiveAssessment",
|
||||
"KnowledgeGap",
|
||||
"assess_head_outputs",
|
||||
"ReasoningTrace",
|
||||
"ReasoningTracer",
|
||||
"TraceStep",
|
||||
]
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
from fusionagi.memory.sharding import Shard, shard_context
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.reasoning.native import analyze_prompt
|
||||
from fusionagi.schemas.atomic import (
|
||||
AtomicSemanticUnit,
|
||||
@@ -14,7 +14,6 @@ from fusionagi.schemas.atomic import (
|
||||
RelationType,
|
||||
SemanticRelation,
|
||||
)
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
def _make_unit_id(prefix: str = "asu") -> str:
|
||||
|
||||
105
fusionagi/reasoning/gpu_scoring.py
Normal file
105
fusionagi/reasoning/gpu_scoring.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""GPU-accelerated scoring integration for reasoning pipeline.
|
||||
|
||||
Provides drop-in GPU replacements for CPU scoring functions used in
|
||||
multi_path.py and consensus_engine.py. Automatically falls back to
|
||||
CPU when GPU is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.reasoning.tot import ThoughtNode
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit, AtomicUnitType
|
||||
|
||||
|
||||
def generate_and_score_gpu(
|
||||
hypotheses: list[str],
|
||||
units: list[AtomicSemanticUnit],
|
||||
score_fn: Callable[[ThoughtNode, list[AtomicSemanticUnit]], float] | None = None,
|
||||
) -> list[tuple[ThoughtNode, float]]:
|
||||
"""GPU-accelerated hypothesis scoring, drop-in for generate_and_score_parallel.
|
||||
|
||||
Uses GPU tensor operations for batched scoring when available,
|
||||
falling back to the original CPU implementation.
|
||||
|
||||
Args:
|
||||
hypotheses: List of hypothesis texts.
|
||||
units: Atomic semantic units for context.
|
||||
score_fn: Optional custom scoring function (overrides GPU scoring).
|
||||
|
||||
Returns:
|
||||
List of (ThoughtNode, score) tuples sorted by score descending.
|
||||
"""
|
||||
if score_fn is not None:
|
||||
from fusionagi.reasoning.multi_path import generate_and_score_parallel
|
||||
|
||||
return generate_and_score_parallel(hypotheses, units, score_fn)
|
||||
|
||||
try:
|
||||
from fusionagi.gpu.tensor_scoring import gpu_score_hypotheses
|
||||
|
||||
results = gpu_score_hypotheses(hypotheses, units)
|
||||
logger.debug(
|
||||
"GPU scoring used for hypotheses",
|
||||
extra={"count": len(hypotheses), "backend": "gpu"},
|
||||
)
|
||||
return results
|
||||
except ImportError:
|
||||
from fusionagi.reasoning.multi_path import generate_and_score_parallel
|
||||
|
||||
logger.debug("GPU not available, using CPU scoring")
|
||||
return generate_and_score_parallel(hypotheses, units)
|
||||
|
||||
|
||||
def score_claims_gpu(
|
||||
claims: list[str],
|
||||
reference: str,
|
||||
) -> list[float]:
|
||||
"""Score claims against a reference using GPU when available.
|
||||
|
||||
Args:
|
||||
claims: List of claim texts.
|
||||
reference: Reference text.
|
||||
|
||||
Returns:
|
||||
List of scores for each claim.
|
||||
"""
|
||||
try:
|
||||
from fusionagi.gpu.tensor_scoring import gpu_score_claims_against_reference
|
||||
|
||||
return gpu_score_claims_against_reference(claims, reference)
|
||||
except ImportError:
|
||||
from fusionagi.reasoning.multi_path import _score_consistency
|
||||
|
||||
scores: list[float] = []
|
||||
for claim in claims:
|
||||
node = ThoughtNode(thought=claim, trace=[claim])
|
||||
unit = AtomicSemanticUnit(
|
||||
unit_id="ref", content=reference, type=AtomicUnitType.FACT, confidence=1.0
|
||||
)
|
||||
scores.append(_score_consistency(node, [unit]))
|
||||
return scores
|
||||
|
||||
|
||||
def deduplicate_claims_gpu(
|
||||
claims: list[str],
|
||||
threshold: float = 0.85,
|
||||
) -> list[list[int]]:
|
||||
"""GPU-accelerated claim deduplication.
|
||||
|
||||
Args:
|
||||
claims: List of claim texts.
|
||||
threshold: Similarity threshold for grouping.
|
||||
|
||||
Returns:
|
||||
List of groups (each group is a list of indices).
|
||||
"""
|
||||
try:
|
||||
from fusionagi.gpu.tensor_similarity import deduplicate_claims
|
||||
|
||||
return deduplicate_claims(claims, threshold)
|
||||
except ImportError:
|
||||
groups: list[list[int]] = [[i] for i in range(len(claims))]
|
||||
return groups
|
||||
247
fusionagi/reasoning/interpretability.py
Normal file
247
fusionagi/reasoning/interpretability.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Interpretability: full reasoning trace from prompt to final answer.
|
||||
|
||||
Every step of the reasoning pipeline can be traced and explained:
|
||||
- Prompt decomposition decisions
|
||||
- Head selection and dispatch
|
||||
- Per-head claim generation with evidence chains
|
||||
- Consensus process (agreements, disputes)
|
||||
- Metacognitive assessment
|
||||
- Verification results
|
||||
- Final synthesis rationale
|
||||
|
||||
The ReasoningTrace captures all of this in a structured, queryable format
|
||||
that can be serialized for debugging, auditing, or user explanation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Return current UTC time (timezone-aware)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceStep:
|
||||
"""A single step in the reasoning trace.
|
||||
|
||||
Attributes:
|
||||
step_id: Unique identifier for this step.
|
||||
stage: Pipeline stage (e.g. ``decomposition``, ``head_dispatch``).
|
||||
component: Component that executed this step.
|
||||
input_summary: Brief summary of the step's input.
|
||||
output_summary: Brief summary of the step's output.
|
||||
duration_ms: Execution time in milliseconds (if measured).
|
||||
metadata: Additional structured data.
|
||||
timestamp: When this step was recorded.
|
||||
"""
|
||||
|
||||
step_id: str = ""
|
||||
stage: str = ""
|
||||
component: str = ""
|
||||
input_summary: str = ""
|
||||
output_summary: str = ""
|
||||
duration_ms: float | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReasoningTrace:
|
||||
"""Complete reasoning trace for a single prompt→response cycle.
|
||||
|
||||
Attributes:
|
||||
trace_id: Unique identifier for this trace.
|
||||
task_id: Associated task ID.
|
||||
prompt: Original user prompt.
|
||||
steps: Ordered list of trace steps.
|
||||
final_answer: The produced answer.
|
||||
overall_confidence: Final confidence score.
|
||||
metacognitive_summary: Summary of metacognitive assessment.
|
||||
verification_summary: Summary of claim verification.
|
||||
created_at: When the trace was started.
|
||||
"""
|
||||
|
||||
trace_id: str = ""
|
||||
task_id: str = ""
|
||||
prompt: str = ""
|
||||
steps: list[TraceStep] = field(default_factory=list)
|
||||
final_answer: str = ""
|
||||
overall_confidence: float = 0.0
|
||||
metacognitive_summary: dict[str, Any] = field(default_factory=dict)
|
||||
verification_summary: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
class ReasoningTracer:
|
||||
"""Records interpretable reasoning traces for the pipeline.
|
||||
|
||||
Attach to the reasoning pipeline to capture every decision point.
|
||||
Each trace can be serialized, stored, and queried for debugging
|
||||
or explanation.
|
||||
|
||||
Args:
|
||||
max_traces: Maximum traces to retain in memory (FIFO).
|
||||
"""
|
||||
|
||||
def __init__(self, max_traces: int = 1000) -> None:
|
||||
self._traces: dict[str, ReasoningTrace] = {}
|
||||
self._trace_order: list[str] = []
|
||||
self._max_traces = max_traces
|
||||
self._step_counter = 0
|
||||
|
||||
def start_trace(self, trace_id: str, task_id: str, prompt: str) -> ReasoningTrace:
|
||||
"""Begin a new reasoning trace.
|
||||
|
||||
Args:
|
||||
trace_id: Unique ID for this trace.
|
||||
task_id: Associated task ID.
|
||||
prompt: The user's prompt.
|
||||
|
||||
Returns:
|
||||
The newly created trace.
|
||||
"""
|
||||
if len(self._traces) >= self._max_traces and self._trace_order:
|
||||
oldest = self._trace_order.pop(0)
|
||||
self._traces.pop(oldest, None)
|
||||
|
||||
trace = ReasoningTrace(
|
||||
trace_id=trace_id,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
self._traces[trace_id] = trace
|
||||
self._trace_order.append(trace_id)
|
||||
return trace
|
||||
|
||||
def add_step(
|
||||
self,
|
||||
trace_id: str,
|
||||
stage: str,
|
||||
component: str,
|
||||
input_summary: str = "",
|
||||
output_summary: str = "",
|
||||
duration_ms: float | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> TraceStep | None:
|
||||
"""Add a step to an existing trace.
|
||||
|
||||
Args:
|
||||
trace_id: The trace to add the step to.
|
||||
stage: Pipeline stage name.
|
||||
component: Component that executed this step.
|
||||
input_summary: Brief input description.
|
||||
output_summary: Brief output description.
|
||||
duration_ms: Execution time.
|
||||
metadata: Additional data.
|
||||
|
||||
Returns:
|
||||
The added step, or ``None`` if trace not found.
|
||||
"""
|
||||
trace = self._traces.get(trace_id)
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
self._step_counter += 1
|
||||
step = TraceStep(
|
||||
step_id=f"step_{self._step_counter}",
|
||||
stage=stage,
|
||||
component=component,
|
||||
input_summary=input_summary[:200],
|
||||
output_summary=output_summary[:200],
|
||||
duration_ms=duration_ms,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
trace.steps.append(step)
|
||||
return step
|
||||
|
||||
def finalize_trace(
|
||||
self,
|
||||
trace_id: str,
|
||||
final_answer: str,
|
||||
confidence: float,
|
||||
metacognitive_summary: dict[str, Any] | None = None,
|
||||
verification_summary: dict[str, Any] | None = None,
|
||||
) -> ReasoningTrace | None:
|
||||
"""Finalize a trace with the final answer and assessments.
|
||||
|
||||
Args:
|
||||
trace_id: The trace to finalize.
|
||||
final_answer: The produced answer.
|
||||
confidence: Overall confidence score.
|
||||
metacognitive_summary: Metacognition assessment summary.
|
||||
verification_summary: Claim verification summary.
|
||||
|
||||
Returns:
|
||||
The finalized trace, or ``None`` if not found.
|
||||
"""
|
||||
trace = self._traces.get(trace_id)
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
trace.final_answer = final_answer
|
||||
trace.overall_confidence = confidence
|
||||
if metacognitive_summary:
|
||||
trace.metacognitive_summary = metacognitive_summary
|
||||
if verification_summary:
|
||||
trace.verification_summary = verification_summary
|
||||
return trace
|
||||
|
||||
def get_trace(self, trace_id: str) -> ReasoningTrace | None:
|
||||
"""Retrieve a trace by ID."""
|
||||
return self._traces.get(trace_id)
|
||||
|
||||
def get_recent_traces(self, limit: int = 10) -> list[ReasoningTrace]:
|
||||
"""Retrieve the most recent traces."""
|
||||
recent_ids = self._trace_order[-limit:]
|
||||
return [self._traces[tid] for tid in recent_ids if tid in self._traces]
|
||||
|
||||
def explain(self, trace_id: str) -> str:
|
||||
"""Generate a human-readable explanation of a reasoning trace.
|
||||
|
||||
Args:
|
||||
trace_id: The trace to explain.
|
||||
|
||||
Returns:
|
||||
Formatted explanation string.
|
||||
"""
|
||||
trace = self._traces.get(trace_id)
|
||||
if trace is None:
|
||||
return f"Trace '{trace_id}' not found."
|
||||
|
||||
lines: list[str] = [
|
||||
f"Reasoning Trace: {trace.trace_id}",
|
||||
f"Task: {trace.task_id}",
|
||||
f"Prompt: {trace.prompt[:100]}",
|
||||
f"Steps: {len(trace.steps)}",
|
||||
"",
|
||||
]
|
||||
|
||||
for i, step in enumerate(trace.steps, 1):
|
||||
lines.append(f" {i}. [{step.stage}] {step.component}")
|
||||
if step.input_summary:
|
||||
lines.append(f" Input: {step.input_summary}")
|
||||
if step.output_summary:
|
||||
lines.append(f" Output: {step.output_summary}")
|
||||
if step.duration_ms is not None:
|
||||
lines.append(f" Time: {step.duration_ms:.1f}ms")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Final Answer: {trace.final_answer[:200]}")
|
||||
lines.append(f"Confidence: {trace.overall_confidence:.2f}")
|
||||
|
||||
if trace.metacognitive_summary:
|
||||
lines.append(f"Metacognition: {trace.metacognitive_summary}")
|
||||
if trace.verification_summary:
|
||||
lines.append(f"Verification: {trace.verification_summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@property
|
||||
def total_traces(self) -> int:
|
||||
"""Number of traces stored."""
|
||||
return len(self._traces)
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit, AtomicUnitType
|
||||
from fusionagi.reasoning.tot import ThoughtNode, expand_node
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.reasoning.tot import ThoughtNode, expand_node
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit, AtomicUnitType
|
||||
|
||||
|
||||
def challenge_assumptions(
|
||||
|
||||
262
fusionagi/reasoning/metacognition.py
Normal file
262
fusionagi/reasoning/metacognition.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Metacognition: self-awareness of knowledge boundaries and reasoning quality.
|
||||
|
||||
The metacognition engine monitors the system's own reasoning processes
|
||||
and produces self-assessments:
|
||||
- Does the system have enough evidence to answer confidently?
|
||||
- Which knowledge gaps exist?
|
||||
- Where are the reasoning weak points?
|
||||
- Should the system seek more information before answering?
|
||||
|
||||
This is distinct from meta_reasoning.py (which challenges assumptions
|
||||
and detects contradictions in content). Metacognition operates on
|
||||
the *process* level — it reasons about the quality of reasoning itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.schemas.head import HeadOutput
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeGap:
|
||||
"""An identified gap in the system's knowledge.
|
||||
|
||||
Attributes:
|
||||
domain: Knowledge domain (e.g. ``legal``, ``medical``).
|
||||
description: What the system doesn't know.
|
||||
severity: Impact on answer quality (``low``, ``medium``, ``high``).
|
||||
resolvable: Whether additional tool calls could fill this gap.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
description: str
|
||||
severity: str = "medium"
|
||||
resolvable: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetacognitiveAssessment:
|
||||
"""Self-assessment of reasoning quality for a given task.
|
||||
|
||||
Attributes:
|
||||
overall_confidence: System's confidence in its answer (0.0–1.0).
|
||||
evidence_sufficiency: Whether evidence is sufficient (0.0–1.0).
|
||||
knowledge_gaps: Identified gaps in knowledge.
|
||||
reasoning_quality: Assessment of the reasoning chain quality.
|
||||
should_seek_more: Whether the system should seek more info.
|
||||
head_agreement: Fraction of heads that agree (0.0–1.0).
|
||||
uncertainty_sources: Where uncertainty comes from.
|
||||
recommendations: What the system should do next.
|
||||
"""
|
||||
|
||||
overall_confidence: float = 0.5
|
||||
evidence_sufficiency: float = 0.5
|
||||
knowledge_gaps: list[KnowledgeGap] = field(default_factory=list)
|
||||
reasoning_quality: float = 0.5
|
||||
should_seek_more: bool = False
|
||||
head_agreement: float = 0.5
|
||||
uncertainty_sources: list[str] = field(default_factory=list)
|
||||
recommendations: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def assess_head_outputs(
|
||||
outputs: list[HeadOutput],
|
||||
user_prompt: str = "",
|
||||
) -> MetacognitiveAssessment:
|
||||
"""Assess reasoning quality from head outputs.
|
||||
|
||||
Analyzes the collection of head outputs for agreement patterns,
|
||||
confidence distribution, evidence coverage, and knowledge gaps.
|
||||
|
||||
Args:
|
||||
outputs: Outputs from Dvādaśa content heads.
|
||||
user_prompt: Original user prompt for context.
|
||||
|
||||
Returns:
|
||||
Metacognitive assessment of reasoning quality.
|
||||
"""
|
||||
if not outputs:
|
||||
return MetacognitiveAssessment(
|
||||
overall_confidence=0.0,
|
||||
evidence_sufficiency=0.0,
|
||||
should_seek_more=True,
|
||||
uncertainty_sources=["No head outputs available"],
|
||||
recommendations=["Execute head pipeline before assessment"],
|
||||
)
|
||||
|
||||
confidences: list[float] = []
|
||||
for out in outputs:
|
||||
if out.claims:
|
||||
confidences.extend(c.confidence for c in out.claims)
|
||||
else:
|
||||
confidences.append(0.0)
|
||||
avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||
|
||||
all_claims: list[str] = []
|
||||
for out in outputs:
|
||||
all_claims.extend(c.claim_text for c in out.claims)
|
||||
|
||||
evidence_counts = []
|
||||
for out in outputs:
|
||||
for c in out.claims:
|
||||
evidence_counts.append(len(c.evidence))
|
||||
avg_evidence = sum(evidence_counts) / max(len(evidence_counts), 1)
|
||||
evidence_sufficiency = min(1.0, avg_evidence / 3.0)
|
||||
|
||||
head_agreement = _compute_head_agreement(outputs)
|
||||
|
||||
gaps = _detect_knowledge_gaps(outputs, user_prompt)
|
||||
|
||||
uncertainty_sources: list[str] = []
|
||||
if avg_confidence < 0.5:
|
||||
uncertainty_sources.append(f"Low average head confidence: {avg_confidence:.2f}")
|
||||
if head_agreement < 0.4:
|
||||
uncertainty_sources.append(f"Low head agreement: {head_agreement:.2f}")
|
||||
if evidence_sufficiency < 0.3:
|
||||
uncertainty_sources.append(f"Insufficient evidence: avg {avg_evidence:.1f} per claim")
|
||||
if gaps:
|
||||
uncertainty_sources.append(f"{len(gaps)} knowledge gap(s) detected")
|
||||
|
||||
conf_variance = _variance(confidences) if len(confidences) > 1 else 0.0
|
||||
if conf_variance > 0.1:
|
||||
uncertainty_sources.append(
|
||||
f"High confidence variance across heads: {conf_variance:.3f}"
|
||||
)
|
||||
|
||||
reasoning_quality = (
|
||||
0.4 * avg_confidence
|
||||
+ 0.3 * head_agreement
|
||||
+ 0.2 * evidence_sufficiency
|
||||
+ 0.1 * (1.0 - min(1.0, len(gaps) * 0.2))
|
||||
)
|
||||
|
||||
should_seek_more = (
|
||||
reasoning_quality < 0.4
|
||||
or evidence_sufficiency < 0.3
|
||||
or any(g.severity == "high" and g.resolvable for g in gaps)
|
||||
)
|
||||
|
||||
recommendations: list[str] = []
|
||||
if should_seek_more:
|
||||
recommendations.append("Seek additional evidence before finalizing answer")
|
||||
if head_agreement < 0.4:
|
||||
recommendations.append("Run second-pass with disputed heads for clarification")
|
||||
for gap in gaps:
|
||||
if gap.resolvable:
|
||||
recommendations.append(f"Fill knowledge gap: {gap.description}")
|
||||
|
||||
overall = min(1.0, 0.5 * reasoning_quality + 0.3 * head_agreement + 0.2 * evidence_sufficiency)
|
||||
|
||||
assessment = MetacognitiveAssessment(
|
||||
overall_confidence=overall,
|
||||
evidence_sufficiency=evidence_sufficiency,
|
||||
knowledge_gaps=gaps,
|
||||
reasoning_quality=reasoning_quality,
|
||||
should_seek_more=should_seek_more,
|
||||
head_agreement=head_agreement,
|
||||
uncertainty_sources=uncertainty_sources,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Metacognition: assessment complete",
|
||||
extra={
|
||||
"overall_confidence": overall,
|
||||
"reasoning_quality": reasoning_quality,
|
||||
"head_agreement": head_agreement,
|
||||
"gaps": len(gaps),
|
||||
"should_seek_more": should_seek_more,
|
||||
},
|
||||
)
|
||||
return assessment
|
||||
|
||||
|
||||
def _compute_head_agreement(outputs: list[HeadOutput]) -> float:
|
||||
"""Measure how much heads agree with each other.
|
||||
|
||||
Uses claim text overlap across heads as a proxy for agreement.
|
||||
"""
|
||||
if len(outputs) < 2:
|
||||
return 1.0
|
||||
|
||||
claim_sets: list[set[str]] = []
|
||||
for out in outputs:
|
||||
words: set[str] = set()
|
||||
for c in out.claims:
|
||||
words.update(c.claim_text.lower().split())
|
||||
claim_sets.append(words)
|
||||
|
||||
agreements: float = 0.0
|
||||
comparisons = 0
|
||||
for i in range(len(claim_sets)):
|
||||
for j in range(i + 1, len(claim_sets)):
|
||||
if not claim_sets[i] or not claim_sets[j]:
|
||||
continue
|
||||
overlap = len(claim_sets[i] & claim_sets[j])
|
||||
union = len(claim_sets[i] | claim_sets[j])
|
||||
if union > 0:
|
||||
agreements += overlap / union
|
||||
comparisons += 1
|
||||
|
||||
return agreements / max(comparisons, 1)
|
||||
|
||||
|
||||
def _detect_knowledge_gaps(
|
||||
outputs: list[HeadOutput],
|
||||
user_prompt: str,
|
||||
) -> list[KnowledgeGap]:
|
||||
"""Detect knowledge gaps from head outputs and prompt analysis."""
|
||||
gaps: list[KnowledgeGap] = []
|
||||
|
||||
for out in outputs:
|
||||
if out.claims:
|
||||
avg_claim_conf = sum(c.confidence for c in out.claims) / len(out.claims)
|
||||
else:
|
||||
avg_claim_conf = 0.0
|
||||
if avg_claim_conf < 0.3:
|
||||
gaps.append(KnowledgeGap(
|
||||
domain=out.head_id.value,
|
||||
description=f"Head '{out.head_id.value}' has very low confidence ({avg_claim_conf:.2f})",
|
||||
severity="high" if avg_claim_conf < 0.15 else "medium",
|
||||
resolvable=True,
|
||||
))
|
||||
|
||||
empty_heads = [o for o in outputs if not o.claims]
|
||||
for out in empty_heads:
|
||||
gaps.append(KnowledgeGap(
|
||||
domain=out.head_id.value,
|
||||
description=f"Head '{out.head_id.value}' produced no claims",
|
||||
severity="medium",
|
||||
resolvable=True,
|
||||
))
|
||||
|
||||
prompt_lower = user_prompt.lower()
|
||||
domain_indicators = {
|
||||
"legal": ["law", "legal", "court", "statute", "regulation", "compliance"],
|
||||
"medical": ["medical", "health", "disease", "treatment", "clinical", "patient"],
|
||||
"financial": ["financial", "stock", "market", "investment", "trading", "portfolio"],
|
||||
"scientific": ["experiment", "hypothesis", "data", "study", "research", "evidence"],
|
||||
}
|
||||
for domain, keywords in domain_indicators.items():
|
||||
if any(kw in prompt_lower for kw in keywords):
|
||||
head_domains = {o.head_id.value for o in outputs}
|
||||
if domain not in head_domains:
|
||||
gaps.append(KnowledgeGap(
|
||||
domain=domain,
|
||||
description=f"Prompt references '{domain}' domain but no specialized head covers it",
|
||||
severity="medium",
|
||||
resolvable=False,
|
||||
))
|
||||
|
||||
return gaps
|
||||
|
||||
|
||||
def _variance(values: list[float]) -> float:
|
||||
"""Compute variance of a list of floats."""
|
||||
if len(values) < 2:
|
||||
return 0.0
|
||||
mean = sum(values) / len(values)
|
||||
return sum((v - mean) ** 2 for v in values) / len(values)
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Multi-path inference: parallel hypothesis generation and scoring."""
|
||||
"""Multi-path inference: parallel hypothesis generation and scoring.
|
||||
|
||||
Supports GPU-accelerated scoring when ``fusionagi[gpu]`` is installed;
|
||||
falls back to CPU ``ThreadPoolExecutor`` otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Callable
|
||||
from typing import Callable
|
||||
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
from fusionagi.reasoning.tot import ThoughtNode
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.reasoning.tot import ThoughtNode
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
|
||||
|
||||
def _score_coherence(node: ThoughtNode, _units: list[AtomicSemanticUnit]) -> float:
|
||||
@@ -24,12 +28,42 @@ def _score_consistency(node: ThoughtNode, units: list[AtomicSemanticUnit]) -> fl
|
||||
return min(1.0, overlap * 2)
|
||||
|
||||
|
||||
def _try_gpu_score(
|
||||
hypotheses: list[str],
|
||||
units: list[AtomicSemanticUnit],
|
||||
) -> list[tuple[ThoughtNode, float]] | None:
|
||||
"""Attempt GPU-accelerated scoring; return ``None`` if unavailable."""
|
||||
try:
|
||||
from fusionagi.gpu.tensor_scoring import gpu_score_hypotheses
|
||||
|
||||
results = gpu_score_hypotheses(hypotheses, units)
|
||||
logger.debug(
|
||||
"multi_path: GPU scoring used",
|
||||
extra={"count": len(hypotheses)},
|
||||
)
|
||||
return results
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def generate_and_score_parallel(
|
||||
hypotheses: list[str],
|
||||
units: list[AtomicSemanticUnit],
|
||||
score_fn: Callable[[ThoughtNode, list[AtomicSemanticUnit]], float] | None = None,
|
||||
*,
|
||||
use_gpu: bool = True,
|
||||
) -> list[tuple[ThoughtNode, float]]:
|
||||
"""Score multiple hypotheses in parallel."""
|
||||
"""Score multiple hypotheses in parallel.
|
||||
|
||||
When *use_gpu* is ``True`` (default) and no custom *score_fn* is
|
||||
provided, tries GPU-accelerated scoring first. Falls back to the
|
||||
threaded CPU implementation when the GPU module is unavailable.
|
||||
"""
|
||||
if use_gpu and score_fn is None:
|
||||
gpu_result = _try_gpu_score(hypotheses, units)
|
||||
if gpu_result is not None:
|
||||
return gpu_result
|
||||
|
||||
score_fn = score_fn or (lambda n, u: _score_coherence(n, u) * 0.5 + _score_consistency(n, u) * 0.5)
|
||||
|
||||
def score_one(h: str, i: int) -> tuple[ThoughtNode, float]:
|
||||
|
||||
@@ -113,7 +113,7 @@ def _derive_claims_for_head(
|
||||
) -> list[HeadClaim]:
|
||||
"""Derive atomic claims from analysis based on head domain."""
|
||||
claims: list[HeadClaim] = []
|
||||
persona = get_persona(head_id)
|
||||
get_persona(head_id)
|
||||
relevance = analysis.domain_signals.get(head_id.value, 0.3)
|
||||
|
||||
# Base claim from prompt summary
|
||||
@@ -297,8 +297,8 @@ class NativeReasoningProvider:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
semantic_memory: "SemanticMemory | None" = None,
|
||||
episodic_memory: "EpisodicMemory | None" = None,
|
||||
semantic_memory: Any | None = None,
|
||||
episodic_memory: Any | None = None,
|
||||
) -> None:
|
||||
self._semantic = semantic_memory
|
||||
self._episodic = episodic_memory
|
||||
@@ -316,4 +316,4 @@ class NativeReasoningProvider:
|
||||
if not self._semantic:
|
||||
return []
|
||||
domain = _domain_for_head(head_id)
|
||||
return self._semantic.query(domain=domain, limit=limit)
|
||||
return self._semantic.query(domain=domain, limit=limit) # type: ignore[no-any-return]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user