chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
+3
@@ -0,0 +1,3 @@
|
||||
"""Node package."""
|
||||
|
||||
__all__: list[str] = []
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
from .memory import *
|
||||
from .providers import *
|
||||
from .skills import *
|
||||
from .thinking import *
|
||||
from .tool import *
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
from .memory_base import MemoryBase, MemoryManager
|
||||
from .builtin_stores import MemoryFactory
|
||||
|
||||
__all__ = [
|
||||
"MemoryBase",
|
||||
"MemoryManager",
|
||||
"MemoryFactory",
|
||||
]
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
"""Lightweight append-only Blackboard memory implementation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from entity.configs.node.memory import BlackboardMemoryConfig
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
|
||||
|
||||
class BlackboardMemory(MemoryBase):
|
||||
"""Simple append-only memory: save raw outputs, retrieve by recency."""
|
||||
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(BlackboardMemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("BlackboardMemory requires a blackboard memory store configuration")
|
||||
super().__init__(store)
|
||||
self.config = config
|
||||
self.memory_path = config.memory_path
|
||||
self.max_items = config.max_items
|
||||
|
||||
# -------- Persistence --------
|
||||
def load(self) -> None:
|
||||
if not self.memory_path or not os.path.exists(self.memory_path):
|
||||
self.contents = []
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.memory_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
contents: List[MemoryItem] = []
|
||||
for raw in data:
|
||||
try:
|
||||
contents.append(MemoryItem.from_dict(raw))
|
||||
except Exception:
|
||||
continue
|
||||
self.contents = contents
|
||||
except Exception:
|
||||
# Corrupted file -> reset to empty to avoid blocking execution
|
||||
self.contents = []
|
||||
|
||||
def save(self) -> None:
|
||||
if not self.memory_path:
|
||||
return
|
||||
|
||||
os.makedirs(os.path.dirname(self.memory_path), exist_ok=True)
|
||||
payload = [item.to_dict() for item in self.contents[-self.max_items :]]
|
||||
with open(self.memory_path, "w", encoding="utf-8") as file:
|
||||
json.dump(payload, file, ensure_ascii=False, indent=2)
|
||||
|
||||
# -------- Memory operations --------
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
if not self.contents:
|
||||
return []
|
||||
|
||||
if top_k <= 0 or top_k >= len(self.contents):
|
||||
return list(self.contents)
|
||||
|
||||
return list(self.contents[-top_k:])
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
snapshot = payload.output_snapshot or payload.input_snapshot
|
||||
content = (snapshot.text if snapshot else payload.inputs_text or "").strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
metadata = {
|
||||
"agent_role": payload.agent_role,
|
||||
"input_preview": (payload.inputs_text or "")[:200],
|
||||
"attachments": snapshot.attachment_overview() if snapshot else [],
|
||||
}
|
||||
|
||||
memory_item = MemoryItem(
|
||||
id=f"bb_{uuid.uuid4().hex}",
|
||||
content_summary=content,
|
||||
metadata=metadata,
|
||||
timestamp=time.time(),
|
||||
input_snapshot=payload.input_snapshot,
|
||||
output_snapshot=payload.output_snapshot,
|
||||
)
|
||||
|
||||
self.contents.append(memory_item)
|
||||
if len(self.contents) > self.max_items:
|
||||
self.contents = self.contents[-self.max_items :]
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
"""Register built-in memory stores."""
|
||||
|
||||
from entity.configs.node.memory import (
|
||||
BlackboardMemoryConfig,
|
||||
FileMemoryConfig,
|
||||
Mem0MemoryConfig,
|
||||
SimpleMemoryConfig,
|
||||
MemoryStoreConfig,
|
||||
)
|
||||
from runtime.node.agent.memory.blackboard_memory import BlackboardMemory
|
||||
from runtime.node.agent.memory.file_memory import FileMemory
|
||||
from runtime.node.agent.memory.memory_base import MemoryBase
|
||||
from runtime.node.agent.memory.simple_memory import SimpleMemory
|
||||
from runtime.node.agent.memory.registry import register_memory_store, get_memory_store_registration
|
||||
|
||||
register_memory_store(
|
||||
"simple",
|
||||
config_cls=SimpleMemoryConfig,
|
||||
factory=lambda store: SimpleMemory(store),
|
||||
summary="In-memory store that resets between runs; best for testing",
|
||||
)
|
||||
|
||||
register_memory_store(
|
||||
"file",
|
||||
config_cls=FileMemoryConfig,
|
||||
factory=lambda store: FileMemory(store),
|
||||
summary="Persists documents on disk and supports embedding search",
|
||||
)
|
||||
|
||||
register_memory_store(
|
||||
"blackboard",
|
||||
config_cls=BlackboardMemoryConfig,
|
||||
factory=lambda store: BlackboardMemory(store),
|
||||
summary="Shared blackboard memory allowing multiple nodes to read/write",
|
||||
)
|
||||
|
||||
|
||||
def _create_mem0_memory(store):
|
||||
from runtime.node.agent.memory.mem0_memory import Mem0Memory
|
||||
return Mem0Memory(store)
|
||||
|
||||
|
||||
register_memory_store(
|
||||
"mem0",
|
||||
config_cls=Mem0MemoryConfig,
|
||||
factory=_create_mem0_memory,
|
||||
summary="Mem0 managed memory with semantic search and graph relationships",
|
||||
)
|
||||
|
||||
|
||||
class MemoryFactory:
|
||||
@staticmethod
|
||||
def create_memory(store: MemoryStoreConfig) -> MemoryBase:
|
||||
registration = get_memory_store_registration(store.type)
|
||||
return registration.factory(store)
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import openai
|
||||
from tenacity import (
|
||||
retry,
|
||||
stop_after_attempt,
|
||||
wait_random_exponential,
|
||||
)
|
||||
|
||||
from entity.configs import EmbeddingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmbeddingBase(ABC):
|
||||
def __init__(self, embedding_config: EmbeddingConfig):
|
||||
self.config = embedding_config
|
||||
|
||||
@abstractmethod
|
||||
def get_embedding(self, text):
|
||||
...
|
||||
|
||||
def _preprocess_text(self, text: str) -> str:
|
||||
"""Preprocess text to improve embedding quality."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove extra whitespace
|
||||
text = re.sub(r'\s+', ' ', text.strip())
|
||||
|
||||
# Remove special characters and emoji
|
||||
text = re.sub(r'[^\w\s\u4e00-\u9fff]', ' ', text)
|
||||
|
||||
# Clean up whitespace again
|
||||
text = re.sub(r'\s+', ' ', text.strip())
|
||||
|
||||
return text
|
||||
|
||||
def _chunk_text(self, text: str, max_length: int = 500) -> List[str]:
|
||||
"""Split long text into chunks to improve embedding quality."""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
# Split by sentence boundaries
|
||||
sentences = re.split(r'[\u3002\uff01\uff1f\uff1b\n]', text)
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if not sentence:
|
||||
continue
|
||||
|
||||
if len(current_chunk + sentence) <= max_length:
|
||||
current_chunk += sentence + "\u3002"
|
||||
else:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = sentence + "\u3002"
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
class EmbeddingFactory:
|
||||
@staticmethod
|
||||
def create_embedding(embedding_config: EmbeddingConfig) -> EmbeddingBase:
|
||||
model = embedding_config.provider
|
||||
if model == 'openai':
|
||||
return OpenAIEmbedding(embedding_config)
|
||||
elif model == 'local':
|
||||
return LocalEmbedding(embedding_config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported embedding model: {model}")
|
||||
|
||||
class OpenAIEmbedding(EmbeddingBase):
|
||||
def __init__(self, embedding_config: EmbeddingConfig):
|
||||
super().__init__(embedding_config)
|
||||
self.base_url = embedding_config.base_url
|
||||
self.api_key = embedding_config.api_key
|
||||
self.model_name = embedding_config.model or "text-embedding-3-small" # Default model
|
||||
self.max_length = embedding_config.params.get('max_length', 8191)
|
||||
self.use_chunking = embedding_config.params.get('use_chunking', False)
|
||||
self.chunk_strategy = embedding_config.params.get('chunk_strategy', 'average')
|
||||
self._fallback_dim = 1536 # Default; updated after first successful call
|
||||
|
||||
if self.base_url:
|
||||
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
else:
|
||||
self.client = openai.OpenAI(api_key=self.api_key)
|
||||
|
||||
@retry(wait=wait_random_exponential(min=2, max=5), stop=stop_after_attempt(10))
|
||||
def get_embedding(self, text):
|
||||
# Preprocess the text
|
||||
processed_text = self._preprocess_text(text)
|
||||
|
||||
if not processed_text:
|
||||
logger.warning("Empty text after preprocessing")
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
# Handle long text via chunking
|
||||
if self.use_chunking and len(processed_text) > self.max_length:
|
||||
return self._get_chunked_embedding(processed_text)
|
||||
|
||||
# Truncate text
|
||||
truncated_text = processed_text[:self.max_length]
|
||||
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
input=truncated_text,
|
||||
model=self.model_name,
|
||||
encoding_format="float"
|
||||
)
|
||||
embedding = response.data[0].embedding
|
||||
self._fallback_dim = len(embedding)
|
||||
return embedding
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting embedding: {e}")
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
def _get_chunked_embedding(self, text: str) -> List[float]:
|
||||
"""Chunk long text, embed each chunk, then aggregate."""
|
||||
chunks = self._chunk_text(text, self.max_length // 2) # Halve the chunk length
|
||||
|
||||
if not chunks:
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
chunk_embeddings = []
|
||||
for chunk in chunks:
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
input=chunk,
|
||||
model=self.model_name,
|
||||
encoding_format="float"
|
||||
)
|
||||
chunk_embeddings.append(response.data[0].embedding)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting chunk embedding: {e}")
|
||||
continue
|
||||
|
||||
if not chunk_embeddings:
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
# Aggregation strategy
|
||||
if self.chunk_strategy == 'average':
|
||||
# Mean aggregation
|
||||
return [sum(chunk[i] for chunk in chunk_embeddings) / len(chunk_embeddings)
|
||||
for i in range(len(chunk_embeddings[0]))]
|
||||
elif self.chunk_strategy == 'weighted':
|
||||
# Weighted aggregation (earlier chunks weigh more)
|
||||
weights = [1.0 / (i + 1) for i in range(len(chunk_embeddings))]
|
||||
total_weight = sum(weights)
|
||||
return [sum(chunk[i] * weights[j] for j, chunk in enumerate(chunk_embeddings)) / total_weight
|
||||
for i in range(len(chunk_embeddings[0]))]
|
||||
else:
|
||||
# Default to the first chunk
|
||||
return chunk_embeddings[0]
|
||||
|
||||
class LocalEmbedding(EmbeddingBase):
|
||||
def __init__(self, embedding_config: EmbeddingConfig):
|
||||
super().__init__(embedding_config)
|
||||
self.model_path = embedding_config.params.get('model_path')
|
||||
self.device = embedding_config.params.get('device', 'cpu')
|
||||
self._fallback_dim = 768 # Default; updated after first successful call
|
||||
|
||||
if not self.model_path:
|
||||
raise ValueError("LocalEmbedding requires model_path parameter")
|
||||
|
||||
# Load the local embedding model (e.g., sentence-transformers)
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self.model = SentenceTransformer(self.model_path, device=self.device)
|
||||
except ImportError:
|
||||
raise ImportError("sentence-transformers is required for LocalEmbedding")
|
||||
|
||||
def get_embedding(self, text):
|
||||
# Preprocess text before encoding
|
||||
processed_text = self._preprocess_text(text)
|
||||
|
||||
if not processed_text:
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
try:
|
||||
embedding = self.model.encode(processed_text, convert_to_tensor=False)
|
||||
result = embedding.tolist()
|
||||
self._fallback_dim = len(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting local embedding: {e}")
|
||||
return [0.0] * self._fallback_dim
|
||||
Executable
+485
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
FileMemory: Memory system for vectorizing and retrieving file contents
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import time
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
from entity.configs import MemoryStoreConfig, FileSourceConfig
|
||||
from entity.configs.node.memory import FileMemoryConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileMemory(MemoryBase):
|
||||
"""
|
||||
File-based memory system that indexes and retrieves content from files/directories.
|
||||
Supports multiple file types, chunking strategies, and incremental updates.
|
||||
"""
|
||||
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(FileMemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("FileMemory requires a file memory store configuration")
|
||||
super().__init__(store)
|
||||
|
||||
if not config.file_sources:
|
||||
raise ValueError("FileMemory requires at least one file_source in configuration")
|
||||
|
||||
self.file_config = config
|
||||
self.file_sources: List[FileSourceConfig] = config.file_sources
|
||||
self.index_path = self.file_config.index_path # Path to store the index
|
||||
|
||||
# Chunking configuration
|
||||
self.chunk_size = 500 # Characters per chunk
|
||||
self.chunk_overlap = 50 # Overlapping characters between chunks
|
||||
|
||||
# File metadata cache {file_path: {hash, chunks_count, ...}}
|
||||
self.file_metadata: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load existing index or build new one from file sources.
|
||||
Validates index integrity and performs incremental updates if needed.
|
||||
"""
|
||||
if self.index_path and os.path.exists(self.index_path):
|
||||
logger.info(f"Loading existing index from {self.index_path}")
|
||||
self._load_from_file()
|
||||
|
||||
# Validate and update if files changed
|
||||
if self._validate_and_update_index():
|
||||
logger.info("Index updated due to file changes")
|
||||
self.save()
|
||||
else:
|
||||
logger.info("Building new index from file sources")
|
||||
self._build_index_from_sources()
|
||||
if self.index_path:
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist the memory index to disk"""
|
||||
if not self.index_path:
|
||||
logger.warning("No index_path specified, skipping save")
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
|
||||
|
||||
# Prepare data for serialization
|
||||
data = {
|
||||
"file_metadata": self.file_metadata,
|
||||
"contents": [item.to_dict() for item in self.contents],
|
||||
"config": {
|
||||
"chunk_size": self.chunk_size,
|
||||
"chunk_overlap": self.chunk_overlap,
|
||||
}
|
||||
}
|
||||
|
||||
# Save to JSON
|
||||
with open(self.index_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Index saved to {self.index_path} ({len(self.contents)} chunks)")
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
"""
|
||||
Retrieve relevant file chunks based on query.
|
||||
|
||||
Args:
|
||||
agent_role: Agent role (not used in file memory)
|
||||
inputs: Query text
|
||||
top_k: Number of results to return
|
||||
similarity_threshold: Minimum similarity score
|
||||
|
||||
Returns:
|
||||
List of MemoryItem with file chunks
|
||||
"""
|
||||
if self.count_memories() == 0:
|
||||
return []
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding.get_embedding(query.text)
|
||||
if isinstance(query_embedding, list):
|
||||
query_embedding = np.array(query_embedding, dtype=np.float32)
|
||||
query_embedding = query_embedding.reshape(1, -1)
|
||||
faiss.normalize_L2(query_embedding)
|
||||
|
||||
expected_dim = query_embedding.shape[1]
|
||||
|
||||
# Collect embeddings from memory items
|
||||
memory_embeddings = []
|
||||
valid_items = []
|
||||
for item in self.contents:
|
||||
if item.embedding is not None:
|
||||
if len(item.embedding) != expected_dim:
|
||||
logger.warning(
|
||||
"Skipping memory item %s: embedding dim %d != expected %d",
|
||||
item.id, len(item.embedding), expected_dim,
|
||||
)
|
||||
continue
|
||||
memory_embeddings.append(item.embedding)
|
||||
valid_items.append(item)
|
||||
|
||||
if not memory_embeddings:
|
||||
return []
|
||||
|
||||
memory_embeddings = np.array(memory_embeddings, dtype=np.float32)
|
||||
|
||||
# Build FAISS index and search
|
||||
index = faiss.IndexFlatIP(memory_embeddings.shape[1])
|
||||
index.add(memory_embeddings)
|
||||
|
||||
similarities, indices = index.search(query_embedding, min(top_k, len(valid_items)))
|
||||
|
||||
# Filter by threshold and return results
|
||||
results = []
|
||||
for i in range(len(indices[0])):
|
||||
idx = indices[0][i]
|
||||
similarity = similarities[0][i]
|
||||
|
||||
if idx != -1 and similarity >= similarity_threshold:
|
||||
results.append(valid_items[idx])
|
||||
|
||||
return results
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
"""
|
||||
FileMemory is read-only, updates are not supported.
|
||||
This method is a no-op to maintain interface compatibility.
|
||||
"""
|
||||
logger.debug("FileMemory.update() called but FileMemory is read-only")
|
||||
pass
|
||||
|
||||
# ========== Private Helper Methods ==========
|
||||
|
||||
def _load_from_file(self) -> None:
|
||||
"""Load index from JSON file"""
|
||||
try:
|
||||
with open(self.index_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.file_metadata = data.get("file_metadata", {})
|
||||
raw_contents = data.get("contents", [])
|
||||
contents: List[MemoryItem] = []
|
||||
for raw in raw_contents:
|
||||
try:
|
||||
contents.append(MemoryItem.from_dict(raw))
|
||||
except Exception:
|
||||
continue
|
||||
self.contents = contents
|
||||
|
||||
# Load config if present
|
||||
config = data.get("config", {})
|
||||
self.chunk_size = config.get("chunk_size", self.chunk_size)
|
||||
self.chunk_overlap = config.get("chunk_overlap", self.chunk_overlap)
|
||||
|
||||
logger.info(f"Loaded {len(self.contents)} chunks from index")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading index: {e}")
|
||||
self.file_metadata = {}
|
||||
self.contents = []
|
||||
|
||||
def _build_index_from_sources(self) -> None:
|
||||
"""Build index by scanning all file sources"""
|
||||
all_chunks = []
|
||||
|
||||
for source in self.file_sources:
|
||||
logger.info(f"Scanning source: {source.source_path}")
|
||||
files = self._scan_files(source)
|
||||
logger.info(f"Found {len(files)} files in {source.source_path}")
|
||||
|
||||
for file_path in files:
|
||||
chunks = self._read_and_chunk_file(file_path, source.encoding)
|
||||
all_chunks.extend(chunks)
|
||||
|
||||
logger.info(f"Total chunks to index: {len(all_chunks)}")
|
||||
|
||||
# Generate embeddings for all chunks
|
||||
self.contents = self._build_embeddings(all_chunks)
|
||||
|
||||
logger.info(f"Index built with {len(self.contents)} chunks")
|
||||
|
||||
def _validate_and_update_index(self) -> bool:
|
||||
"""
|
||||
Validate index integrity and update if files changed.
|
||||
|
||||
Returns:
|
||||
True if index was updated, False otherwise
|
||||
"""
|
||||
updated = False
|
||||
current_files = set()
|
||||
|
||||
# Scan current files
|
||||
for source in self.file_sources:
|
||||
files = self._scan_files(source)
|
||||
current_files.update(files)
|
||||
|
||||
# Check for deleted files
|
||||
indexed_files = set(self.file_metadata.keys())
|
||||
deleted_files = indexed_files - current_files
|
||||
|
||||
if deleted_files:
|
||||
logger.info(f"Removing {len(deleted_files)} deleted files from index")
|
||||
self._remove_files_from_index(deleted_files)
|
||||
updated = True
|
||||
|
||||
# Check for new or modified files
|
||||
for source in self.file_sources:
|
||||
files = self._scan_files(source)
|
||||
|
||||
for file_path in files:
|
||||
file_hash = self._compute_file_hash(file_path)
|
||||
|
||||
# New file
|
||||
if file_path not in self.file_metadata:
|
||||
logger.info(f"Indexing new file: {file_path}")
|
||||
self._index_file(file_path, source.encoding)
|
||||
updated = True
|
||||
|
||||
# Modified file
|
||||
elif self.file_metadata[file_path].get("hash") != file_hash:
|
||||
logger.info(f"Re-indexing modified file: {file_path}")
|
||||
self._remove_files_from_index([file_path])
|
||||
self._index_file(file_path, source.encoding)
|
||||
updated = True
|
||||
|
||||
return updated
|
||||
|
||||
def _scan_files(self, source: FileSourceConfig) -> List[str]:
|
||||
"""
|
||||
Scan file path and return list of matching files.
|
||||
|
||||
Args:
|
||||
source: FileSourceConfig with path and filters
|
||||
|
||||
Returns:
|
||||
List of absolute file paths
|
||||
"""
|
||||
path = Path(source.source_path).expanduser().resolve()
|
||||
|
||||
# Single file
|
||||
if path.is_file():
|
||||
if self._matches_file_types(path, source.file_types):
|
||||
return [str(path)]
|
||||
return []
|
||||
|
||||
# Directory
|
||||
if not path.is_dir():
|
||||
logger.warning(f"Path does not exist: {source.source_path}")
|
||||
return []
|
||||
|
||||
files = []
|
||||
|
||||
if source.recursive:
|
||||
# Recursive scan
|
||||
for file_path in path.rglob("*"):
|
||||
if file_path.is_file() and self._matches_file_types(file_path, source.file_types):
|
||||
files.append(str(file_path))
|
||||
else:
|
||||
# Non-recursive scan
|
||||
for file_path in path.glob("*"):
|
||||
if file_path.is_file() and self._matches_file_types(file_path, source.file_types):
|
||||
files.append(str(file_path))
|
||||
|
||||
return files
|
||||
|
||||
def _matches_file_types(self, file_path: Path, file_types: List[str]) -> bool:
|
||||
"""Check if file matches the file type filter"""
|
||||
if file_types is None:
|
||||
return True
|
||||
return file_path.suffix in file_types
|
||||
|
||||
def _read_and_chunk_file(self, file_path: str, encoding: str = "utf-8") -> List[Dict]:
|
||||
"""
|
||||
Read file and split into chunks.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
encoding: File encoding
|
||||
|
||||
Returns:
|
||||
List of chunk dictionaries with content and metadata
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding=encoding, errors='ignore') as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading file {file_path}: {e}")
|
||||
return []
|
||||
|
||||
if not content.strip():
|
||||
return []
|
||||
|
||||
# Compute file hash
|
||||
file_hash = self._compute_file_hash(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Chunk the content
|
||||
chunks = self._chunk_text(content)
|
||||
|
||||
# Build chunk metadata
|
||||
chunk_dicts = []
|
||||
for i, chunk_text in enumerate(chunks):
|
||||
chunk_dicts.append({
|
||||
"content": chunk_text,
|
||||
"metadata": {
|
||||
"source_type": "file",
|
||||
"file_path": file_path,
|
||||
"file_name": os.path.basename(file_path),
|
||||
"file_hash": file_hash,
|
||||
"file_size": file_size,
|
||||
"chunk_index": i,
|
||||
"total_chunks": len(chunks),
|
||||
"encoding": encoding,
|
||||
}
|
||||
})
|
||||
|
||||
# Update file metadata cache
|
||||
self.file_metadata[file_path] = {
|
||||
"hash": file_hash,
|
||||
"size": file_size,
|
||||
"chunks_count": len(chunks),
|
||||
"indexed_at": time.time(),
|
||||
}
|
||||
|
||||
return chunk_dicts
|
||||
|
||||
def _chunk_text(self, text: str) -> List[str]:
|
||||
"""
|
||||
Split text into chunks with overlap.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
List of text chunks
|
||||
"""
|
||||
if len(text) <= self.chunk_size:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
|
||||
while start < len(text):
|
||||
end = start + self.chunk_size
|
||||
chunk = text[start:end]
|
||||
|
||||
# Try to break at sentence boundary
|
||||
if end < len(text):
|
||||
# Look for sentence endings
|
||||
last_sentence = max(
|
||||
chunk.rfind('。'),
|
||||
chunk.rfind('!'),
|
||||
chunk.rfind('?'),
|
||||
chunk.rfind('.'),
|
||||
chunk.rfind('!'),
|
||||
chunk.rfind('?'),
|
||||
chunk.rfind('\n')
|
||||
)
|
||||
|
||||
if last_sentence > self.chunk_size // 2: # Don't break too early
|
||||
chunk = chunk[:last_sentence + 1]
|
||||
end = start + last_sentence + 1
|
||||
|
||||
chunks.append(chunk.strip())
|
||||
|
||||
# Move start with overlap
|
||||
start = end - self.chunk_overlap
|
||||
|
||||
if start >= len(text):
|
||||
break
|
||||
|
||||
return [c for c in chunks if c] # Filter empty chunks
|
||||
|
||||
def _build_embeddings(self, chunks: List[Dict]) -> List[MemoryItem]:
|
||||
"""
|
||||
Generate embeddings for chunks and create MemoryItems.
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dictionaries
|
||||
|
||||
Returns:
|
||||
List of MemoryItem objects
|
||||
"""
|
||||
memory_items = []
|
||||
|
||||
for chunk_dict in chunks:
|
||||
content = chunk_dict["content"]
|
||||
metadata = chunk_dict["metadata"]
|
||||
|
||||
# Generate embedding
|
||||
try:
|
||||
embedding = self.embedding.get_embedding(content)
|
||||
if isinstance(embedding, list):
|
||||
embedding = np.array(embedding, dtype=np.float32).reshape(1, -1)
|
||||
faiss.normalize_L2(embedding)
|
||||
embedding_list = embedding.tolist()[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating embedding for chunk: {e}")
|
||||
continue
|
||||
|
||||
# Create MemoryItem
|
||||
item_id = f"{metadata['file_hash']}_{metadata['chunk_index']}"
|
||||
memory_item = MemoryItem(
|
||||
id=item_id,
|
||||
content_summary=content,
|
||||
metadata=metadata,
|
||||
embedding=embedding_list,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
|
||||
memory_items.append(memory_item)
|
||||
|
||||
return memory_items
|
||||
|
||||
def _compute_file_hash(self, file_path: str) -> str:
|
||||
"""Compute MD5 hash of file"""
|
||||
hash_md5 = hashlib.md5()
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_md5.update(chunk)
|
||||
return hash_md5.hexdigest()[:16]
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing hash for {file_path}: {e}")
|
||||
return "error"
|
||||
|
||||
def _index_file(self, file_path: str, encoding: str = "utf-8") -> None:
|
||||
"""Index a single file (helper for incremental updates)"""
|
||||
chunks = self._read_and_chunk_file(file_path, encoding)
|
||||
if chunks:
|
||||
new_items = self._build_embeddings(chunks)
|
||||
self.contents.extend(new_items)
|
||||
|
||||
def _remove_files_from_index(self, file_paths: List[str]) -> None:
|
||||
"""Remove chunks from deleted files"""
|
||||
file_paths_set = set(file_paths)
|
||||
|
||||
# Filter out chunks from deleted files
|
||||
self.contents = [
|
||||
item for item in self.contents
|
||||
if item.metadata.get("file_path") not in file_paths_set
|
||||
]
|
||||
|
||||
# Remove from metadata
|
||||
for file_path in file_paths:
|
||||
self.file_metadata.pop(file_path, None)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Mem0 managed memory store implementation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from entity.configs.node.memory import Mem0MemoryConfig
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_mem0_client(config: Mem0MemoryConfig):
|
||||
"""Lazy-import mem0ai and create a MemoryClient."""
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0ai is required for Mem0Memory. Install it with: pip install mem0ai"
|
||||
)
|
||||
|
||||
client_kwargs: Dict[str, Any] = {}
|
||||
if config.api_key:
|
||||
client_kwargs["api_key"] = config.api_key
|
||||
if config.org_id:
|
||||
client_kwargs["org_id"] = config.org_id
|
||||
if config.project_id:
|
||||
client_kwargs["project_id"] = config.project_id
|
||||
|
||||
return MemoryClient(**client_kwargs)
|
||||
|
||||
|
||||
class Mem0Memory(MemoryBase):
|
||||
"""Memory store backed by Mem0's managed cloud service.
|
||||
|
||||
Mem0 handles embeddings, storage, and semantic search server-side.
|
||||
No local persistence or embedding computation is needed.
|
||||
|
||||
Important API constraints:
|
||||
- Agent memories use role="assistant" + agent_id
|
||||
- user_id and agent_id are independent scoping dimensions and can be
|
||||
combined in both add() and search() calls.
|
||||
- search() uses filters dict; add() uses top-level kwargs.
|
||||
- SDK returns {"memories": [...]} from search.
|
||||
"""
|
||||
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(Mem0MemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("Mem0Memory requires a Mem0 memory store configuration")
|
||||
super().__init__(store)
|
||||
self.config = config
|
||||
self.client = _get_mem0_client(config)
|
||||
self.user_id = config.user_id
|
||||
self.agent_id = config.agent_id
|
||||
|
||||
# -------- Persistence (no-ops for cloud-managed store) --------
|
||||
|
||||
def load(self) -> None:
|
||||
"""No-op: Mem0 manages persistence server-side."""
|
||||
pass
|
||||
|
||||
def save(self) -> None:
|
||||
"""No-op: Mem0 manages persistence server-side."""
|
||||
pass
|
||||
|
||||
# -------- Retrieval --------
|
||||
|
||||
def _build_search_filters(self, agent_role: str) -> Dict[str, Any]:
|
||||
"""Build the filters dict for Mem0 search.
|
||||
|
||||
Mem0 search requires a filters dict for entity scoping.
|
||||
user_id and agent_id are stored as separate records, so
|
||||
when both are configured we use an OR filter to match either.
|
||||
"""
|
||||
if self.user_id and self.agent_id:
|
||||
return {
|
||||
"OR": [
|
||||
{"user_id": self.user_id},
|
||||
{"agent_id": self.agent_id},
|
||||
]
|
||||
}
|
||||
elif self.user_id:
|
||||
return {"user_id": self.user_id}
|
||||
elif self.agent_id:
|
||||
return {"agent_id": self.agent_id}
|
||||
else:
|
||||
# Fallback: use agent_role as agent_id
|
||||
return {"agent_id": agent_role}
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
"""Search Mem0 for relevant memories.
|
||||
|
||||
Uses the filters dict to scope by user_id, agent_id, or both
|
||||
(via OR filter). The SDK returns {"memories": [...]}.
|
||||
"""
|
||||
if not query.text.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
filters = self._build_search_filters(agent_role)
|
||||
search_kwargs: Dict[str, Any] = {
|
||||
"query": query.text,
|
||||
"top_k": top_k,
|
||||
"filters": filters,
|
||||
}
|
||||
if similarity_threshold >= 0:
|
||||
search_kwargs["threshold"] = similarity_threshold
|
||||
|
||||
response = self.client.search(**search_kwargs)
|
||||
|
||||
# SDK returns {"memories": [...]} — extract the list
|
||||
if isinstance(response, dict):
|
||||
raw_results = response.get("memories", response.get("results", []))
|
||||
else:
|
||||
raw_results = response
|
||||
except Exception as e:
|
||||
logger.error("Mem0 search failed: %s", e)
|
||||
return []
|
||||
|
||||
items: List[MemoryItem] = []
|
||||
for entry in raw_results:
|
||||
item = MemoryItem(
|
||||
id=entry.get("id", f"mem0_{uuid.uuid4().hex}"),
|
||||
content_summary=entry.get("memory", ""),
|
||||
metadata={
|
||||
"agent_role": agent_role,
|
||||
"score": entry.get("score"),
|
||||
"categories": entry.get("categories", []),
|
||||
"source": "mem0",
|
||||
},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
# -------- Update --------
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
"""Store user input as a memory in Mem0.
|
||||
|
||||
Only user input is sent for extraction. Assistant output is excluded
|
||||
to prevent noise memories from the LLM's responses.
|
||||
"""
|
||||
raw_input = payload.inputs_text or ""
|
||||
if not raw_input.strip():
|
||||
return
|
||||
|
||||
messages = self._build_messages(payload)
|
||||
if not messages:
|
||||
return
|
||||
|
||||
add_kwargs: Dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"infer": True,
|
||||
}
|
||||
|
||||
# Include both user_id and agent_id when available — they are
|
||||
# independent scoping dimensions in Mem0, not mutually exclusive.
|
||||
if self.agent_id:
|
||||
add_kwargs["agent_id"] = self.agent_id
|
||||
if self.user_id:
|
||||
add_kwargs["user_id"] = self.user_id
|
||||
|
||||
# Fallback when neither is configured
|
||||
if "agent_id" not in add_kwargs and "user_id" not in add_kwargs:
|
||||
add_kwargs["agent_id"] = payload.agent_role
|
||||
|
||||
try:
|
||||
result = self.client.add(**add_kwargs)
|
||||
logger.info("Mem0 add result: %s", result)
|
||||
except Exception as e:
|
||||
logger.error("Mem0 add failed: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _clean_pipeline_text(text: str) -> str:
|
||||
"""Strip ChatDev pipeline headers so Mem0 sees clean conversational text.
|
||||
|
||||
The executor wraps each input with '=== INPUT FROM <source> (<role>) ==='
|
||||
headers. Mem0's extraction LLM treats these as system metadata and skips
|
||||
them, resulting in zero memories extracted.
|
||||
"""
|
||||
cleaned = re.sub(r"===\s*INPUT FROM\s+\S+\s*\(\w+\)\s*===\s*", "", text)
|
||||
return cleaned.strip()
|
||||
|
||||
def _build_messages(self, payload: MemoryWritePayload) -> List[Dict[str, str]]:
|
||||
"""Build Mem0-compatible message list from write payload.
|
||||
|
||||
Only sends user input to Mem0. Assistant output is excluded because
|
||||
Mem0's extraction LLM processes ALL messages and extracts facts from
|
||||
assistant responses too, creating noise memories like "Assistant says
|
||||
Python is fascinating" instead of actual user facts.
|
||||
"""
|
||||
messages: List[Dict[str, str]] = []
|
||||
|
||||
raw_input = payload.inputs_text or ""
|
||||
clean_input = self._clean_pipeline_text(raw_input)
|
||||
if clean_input:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": clean_input,
|
||||
})
|
||||
|
||||
return messages
|
||||
Executable
+304
@@ -0,0 +1,304 @@
|
||||
"""Base memory abstractions with multimodal snapshots."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
|
||||
from entity.configs import MemoryAttachmentConfig, MemoryStoreConfig
|
||||
from entity.configs.node.memory import FileMemoryConfig, SimpleMemoryConfig
|
||||
from entity.enums import AgentExecFlowStage
|
||||
from entity.messages import Message, MessageBlock
|
||||
from runtime.node.agent.memory.embedding import EmbeddingBase, EmbeddingFactory
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryContentSnapshot:
|
||||
"""Lightweight serialization of a multimodal payload."""
|
||||
|
||||
text: str
|
||||
blocks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"text": self.text, "blocks": self.blocks}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any] | None) -> "MemoryContentSnapshot | None":
|
||||
if not payload:
|
||||
return None
|
||||
text = payload.get("text", "")
|
||||
blocks = payload.get("blocks") or []
|
||||
return cls(text=text, blocks=list(blocks))
|
||||
|
||||
@classmethod
|
||||
def from_message(cls, message: Message | str | None) -> "MemoryContentSnapshot | None":
|
||||
if message is None:
|
||||
return None
|
||||
if isinstance(message, Message):
|
||||
return cls(
|
||||
text=message.text_content(),
|
||||
blocks=[
|
||||
{
|
||||
"role": message.role.value,
|
||||
"block": block.to_dict(include_data=True),
|
||||
}
|
||||
for block in message.blocks()
|
||||
],
|
||||
)
|
||||
if isinstance(message, str):
|
||||
return cls(text=message, blocks=[])
|
||||
return cls(text=str(message), blocks=[])
|
||||
|
||||
@classmethod
|
||||
def from_messages(cls, messages: List[Message]) -> "MemoryContentSnapshot | None":
|
||||
if not messages:
|
||||
return None
|
||||
parts: List[str] = []
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for message in messages:
|
||||
parts.append(f"({message.role.value}) {message.text_content()}")
|
||||
for block in message.blocks():
|
||||
blocks.append(
|
||||
{
|
||||
"role": message.role.value,
|
||||
"block": block.to_dict(include_data=True),
|
||||
}
|
||||
)
|
||||
return cls(text="\n\n".join(parts), blocks=blocks)
|
||||
|
||||
def to_message_blocks(self) -> List[MessageBlock]:
|
||||
blocks: List[MessageBlock] = []
|
||||
for payload in self.blocks:
|
||||
block_data = payload.get("block") if isinstance(payload, dict) else None
|
||||
if not isinstance(block_data, dict):
|
||||
continue
|
||||
try:
|
||||
blocks.append(MessageBlock.from_dict(block_data))
|
||||
except Exception:
|
||||
continue
|
||||
return blocks
|
||||
|
||||
def attachment_overview(self) -> List[Dict[str, Any]]:
|
||||
attachments: List[Dict[str, Any]] = []
|
||||
for payload in self.blocks:
|
||||
block_data = payload.get("block") if isinstance(payload, dict) else None
|
||||
if not isinstance(block_data, dict):
|
||||
continue
|
||||
attachment = block_data.get("attachment")
|
||||
if attachment:
|
||||
attachments.append(
|
||||
{
|
||||
"role": payload.get("role"),
|
||||
"attachment_id": attachment.get("attachment_id"),
|
||||
"mime_type": attachment.get("mime_type"),
|
||||
"name": attachment.get("name"),
|
||||
"size": attachment.get("size"),
|
||||
}
|
||||
)
|
||||
return attachments
|
||||
|
||||
@classmethod
|
||||
def from_blocks(
|
||||
cls,
|
||||
*,
|
||||
text: str,
|
||||
blocks: List[MessageBlock],
|
||||
role: str = "input",
|
||||
) -> "MemoryContentSnapshot":
|
||||
serialized = [
|
||||
{
|
||||
"role": role,
|
||||
"block": block.to_dict(include_data=True),
|
||||
}
|
||||
for block in blocks
|
||||
]
|
||||
return cls(text=text, blocks=serialized)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryItem:
|
||||
id: str
|
||||
content_summary: str
|
||||
metadata: Dict[str, Any]
|
||||
embedding: Optional[List[float]] = None
|
||||
timestamp: float | None = None
|
||||
input_snapshot: MemoryContentSnapshot | None = None
|
||||
output_snapshot: MemoryContentSnapshot | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.timestamp is None:
|
||||
self.timestamp = time.time()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"content_summary": self.content_summary,
|
||||
"metadata": self.metadata,
|
||||
"embedding": self.embedding,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
if self.input_snapshot:
|
||||
payload["input_snapshot"] = self.input_snapshot.to_dict()
|
||||
if self.output_snapshot:
|
||||
payload["output_snapshot"] = self.output_snapshot.to_dict()
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "MemoryItem":
|
||||
return cls(
|
||||
id=payload["id"],
|
||||
content_summary=payload.get("content_summary", ""),
|
||||
metadata=payload.get("metadata") or {},
|
||||
embedding=payload.get("embedding"),
|
||||
timestamp=payload.get("timestamp"),
|
||||
input_snapshot=MemoryContentSnapshot.from_dict(payload.get("input_snapshot")),
|
||||
output_snapshot=MemoryContentSnapshot.from_dict(payload.get("output_snapshot")),
|
||||
)
|
||||
|
||||
def attachments(self) -> List[Dict[str, Any]]:
|
||||
attachments: List[Dict[str, Any]] = []
|
||||
if self.input_snapshot:
|
||||
attachments.extend(self.input_snapshot.attachment_overview())
|
||||
if self.output_snapshot:
|
||||
attachments.extend(self.output_snapshot.attachment_overview())
|
||||
return attachments
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryWritePayload:
|
||||
agent_role: str
|
||||
inputs_text: str
|
||||
input_snapshot: MemoryContentSnapshot | None
|
||||
output_snapshot: MemoryContentSnapshot | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryRetrievalResult:
|
||||
formatted_text: str
|
||||
items: List[MemoryItem]
|
||||
|
||||
def has_multimodal(self) -> bool:
|
||||
return any(
|
||||
(item.input_snapshot and item.input_snapshot.blocks)
|
||||
or (item.output_snapshot and item.output_snapshot.blocks)
|
||||
for item in self.items
|
||||
)
|
||||
|
||||
def attachment_overview(self) -> List[Dict[str, Any]]:
|
||||
attachments: List[Dict[str, Any]] = []
|
||||
for item in self.items:
|
||||
attachments.extend(item.attachments())
|
||||
return attachments
|
||||
|
||||
|
||||
class MemoryBase:
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
self.store = store
|
||||
self.name = store.name
|
||||
self.contents: List[MemoryItem] = []
|
||||
|
||||
embedding_cfg = None
|
||||
simple_cfg = store.as_config(SimpleMemoryConfig)
|
||||
file_cfg = store.as_config(FileMemoryConfig)
|
||||
if simple_cfg and simple_cfg.embedding:
|
||||
embedding_cfg = simple_cfg.embedding
|
||||
elif file_cfg and file_cfg.embedding:
|
||||
embedding_cfg = file_cfg.embedding
|
||||
|
||||
self.embedding: EmbeddingBase | None = (
|
||||
EmbeddingFactory.create_embedding(embedding_cfg) if embedding_cfg else None
|
||||
)
|
||||
|
||||
def count_memories(self) -> int:
|
||||
return len(self.contents)
|
||||
|
||||
def load(self) -> None: # pragma: no cover - implemented by subclasses
|
||||
raise NotImplementedError
|
||||
|
||||
def save(self) -> None: # pragma: no cover - implemented by subclasses
|
||||
raise NotImplementedError
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, attachments: List[MemoryAttachmentConfig], stores: Dict[str, MemoryBase]):
|
||||
self.attachments = attachments
|
||||
self.memories: Dict[str, MemoryBase] = {}
|
||||
for attachment in attachments:
|
||||
memory = stores.get(attachment.name)
|
||||
if not memory:
|
||||
raise ValueError(f"memory store {attachment.name} not found")
|
||||
self.memories[attachment.name] = memory
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
current_stage: AgentExecFlowStage,
|
||||
) -> MemoryRetrievalResult | None:
|
||||
results: List[tuple[str, MemoryItem, float]] = []
|
||||
for attachment in self.attachments:
|
||||
if attachment.retrieve_stage and current_stage not in attachment.retrieve_stage:
|
||||
continue
|
||||
if not attachment.read:
|
||||
continue
|
||||
memory = self.memories.get(attachment.name)
|
||||
if not memory:
|
||||
continue
|
||||
items = memory.retrieve(agent_role, query, attachment.top_k, attachment.similarity_threshold)
|
||||
for item in items:
|
||||
combined_score = self._score_memory(item, query.text)
|
||||
results.append((attachment.name, item, combined_score))
|
||||
|
||||
if not results:
|
||||
return None
|
||||
|
||||
results.sort(key=lambda entry: entry[2], reverse=True)
|
||||
formatted = ["===== Related Memories ====="]
|
||||
grouped: Dict[str, List[MemoryItem]] = {}
|
||||
for name, item, _ in results:
|
||||
grouped.setdefault(name, []).append(item)
|
||||
for name, items in grouped.items():
|
||||
formatted.append(f"\n--- {name} ---")
|
||||
for idx, item in enumerate(items, 1):
|
||||
formatted.append(f"{idx}. {item.content_summary}")
|
||||
formatted.append("\n===== End of Memory =====")
|
||||
|
||||
ordered_items = [item for _, item, _ in results]
|
||||
return MemoryRetrievalResult(formatted_text="\n".join(formatted), items=ordered_items)
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
for attachment in self.attachments:
|
||||
if not attachment.write:
|
||||
continue
|
||||
memory = self.memories.get(attachment.name)
|
||||
if not memory:
|
||||
continue
|
||||
memory.update(payload)
|
||||
memory.save()
|
||||
|
||||
def _score_memory(self, memory_item: MemoryItem, query: str) -> float:
|
||||
current_time = time.time()
|
||||
age_hours = (current_time - (memory_item.timestamp or current_time)) / 3600
|
||||
time_decay = max(0.1, 1.0 - age_hours / (24 * 30))
|
||||
length = len(memory_item.content_summary)
|
||||
if length < 20:
|
||||
length_factor = 0.5
|
||||
elif length > 200:
|
||||
length_factor = 0.8
|
||||
else:
|
||||
length_factor = 1.0
|
||||
query_words = set(query.lower().split())
|
||||
content_words = set(memory_item.content_summary.lower().split())
|
||||
relevance = len(query_words & content_words) / len(query_words) if query_words else 0.0
|
||||
return 0.7 * time_decay * length_factor + 0.3 * relevance
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
"""Registry for memory store implementations."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, Callable, Dict, Type
|
||||
|
||||
from schema_registry import register_memory_store_schema
|
||||
from utils.registry import Registry, RegistryEntry, RegistryError
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from runtime.node.agent.memory.memory_base import MemoryBase
|
||||
|
||||
memory_store_registry = Registry("memory_store")
|
||||
_BUILTINS_LOADED = False
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemoryStoreRegistration:
|
||||
name: str
|
||||
config_cls: Type[Any]
|
||||
factory: Callable[["MemoryStoreConfig"], "MemoryBase"]
|
||||
summary: str | None = None
|
||||
|
||||
|
||||
def _ensure_builtins_loaded() -> None:
|
||||
global _BUILTINS_LOADED
|
||||
if not _BUILTINS_LOADED:
|
||||
import_module("runtime.node.agent.memory.builtin_stores")
|
||||
_BUILTINS_LOADED = True
|
||||
|
||||
|
||||
def register_memory_store(
|
||||
name: str,
|
||||
*,
|
||||
config_cls: Type[Any],
|
||||
factory: Callable[["MemoryStoreConfig"], "MemoryBase"],
|
||||
summary: str | None = None,
|
||||
) -> None:
|
||||
if name in memory_store_registry.names():
|
||||
raise RegistryError(f"Memory store '{name}' already registered")
|
||||
entry = MemoryStoreRegistration(name=name, config_cls=config_cls, factory=factory, summary=summary)
|
||||
memory_store_registry.register(name, target=entry)
|
||||
register_memory_store_schema(name, config_cls=config_cls, summary=summary)
|
||||
|
||||
|
||||
def get_memory_store_registration(name: str) -> MemoryStoreRegistration:
|
||||
_ensure_builtins_loaded()
|
||||
entry: RegistryEntry = memory_store_registry.get(name)
|
||||
registration = entry.load()
|
||||
if not isinstance(registration, MemoryStoreRegistration):
|
||||
raise RegistryError(f"Entry '{name}' is not a MemoryStoreRegistration")
|
||||
return registration
|
||||
|
||||
|
||||
def iter_memory_store_registrations() -> Dict[str, MemoryStoreRegistration]:
|
||||
_ensure_builtins_loaded()
|
||||
return {name: entry.load() for name, entry in memory_store_registry.items()}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"memory_store_registry",
|
||||
"MemoryStoreRegistration",
|
||||
"register_memory_store",
|
||||
"get_memory_store_registration",
|
||||
"iter_memory_store_registrations",
|
||||
]
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from entity.configs.node.memory import SimpleMemoryConfig
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
import faiss
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SimpleMemory(MemoryBase):
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(SimpleMemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("SimpleMemory requires a simple memory store configuration")
|
||||
super().__init__(store)
|
||||
self.config = config
|
||||
# Optimized prompt templates for clarity
|
||||
self.retrieve_prompt = "Query: {input}"
|
||||
self.update_prompt = "Input: {input}\nOutput: {output}"
|
||||
self.memory_path = self.config.memory_path # auto
|
||||
|
||||
# Content extraction configuration
|
||||
self.max_content_length = 500 # Maximum content length
|
||||
self.min_content_length = 20 # Minimum content length
|
||||
|
||||
def _extract_key_content(self, content: str) -> str:
|
||||
"""Extract key content while stripping redundant text."""
|
||||
# Remove redundant whitespace
|
||||
content = re.sub(r'\s+', ' ', content.strip())
|
||||
|
||||
# Skip heavy processing for short snippets
|
||||
if len(content) <= 100:
|
||||
return content
|
||||
|
||||
# Remove common templated instructions
|
||||
content = re.sub(r'(?:Agent|Model) Role:.*?\n\n', '', content)
|
||||
content = re.sub("(?:You are|\u4f60\u662f\u4e00\u4f4d).*?(?:,|\uff0c)", '', content)
|
||||
content = re.sub("(?:User will input|\u7528\u6237\u4f1a\u8f93\u5165).*?(?:,|\uff0c)", '', content)
|
||||
content = re.sub("(?:You need to|\u4f60\u9700\u8981).*?(?:,|\uff0c)", '', content)
|
||||
|
||||
# Extract key sentences while skipping very short ones
|
||||
sentences = re.split(r'[\u3002\uff01\uff1f\uff1b\n]', content)
|
||||
key_sentences = [s.strip() for s in sentences if len(s.strip()) >= self.min_content_length]
|
||||
|
||||
# Fallback to original content when no sentence survives
|
||||
if not key_sentences:
|
||||
return content[:self.max_content_length]
|
||||
|
||||
# Recombine and limit the number of sentences (max 3)
|
||||
extracted_content = '\u3002'.join(key_sentences[:3])
|
||||
if len(extracted_content) > self.max_content_length:
|
||||
extracted_content = extracted_content[:self.max_content_length] + "..."
|
||||
|
||||
return extracted_content.strip()
|
||||
|
||||
def _generate_content_hash(self, content: str) -> str:
|
||||
"""Generate a content hash used for deduplication."""
|
||||
return hashlib.md5(content.encode('utf-8')).hexdigest()[:8]
|
||||
|
||||
def load(self) -> None:
|
||||
if self.memory_path and os.path.exists(self.memory_path) and self.memory_path.endswith(".json"):
|
||||
try:
|
||||
with open(self.memory_path) as file:
|
||||
raw_data = json.load(file)
|
||||
contents = []
|
||||
for raw in raw_data:
|
||||
try:
|
||||
contents.append(MemoryItem.from_dict(raw))
|
||||
except Exception:
|
||||
continue
|
||||
self.contents = contents
|
||||
except Exception:
|
||||
self.contents = []
|
||||
|
||||
def save(self) -> None:
|
||||
if self.memory_path and self.memory_path.endswith(".json"):
|
||||
os.makedirs(os.path.dirname(self.memory_path), exist_ok=True)
|
||||
with open(self.memory_path, "w") as file:
|
||||
json.dump([item.to_dict() for item in self.contents], file, indent=2, ensure_ascii=False)
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
if self.count_memories() == 0 or not self.embedding:
|
||||
return []
|
||||
|
||||
# Build an optimized query for retrieval
|
||||
query_text = self.retrieve_prompt.format(input=query.text)
|
||||
query_text = self._extract_key_content(query_text)
|
||||
|
||||
inputs_embedding = self.embedding.get_embedding(query_text)
|
||||
if isinstance(inputs_embedding, list):
|
||||
inputs_embedding = np.array(inputs_embedding, dtype=np.float32)
|
||||
inputs_embedding = inputs_embedding.reshape(1, -1)
|
||||
faiss.normalize_L2(inputs_embedding)
|
||||
|
||||
expected_dim = inputs_embedding.shape[1]
|
||||
|
||||
memory_embeddings = []
|
||||
valid_items = []
|
||||
for item in self.contents:
|
||||
if item.embedding is not None:
|
||||
if len(item.embedding) != expected_dim:
|
||||
logger.warning(
|
||||
"Skipping memory item %s: embedding dim %d != expected %d",
|
||||
item.id, len(item.embedding), expected_dim,
|
||||
)
|
||||
continue
|
||||
memory_embeddings.append(item.embedding)
|
||||
valid_items.append(item)
|
||||
|
||||
if not memory_embeddings:
|
||||
return []
|
||||
|
||||
memory_embeddings = np.array(memory_embeddings, dtype=np.float32)
|
||||
|
||||
# Use an efficient inner-product index
|
||||
index = faiss.IndexFlatIP(memory_embeddings.shape[1])
|
||||
index.add(memory_embeddings)
|
||||
|
||||
# Retrieve extra candidates for reranking
|
||||
retrieval_k = min(top_k * 3, len(valid_items))
|
||||
similarities, indices = index.search(inputs_embedding, retrieval_k)
|
||||
|
||||
# Filter and rerank the candidates
|
||||
candidates = []
|
||||
for i in range(len(indices[0])):
|
||||
idx = indices[0][i]
|
||||
similarity = similarities[0][i]
|
||||
|
||||
if idx != -1 and similarity >= similarity_threshold:
|
||||
item = valid_items[idx]
|
||||
# Calculate an auxiliary semantic similarity score
|
||||
semantic_score = self._calculate_semantic_similarity(query_text, item.content_summary)
|
||||
# Combine similarity metrics
|
||||
combined_score = 0.7 * similarity + 0.3 * semantic_score
|
||||
candidates.append((item, combined_score))
|
||||
|
||||
# Sort by the combined score and return the top_k items
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
results = [item for item, score in candidates[:top_k]]
|
||||
|
||||
return results
|
||||
|
||||
def _calculate_semantic_similarity(self, query: str, content: str) -> float:
|
||||
"""Compute a semantic similarity value."""
|
||||
# Enhanced semantic similarity computation
|
||||
query_lower = query.lower()
|
||||
content_lower = content.lower()
|
||||
|
||||
# 1. Token overlap (Jaccard similarity)
|
||||
query_words = set(query_lower.split())
|
||||
content_words = set(content_lower.split())
|
||||
|
||||
if not query_words or not content_words:
|
||||
jaccard_sim = 0.0
|
||||
else:
|
||||
intersection = query_words & content_words
|
||||
union = query_words | content_words
|
||||
jaccard_sim = len(intersection) / len(union) if union else 0.0
|
||||
|
||||
# 2. Longest common subsequence similarity
|
||||
lcs_sim = self._calculate_lcs_similarity(query_lower, content_lower)
|
||||
|
||||
# 3. Keyword match score
|
||||
keyword_sim = self._calculate_keyword_similarity(query_lower, content_lower)
|
||||
|
||||
# 4. Length penalty factor (avoid overly short/long matches)
|
||||
length_factor = self._calculate_length_factor(query_lower, content_lower)
|
||||
|
||||
# Weighted final score
|
||||
final_score = (0.4 * jaccard_sim +
|
||||
0.3 * lcs_sim +
|
||||
0.2 * keyword_sim +
|
||||
0.1 * length_factor)
|
||||
|
||||
return min(final_score, 1.0)
|
||||
|
||||
def _calculate_lcs_similarity(self, s1: str, s2: str) -> float:
|
||||
"""Compute longest common subsequence similarity."""
|
||||
m, n = len(s1), len(s2)
|
||||
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
||||
|
||||
for i in range(1, m + 1):
|
||||
for j in range(1, n + 1):
|
||||
if s1[i-1] == s2[j-1]:
|
||||
dp[i][j] = dp[i-1][j-1] + 1
|
||||
else:
|
||||
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
|
||||
|
||||
lcs_length = dp[m][n]
|
||||
return lcs_length / max(len(s1), len(s2)) if max(len(s1), len(s2)) > 0 else 0.0
|
||||
|
||||
def _calculate_keyword_similarity(self, query: str, content: str) -> float:
|
||||
"""Compute keyword match similarity."""
|
||||
# Extract potential keywords (length >= 2)
|
||||
query_keywords = set(word for word in query.split() if len(word) >= 2)
|
||||
content_keywords = set(word for word in content.split() if len(word) >= 2)
|
||||
|
||||
if not query_keywords:
|
||||
return 0.0
|
||||
|
||||
matches = query_keywords & content_keywords
|
||||
return len(matches) / len(query_keywords)
|
||||
|
||||
def _calculate_length_factor(self, query: str, content: str) -> float:
|
||||
"""Penalize matches that deviate too much in length."""
|
||||
query_len = len(query)
|
||||
content_len = len(content)
|
||||
|
||||
if content_len == 0:
|
||||
return 0.0
|
||||
|
||||
# Ideal length ratio range
|
||||
ideal_ratio_min = 0.5
|
||||
ideal_ratio_max = 2.0
|
||||
|
||||
ratio = content_len / query_len
|
||||
|
||||
if ideal_ratio_min <= ratio <= ideal_ratio_max:
|
||||
return 1.0
|
||||
elif ratio < ideal_ratio_min:
|
||||
return ratio / ideal_ratio_min
|
||||
else:
|
||||
return max(0.1, ideal_ratio_max / ratio)
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
if not self.embedding:
|
||||
return
|
||||
|
||||
snapshot = payload.output_snapshot
|
||||
if not snapshot or not snapshot.text.strip():
|
||||
return
|
||||
|
||||
raw_content = self.update_prompt.format(
|
||||
input=payload.inputs_text,
|
||||
output=snapshot.text,
|
||||
)
|
||||
extracted_content = self._extract_key_content(raw_content)
|
||||
|
||||
if len(extracted_content) < self.min_content_length:
|
||||
return
|
||||
|
||||
content_hash = self._generate_content_hash(extracted_content)
|
||||
for existing_item in self.contents:
|
||||
existing_hash = self._generate_content_hash(existing_item.content_summary)
|
||||
if existing_hash == content_hash:
|
||||
return
|
||||
|
||||
embedding_vector = self.embedding.get_embedding(extracted_content)
|
||||
if isinstance(embedding_vector, list):
|
||||
embedding_vector = np.array(embedding_vector, dtype=np.float32)
|
||||
if embedding_vector is None:
|
||||
return
|
||||
embedding_array = np.array(embedding_vector, dtype=np.float32).reshape(1, -1)
|
||||
faiss.normalize_L2(embedding_array)
|
||||
|
||||
metadata = {
|
||||
"agent_role": payload.agent_role,
|
||||
"input_preview": (payload.inputs_text or "")[:200],
|
||||
"content_length": len(extracted_content),
|
||||
"attachments": snapshot.attachment_overview(),
|
||||
}
|
||||
|
||||
memory_item = MemoryItem(
|
||||
id=f"{content_hash}_{int(time.time())}",
|
||||
content_summary=extracted_content,
|
||||
metadata=metadata,
|
||||
embedding=embedding_array.tolist()[0],
|
||||
input_snapshot=payload.input_snapshot,
|
||||
output_snapshot=snapshot,
|
||||
)
|
||||
|
||||
self.contents.append(memory_item)
|
||||
|
||||
max_memories = 1000
|
||||
if len(self.contents) > max_memories:
|
||||
self.contents = self.contents[-max_memories:]
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
from .base import ModelProvider, ProviderRegistry
|
||||
from .response import ModelResponse
|
||||
|
||||
__all__ = [
|
||||
"ModelProvider",
|
||||
"ProviderRegistry",
|
||||
"ModelResponse",
|
||||
]
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
"""Abstract base classes for agent providers."""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from entity.configs import AgentConfig
|
||||
from entity.messages import Message
|
||||
from schema_registry import register_model_provider_schema
|
||||
from entity.tool_spec import ToolSpec
|
||||
from runtime.node.agent.providers.response import ModelResponse
|
||||
from utils.token_tracker import TokenUsage
|
||||
from utils.registry import Registry
|
||||
|
||||
|
||||
class ModelProvider(ABC):
|
||||
"""Abstract base class for all agent providers."""
|
||||
|
||||
def __init__(self, config: AgentConfig):
|
||||
"""
|
||||
Initialize the agent provider with configuration.
|
||||
|
||||
Args:
|
||||
config: Agent configuration instance
|
||||
"""
|
||||
self.config = config
|
||||
self.base_url = config.base_url
|
||||
self.api_key = config.api_key
|
||||
self.model_name = config.name if isinstance(config.name, str) else str(config.name)
|
||||
self.provider = config.provider
|
||||
self.params = config.params or {}
|
||||
|
||||
@abstractmethod
|
||||
def create_client(self):
|
||||
"""
|
||||
Create and return the appropriate client for this provider.
|
||||
|
||||
Returns:
|
||||
Client instance for making API calls
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def call_model(
|
||||
self,
|
||||
client,
|
||||
conversation: List[Message],
|
||||
timeline: List[Any],
|
||||
tool_specs: Optional[List[ToolSpec]] = None,
|
||||
**kwargs,
|
||||
) -> ModelResponse:
|
||||
"""
|
||||
Call the model with the given messages and parameters.
|
||||
|
||||
Args:
|
||||
client: Provider-specific client instance
|
||||
conversation: List of messages in the conversation
|
||||
tool_specs: Tool specifications available for this call
|
||||
**kwargs: Additional parameters for the model call
|
||||
|
||||
Returns:
|
||||
ModelResponse containing content and potentially tool calls
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def extract_token_usage(self, response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract token usage from the API response.
|
||||
|
||||
Args:
|
||||
response: Raw API response from the model call
|
||||
|
||||
Returns:
|
||||
TokenUsage instance with token counts
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
_provider_registry = Registry("agent_provider")
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""Registry facade for agent providers."""
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
name: str,
|
||||
provider_class: type,
|
||||
*,
|
||||
label: str | None = None,
|
||||
summary: str | None = None,
|
||||
) -> None:
|
||||
metadata = {
|
||||
"label": label,
|
||||
"summary": summary,
|
||||
}
|
||||
# Drop None values so schema consumers don't need to filter.
|
||||
metadata = {key: value for key, value in metadata.items() if value is not None}
|
||||
_provider_registry.register(name, target=provider_class, metadata=metadata)
|
||||
register_model_provider_schema(name, label=label, summary=summary)
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, name: str) -> type | None:
|
||||
try:
|
||||
entry = _provider_registry.get(name)
|
||||
except Exception:
|
||||
return None
|
||||
return entry.load()
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls) -> List[str]:
|
||||
return list(_provider_registry.names())
|
||||
|
||||
@classmethod
|
||||
def iter_metadata(cls) -> Dict[str, Dict[str, Any]]:
|
||||
return {name: dict(entry.metadata or {}) for name, entry in _provider_registry.items()}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""Register built-in agent providers."""
|
||||
|
||||
from runtime.node.agent.providers.base import ProviderRegistry
|
||||
|
||||
from runtime.node.agent.providers.openai_provider import OpenAIProvider
|
||||
|
||||
ProviderRegistry.register(
|
||||
"openai",
|
||||
OpenAIProvider,
|
||||
label="OpenAI",
|
||||
summary="OpenAI models via the official OpenAI SDK (responses API)",
|
||||
)
|
||||
|
||||
try:
|
||||
from runtime.node.agent.providers.gemini_provider import GeminiProvider
|
||||
except ImportError:
|
||||
GeminiProvider = None
|
||||
|
||||
if GeminiProvider is not None:
|
||||
ProviderRegistry.register(
|
||||
"gemini",
|
||||
GeminiProvider,
|
||||
label="Google Gemini",
|
||||
summary="Google Gemini models via google-genai",
|
||||
)
|
||||
else:
|
||||
print("Gemini provider not registered: google-genai library not found.")
|
||||
+833
@@ -0,0 +1,833 @@
|
||||
"""Gemini provider implementation."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from google.genai.types import GenerateContentResponse
|
||||
|
||||
from entity.messages import (
|
||||
AttachmentRef,
|
||||
FunctionCallOutputEvent,
|
||||
Message,
|
||||
MessageBlock,
|
||||
MessageBlockType,
|
||||
MessageRole,
|
||||
ToolCallPayload,
|
||||
)
|
||||
from entity.tool_spec import ToolSpec
|
||||
from runtime.node.agent import ModelProvider
|
||||
from runtime.node.agent import ModelResponse
|
||||
from utils.token_tracker import TokenUsage
|
||||
|
||||
|
||||
class GeminiProvider(ModelProvider):
|
||||
"""Gemini provider implementation."""
|
||||
|
||||
CSV_INLINE_CHAR_LIMIT = 200_000
|
||||
CSV_INLINE_SIZE_THRESHOLD_BYTES = 3 * 1024 * 1024 # 3 MB
|
||||
|
||||
def create_client(self):
|
||||
"""
|
||||
Create and return the Gemini client.
|
||||
"""
|
||||
client_kwargs: Dict[str, Any] = {}
|
||||
if self.api_key:
|
||||
client_kwargs["api_key"] = self.api_key
|
||||
|
||||
base_url = (self.base_url or "").strip()
|
||||
http_options = self._build_http_options(base_url)
|
||||
if http_options:
|
||||
client_kwargs["http_options"] = http_options
|
||||
|
||||
return genai.Client(**client_kwargs)
|
||||
|
||||
def call_model(
|
||||
self,
|
||||
client,
|
||||
conversation: List[Message],
|
||||
timeline: List[Any],
|
||||
tool_specs: Optional[List[ToolSpec]] = None,
|
||||
**kwargs,
|
||||
) -> ModelResponse:
|
||||
"""
|
||||
Call the Gemini model using the unified conversation timeline.
|
||||
"""
|
||||
contents, system_instruction = self._build_contents(timeline)
|
||||
config = self._build_generation_config(system_instruction, tool_specs, kwargs)
|
||||
# print(contents)
|
||||
# print(config)
|
||||
|
||||
response: GenerateContentResponse = client.models.generate_content(
|
||||
model=self.model_name,
|
||||
contents=contents,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# print(response)
|
||||
|
||||
self._track_token_usage(response)
|
||||
self._append_response_contents(timeline, response)
|
||||
message = self._deserialize_response(response)
|
||||
return ModelResponse(message=message, raw_response=response)
|
||||
|
||||
def extract_token_usage(self, response: Any) -> TokenUsage:
|
||||
"""Extract token usage from Gemini usage metadata."""
|
||||
usage_metadata = getattr(response, "usage_metadata", None)
|
||||
if not usage_metadata:
|
||||
return TokenUsage()
|
||||
|
||||
prompt_tokens = getattr(usage_metadata, "prompt_token_count", None) or 0
|
||||
candidate_tokens = getattr(usage_metadata, "candidates_token_count", None) or 0
|
||||
total_tokens = getattr(usage_metadata, "total_token_count", None)
|
||||
cached_tokens = getattr(usage_metadata, "cached_content_token_count", None)
|
||||
|
||||
metadata = {
|
||||
"prompt_token_count": prompt_tokens,
|
||||
"candidates_token_count": candidate_tokens,
|
||||
}
|
||||
if total_tokens is not None:
|
||||
metadata["total_token_count"] = total_tokens
|
||||
if cached_tokens is not None:
|
||||
metadata["cached_content_token_count"] = cached_tokens
|
||||
|
||||
return TokenUsage(
|
||||
input_tokens=prompt_tokens,
|
||||
output_tokens=candidate_tokens,
|
||||
total_tokens=total_tokens or (prompt_tokens + candidate_tokens),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _build_contents(
|
||||
self,
|
||||
timeline: List[Any],
|
||||
) -> Tuple[List[genai_types.Content], Optional[str]]:
|
||||
contents: List[genai_types.Content] = []
|
||||
system_prompts: List[str] = []
|
||||
|
||||
for item in timeline:
|
||||
if isinstance(item, Message):
|
||||
if item.role is MessageRole.SYSTEM:
|
||||
text = item.text_content().strip()
|
||||
if text:
|
||||
system_prompts.append(text)
|
||||
continue
|
||||
contents.append(self._message_to_content(item))
|
||||
continue
|
||||
|
||||
if isinstance(item, FunctionCallOutputEvent):
|
||||
contents.append(self._function_output_event_to_content(item))
|
||||
continue
|
||||
|
||||
if isinstance(item, genai_types.Content):
|
||||
contents.append(item)
|
||||
|
||||
if not contents:
|
||||
contents.append(
|
||||
genai_types.Content(
|
||||
role="user",
|
||||
parts=[genai_types.Part(text="")],
|
||||
)
|
||||
)
|
||||
|
||||
system_instruction = "\n\n".join(system_prompts) if system_prompts else None
|
||||
return contents, system_instruction
|
||||
|
||||
def _append_response_contents(self, timeline: List[Any], response: Any) -> None:
|
||||
candidates = getattr(response, "candidates", None)
|
||||
if not candidates:
|
||||
return
|
||||
for candidate in candidates:
|
||||
content = getattr(candidate, "content", None)
|
||||
if content:
|
||||
timeline.append(content)
|
||||
|
||||
def _message_to_content(self, message: Message) -> genai_types.Content:
|
||||
role = self._map_role(message.role)
|
||||
if message.role is MessageRole.TOOL:
|
||||
part = self._build_tool_response_part(message)
|
||||
return genai_types.Content(role="user", parts=[part])
|
||||
|
||||
parts: List[genai_types.Part] = []
|
||||
for block in message.blocks():
|
||||
parts.extend(self._block_to_parts(block))
|
||||
if not parts:
|
||||
text = message.text_content()
|
||||
parts.append(genai_types.Part(text=text))
|
||||
return genai_types.Content(role=role, parts=parts)
|
||||
|
||||
def _function_output_event_to_content(
|
||||
self,
|
||||
event: FunctionCallOutputEvent,
|
||||
) -> genai_types.Content:
|
||||
function_name = event.function_name or event.call_id or "tool"
|
||||
payload: Dict[str, Any] = {}
|
||||
function_result_parts: List[genai_types.FunctionResponsePart] = []
|
||||
result_texts: List[str] = []
|
||||
|
||||
if event.output_blocks:
|
||||
for block in event.output_blocks:
|
||||
# Describe the block for the text result
|
||||
desc = self._describe_block(block)
|
||||
if desc:
|
||||
result_texts.append(desc)
|
||||
|
||||
if self._block_has_attachment(block):
|
||||
# Check if we should inline this attachment as text
|
||||
if self._should_inline_attachment_as_text(block):
|
||||
text_content = self._read_attachment_text(block.attachment)
|
||||
if text_content:
|
||||
result_texts.append(f"\n[Attachment Content: {block.attachment.name}]\n{text_content}")
|
||||
continue
|
||||
|
||||
# Otherwise treat as binary part
|
||||
general_parts = self._block_to_parts(block)
|
||||
function_result_parts.extend(self._general_parts_to_function_response_parts(general_parts))
|
||||
else:
|
||||
if event.output_text:
|
||||
result_texts.append(event.output_text)
|
||||
|
||||
payload["result"] = "\n".join(result_texts)
|
||||
|
||||
function_part = genai_types.Part.from_function_response(
|
||||
name=function_name,
|
||||
response=payload or {"result": ""},
|
||||
parts=function_result_parts or None
|
||||
)
|
||||
|
||||
parts: List[genai_types.Part] = [function_part]
|
||||
return genai_types.Content(role="user", parts=parts)
|
||||
|
||||
def _should_inline_attachment_as_text(self, block: MessageBlock) -> bool:
|
||||
if not block.attachment:
|
||||
return False
|
||||
mime = (block.attachment.mime_type or "").lower()
|
||||
return (
|
||||
mime.startswith("text/") or
|
||||
mime == "application/json" or
|
||||
mime.endswith("+json") or
|
||||
mime.endswith("+xml")
|
||||
)
|
||||
|
||||
def _read_attachment_text(self, attachment: AttachmentRef) -> Optional[str]:
|
||||
data_bytes = self._read_attachment_bytes(attachment)
|
||||
return self._bytes_to_text(data_bytes)
|
||||
|
||||
def _general_parts_to_function_response_parts(self, parts: List[genai_types.Part]) -> List[genai_types.FunctionResponsePart]:
|
||||
function_response_parts: List[genai_types.FunctionResponsePart] = []
|
||||
for part in parts:
|
||||
if part.inline_data:
|
||||
# Convert inline_data (bytes) to base64 data URI and use from_uri
|
||||
function_response_parts.append(
|
||||
genai_types.FunctionResponsePart.from_bytes(data=part.inline_data.data, mime_type=part.inline_data.mime_type or "application/octet-stream")
|
||||
)
|
||||
if part.file_data:
|
||||
function_response_parts.append(
|
||||
genai_types.FunctionResponsePart.from_uri(file_uri=part.file_data.file_uri, mime_type=part.file_data.mime_type or "application/octet-stream")
|
||||
)
|
||||
return function_response_parts
|
||||
|
||||
def _build_tool_response_part(self, message: Message) -> genai_types.Part:
|
||||
tool_name = message.metadata.get("tool_name") if isinstance(message.metadata, dict) else None
|
||||
tool_name = tool_name or message.tool_call_id or "tool"
|
||||
payload, block_parts = self._serialize_tool_message_payload(message)
|
||||
return genai_types.Part(
|
||||
function_response=genai_types.FunctionResponse(
|
||||
name=tool_name,
|
||||
response=payload,
|
||||
parts=block_parts or None,
|
||||
)
|
||||
)
|
||||
|
||||
def _block_has_attachment(self, block: Any) -> bool:
|
||||
return isinstance(block, MessageBlock) and block.attachment is not None
|
||||
|
||||
def _serialize_tool_message_payload(self, message: Message) -> Tuple[Dict[str, Any], List[genai_types.FunctionResponsePart]]:
|
||||
content = message.content
|
||||
blocks: List[MessageBlock] = []
|
||||
if isinstance(content, str):
|
||||
stripped = content.strip()
|
||||
if stripped:
|
||||
try:
|
||||
payload = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
payload = {"result": stripped}
|
||||
else:
|
||||
payload = {"result": ""}
|
||||
return payload, []
|
||||
|
||||
if isinstance(content, list):
|
||||
blocks_payload = []
|
||||
for block in content:
|
||||
if isinstance(block, MessageBlock):
|
||||
blocks_payload.append(block.to_dict())
|
||||
blocks.append(block)
|
||||
elif isinstance(block, dict):
|
||||
blocks_payload.append(block)
|
||||
try:
|
||||
blocks.append(MessageBlock.from_dict(block))
|
||||
except Exception:
|
||||
continue
|
||||
parts = self._blocks_to_function_parts(blocks)
|
||||
return {"blocks": blocks_payload, "result": message.text_content()}, parts
|
||||
|
||||
parts = self._blocks_to_function_parts(blocks)
|
||||
return {"result": message.text_content()}, parts
|
||||
|
||||
def _describe_block(self, block: Any) -> str:
|
||||
if isinstance(block, MessageBlock):
|
||||
return block.describe()
|
||||
if isinstance(block, dict):
|
||||
text = block.get("text")
|
||||
if text:
|
||||
return str(text)
|
||||
return str(block)
|
||||
|
||||
def _block_to_parts(self, block: MessageBlock) -> List[genai_types.Part]:
|
||||
if block.type is MessageBlockType.TEXT:
|
||||
return [genai_types.Part(text=block.text or "")]
|
||||
|
||||
if block.type is MessageBlockType.FILE:
|
||||
csv_text = self._maybe_inline_large_csv(block)
|
||||
if csv_text is not None:
|
||||
return [genai_types.Part(text=csv_text)]
|
||||
|
||||
if block.type in (
|
||||
MessageBlockType.IMAGE,
|
||||
MessageBlockType.AUDIO,
|
||||
MessageBlockType.VIDEO,
|
||||
MessageBlockType.FILE,
|
||||
):
|
||||
media_part = self._attachment_block_to_part(block)
|
||||
return [media_part] if media_part else []
|
||||
|
||||
if block.type is MessageBlockType.DATA:
|
||||
data_payload = block.data or {}
|
||||
text = block.text or json.dumps(data_payload, ensure_ascii=False)
|
||||
return [genai_types.Part(text=text)]
|
||||
|
||||
return []
|
||||
|
||||
def _maybe_inline_large_csv(self, block: MessageBlock) -> Optional[str]:
|
||||
"""Convert large CSV attachments to inline text to avoid Gemini upload size limits."""
|
||||
|
||||
attachment = block.attachment
|
||||
if not attachment:
|
||||
return None
|
||||
|
||||
mime = (attachment.mime_type or "").lower()
|
||||
name = (attachment.name or "").lower()
|
||||
if "text/csv" not in mime and not name.endswith(".csv"):
|
||||
return None
|
||||
if attachment.remote_file_id:
|
||||
return None
|
||||
|
||||
threshold = getattr(
|
||||
self,
|
||||
"csv_inline_size_threshold_bytes",
|
||||
self.CSV_INLINE_SIZE_THRESHOLD_BYTES,
|
||||
)
|
||||
|
||||
size_bytes = attachment.size
|
||||
data_bytes: Optional[bytes] = None
|
||||
if size_bytes is None:
|
||||
data_bytes = self._read_attachment_bytes(attachment)
|
||||
if data_bytes is None:
|
||||
return None
|
||||
size_bytes = len(data_bytes)
|
||||
|
||||
if size_bytes is None or size_bytes <= threshold:
|
||||
return None
|
||||
|
||||
if data_bytes is None:
|
||||
data_bytes = self._read_attachment_bytes(attachment)
|
||||
if data_bytes is None:
|
||||
return None
|
||||
|
||||
text = self._bytes_to_text(data_bytes)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
char_limit = getattr(self, "csv_inline_char_limit", self.CSV_INLINE_CHAR_LIMIT)
|
||||
truncated = False
|
||||
if len(text) > char_limit:
|
||||
text = text[:char_limit]
|
||||
truncated = True
|
||||
|
||||
display_name = attachment.name or attachment.attachment_id or "attachment.csv"
|
||||
suffix = f"\n\n[truncated after {char_limit} characters]" if truncated else ""
|
||||
return f"CSV file '{display_name}' (converted from >3MB upload):\n{text}{suffix}"
|
||||
|
||||
def _bytes_to_text(self, data_bytes: Optional[bytes]) -> Optional[str]:
|
||||
if data_bytes is None:
|
||||
return None
|
||||
try:
|
||||
return data_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
def _attachment_block_to_part(self, block: MessageBlock) -> Optional[genai_types.Part]:
|
||||
attachment = block.attachment
|
||||
if not attachment:
|
||||
return None
|
||||
|
||||
metadata = attachment.metadata or {}
|
||||
gemini_file_uri = metadata.get("gemini_file_uri") or attachment.remote_file_id
|
||||
mime_type = attachment.mime_type or self._guess_mime_from_block(block)
|
||||
|
||||
if gemini_file_uri:
|
||||
return genai_types.Part(
|
||||
file_data=genai_types.FileData(
|
||||
file_uri=gemini_file_uri,
|
||||
mime_type=mime_type,
|
||||
# display_name=attachment.name
|
||||
)
|
||||
)
|
||||
|
||||
blob_data = self._read_attachment_bytes(attachment)
|
||||
if blob_data is None:
|
||||
return None
|
||||
|
||||
return genai_types.Part(
|
||||
inline_data=genai_types.Blob(
|
||||
mime_type=mime_type or "application/octet-stream",
|
||||
data=blob_data,
|
||||
# display_name=attachment.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _blocks_to_function_parts(
|
||||
self,
|
||||
blocks: Optional[Sequence[Any]],
|
||||
) -> List[genai_types.FunctionResponsePart]:
|
||||
if not blocks:
|
||||
return []
|
||||
parts: List[genai_types.FunctionResponsePart] = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, MessageBlock):
|
||||
if isinstance(block, dict):
|
||||
try:
|
||||
block = MessageBlock.from_dict(block)
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
attachment = block.attachment
|
||||
if not attachment:
|
||||
continue
|
||||
mime_type = attachment.mime_type or self._guess_mime_from_block(block)
|
||||
file_uri = (attachment.metadata or {}).get("gemini_file_uri") or attachment.remote_file_id
|
||||
if file_uri:
|
||||
parts.append(
|
||||
genai_types.FunctionResponsePart(
|
||||
file_data=genai_types.FunctionResponseFileData(
|
||||
file_uri=file_uri,
|
||||
mime_type=mime_type,
|
||||
display_name=attachment.name,
|
||||
)
|
||||
)
|
||||
)
|
||||
continue
|
||||
data_bytes = self._read_attachment_bytes(attachment)
|
||||
if not data_bytes:
|
||||
continue
|
||||
parts.append(
|
||||
genai_types.FunctionResponsePart(
|
||||
inline_data=genai_types.FunctionResponseBlob(
|
||||
mime_type=mime_type or "application/octet-stream",
|
||||
data=data_bytes,
|
||||
display_name=attachment.name,
|
||||
)
|
||||
)
|
||||
)
|
||||
return parts
|
||||
|
||||
def _coerce_message_blocks(self, payload: Any) -> List[MessageBlock]:
|
||||
if not isinstance(payload, Sequence) or isinstance(payload, (str, bytes, bytearray)):
|
||||
return []
|
||||
blocks: List[MessageBlock] = []
|
||||
for item in payload:
|
||||
if isinstance(item, MessageBlock):
|
||||
blocks.append(item)
|
||||
elif isinstance(item, dict):
|
||||
try:
|
||||
blocks.append(MessageBlock.from_dict(item))
|
||||
except Exception:
|
||||
continue
|
||||
return blocks
|
||||
|
||||
def _encode_thought_signature(self, value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return base64.b64encode(value).decode("ascii")
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _read_attachment_bytes(self, attachment: AttachmentRef) -> Optional[bytes]:
|
||||
if attachment.data_uri:
|
||||
decoded = self._decode_data_uri(attachment.data_uri)
|
||||
if decoded is not None:
|
||||
return decoded
|
||||
if attachment.local_path and os.path.exists(attachment.local_path):
|
||||
try:
|
||||
with open(attachment.local_path, "rb") as handle:
|
||||
return handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _decode_data_uri(self, data_uri: str) -> Optional[bytes]:
|
||||
if not data_uri.startswith("data:"):
|
||||
return None
|
||||
header, _, data = data_uri.partition(",")
|
||||
if not _:
|
||||
return None
|
||||
if ";base64" in header:
|
||||
try:
|
||||
return base64.b64decode(data)
|
||||
except (ValueError, binascii.Error):
|
||||
return None
|
||||
return data.encode("utf-8")
|
||||
|
||||
def _guess_mime_from_block(self, block: MessageBlock) -> str:
|
||||
if block.attachment and block.attachment.mime_type:
|
||||
return block.attachment.mime_type
|
||||
if block.type is MessageBlockType.IMAGE:
|
||||
return "image/png"
|
||||
if block.type is MessageBlockType.AUDIO:
|
||||
return "audio/mpeg"
|
||||
if block.type is MessageBlockType.VIDEO:
|
||||
return "video/mp4"
|
||||
return "application/octet-stream"
|
||||
|
||||
def _map_role(self, role: MessageRole) -> str:
|
||||
if role is MessageRole.USER:
|
||||
return "user"
|
||||
if role is MessageRole.ASSISTANT:
|
||||
return "model"
|
||||
if role is MessageRole.TOOL:
|
||||
return "tool"
|
||||
return "user"
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Config builders
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _build_generation_config(
|
||||
self,
|
||||
system_instruction: Optional[str],
|
||||
tool_specs: Optional[List[ToolSpec]],
|
||||
call_params: Dict[str, Any],
|
||||
) -> genai_types.GenerateContentConfig:
|
||||
params = dict(self.params or {})
|
||||
params.update(call_params)
|
||||
|
||||
config_kwargs: Dict[str, Any] = {}
|
||||
if system_instruction:
|
||||
config_kwargs["system_instruction"] = system_instruction
|
||||
|
||||
for key in (
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"candidate_count",
|
||||
"max_output_tokens",
|
||||
"response_modalities",
|
||||
"stop_sequences",
|
||||
"seed",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
):
|
||||
if key in params:
|
||||
config_kwargs[key] = params.pop(key)
|
||||
|
||||
safety_settings = params.pop("safety_settings", None)
|
||||
if safety_settings:
|
||||
config_kwargs["safety_settings"] = safety_settings
|
||||
|
||||
image_config = params.pop("image_config", None)
|
||||
aspect_ratio = params.pop("aspect_ratio", None)
|
||||
if aspect_ratio:
|
||||
if image_config is None:
|
||||
image_config = {"aspect_ratio": aspect_ratio}
|
||||
elif isinstance(image_config, dict):
|
||||
image_config = dict(image_config)
|
||||
image_config.setdefault("aspect_ratio", aspect_ratio)
|
||||
elif isinstance(image_config, genai_types.ImageConfig):
|
||||
try:
|
||||
image_config.aspect_ratio = aspect_ratio
|
||||
except Exception:
|
||||
image_config = {"aspect_ratio": aspect_ratio}
|
||||
else:
|
||||
image_config = {"aspect_ratio": aspect_ratio}
|
||||
if image_config:
|
||||
config_kwargs["image_config"] = self._coerce_image_config(image_config)
|
||||
|
||||
audio_config = params.pop("audio_config", None)
|
||||
if audio_config:
|
||||
config_kwargs["audio_config"] = audio_config
|
||||
|
||||
video_config = params.pop("video_config", None)
|
||||
if video_config:
|
||||
config_kwargs["video_config"] = video_config
|
||||
|
||||
tools = self._build_tools(tool_specs or [])
|
||||
if tools:
|
||||
config_kwargs["tools"] = tools
|
||||
|
||||
tool_config_payload = params.pop("tool_config", None)
|
||||
function_calling_payload = params.pop("function_calling_config", None)
|
||||
if function_calling_payload:
|
||||
tool_config_payload = tool_config_payload or {}
|
||||
tool_config_payload["function_calling_config"] = function_calling_payload
|
||||
|
||||
if tool_config_payload:
|
||||
config_kwargs["tool_config"] = self._coerce_tool_config(tool_config_payload)
|
||||
|
||||
automatic_fn_calling = params.pop("automatic_function_calling", None)
|
||||
if automatic_fn_calling:
|
||||
config_kwargs["automatic_function_calling"] = self._coerce_automatic_function_calling(
|
||||
automatic_fn_calling
|
||||
)
|
||||
|
||||
return genai_types.GenerateContentConfig(**config_kwargs)
|
||||
|
||||
def _build_http_options(self, base_url: str) -> Optional[genai_types.HttpOptions]:
|
||||
if not base_url:
|
||||
return None
|
||||
try:
|
||||
return genai_types.HttpOptions(base_url=base_url, timeout=4 * 60 * 1000) # 4 min
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _coerce_image_config(self, image_config: Any) -> Any:
|
||||
if isinstance(image_config, genai_types.ImageConfig):
|
||||
return image_config
|
||||
if isinstance(image_config, dict):
|
||||
try:
|
||||
return genai_types.ImageConfig(**image_config)
|
||||
except Exception:
|
||||
return image_config
|
||||
return image_config
|
||||
|
||||
def _build_tools(self, tool_specs: List[ToolSpec]) -> List[genai_types.Tool]:
|
||||
if not tool_specs:
|
||||
return []
|
||||
|
||||
declarations = []
|
||||
for spec in tool_specs:
|
||||
fn_payload = spec.to_gemini_function()
|
||||
parameters = fn_payload.get("parameters") or {"type": "object", "properties": {}}
|
||||
if 'title' in parameters:
|
||||
parameters.pop('title')
|
||||
# Replace 'title' with 'description' in properties
|
||||
for prop_name, prop_value in parameters.get('properties', {}).items():
|
||||
if isinstance(prop_value, dict) and 'title' in prop_value:
|
||||
prop_value['description'] = prop_value.pop('title')
|
||||
declarations.append(
|
||||
genai_types.FunctionDeclaration(
|
||||
name=fn_payload.get("name", ""),
|
||||
description=fn_payload.get("description") or "",
|
||||
parameters=parameters,
|
||||
)
|
||||
)
|
||||
return [genai_types.Tool(function_declarations=declarations)]
|
||||
|
||||
def _coerce_tool_config(self, payload: Any) -> genai_types.ToolConfig:
|
||||
if isinstance(payload, genai_types.ToolConfig):
|
||||
return payload
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if isinstance(payload, dict):
|
||||
fn_payload = payload.get("function_calling_config")
|
||||
if fn_payload:
|
||||
kwargs["function_calling_config"] = self._coerce_function_calling_config(fn_payload)
|
||||
return genai_types.ToolConfig(**kwargs)
|
||||
|
||||
def _coerce_function_calling_config(self, payload: Any) -> genai_types.FunctionCallingConfig:
|
||||
if isinstance(payload, genai_types.FunctionCallingConfig):
|
||||
return payload
|
||||
if isinstance(payload, str):
|
||||
return genai_types.FunctionCallingConfig(mode=payload)
|
||||
if isinstance(payload, dict):
|
||||
return genai_types.FunctionCallingConfig(**payload)
|
||||
raise ValueError("Invalid function calling configuration payload")
|
||||
|
||||
def _coerce_automatic_function_calling(self, payload: Any) -> Any:
|
||||
config_cls = getattr(genai_types, "AutomaticFunctionCallingConfig", None)
|
||||
if config_cls is None:
|
||||
raise ValueError("Automatic function calling config not supported in current SDK version")
|
||||
if isinstance(payload, config_cls):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
return config_cls(**payload)
|
||||
raise ValueError("Invalid automatic function calling config payload")
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Response parsing
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _deserialize_response(self, response: Any) -> Message:
|
||||
candidate = self._select_primary_candidate(response)
|
||||
if not candidate:
|
||||
return Message(role=MessageRole.ASSISTANT, content="")
|
||||
|
||||
content = getattr(candidate, "content", None)
|
||||
if not content:
|
||||
return Message(role=MessageRole.ASSISTANT, content=response.text if hasattr(response, "text") else "")
|
||||
|
||||
blocks, tool_calls = self._parse_candidate_parts(getattr(content, "parts", []) or [])
|
||||
if not blocks:
|
||||
fallback = getattr(response, "text", None) or ""
|
||||
blocks = [MessageBlock(MessageBlockType.TEXT, text=fallback)] if fallback else []
|
||||
|
||||
return Message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=blocks or "",
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
def _select_primary_candidate(self, response: Any) -> Any:
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
if not candidates:
|
||||
return None
|
||||
return candidates[0]
|
||||
|
||||
def _parse_candidate_parts(
|
||||
self,
|
||||
parts: Sequence[Any],
|
||||
) -> Tuple[List[MessageBlock], List[ToolCallPayload]]:
|
||||
blocks: List[MessageBlock] = []
|
||||
tool_calls: List[ToolCallPayload] = []
|
||||
|
||||
for part in parts:
|
||||
if hasattr(part, "text") and part.text is not None:
|
||||
blocks.append(MessageBlock(MessageBlockType.TEXT, text=part.text))
|
||||
continue
|
||||
|
||||
function_call = getattr(part, "function_call", None)
|
||||
if function_call:
|
||||
thought_signature = getattr(part, "thought_signature", None)
|
||||
tool_calls.append(
|
||||
self._build_tool_call_payload(function_call, thought_signature=thought_signature)
|
||||
)
|
||||
continue
|
||||
|
||||
inline_data = getattr(part, "inline_data", None)
|
||||
if inline_data:
|
||||
blocks.append(self._build_inline_block(inline_data))
|
||||
continue
|
||||
|
||||
file_data = getattr(part, "file_data", None)
|
||||
if file_data:
|
||||
blocks.append(self._build_file_block(file_data))
|
||||
continue
|
||||
|
||||
function_response = getattr(part, "function_response", None)
|
||||
if function_response:
|
||||
blocks.append(
|
||||
MessageBlock(
|
||||
type=MessageBlockType.DATA,
|
||||
text=json.dumps(function_response.response or {}, ensure_ascii=False),
|
||||
data={
|
||||
"function_name": getattr(function_response, "name", ""),
|
||||
"response": function_response.response or {},
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
return blocks, tool_calls
|
||||
|
||||
def _build_tool_call_payload(self, fn_call: Any, *, thought_signature: Any = None) -> ToolCallPayload:
|
||||
call_id = getattr(fn_call, "name", "") or uuid.uuid4().hex
|
||||
arguments = getattr(fn_call, "args", {}) or {}
|
||||
try:
|
||||
arg_str = json.dumps(arguments, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
arg_str = str(arguments)
|
||||
metadata: Dict[str, Any] = {}
|
||||
encoded_signature = self._encode_thought_signature(thought_signature)
|
||||
if encoded_signature:
|
||||
metadata["gemini_thought_signature_b64"] = encoded_signature
|
||||
return ToolCallPayload(
|
||||
id=call_id,
|
||||
function_name=getattr(fn_call, "name", "") or call_id,
|
||||
arguments=arg_str,
|
||||
type="function",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _build_inline_block(self, blob: Any) -> MessageBlock:
|
||||
mime_type = getattr(blob, "mime_type", "") or "application/octet-stream"
|
||||
data_bytes = getattr(blob, "data", None) or b""
|
||||
data_uri = self._encode_data_uri(mime_type, data_bytes)
|
||||
block_type = self._block_type_from_mime(mime_type)
|
||||
return MessageBlock(
|
||||
type=block_type,
|
||||
attachment=AttachmentRef(
|
||||
attachment_id=uuid.uuid4().hex,
|
||||
mime_type=mime_type,
|
||||
data_uri=data_uri,
|
||||
metadata={"source": "gemini_inline"},
|
||||
),
|
||||
)
|
||||
|
||||
def _build_file_block(self, file_data: Any) -> MessageBlock:
|
||||
mime_type = getattr(file_data, "mime_type", None)
|
||||
file_uri = getattr(file_data, "file_uri", None) or getattr(file_data, "file", None)
|
||||
block_type = self._block_type_from_mime(mime_type or "")
|
||||
return MessageBlock(
|
||||
type=block_type,
|
||||
attachment=AttachmentRef(
|
||||
attachment_id=uuid.uuid4().hex,
|
||||
mime_type=mime_type,
|
||||
remote_file_id=file_uri,
|
||||
metadata={"gemini_file_uri": file_uri, "source": "gemini_file"},
|
||||
),
|
||||
)
|
||||
|
||||
def _block_type_from_mime(self, mime_type: str) -> MessageBlockType:
|
||||
if mime_type.startswith("image/"):
|
||||
return MessageBlockType.IMAGE
|
||||
if mime_type.startswith("audio/"):
|
||||
return MessageBlockType.AUDIO
|
||||
if mime_type.startswith("video/"):
|
||||
return MessageBlockType.VIDEO
|
||||
return MessageBlockType.FILE
|
||||
|
||||
def _encode_data_uri(self, mime_type: str, data: bytes) -> str:
|
||||
encoded = base64.b64encode(data).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Token tracking
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _track_token_usage(self, response: Any) -> None:
|
||||
token_tracker = getattr(self.config, "token_tracker", None)
|
||||
if not token_tracker:
|
||||
return
|
||||
|
||||
usage = self.extract_token_usage(response)
|
||||
if usage.input_tokens == 0 and usage.output_tokens == 0 and not usage.metadata:
|
||||
return
|
||||
|
||||
node_id = getattr(self.config, "node_id", "ALL")
|
||||
usage.node_id = node_id
|
||||
usage.model_name = self.model_name
|
||||
usage.workflow_id = token_tracker.workflow_id
|
||||
usage.provider = "gemini"
|
||||
|
||||
token_tracker.record_usage(node_id, self.model_name, usage, provider="gemini")
|
||||
+809
@@ -0,0 +1,809 @@
|
||||
"""OpenAI provider implementation."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
import binascii
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import unquote_to_bytes
|
||||
|
||||
import openai
|
||||
from openai import OpenAI
|
||||
|
||||
from entity.messages import (
|
||||
AttachmentRef,
|
||||
FunctionCallOutputEvent,
|
||||
Message,
|
||||
MessageBlock,
|
||||
MessageBlockType,
|
||||
MessageRole,
|
||||
ToolCallPayload,
|
||||
)
|
||||
from entity.tool_spec import ToolSpec
|
||||
from runtime.node.agent import ModelProvider
|
||||
from runtime.node.agent import ModelResponse
|
||||
from utils.token_tracker import TokenUsage
|
||||
|
||||
|
||||
class OpenAIProvider(ModelProvider):
|
||||
"""OpenAI provider implementation."""
|
||||
|
||||
CSV_INLINE_CHAR_LIMIT = 200_000 # safeguard large attachments
|
||||
TEXT_INLINE_CHAR_LIMIT = 200_000 # safeguard large text/* attachments
|
||||
MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 # OpenAI function output limit (~50 MB)
|
||||
|
||||
def create_client(self):
|
||||
"""
|
||||
Create and return the OpenAI client.
|
||||
|
||||
Returns:
|
||||
OpenAI client instance with token tracking if available
|
||||
"""
|
||||
if self.base_url:
|
||||
return OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
else:
|
||||
return OpenAI(
|
||||
api_key=self.api_key,
|
||||
)
|
||||
|
||||
def call_model(
|
||||
self,
|
||||
client: openai.Client,
|
||||
conversation: List[Message],
|
||||
timeline: List[Any],
|
||||
tool_specs: Optional[List[ToolSpec]] = None,
|
||||
**kwargs,
|
||||
) -> ModelResponse:
|
||||
"""
|
||||
Call the OpenAI model with the given messages and parameters.
|
||||
"""
|
||||
# 1. Determine if we should use Chat Completions directly
|
||||
is_chat = self._is_chat_completions_mode(client)
|
||||
|
||||
if is_chat:
|
||||
request_payload = self._build_chat_payload(conversation, tool_specs, kwargs)
|
||||
response = client.chat.completions.create(**request_payload)
|
||||
self._track_token_usage(response)
|
||||
self._append_chat_response_output(timeline, response)
|
||||
message = self._deserialize_chat_response(response)
|
||||
return ModelResponse(message=message, raw_response=response)
|
||||
|
||||
# 2. Try Responses API with fallback
|
||||
request_payload = self._build_request_payload(timeline, tool_specs, kwargs)
|
||||
try:
|
||||
response = client.responses.create(**request_payload)
|
||||
self._track_token_usage(response)
|
||||
self._append_response_output(timeline, response)
|
||||
message = self._deserialize_response(response)
|
||||
return ModelResponse(message=message, raw_response=response)
|
||||
except Exception as e:
|
||||
new_request_payload = self._build_chat_payload(conversation, tool_specs, kwargs)
|
||||
response = client.chat.completions.create(**new_request_payload)
|
||||
self._track_token_usage(response)
|
||||
self._append_chat_response_output(timeline, response)
|
||||
message = self._deserialize_chat_response(response)
|
||||
return ModelResponse(message=message, raw_response=response)
|
||||
|
||||
def _is_chat_completions_mode(self, client: Any) -> bool:
|
||||
"""Determine if we should use standard chat completions instead of responses API."""
|
||||
protocol = self.params.get("protocol")
|
||||
if protocol == "chat":
|
||||
return True
|
||||
if protocol == "responses":
|
||||
return False
|
||||
# Default to Responses API only if it exists on the client
|
||||
return not hasattr(client, "responses")
|
||||
|
||||
def extract_token_usage(self, response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract token usage from the OpenAI API response.
|
||||
|
||||
Args:
|
||||
response: OpenAI API response from the model call
|
||||
|
||||
Returns:
|
||||
TokenUsage instance with token counts
|
||||
"""
|
||||
usage = getattr(response, "usage", None)
|
||||
if not usage:
|
||||
return TokenUsage()
|
||||
|
||||
def _get(name: str) -> Any:
|
||||
if hasattr(usage, name):
|
||||
return getattr(usage, name)
|
||||
if isinstance(usage, dict):
|
||||
return usage.get(name)
|
||||
return None
|
||||
|
||||
prompt_tokens = _get("prompt_tokens")
|
||||
completion_tokens = _get("completion_tokens")
|
||||
input_tokens = _get("input_tokens")
|
||||
output_tokens = _get("output_tokens")
|
||||
|
||||
resolved_input = input_tokens if input_tokens is not None else prompt_tokens or 0
|
||||
resolved_output = output_tokens if output_tokens is not None else completion_tokens or 0
|
||||
|
||||
total_tokens = _get("total_tokens")
|
||||
if total_tokens is None:
|
||||
total_tokens = (resolved_input or 0) + (resolved_output or 0)
|
||||
|
||||
metadata = {
|
||||
"prompt_tokens": prompt_tokens or 0,
|
||||
"completion_tokens": completion_tokens or 0,
|
||||
"input_tokens": resolved_input or 0,
|
||||
"output_tokens": resolved_output or 0,
|
||||
"total_tokens": total_tokens or 0,
|
||||
}
|
||||
|
||||
return TokenUsage(
|
||||
input_tokens=resolved_input or 0,
|
||||
output_tokens=resolved_output or 0,
|
||||
total_tokens=total_tokens or 0,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _track_token_usage(self, response: Any) -> None:
|
||||
"""Record token usage if a tracker is attached to the config."""
|
||||
token_tracker = getattr(self.config, "token_tracker", None)
|
||||
if not token_tracker:
|
||||
return
|
||||
|
||||
usage = self.extract_token_usage(response)
|
||||
if usage.input_tokens == 0 and usage.output_tokens == 0 and not usage.metadata:
|
||||
return
|
||||
|
||||
node_id = getattr(self.config, "node_id", "ALL")
|
||||
usage.node_id = node_id
|
||||
usage.model_name = self.model_name
|
||||
usage.workflow_id = token_tracker.workflow_id
|
||||
usage.provider = "openai"
|
||||
|
||||
token_tracker.record_usage(node_id, self.model_name, usage, provider="openai")
|
||||
|
||||
def _build_request_payload(
|
||||
self,
|
||||
timeline: List[Any],
|
||||
tool_specs: Optional[List[ToolSpec]],
|
||||
raw_params: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Construct the Responses API payload from event timeline."""
|
||||
params = dict(raw_params)
|
||||
max_tokens = params.pop("max_tokens", None)
|
||||
max_output_tokens = params.pop("max_output_tokens", None)
|
||||
if max_output_tokens is None and max_tokens is not None:
|
||||
max_output_tokens = max_tokens
|
||||
|
||||
input_messages: List[Any] = []
|
||||
for item in timeline:
|
||||
serialized = self._serialize_timeline_item(item)
|
||||
if serialized is not None:
|
||||
input_messages.append(serialized)
|
||||
|
||||
if not input_messages:
|
||||
input_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": ""}],
|
||||
}
|
||||
]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": self.model_name,
|
||||
"input": input_messages,
|
||||
"temperature": params.pop("temperature", 0.7),
|
||||
"timeout": params.pop("timeout", 300), # 5 min
|
||||
}
|
||||
if max_output_tokens is not None:
|
||||
payload["max_output_tokens"] = max_output_tokens
|
||||
elif self.params.get("max_output_tokens"):
|
||||
payload["max_output_tokens"] = self.params["max_output_tokens"]
|
||||
|
||||
user_tools = params.pop("tools", None)
|
||||
merged_tools: List[Any] = []
|
||||
if isinstance(user_tools, list):
|
||||
merged_tools.extend(user_tools)
|
||||
elif user_tools is not None:
|
||||
raise ValueError("params.tools must be a list when provided")
|
||||
|
||||
if tool_specs:
|
||||
merged_tools.extend(spec.to_openai_dict() for spec in tool_specs)
|
||||
|
||||
if merged_tools:
|
||||
payload["tools"] = merged_tools
|
||||
|
||||
tool_choice = params.pop("tool_choice", None)
|
||||
if tool_choice is not None:
|
||||
payload["tool_choice"] = tool_choice
|
||||
elif tool_specs:
|
||||
payload.setdefault("tool_choice", "auto")
|
||||
|
||||
# Pass any remaining kwargs directly
|
||||
payload.update(params)
|
||||
return payload
|
||||
|
||||
def _build_chat_payload(
|
||||
self,
|
||||
conversation: List[Message],
|
||||
tool_specs: Optional[List[ToolSpec]],
|
||||
raw_params: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Construct standard Chat Completions API payload."""
|
||||
params = dict(raw_params)
|
||||
max_output_tokens = params.pop("max_output_tokens", None)
|
||||
max_tokens = params.pop("max_tokens", None)
|
||||
if max_tokens is None and max_output_tokens is not None:
|
||||
max_tokens = max_output_tokens
|
||||
|
||||
messages: List[Any] = []
|
||||
for item in conversation:
|
||||
serialized = self._serialize_message_for_chat(item)
|
||||
if serialized is not None:
|
||||
messages.append(serialized)
|
||||
|
||||
if not messages:
|
||||
messages = [{"role": "user", "content": ""}]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": self.model_name,
|
||||
"messages": messages,
|
||||
"temperature": params.pop("temperature", 0.7),
|
||||
}
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
elif self.params.get("max_tokens"):
|
||||
payload["max_tokens"] = self.params["max_tokens"]
|
||||
|
||||
user_tools = params.pop("tools", None)
|
||||
merged_tools: List[Any] = []
|
||||
if isinstance(user_tools, list):
|
||||
merged_tools.extend(user_tools)
|
||||
|
||||
if tool_specs:
|
||||
for spec in tool_specs:
|
||||
merged_tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": spec.parameters or {"type": "object", "properties": {}},
|
||||
}
|
||||
})
|
||||
|
||||
if merged_tools:
|
||||
payload["tools"] = merged_tools
|
||||
|
||||
tool_choice = params.pop("tool_choice", None)
|
||||
if tool_choice is not None:
|
||||
payload["tool_choice"] = tool_choice
|
||||
elif tool_specs:
|
||||
payload.setdefault("tool_choice", "auto")
|
||||
|
||||
payload.update(params)
|
||||
return payload
|
||||
|
||||
def _serialize_timeline_item_for_chat(self, item: Any) -> Optional[Any]:
|
||||
if isinstance(item, Message):
|
||||
return self._serialize_message_for_chat(item)
|
||||
if isinstance(item, FunctionCallOutputEvent):
|
||||
return self._serialize_function_call_output_event_for_chat(item)
|
||||
if isinstance(item, dict):
|
||||
# basic conversion if it looks like a Responses output
|
||||
role = item.get("role")
|
||||
content = item.get("content")
|
||||
tool_calls = item.get("tool_calls")
|
||||
if role and (content or tool_calls):
|
||||
return {
|
||||
"role": role,
|
||||
"content": self._transform_blocks_for_chat(content) if isinstance(content, list) else content,
|
||||
"tool_calls": tool_calls
|
||||
}
|
||||
return None
|
||||
|
||||
def _serialize_message_for_chat(self, message: Message) -> Dict[str, Any]:
|
||||
"""Convert internal Message to standard Chat Completions schema."""
|
||||
role_value = message.role.value
|
||||
blocks = message.blocks()
|
||||
if not blocks or message.role == MessageRole.TOOL:
|
||||
content = message.text_content()
|
||||
else:
|
||||
content = self._transform_blocks_for_chat(self._serialize_blocks(blocks, message.role))
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"role": role_value,
|
||||
"content": content,
|
||||
}
|
||||
if message.name:
|
||||
payload["name"] = message.name
|
||||
if message.tool_call_id:
|
||||
payload["tool_call_id"] = message.tool_call_id
|
||||
if message.tool_calls:
|
||||
payload["tool_calls"] = [tc.to_openai_dict() for tc in message.tool_calls]
|
||||
return payload
|
||||
|
||||
def _serialize_function_call_output_event_for_chat(self, event: FunctionCallOutputEvent) -> Dict[str, Any]:
|
||||
"""Convert tool result to standard Chat Completions schema."""
|
||||
text = event.output_text or ""
|
||||
if event.output_blocks:
|
||||
# simple concatenation for tool output in chat mode
|
||||
text = "\n".join(b.describe() for b in event.output_blocks)
|
||||
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": event.call_id or "tool_call",
|
||||
"content": text,
|
||||
}
|
||||
|
||||
def _transform_blocks_for_chat(self, blocks: List[Dict[str, Any]]) -> Union[str, List[Dict[str, Any]]]:
|
||||
"""Convert Responses block types to Chat block types (e.g., input_text -> text)."""
|
||||
transformed: List[Dict[str, Any]] = []
|
||||
for block in blocks:
|
||||
b_type = block.get("type", "")
|
||||
if b_type in ("input_text", "output_text"):
|
||||
transformed.append({"type": "text", "text": block.get("text", "")})
|
||||
elif b_type in ("input_image", "output_image"):
|
||||
transformed.append({"type": "image_url", "image_url": {"url": block.get("image_url", "")}})
|
||||
else:
|
||||
# Keep as is or drop if complex
|
||||
transformed.append(block)
|
||||
|
||||
# If only one text block, return as string for better compatibility
|
||||
if len(transformed) == 1 and transformed[0]["type"] == "text":
|
||||
return transformed[0]["text"]
|
||||
return transformed
|
||||
|
||||
def _deserialize_chat_response(self, response: Any) -> Message:
|
||||
"""Convert Chat Completions output to internal Message."""
|
||||
choices = self._get_attr(response, "choices") or []
|
||||
if not choices:
|
||||
return Message(role=MessageRole.ASSISTANT, content="")
|
||||
|
||||
choice = choices[0]
|
||||
msg = self._get_attr(choice, "message")
|
||||
|
||||
tool_calls: List[ToolCallPayload] = []
|
||||
tc_data = self._get_attr(msg, "tool_calls")
|
||||
if tc_data:
|
||||
for idx, tc in enumerate(tc_data):
|
||||
f_data = self._get_attr(tc, "function") or {}
|
||||
function_name = self._get_attr(f_data, "name") or ""
|
||||
arguments = self._get_attr(f_data, "arguments") or ""
|
||||
if not isinstance(arguments, str):
|
||||
arguments = str(arguments)
|
||||
call_id = self._get_attr(tc, "id")
|
||||
if not call_id:
|
||||
call_id = self._build_tool_call_id(function_name, arguments, fallback_prefix=f"tool_call_{idx}")
|
||||
tool_calls.append(ToolCallPayload(
|
||||
id=call_id,
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
type="function"
|
||||
))
|
||||
|
||||
content = self._get_attr(msg, "content") or ""
|
||||
content = self._strip_thinking_tokens(content)
|
||||
|
||||
return Message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=content,
|
||||
tool_calls=tool_calls
|
||||
)
|
||||
|
||||
_THINK_PATTERN = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
|
||||
|
||||
@classmethod
|
||||
def _strip_thinking_tokens(cls, text: str) -> str:
|
||||
"""Strip <think>...</think> blocks from model output (e.g. DeepSeek-R1, MiniMax-M2.7)."""
|
||||
if "<think>" not in text:
|
||||
return text
|
||||
return cls._THINK_PATTERN.sub("", text).strip()
|
||||
|
||||
def _append_chat_response_output(self, timeline: List[Any], response: Any) -> None:
|
||||
"""Add chat response to timeline, preserving tool_calls (Chat API compatible)."""
|
||||
msg = response.choices[0].message
|
||||
content = self._strip_thinking_tokens(msg.content or "")
|
||||
assistant_msg = {
|
||||
"role": "assistant",
|
||||
"content": content
|
||||
}
|
||||
|
||||
if getattr(msg, "tool_calls", None):
|
||||
assistant_msg["tool_calls"] = []
|
||||
for idx, tc in enumerate(msg.tool_calls):
|
||||
function_name = tc.function.name
|
||||
arguments = tc.function.arguments or ""
|
||||
if not isinstance(arguments, str):
|
||||
arguments = str(arguments)
|
||||
call_id = tc.id or self._build_tool_call_id(function_name, arguments, fallback_prefix=f"tool_call_{idx}")
|
||||
assistant_msg["tool_calls"].append({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_name,
|
||||
"arguments": arguments,
|
||||
},
|
||||
})
|
||||
|
||||
timeline.append(assistant_msg)
|
||||
|
||||
def _serialize_timeline_item(self, item: Any) -> Optional[Any]:
|
||||
if isinstance(item, Message):
|
||||
return self._serialize_message_for_responses(item)
|
||||
if isinstance(item, FunctionCallOutputEvent):
|
||||
return self._serialize_function_call_output_event(item)
|
||||
return item
|
||||
|
||||
def _serialize_message_for_responses(self, message: Message) -> Dict[str, Any]:
|
||||
"""Convert internal Message to Responses input schema."""
|
||||
role_value = message.role.value
|
||||
content_blocks = self._serialize_content_blocks(message)
|
||||
payload: Dict[str, Any] = {
|
||||
"role": role_value,
|
||||
"content": content_blocks,
|
||||
}
|
||||
if message.name:
|
||||
payload["name"] = message.name
|
||||
if message.tool_call_id:
|
||||
payload["tool_call_id"] = message.tool_call_id
|
||||
return payload
|
||||
|
||||
def _serialize_content_blocks(self, message: Message) -> List[Dict[str, Any]]:
|
||||
blocks = message.blocks()
|
||||
if not blocks:
|
||||
text = message.text_content()
|
||||
block_type = "output_text" if message.role is MessageRole.ASSISTANT else "input_text"
|
||||
return [{"type": block_type, "text": text}]
|
||||
|
||||
return self._serialize_blocks(blocks, message.role)
|
||||
|
||||
def _serialize_blocks(self, blocks: List[MessageBlock], role: MessageRole) -> List[Dict[str, Any]]:
|
||||
serialized: List[Dict[str, Any]] = []
|
||||
for block in blocks:
|
||||
serialized.append(self._serialize_block(block, role))
|
||||
return serialized
|
||||
|
||||
def _serialize_block(self, block: MessageBlock, role: MessageRole) -> Dict[str, Any]:
|
||||
if block.type is MessageBlockType.TEXT:
|
||||
content_type = "output_text" if role is MessageRole.ASSISTANT else "input_text"
|
||||
return {
|
||||
"type": content_type,
|
||||
"text": block.text or "",
|
||||
}
|
||||
|
||||
attachment = block.attachment
|
||||
if block.type is MessageBlockType.IMAGE:
|
||||
media_type = "output_image" if role is MessageRole.ASSISTANT else "input_image"
|
||||
return self._serialize_media_block(media_type, attachment)
|
||||
if block.type is MessageBlockType.AUDIO:
|
||||
media_type = "output_audio" if role is MessageRole.ASSISTANT else "input_audio"
|
||||
return self._serialize_media_block(media_type, attachment)
|
||||
if block.type is MessageBlockType.VIDEO:
|
||||
media_type = "output_video" if role is MessageRole.ASSISTANT else "input_video"
|
||||
return self._serialize_media_block(media_type, attachment)
|
||||
if block.type is MessageBlockType.FILE:
|
||||
inline_text = self._maybe_inline_text_file(block)
|
||||
if inline_text is not None:
|
||||
content_type = "output_text" if role is MessageRole.ASSISTANT else "input_text"
|
||||
return {
|
||||
"type": content_type,
|
||||
"text": inline_text,
|
||||
}
|
||||
return self._serialize_file_block(attachment, block)
|
||||
|
||||
# Fallback: treat as text/data
|
||||
return {
|
||||
"type": "input_text",
|
||||
"text": block.describe(),
|
||||
}
|
||||
|
||||
def _serialize_media_block(
|
||||
self,
|
||||
media_type: str,
|
||||
attachment: Optional[AttachmentRef],
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"type": media_type}
|
||||
if not attachment:
|
||||
return payload
|
||||
|
||||
url_key = {
|
||||
"input_image": "image_url",
|
||||
"output_image": "image_url",
|
||||
"input_audio": "audio_url",
|
||||
"output_audio": "audio_url",
|
||||
"input_video": "video_url",
|
||||
"output_video": "video_url",
|
||||
}.get(media_type)
|
||||
|
||||
if attachment.remote_file_id:
|
||||
payload["file_id"] = attachment.remote_file_id
|
||||
elif attachment.data_uri and url_key:
|
||||
payload[url_key] = attachment.data_uri
|
||||
elif attachment.local_path and url_key:
|
||||
payload[url_key] = self._make_data_uri_from_path(attachment.local_path, attachment.mime_type)
|
||||
return payload
|
||||
|
||||
def _serialize_file_block(
|
||||
self,
|
||||
attachment: Optional[AttachmentRef],
|
||||
block: MessageBlock,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"type": "input_file"}
|
||||
if attachment:
|
||||
if attachment.remote_file_id:
|
||||
payload["file_id"] = attachment.remote_file_id
|
||||
else:
|
||||
data_uri = attachment.data_uri
|
||||
if not data_uri and attachment.local_path:
|
||||
data_uri = self._make_data_uri_from_path(attachment.local_path, attachment.mime_type)
|
||||
if data_uri:
|
||||
payload["file_data"] = data_uri
|
||||
else:
|
||||
raise ValueError("Attachment missing file_id or data for input_file block")
|
||||
if attachment.name:
|
||||
payload["filename"] = attachment.name
|
||||
else:
|
||||
raise ValueError("File block requires an attachment reference")
|
||||
return payload
|
||||
|
||||
def _maybe_inline_text_file(self, block: MessageBlock) -> Optional[str]:
|
||||
"""Inline local text/* attachments to avoid unsupported file-type uploads."""
|
||||
|
||||
attachment = block.attachment
|
||||
if not attachment:
|
||||
return None
|
||||
|
||||
mime = (attachment.mime_type or "").lower()
|
||||
name = (attachment.name or "").lower()
|
||||
is_json = mime in {
|
||||
"application/json",
|
||||
"application/jsonl",
|
||||
"application/x-ndjson",
|
||||
"application/ndjson",
|
||||
} or name.endswith((".json", ".jsonl", ".ndjson"))
|
||||
if not (mime.startswith("text/") or is_json):
|
||||
return None
|
||||
if attachment.remote_file_id:
|
||||
return None # nothing to inline if already remote-only
|
||||
|
||||
text = self._read_attachment_text(attachment)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
is_csv = "text/csv" in mime or name.endswith(".csv")
|
||||
limit_attr = "csv_inline_char_limit" if is_csv else "text_inline_char_limit"
|
||||
default_limit = self.CSV_INLINE_CHAR_LIMIT if is_csv else self.TEXT_INLINE_CHAR_LIMIT
|
||||
limit = getattr(self, limit_attr, default_limit)
|
||||
truncated = False
|
||||
if len(text) > limit:
|
||||
text = text[:limit]
|
||||
truncated = True
|
||||
|
||||
display_name = attachment.name or attachment.attachment_id or ("attachment.csv" if is_csv else "attachment.txt")
|
||||
suffix = "\n\n[truncated after %d characters]" % limit if truncated else ""
|
||||
if is_csv:
|
||||
return f"CSV file '{display_name}':\n{text}{suffix}"
|
||||
mime_display = attachment.mime_type or "text/*"
|
||||
return f"Text file '{display_name}' ({mime_display}):\n```text\n{text}\n```{suffix}"
|
||||
|
||||
def _maybe_inline_csv(self, block: MessageBlock) -> Optional[str]:
|
||||
"""Backward compatible alias for older call sites/tests."""
|
||||
return self._maybe_inline_text_file(block)
|
||||
|
||||
def _read_attachment_text(self, attachment: AttachmentRef) -> Optional[str]:
|
||||
data_bytes: Optional[bytes] = None
|
||||
if attachment.data_uri:
|
||||
data_bytes = self._decode_data_uri(attachment.data_uri)
|
||||
elif attachment.local_path and os.path.exists(attachment.local_path):
|
||||
try:
|
||||
with open(attachment.local_path, "rb") as handle:
|
||||
data_bytes = handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
if data_bytes is None:
|
||||
return None
|
||||
try:
|
||||
return data_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
def _decode_data_uri(self, data_uri: str) -> Optional[bytes]:
|
||||
if not data_uri.startswith("data:"):
|
||||
return None
|
||||
header, _, data = data_uri.partition(",")
|
||||
if not _:
|
||||
return None
|
||||
if ";base64" in header:
|
||||
try:
|
||||
return base64.b64decode(data)
|
||||
except (ValueError, binascii.Error):
|
||||
return None
|
||||
return unquote_to_bytes(data)
|
||||
|
||||
def _deserialize_response(self, response: Any) -> Message:
|
||||
"""Convert Responses API output to internal Message."""
|
||||
output_blocks = getattr(response, "output", []) or []
|
||||
assistant_blocks: List[MessageBlock] = []
|
||||
tool_calls: List[ToolCallPayload] = []
|
||||
|
||||
for item in output_blocks:
|
||||
item_type = self._get_attr(item, "type")
|
||||
if item_type == "message":
|
||||
role_value = self._get_attr(item, "role") or "assistant"
|
||||
if role_value != "assistant":
|
||||
continue
|
||||
content_items = self._get_attr(item, "content") or []
|
||||
parsed_blocks, parsed_calls = self._parse_output_content(content_items)
|
||||
assistant_blocks.extend(parsed_blocks)
|
||||
tool_calls.extend(parsed_calls)
|
||||
elif item_type == "image_generation_call":
|
||||
assistant_blocks.append(self._parse_image_generation_call(item))
|
||||
elif item_type in {"tool_call", "function_call"}:
|
||||
parsed_call = self._parse_tool_call(item)
|
||||
if parsed_call:
|
||||
tool_calls.append(parsed_call)
|
||||
|
||||
if not assistant_blocks:
|
||||
fallback_text = self._extract_fallback_text(response)
|
||||
if fallback_text:
|
||||
assistant_blocks.append(MessageBlock(MessageBlockType.TEXT, text=fallback_text))
|
||||
|
||||
return Message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=assistant_blocks or "",
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
def _extract_fallback_text(self, response: Any) -> Optional[str]:
|
||||
"""Return the concatenated output text without triggering Responses errors."""
|
||||
output = getattr(response, "output", None)
|
||||
if not output:
|
||||
return None
|
||||
try:
|
||||
return getattr(response, "output_text", None)
|
||||
except TypeError:
|
||||
# OpenAI SDK raises TypeError when output is None; treat as missing text
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def _parse_output_content(
|
||||
self,
|
||||
content_items: List[Any],
|
||||
) -> tuple[List[MessageBlock], List[ToolCallPayload]]:
|
||||
blocks: List[MessageBlock] = []
|
||||
tool_calls: List[ToolCallPayload] = []
|
||||
for part in content_items:
|
||||
part_type = self._get_attr(part, "type")
|
||||
if part_type in {"output_text", "text"}:
|
||||
blocks.append(MessageBlock(MessageBlockType.TEXT, text=self._get_attr(part, "text") or ""))
|
||||
elif part_type in {"output_image", "image"}:
|
||||
blocks.append(
|
||||
MessageBlock(
|
||||
type=MessageBlockType.IMAGE,
|
||||
attachment=AttachmentRef(
|
||||
attachment_id=self._get_attr(part, "id") or "",
|
||||
data_uri=self._get_attr(part, "image_base64"),
|
||||
metadata=self._get_attr(part, "metadata") or {},
|
||||
),
|
||||
)
|
||||
)
|
||||
elif part_type in {"tool_call", "function_call"}:
|
||||
parsed = self._parse_tool_call(part)
|
||||
if parsed:
|
||||
tool_calls.append(parsed)
|
||||
else:
|
||||
blocks.append(
|
||||
MessageBlock(
|
||||
type=MessageBlockType.DATA,
|
||||
text=str(self._get_attr(part, "text") or ""),
|
||||
data=self._maybe_to_dict(part),
|
||||
)
|
||||
)
|
||||
return blocks, tool_calls
|
||||
|
||||
def _parse_image_generation_call(self, payload: Any) -> MessageBlock:
|
||||
status = self._get_attr(payload, "status") or ""
|
||||
if status != "completed":
|
||||
raise RuntimeError(f"Image generation call not completed (status={status})")
|
||||
image_b64 = self._get_attr(payload, "result")
|
||||
if not image_b64:
|
||||
raise RuntimeError("Image generation call returned empty result")
|
||||
attachment_id = self._get_attr(payload, "id") or ""
|
||||
data_uri = f"data:image/png;base64,{image_b64}"
|
||||
return MessageBlock(
|
||||
type=MessageBlockType.IMAGE,
|
||||
attachment=AttachmentRef(
|
||||
attachment_id=attachment_id,
|
||||
data_uri=data_uri,
|
||||
metadata={"source": "image_generation_call"},
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_tool_call(self, payload: Any) -> Optional[ToolCallPayload]:
|
||||
function_payload = self._get_attr(payload, "function") or {}
|
||||
function_name = self._get_attr(function_payload, "name") or self._get_attr(payload, "name") or ""
|
||||
arguments = self._get_attr(function_payload, "arguments") or self._get_attr(payload, "arguments") or ""
|
||||
if not function_name:
|
||||
return None
|
||||
if isinstance(arguments, (dict, list)):
|
||||
try:
|
||||
import json
|
||||
|
||||
arguments_str = json.dumps(arguments, ensure_ascii=False)
|
||||
except Exception:
|
||||
arguments_str = str(arguments)
|
||||
else:
|
||||
arguments_str = str(arguments)
|
||||
call_id = self._get_attr(payload, "call_id") or self._get_attr(payload, "id") or ""
|
||||
if not call_id:
|
||||
call_id = self._build_tool_call_id(function_name, arguments_str)
|
||||
return ToolCallPayload(
|
||||
id=call_id,
|
||||
function_name=function_name,
|
||||
arguments=arguments_str,
|
||||
type="function",
|
||||
)
|
||||
|
||||
def _build_tool_call_id(self, function_name: str, arguments: str, *, fallback_prefix: str = "tool_call") -> str:
|
||||
base = function_name or fallback_prefix
|
||||
payload = f"{base}:{arguments or ''}".encode("utf-8")
|
||||
digest = hashlib.md5(payload).hexdigest()[:8]
|
||||
return f"{base}_{digest}"
|
||||
|
||||
def _get_attr(self, payload: Any, key: str) -> Any:
|
||||
if hasattr(payload, key):
|
||||
return getattr(payload, key)
|
||||
if isinstance(payload, dict):
|
||||
return payload.get(key)
|
||||
return None
|
||||
|
||||
def _maybe_to_dict(self, payload: Any) -> Dict[str, Any]:
|
||||
if hasattr(payload, "model_dump"):
|
||||
try:
|
||||
return payload.model_dump()
|
||||
except Exception:
|
||||
return {}
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
return {}
|
||||
|
||||
def _make_data_uri_from_path(self, path: str, mime_type: Optional[str]) -> str:
|
||||
mime = mime_type or "application/octet-stream"
|
||||
file_size = os.path.getsize(path)
|
||||
if file_size > self.MAX_INLINE_FILE_BYTES:
|
||||
raise ValueError(
|
||||
f"Attachment '{path}' is {file_size} bytes; exceeds inline limit of {self.MAX_INLINE_FILE_BYTES} bytes"
|
||||
)
|
||||
with open(path, "rb") as handle:
|
||||
encoded = base64.b64encode(handle.read()).decode("utf-8")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
def _serialize_function_call_output_event(
|
||||
self,
|
||||
event: FunctionCallOutputEvent,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"type": event.type,
|
||||
"call_id": event.call_id or event.function_name or "tool_call",
|
||||
}
|
||||
if event.output_blocks:
|
||||
payload["output"] = self._serialize_blocks(event.output_blocks, MessageRole.TOOL)
|
||||
else:
|
||||
text = event.output_text or ""
|
||||
payload["output"] = [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": text,
|
||||
}
|
||||
]
|
||||
return payload
|
||||
|
||||
def _append_response_output(self, timeline: List[Any], response: Any) -> None:
|
||||
output = getattr(response, "output", None)
|
||||
if not output:
|
||||
return
|
||||
timeline.extend(output)
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
"""Normalized provider response dataclasses."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from entity.messages import Message
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelResponse:
|
||||
"""Represents a provider response with normalized message payload."""
|
||||
|
||||
message: Message
|
||||
raw_response: Any | None = None
|
||||
|
||||
def has_tool_calls(self) -> bool:
|
||||
return bool(self.message.tool_calls)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return a simple dict representation for compatibility."""
|
||||
payload = {
|
||||
"role": self.message.role.value,
|
||||
}
|
||||
if isinstance(self.message.content, list):
|
||||
payload["content"] = [
|
||||
block.to_dict() if hasattr(block, "to_dict") else block for block in self.message.content # type: ignore[arg-type]
|
||||
]
|
||||
else:
|
||||
payload["content"] = self.message.content
|
||||
if self.message.tool_calls:
|
||||
payload["tool_calls"] = [call.to_openai_dict() for call in self.message.tool_calls]
|
||||
if self.message.tool_call_id:
|
||||
payload["tool_call_id"] = self.message.tool_call_id
|
||||
if self.message.name:
|
||||
payload["name"] = self.message.name
|
||||
return payload
|
||||
|
||||
def str_raw_response(self):
|
||||
return self.raw_response.__str__()
|
||||
@@ -0,0 +1,8 @@
|
||||
from .manager import AgentSkillManager, SkillMetadata, SkillValidationError, parse_skill_file
|
||||
|
||||
__all__ = [
|
||||
"AgentSkillManager",
|
||||
"SkillMetadata",
|
||||
"SkillValidationError",
|
||||
"parse_skill_file",
|
||||
]
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Agent Skills discovery and loading helpers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Iterable, List, Mapping, Sequence
|
||||
|
||||
import yaml
|
||||
|
||||
from entity.tool_spec import ToolSpec
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
|
||||
MAX_SKILL_FILE_BYTES = 128 * 1024
|
||||
|
||||
|
||||
class SkillValidationError(ValueError):
|
||||
"""Raised when a skill directory or SKILL.md file is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillMetadata:
|
||||
name: str
|
||||
description: str
|
||||
skill_dir: Path
|
||||
skill_file: Path
|
||||
frontmatter: Mapping[str, object]
|
||||
allowed_tools: tuple[str, ...]
|
||||
compatibility: Mapping[str, object]
|
||||
|
||||
|
||||
def parse_skill_file(skill_file: str | Path) -> SkillMetadata:
|
||||
path = Path(skill_file).resolve()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
frontmatter = _parse_frontmatter(text, path)
|
||||
|
||||
raw_name = frontmatter.get("name")
|
||||
raw_description = frontmatter.get("description")
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise SkillValidationError(f"{path}: skill frontmatter must define a non-empty name")
|
||||
if not isinstance(raw_description, str) or not raw_description.strip():
|
||||
raise SkillValidationError(f"{path}: skill frontmatter must define a non-empty description")
|
||||
|
||||
name = raw_name.strip()
|
||||
description = raw_description.strip()
|
||||
if path.parent.name != name:
|
||||
raise SkillValidationError(
|
||||
f"{path}: skill name '{name}' must match directory name '{path.parent.name}'"
|
||||
)
|
||||
|
||||
allowed_tools = _parse_optional_str_list(frontmatter.get("allowed-tools"), path, "allowed-tools")
|
||||
compatibility = _parse_optional_mapping(frontmatter.get("compatibility"), path, "compatibility")
|
||||
|
||||
return SkillMetadata(
|
||||
name=name,
|
||||
description=description,
|
||||
skill_dir=path.parent,
|
||||
skill_file=path,
|
||||
frontmatter=dict(frontmatter),
|
||||
allowed_tools=tuple(allowed_tools),
|
||||
compatibility=dict(compatibility),
|
||||
)
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str, path: Path) -> Mapping[str, object]:
|
||||
if not text.startswith("---"):
|
||||
raise SkillValidationError(f"{path}: SKILL.md must start with YAML frontmatter")
|
||||
|
||||
lines = text.splitlines()
|
||||
end_idx = None
|
||||
for idx in range(1, len(lines)):
|
||||
if lines[idx].strip() == "---":
|
||||
end_idx = idx
|
||||
break
|
||||
if end_idx is None:
|
||||
raise SkillValidationError(f"{path}: closing frontmatter delimiter not found")
|
||||
|
||||
payload = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
data = yaml.safe_load(payload) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise SkillValidationError(f"{path}: invalid YAML frontmatter: {exc}") from exc
|
||||
if not isinstance(data, Mapping):
|
||||
raise SkillValidationError(f"{path}: skill frontmatter must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
def _parse_optional_str_list(value: object, path: Path, field_name: str) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [item for item in value.split() if item]
|
||||
if not isinstance(value, list):
|
||||
raise SkillValidationError(f"{path}: {field_name} must be a list of strings")
|
||||
|
||||
result: List[str] = []
|
||||
for idx, item in enumerate(value):
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
raise SkillValidationError(f"{path}: {field_name}[{idx}] must be a non-empty string")
|
||||
result.append(item.strip())
|
||||
return result
|
||||
|
||||
|
||||
def _parse_optional_mapping(value: object, path: Path, field_name: str) -> Mapping[str, object]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise SkillValidationError(f"{path}: {field_name} must be a mapping")
|
||||
return {str(key): value[key] for key in value}
|
||||
|
||||
|
||||
class AgentSkillManager:
|
||||
"""Discover and read Agent Skills from the fixed project-level skills directory."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allow: Sequence[str] | None = None,
|
||||
available_tool_names: Sequence[str] | None = None,
|
||||
warning_reporter: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.root = DEFAULT_SKILLS_ROOT
|
||||
self.allow = {item.strip() for item in (allow or []) if item and item.strip()}
|
||||
self.available_tool_names = {item.strip() for item in (available_tool_names or []) if item and item.strip()}
|
||||
self.warning_reporter = warning_reporter
|
||||
self._skills_by_name: Dict[str, SkillMetadata] | None = None
|
||||
self._skill_content_cache: Dict[str, str] = {}
|
||||
self._activation_state: Dict[str, bool] = {}
|
||||
self._current_skill_name: str | None = None
|
||||
self._discovery_warnings: List[str] = []
|
||||
|
||||
def discover(self) -> List[SkillMetadata]:
|
||||
if self._skills_by_name is None:
|
||||
discovered: Dict[str, SkillMetadata] = {}
|
||||
root = self.root
|
||||
if root.exists() and root.is_dir():
|
||||
for metadata in self._iter_root_skills(root):
|
||||
if self.allow and metadata.name not in self.allow:
|
||||
continue
|
||||
if not self._is_skill_compatible(metadata):
|
||||
continue
|
||||
discovered.setdefault(metadata.name, metadata)
|
||||
self._skills_by_name = discovered
|
||||
return list(self._skills_by_name.values())
|
||||
|
||||
def has_skills(self) -> bool:
|
||||
return bool(self.discover())
|
||||
|
||||
def build_available_skills_xml(self) -> str:
|
||||
skills = self.discover()
|
||||
if not skills:
|
||||
return ""
|
||||
|
||||
lines = ["<available_skills>"]
|
||||
for skill in skills:
|
||||
lines.extend(
|
||||
[
|
||||
" <skill>",
|
||||
f" <name>{escape(skill.name)}</name>",
|
||||
f" <description>{escape(skill.description)}</description>",
|
||||
f" <location>{escape(str(skill.skill_file))}</location>",
|
||||
]
|
||||
)
|
||||
if skill.allowed_tools:
|
||||
lines.append(" <allowed_tools>")
|
||||
for tool_name in skill.allowed_tools:
|
||||
lines.append(f" <tool>{escape(tool_name)}</tool>")
|
||||
lines.append(" </allowed_tools>")
|
||||
lines.append(" </skill>")
|
||||
lines.append("</available_skills>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def activate_skill(self, skill_name: str) -> Dict[str, str | List[str]]:
|
||||
skill = self._get_skill(skill_name)
|
||||
cached = self._skill_content_cache.get(skill.name)
|
||||
if cached is None:
|
||||
cached = skill.skill_file.read_text(encoding="utf-8")
|
||||
self._skill_content_cache[skill.name] = cached
|
||||
self._activation_state[skill.name] = True
|
||||
self._current_skill_name = skill.name
|
||||
return {
|
||||
"skill_name": skill.name,
|
||||
"path": str(skill.skill_file),
|
||||
"instructions": cached,
|
||||
"allowed_tools": list(skill.allowed_tools),
|
||||
}
|
||||
|
||||
def read_skill_file(self, skill_name: str, relative_path: str) -> Dict[str, str]:
|
||||
skill = self._get_skill(skill_name)
|
||||
if not self.is_activated(skill.name):
|
||||
raise ValueError(f"Skill '{skill.name}' must be activated before reading files")
|
||||
|
||||
normalized = relative_path.strip()
|
||||
if not normalized:
|
||||
raise ValueError("relative_path is required")
|
||||
|
||||
candidate = (skill.skill_dir / normalized).resolve()
|
||||
try:
|
||||
candidate.relative_to(skill.skill_dir)
|
||||
except ValueError as exc:
|
||||
raise ValueError("relative_path must stay within the skill directory") from exc
|
||||
|
||||
if not candidate.exists() or not candidate.is_file():
|
||||
raise ValueError(f"Skill file '{normalized}' not found")
|
||||
if candidate.stat().st_size > MAX_SKILL_FILE_BYTES:
|
||||
raise ValueError(f"Skill file '{normalized}' exceeds the {MAX_SKILL_FILE_BYTES} byte limit")
|
||||
|
||||
return {
|
||||
"skill_name": skill.name,
|
||||
"path": str(candidate),
|
||||
"relative_path": str(candidate.relative_to(skill.skill_dir)),
|
||||
"content": candidate.read_text(encoding="utf-8"),
|
||||
}
|
||||
|
||||
def is_activated(self, skill_name: str) -> bool:
|
||||
return bool(self._activation_state.get(skill_name))
|
||||
|
||||
def active_skill(self) -> SkillMetadata | None:
|
||||
if self._current_skill_name is None:
|
||||
return None
|
||||
skills = self._skills_by_name
|
||||
if skills is None:
|
||||
return None
|
||||
return skills.get(self._current_skill_name)
|
||||
|
||||
def discovery_warnings(self) -> List[str]:
|
||||
self.discover()
|
||||
return list(self._discovery_warnings)
|
||||
|
||||
def build_tool_specs(self) -> List[ToolSpec]:
|
||||
if not self.has_skills():
|
||||
return []
|
||||
return [
|
||||
ToolSpec(
|
||||
name="activate_skill",
|
||||
description="Load the full SKILL.md instructions for a discovered agent skill.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Exact skill name from <available_skills>.",
|
||||
}
|
||||
},
|
||||
"required": ["skill_name"],
|
||||
},
|
||||
metadata={"source": "agent_skill_internal"},
|
||||
),
|
||||
ToolSpec(
|
||||
name="read_skill_file",
|
||||
description="Read a text file inside an activated skill directory, such as references or scripts.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Exact activated skill name from <available_skills>.",
|
||||
},
|
||||
"relative_path": {
|
||||
"type": "string",
|
||||
"description": "Path relative to the skill directory, for example references/example.md.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "relative_path"],
|
||||
},
|
||||
metadata={"source": "agent_skill_internal"},
|
||||
),
|
||||
]
|
||||
|
||||
def _iter_root_skills(self, root: Path) -> Iterable[SkillMetadata]:
|
||||
for candidate in sorted(root.iterdir()):
|
||||
if not candidate.is_dir():
|
||||
continue
|
||||
skill_file = candidate / "SKILL.md"
|
||||
if not skill_file.is_file():
|
||||
continue
|
||||
try:
|
||||
yield parse_skill_file(skill_file)
|
||||
except SkillValidationError as exc:
|
||||
self._warn(str(exc))
|
||||
continue
|
||||
|
||||
def _get_skill(self, skill_name: str) -> SkillMetadata:
|
||||
for skill in self.discover():
|
||||
if skill.name == skill_name:
|
||||
return skill
|
||||
raise ValueError(f"Skill '{skill_name}' not found")
|
||||
|
||||
def _is_skill_compatible(self, skill: SkillMetadata) -> bool:
|
||||
if not skill.allowed_tools:
|
||||
return True
|
||||
if not self.available_tool_names:
|
||||
self._warn(
|
||||
f"Skipping skill '{skill.name}': skill declares allowed-tools "
|
||||
f"{list(skill.allowed_tools)} but this agent has no bound external tools."
|
||||
)
|
||||
return False
|
||||
if not any(tool_name in self.available_tool_names for tool_name in skill.allowed_tools):
|
||||
self._warn(
|
||||
f"Skipping skill '{skill.name}': none of its allowed-tools "
|
||||
f"{list(skill.allowed_tools)} are configured on this agent."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _warn(self, message: str) -> None:
|
||||
self._discovery_warnings.append(message)
|
||||
if self.warning_reporter is not None:
|
||||
self.warning_reporter(message)
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
from .thinking_manager import ThinkingManagerBase, ThinkingPayload
|
||||
from .builtin_thinking import ThinkingManagerFactory
|
||||
|
||||
__all__ = [
|
||||
"ThinkingManagerBase",
|
||||
"ThinkingPayload",
|
||||
"ThinkingManagerFactory",
|
||||
]
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"""Register built-in thinking modes."""
|
||||
|
||||
from entity.configs.node.thinking import ReflectionThinkingConfig, ThinkingConfig
|
||||
from runtime.node.agent.thinking.thinking_manager import ThinkingManagerBase
|
||||
from runtime.node.agent.thinking.self_reflection import SelfReflectionThinkingManager
|
||||
from runtime.node.agent.thinking.registry import (
|
||||
register_thinking_mode,
|
||||
get_thinking_registration,
|
||||
)
|
||||
|
||||
register_thinking_mode(
|
||||
"reflection",
|
||||
config_cls=ReflectionThinkingConfig,
|
||||
manager_cls=SelfReflectionThinkingManager,
|
||||
summary="LLM reflects on its output and refine its output",
|
||||
)
|
||||
|
||||
|
||||
class ThinkingManagerFactory:
|
||||
@staticmethod
|
||||
def get_thinking_manager(config: ThinkingConfig) -> ThinkingManagerBase:
|
||||
registration = get_thinking_registration(config.type)
|
||||
typed_config = config.as_config(registration.config_cls)
|
||||
if not typed_config:
|
||||
raise ValueError(f"Invalid thinking config for type '{config.type}'")
|
||||
return registration.manager_cls(typed_config)
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
"""Registry for thinking managers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Type
|
||||
|
||||
from schema_registry import register_thinking_schema
|
||||
from utils.registry import Registry, RegistryEntry, RegistryError
|
||||
from runtime.node.agent.thinking.thinking_manager import ThinkingManagerBase
|
||||
|
||||
thinking_registry = Registry("thinking_mode")
|
||||
_BUILTINS_LOADED = False
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ThinkingRegistration:
|
||||
name: str
|
||||
config_cls: Type[Any]
|
||||
manager_cls: Type["ThinkingManagerBase"]
|
||||
summary: str | None = None
|
||||
|
||||
|
||||
def _ensure_builtins_loaded() -> None:
|
||||
global _BUILTINS_LOADED
|
||||
if not _BUILTINS_LOADED:
|
||||
import_module("runtime.node.agent.thinking.builtin_thinking")
|
||||
_BUILTINS_LOADED = True
|
||||
|
||||
|
||||
def register_thinking_mode(
|
||||
name: str,
|
||||
*,
|
||||
config_cls: Type[Any],
|
||||
manager_cls: Type["ThinkingManagerBase"],
|
||||
summary: str | None = None,
|
||||
) -> None:
|
||||
if name in thinking_registry.names():
|
||||
raise RegistryError(f"Thinking mode '{name}' already registered")
|
||||
entry = ThinkingRegistration(name=name, config_cls=config_cls, manager_cls=manager_cls, summary=summary)
|
||||
thinking_registry.register(name, target=entry)
|
||||
register_thinking_schema(name, config_cls=config_cls, summary=summary)
|
||||
|
||||
|
||||
def get_thinking_registration(name: str) -> ThinkingRegistration:
|
||||
_ensure_builtins_loaded()
|
||||
entry: RegistryEntry = thinking_registry.get(name)
|
||||
registration = entry.load()
|
||||
if not isinstance(registration, ThinkingRegistration):
|
||||
raise RegistryError(f"Entry '{name}' is not a ThinkingRegistration")
|
||||
return registration
|
||||
|
||||
|
||||
def iter_thinking_registrations() -> Dict[str, ThinkingRegistration]:
|
||||
_ensure_builtins_loaded()
|
||||
return {name: entry.load() for name, entry in thinking_registry.items()}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"thinking_registry",
|
||||
"ThinkingRegistration",
|
||||
"register_thinking_mode",
|
||||
"get_thinking_registration",
|
||||
"iter_thinking_registrations",
|
||||
]
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
from entity.configs import ReflectionThinkingConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
from runtime.node.agent.thinking.thinking_manager import (
|
||||
ThinkingManagerBase,
|
||||
AgentInvoker,
|
||||
ThinkingPayload,
|
||||
)
|
||||
|
||||
|
||||
class SelfReflectionThinkingManager(ThinkingManagerBase):
|
||||
"""
|
||||
A simple implementation of thinking manager, named self-reflection.
|
||||
This part of the code is borrowed from ChatDev (https://github.com/OpenBMB/ChatDev) and adapted.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ReflectionThinkingConfig):
|
||||
super().__init__(config)
|
||||
self.before_gen_think_enabled = False
|
||||
self.after_gen_think_enabled = True
|
||||
self.base_prompt = """Here is a conversation between two roles: {conversations} {reflection_prompt}"""
|
||||
|
||||
self.reflection_prompt = config.reflection_prompt or "Reflect on the given information and summarize key points in a few words."
|
||||
|
||||
def _before_gen_think(
|
||||
self,
|
||||
agent_invoker: AgentInvoker,
|
||||
input_payload: ThinkingPayload,
|
||||
agent_role: str,
|
||||
memory: ThinkingPayload | None,
|
||||
) -> tuple[str, bool]:
|
||||
...
|
||||
|
||||
def _after_gen_think(
|
||||
self,
|
||||
agent_invoker: AgentInvoker,
|
||||
input_payload: ThinkingPayload,
|
||||
agent_role: str,
|
||||
memory: ThinkingPayload | None,
|
||||
gen_payload: ThinkingPayload,
|
||||
) -> tuple[str, bool]:
|
||||
conversations = [
|
||||
f"SYSTEM: {agent_role}",
|
||||
f"USER: {input_payload.text}",
|
||||
f"ASSISTANT: {gen_payload.text}",
|
||||
]
|
||||
if memory and memory.text:
|
||||
conversations = [memory.text] + conversations
|
||||
prompt = self.base_prompt.format(conversations="\n\n".join(conversations),
|
||||
reflection_prompt=self.reflection_prompt)
|
||||
|
||||
reflection_message = agent_invoker(
|
||||
[Message(role=MessageRole.USER, content=prompt)]
|
||||
)
|
||||
return reflection_message.text_content(), True
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
from abc import abstractmethod, ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from entity.configs import ThinkingConfig
|
||||
from entity.messages import Message, MessageRole, MessageBlock
|
||||
|
||||
AgentInvoker = Callable[[List[Message]], Message]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingPayload:
|
||||
"""Container used to pass multimodal context into thinking managers."""
|
||||
|
||||
text: str
|
||||
blocks: List[MessageBlock] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
raw: Any | None = None
|
||||
|
||||
def describe(self) -> str:
|
||||
return self.text
|
||||
|
||||
|
||||
|
||||
class ThinkingManagerBase(ABC):
|
||||
def __init__(self, config: ThinkingConfig):
|
||||
self.config = config
|
||||
self.before_gen_think_enabled = False
|
||||
self.after_gen_think_enabled = False
|
||||
|
||||
# you can customize the prompt by override this attribute
|
||||
self.thinking_concat_prompt = "{origin}\n\nThinking Result: {thinking}"
|
||||
|
||||
@abstractmethod
|
||||
def _before_gen_think(
|
||||
self,
|
||||
agent_invoker: AgentInvoker,
|
||||
input_payload: ThinkingPayload,
|
||||
agent_role: str,
|
||||
memory: ThinkingPayload | None,
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
think based on input_data before calling model API for node to generate
|
||||
|
||||
Returns:
|
||||
str: thinking result
|
||||
bool: whether to replace the original input_data with the modified one
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _after_gen_think(
|
||||
self,
|
||||
agent_invoker: AgentInvoker,
|
||||
input_payload: ThinkingPayload,
|
||||
agent_role: str,
|
||||
memory: ThinkingPayload | None,
|
||||
gen_payload: ThinkingPayload,
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
think based on input_data and gen_data after calling model API for node to generate
|
||||
|
||||
Returns:
|
||||
str: thinking result
|
||||
bool: whether to replace the original gen_data with the modified one
|
||||
"""
|
||||
...
|
||||
|
||||
def think(
|
||||
self,
|
||||
agent_invoker: AgentInvoker,
|
||||
input_payload: ThinkingPayload,
|
||||
agent_role: str,
|
||||
memory: ThinkingPayload | None,
|
||||
gen_payload: ThinkingPayload | None = None,
|
||||
) -> str | Message:
|
||||
"""
|
||||
think based on input_data and gen_data if provided
|
||||
|
||||
Returns:
|
||||
str: result for next step
|
||||
"""
|
||||
|
||||
normalized_input = input_payload.text
|
||||
normalized_gen = gen_payload.text if gen_payload is not None else None
|
||||
|
||||
if gen_payload is None and self.before_gen_think_enabled:
|
||||
think_result, replace_input = self._before_gen_think(
|
||||
agent_invoker, input_payload, agent_role, memory
|
||||
)
|
||||
if replace_input:
|
||||
return think_result
|
||||
else:
|
||||
return self.thinking_concat_prompt.format(origin=normalized_input, thinking=think_result)
|
||||
elif gen_payload is not None and self.after_gen_think_enabled:
|
||||
think_result, replace_gen = self._after_gen_think(
|
||||
agent_invoker, input_payload, agent_role, memory, gen_payload
|
||||
)
|
||||
if replace_gen:
|
||||
return think_result
|
||||
else:
|
||||
return self.thinking_concat_prompt.format(origin=normalized_gen or "", thinking=think_result)
|
||||
else:
|
||||
if gen_payload is not None:
|
||||
return gen_payload.raw if gen_payload.raw is not None else gen_payload.text
|
||||
return input_payload.raw if input_payload.raw is not None else input_payload.text
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
from .tool_manager import ToolManager
|
||||
|
||||
__all__ = [
|
||||
"ToolManager"
|
||||
]
|
||||
Executable
+558
@@ -0,0 +1,558 @@
|
||||
"""Tool management for function calling and MCP."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Sequence
|
||||
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.client import CallToolResult as FastMcpCallToolResult
|
||||
from fastmcp.client.transports import StreamableHttpTransport, StdioTransport
|
||||
from mcp import types
|
||||
|
||||
from entity.configs import ToolingConfig, ConfigError
|
||||
from entity.configs.node.tooling import FunctionToolConfig, McpLocalConfig, McpRemoteConfig
|
||||
from entity.messages import MessageBlock, MessageBlockType
|
||||
from entity.tool_spec import ToolSpec
|
||||
from utils.attachments import AttachmentStore
|
||||
from utils.function_manager import FUNCTION_CALLING_DIR, FunctionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MCP_HTTP_TIMEOUT = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FunctionManagerCacheEntry:
|
||||
manager: FunctionManager
|
||||
auto_loaded: bool = False
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""Manage function tools for agent nodes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._functions_dir: Path = FUNCTION_CALLING_DIR
|
||||
self._function_managers: Dict[Path, _FunctionManagerCacheEntry] = {}
|
||||
self._mcp_tool_cache: Dict[str, List[Any]] = {}
|
||||
self._mcp_stdio_clients: Dict[str, "_StdioClientWrapper"] = {}
|
||||
|
||||
def _get_function_manager(self) -> FunctionManager:
|
||||
entry = self._function_managers.get(self._functions_dir)
|
||||
if entry is None:
|
||||
entry = _FunctionManagerCacheEntry(manager=FunctionManager(self._functions_dir))
|
||||
self._function_managers[self._functions_dir] = entry
|
||||
return entry.manager
|
||||
|
||||
def _ensure_functions_loaded(self, auto_load: bool) -> None:
|
||||
if not auto_load:
|
||||
return
|
||||
entry = self._function_managers.setdefault(
|
||||
self._functions_dir,
|
||||
_FunctionManagerCacheEntry(manager=FunctionManager(self._functions_dir))
|
||||
)
|
||||
if not entry.auto_loaded:
|
||||
entry.manager.load_functions()
|
||||
entry.auto_loaded = True
|
||||
|
||||
async def _fetch_mcp_tools_http(
|
||||
self,
|
||||
server_url: str,
|
||||
*,
|
||||
headers: Dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
attempts: int = 3,
|
||||
) -> List[Any]:
|
||||
delay = 0.5
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport(server_url, headers=headers or None),
|
||||
timeout=timeout or DEFAULT_MCP_HTTP_TIMEOUT,
|
||||
)
|
||||
async with client:
|
||||
return await client.list_tools()
|
||||
except Exception as exc: # pragma: no cover - passthrough to caller
|
||||
last_error = exc
|
||||
if attempt == attempts:
|
||||
raise
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
if last_error:
|
||||
raise last_error
|
||||
return []
|
||||
|
||||
async def _fetch_mcp_tools_stdio(self, config: McpLocalConfig, launch_key: str) -> List[Any]:
|
||||
client = self._get_stdio_client(config, launch_key)
|
||||
return client.list_tools()
|
||||
|
||||
def get_tool_specs(self, tool_configs: List[ToolingConfig] | None) -> List[ToolSpec]:
|
||||
"""Return provider-agnostic tool specifications for the given config list."""
|
||||
if not tool_configs:
|
||||
return []
|
||||
|
||||
specs: List[ToolSpec] = []
|
||||
seen_tools: set[str] = set()
|
||||
|
||||
for idx, tool_config in enumerate(tool_configs):
|
||||
current_specs: List[ToolSpec] = []
|
||||
if tool_config.type == "function":
|
||||
config = tool_config.as_config(FunctionToolConfig)
|
||||
if not config:
|
||||
raise ValueError("Function tooling configuration missing")
|
||||
current_specs = self._build_function_specs(config)
|
||||
elif tool_config.type == "mcp_remote":
|
||||
config = tool_config.as_config(McpRemoteConfig)
|
||||
if not config:
|
||||
raise ValueError("MCP remote configuration missing")
|
||||
current_specs = self._build_mcp_remote_specs(config)
|
||||
elif tool_config.type == "mcp_local":
|
||||
config = tool_config.as_config(McpLocalConfig)
|
||||
if not config:
|
||||
raise ValueError("MCP local configuration missing")
|
||||
current_specs = self._build_mcp_local_specs(config)
|
||||
else:
|
||||
# Skip unknown types or raise error? Existing code raised error in execute but ignored in get_specs?
|
||||
# Better to ignore or log warning for robustness, but let's stick to safe behavior.
|
||||
pass
|
||||
|
||||
prefix = tool_config.prefix
|
||||
for spec in current_specs:
|
||||
original_name = spec.name
|
||||
final_name = f"{prefix}_{original_name}" if prefix else original_name
|
||||
|
||||
if final_name in seen_tools:
|
||||
raise ConfigError(
|
||||
f"Duplicate tool name '{final_name}' detected. "
|
||||
f"Please use a unique 'prefix' in your tooling configuration."
|
||||
)
|
||||
seen_tools.add(final_name)
|
||||
|
||||
# Update spec
|
||||
spec.name = final_name
|
||||
spec.metadata["_config_index"] = idx
|
||||
spec.metadata["original_name"] = original_name
|
||||
specs.append(spec)
|
||||
|
||||
return specs
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
tool_config: ToolingConfig,
|
||||
*,
|
||||
tool_context: Dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Execute a tool using the provided configuration."""
|
||||
if tool_config.type == "function":
|
||||
config = tool_config.as_config(FunctionToolConfig)
|
||||
if not config:
|
||||
raise ValueError("Function tooling configuration missing")
|
||||
return self._execute_function_tool(tool_name, arguments, config, tool_context)
|
||||
|
||||
if tool_config.type == "mcp_remote":
|
||||
config = tool_config.as_config(McpRemoteConfig)
|
||||
if not config:
|
||||
raise ValueError("MCP remote configuration missing")
|
||||
return await self._execute_mcp_remote_tool(tool_name, arguments, config, tool_context)
|
||||
|
||||
if tool_config.type == "mcp_local":
|
||||
config = tool_config.as_config(McpLocalConfig)
|
||||
if not config:
|
||||
raise ValueError("MCP local configuration missing")
|
||||
return await self._execute_mcp_local_tool(tool_name, arguments, config, tool_context)
|
||||
|
||||
raise ValueError(f"Unsupported tool type: {tool_config.type}")
|
||||
|
||||
def _build_function_specs(self, config: FunctionToolConfig) -> List[ToolSpec]:
|
||||
self._ensure_functions_loaded(config.auto_load)
|
||||
specs: List[ToolSpec] = []
|
||||
for tool in config.tools:
|
||||
parameters = tool.get("parameters")
|
||||
if not isinstance(parameters, Mapping):
|
||||
parameters = {"type": "object", "properties": {}}
|
||||
specs.append(
|
||||
ToolSpec(
|
||||
name=tool.get("name", ""),
|
||||
description=tool.get("description") or "",
|
||||
parameters=parameters,
|
||||
metadata={"source": "function"},
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
def _build_mcp_remote_specs(self, config: McpRemoteConfig) -> List[ToolSpec]:
|
||||
cache_key = f"remote:{config.cache_key()}"
|
||||
tools = self._mcp_tool_cache.get(cache_key)
|
||||
if tools is None:
|
||||
tools = asyncio.run(
|
||||
self._fetch_mcp_tools_http(
|
||||
config.server,
|
||||
headers=config.headers,
|
||||
timeout=config.timeout,
|
||||
)
|
||||
)
|
||||
self._mcp_tool_cache[cache_key] = tools
|
||||
|
||||
specs: List[ToolSpec] = []
|
||||
for tool in tools:
|
||||
specs.append(
|
||||
ToolSpec(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
parameters=tool.inputSchema or {"type": "object", "properties": {}},
|
||||
metadata={"source": "mcp", "server": config.server, "mode": "remote"},
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
def _build_mcp_local_specs(self, config: McpLocalConfig) -> List[ToolSpec]:
|
||||
launch_key = config.cache_key()
|
||||
if not launch_key:
|
||||
raise ValueError("MCP local configuration missing launch key")
|
||||
|
||||
cache_key = f"stdio:{launch_key}"
|
||||
tools = self._mcp_tool_cache.get(cache_key)
|
||||
if tools is None:
|
||||
tools = asyncio.run(self._fetch_mcp_tools_stdio(config, launch_key))
|
||||
self._mcp_tool_cache[cache_key] = tools
|
||||
|
||||
specs: List[ToolSpec] = []
|
||||
for tool in tools:
|
||||
specs.append(
|
||||
ToolSpec(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
parameters=tool.inputSchema or {"type": "object", "properties": {}},
|
||||
metadata={"source": "mcp", "server": "stdio", "mode": "local"},
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
def _execute_function_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
config: FunctionToolConfig,
|
||||
tool_context: Dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
mgr = self._get_function_manager()
|
||||
if config.auto_load:
|
||||
mgr.load_functions()
|
||||
func = mgr.get_function(tool_name)
|
||||
if func is None:
|
||||
raise ValueError(f"Tool {tool_name} not found in {self._functions_dir}")
|
||||
|
||||
call_args = dict(arguments or {})
|
||||
if (
|
||||
tool_context is not None
|
||||
# and "_context" not in call_args
|
||||
and self._function_accepts_context(func)
|
||||
):
|
||||
call_args["_context"] = tool_context
|
||||
return func(**call_args)
|
||||
|
||||
def _function_accepts_context(self, func: Any) -> bool:
|
||||
try:
|
||||
signature = inspect.signature(func)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
for param in signature.parameters.values():
|
||||
if param.kind is inspect.Parameter.VAR_KEYWORD:
|
||||
return True
|
||||
if param.name == "_context" and param.kind in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _execute_mcp_remote_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
config: McpRemoteConfig,
|
||||
tool_context: Dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport(config.server, headers=config.headers or None),
|
||||
timeout=config.timeout or DEFAULT_MCP_HTTP_TIMEOUT,
|
||||
)
|
||||
async with client:
|
||||
result = await client.call_tool(tool_name, arguments)
|
||||
return self._normalize_mcp_result(tool_name, result, tool_context)
|
||||
|
||||
async def _execute_mcp_local_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
config: McpLocalConfig,
|
||||
tool_context: Dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
launch_key = config.cache_key()
|
||||
if not launch_key:
|
||||
raise ValueError("MCP local configuration missing launch key")
|
||||
stdio_client = self._get_stdio_client(config, launch_key)
|
||||
result = stdio_client.call_tool(tool_name, arguments)
|
||||
return self._normalize_mcp_result(tool_name, result, tool_context)
|
||||
|
||||
def _normalize_mcp_result(
|
||||
self,
|
||||
tool_name: str,
|
||||
result: FastMcpCallToolResult,
|
||||
tool_context: Dict[str, Any] | None,
|
||||
) -> Any:
|
||||
attachment_store = self._extract_attachment_store(tool_context)
|
||||
blocks = self._convert_mcp_content_to_blocks(tool_name, result.content, attachment_store)
|
||||
if blocks:
|
||||
return blocks
|
||||
if result.structured_content is not None:
|
||||
return result.structured_content
|
||||
if result.content:
|
||||
content = result.content[0]
|
||||
if isinstance(content, types.TextContent):
|
||||
return content.text
|
||||
return str(content)
|
||||
return None
|
||||
|
||||
def _extract_attachment_store(self, tool_context: Dict[str, Any] | None) -> AttachmentStore | None:
|
||||
if not tool_context:
|
||||
return None
|
||||
candidate = tool_context.get("attachment_store")
|
||||
if isinstance(candidate, AttachmentStore):
|
||||
return candidate
|
||||
if candidate is not None:
|
||||
logger.warning(
|
||||
"attachment_store in tool_context is not AttachmentStore (got %s)",
|
||||
type(candidate).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
def _convert_mcp_content_to_blocks(
|
||||
self,
|
||||
tool_name: str,
|
||||
contents: Sequence[types.ContentBlock] | None,
|
||||
attachment_store: AttachmentStore | None,
|
||||
) -> List[MessageBlock]:
|
||||
blocks: List[MessageBlock] = []
|
||||
if not contents:
|
||||
return blocks
|
||||
for idx, content in enumerate(contents):
|
||||
converted = self._convert_single_mcp_block(tool_name, content, idx, attachment_store)
|
||||
if converted:
|
||||
blocks.extend(converted)
|
||||
return blocks
|
||||
|
||||
def _convert_single_mcp_block(
|
||||
self,
|
||||
tool_name: str,
|
||||
content: types.ContentBlock,
|
||||
block_index: int,
|
||||
attachment_store: AttachmentStore | None,
|
||||
) -> List[MessageBlock]:
|
||||
if isinstance(content, types.TextContent):
|
||||
return [MessageBlock.text_block(content.text)]
|
||||
if isinstance(content, types.ImageContent):
|
||||
return self._materialize_mcp_binary_block(
|
||||
tool_name,
|
||||
content.data,
|
||||
content.mimeType,
|
||||
MessageBlockType.IMAGE,
|
||||
block_index,
|
||||
attachment_store,
|
||||
)
|
||||
if isinstance(content, types.AudioContent):
|
||||
return self._materialize_mcp_binary_block(
|
||||
tool_name,
|
||||
content.data,
|
||||
content.mimeType,
|
||||
MessageBlockType.AUDIO,
|
||||
block_index,
|
||||
attachment_store,
|
||||
)
|
||||
if isinstance(content, types.EmbeddedResource):
|
||||
resource = content.resource
|
||||
if isinstance(resource, types.TextResourceContents):
|
||||
data_payload = {
|
||||
"uri": str(resource.uri),
|
||||
"mime_type": resource.mimeType,
|
||||
}
|
||||
return [
|
||||
MessageBlock(
|
||||
type=MessageBlockType.TEXT,
|
||||
text=resource.text,
|
||||
data={k: v for k, v in data_payload.items() if v is not None},
|
||||
)
|
||||
]
|
||||
if isinstance(resource, types.BlobResourceContents):
|
||||
extra = {
|
||||
"resource_uri": str(resource.uri),
|
||||
}
|
||||
return self._materialize_mcp_binary_block(
|
||||
tool_name,
|
||||
resource.blob,
|
||||
resource.mimeType,
|
||||
self._message_block_type_from_mime(resource.mimeType),
|
||||
block_index,
|
||||
attachment_store,
|
||||
extra=extra,
|
||||
)
|
||||
if isinstance(content, types.ResourceLink):
|
||||
data_payload = {
|
||||
"uri": str(content.uri),
|
||||
"mime_type": content.mimeType,
|
||||
"description": content.description,
|
||||
}
|
||||
return [
|
||||
MessageBlock(
|
||||
type=MessageBlockType.DATA,
|
||||
text=content.description or f"Resource link: {content.uri}",
|
||||
data={k: v for k, v in data_payload.items() if v is not None},
|
||||
)
|
||||
]
|
||||
logger.warning("Unhandled MCP content block type: %s", type(content).__name__)
|
||||
return []
|
||||
|
||||
def _materialize_mcp_binary_block(
|
||||
self,
|
||||
tool_name: str,
|
||||
payload_b64: str,
|
||||
mime_type: str | None,
|
||||
block_type: MessageBlockType,
|
||||
block_index: int,
|
||||
attachment_store: AttachmentStore | None,
|
||||
*,
|
||||
extra: Dict[str, Any] | None = None,
|
||||
) -> List[MessageBlock]:
|
||||
display_name = self._build_attachment_name(tool_name, block_type, block_index, mime_type)
|
||||
try:
|
||||
binary = base64.b64decode(payload_b64)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
logger.warning("Failed to decode MCP %s payload for %s: %s", block_type.value, tool_name, exc)
|
||||
return [
|
||||
MessageBlock.text_block(
|
||||
f"[failed to decode {block_type.value} content from {tool_name}]"
|
||||
)
|
||||
]
|
||||
|
||||
metadata = {
|
||||
"source": "mcp_tool",
|
||||
"tool_name": tool_name,
|
||||
"block_type": block_type.value,
|
||||
}
|
||||
if extra:
|
||||
metadata.update(extra)
|
||||
|
||||
if attachment_store is None:
|
||||
placeholder = (
|
||||
f"[binary content omitted: {display_name} ({mime_type or 'application/octet-stream'})]"
|
||||
)
|
||||
return [
|
||||
MessageBlock(
|
||||
type=MessageBlockType.TEXT,
|
||||
text=placeholder,
|
||||
data={**metadata, "reason": "attachment_store_missing", "mime_type": mime_type},
|
||||
)
|
||||
]
|
||||
|
||||
record = attachment_store.register_bytes(
|
||||
binary,
|
||||
kind=block_type,
|
||||
mime_type=mime_type,
|
||||
display_name=display_name,
|
||||
extra=metadata,
|
||||
)
|
||||
return [record.as_message_block()]
|
||||
|
||||
def _build_attachment_name(
|
||||
self,
|
||||
tool_name: str,
|
||||
block_type: MessageBlockType,
|
||||
block_index: int,
|
||||
mime_type: str | None,
|
||||
) -> str:
|
||||
base = f"{tool_name}_{block_type.value}_{block_index + 1}".strip() or "attachment"
|
||||
safe_base = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base)
|
||||
ext = mimetypes.guess_extension(mime_type or "") or ""
|
||||
return f"{safe_base}{ext}"
|
||||
|
||||
def _message_block_type_from_mime(self, mime_type: str | None) -> MessageBlockType:
|
||||
if not mime_type:
|
||||
return MessageBlockType.FILE
|
||||
if mime_type.startswith("image/"):
|
||||
return MessageBlockType.IMAGE
|
||||
if mime_type.startswith("audio/"):
|
||||
return MessageBlockType.AUDIO
|
||||
if mime_type.startswith("video/"):
|
||||
return MessageBlockType.VIDEO
|
||||
return MessageBlockType.FILE
|
||||
|
||||
def _get_stdio_client(self, config: McpLocalConfig, launch_key: str) -> "_StdioClientWrapper":
|
||||
client = self._mcp_stdio_clients.get(launch_key)
|
||||
if client is None:
|
||||
client = _StdioClientWrapper(config)
|
||||
self._mcp_stdio_clients[launch_key] = client
|
||||
return client
|
||||
|
||||
|
||||
class _StdioClientWrapper:
|
||||
def __init__(self, config: McpLocalConfig) -> None:
|
||||
env = os.environ.copy() if config.inherit_env else {}
|
||||
env.update(config.env)
|
||||
env_payload = env or None
|
||||
transport = StdioTransport(
|
||||
command=config.command,
|
||||
args=list(config.args),
|
||||
env=env_payload,
|
||||
cwd=config.cwd,
|
||||
keep_alive=True,
|
||||
)
|
||||
self._client = Client(transport=transport)
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
init_future = asyncio.run_coroutine_threadsafe(self._initialize(), self._loop)
|
||||
init_future.result()
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_forever()
|
||||
|
||||
async def _initialize(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
await self._client.__aenter__()
|
||||
|
||||
def list_tools(self) -> List[Any]:
|
||||
future = asyncio.run_coroutine_threadsafe(self._call("list_tools"), self._loop)
|
||||
return future.result()
|
||||
|
||||
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._call("call_tool", name, arguments),
|
||||
self._loop,
|
||||
)
|
||||
return future.result()
|
||||
|
||||
async def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
async with self._lock:
|
||||
func = getattr(self._client, method)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
def close(self) -> None:
|
||||
future = asyncio.run_coroutine_threadsafe(self._shutdown(), self._loop)
|
||||
future.result()
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self._thread.join()
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
async with self._lock:
|
||||
await self._client.__aexit__(None, None, None)
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
"""Register built-in workflow node types."""
|
||||
|
||||
from entity.configs.node.agent import AgentConfig
|
||||
from entity.configs.node.human import HumanConfig
|
||||
from entity.configs.node.subgraph import (
|
||||
SubgraphConfig,
|
||||
SubgraphFileConfig,
|
||||
SubgraphInlineConfig,
|
||||
register_subgraph_source,
|
||||
)
|
||||
from entity.configs.node.passthrough import PassthroughConfig
|
||||
from entity.configs.node.literal import LiteralNodeConfig
|
||||
from entity.configs.node.python_runner import PythonRunnerConfig
|
||||
from entity.configs.node.loop_counter import LoopCounterConfig
|
||||
from entity.configs.node.loop_timer import LoopTimerConfig
|
||||
from runtime.node.executor.agent_executor import AgentNodeExecutor
|
||||
from runtime.node.executor.human_executor import HumanNodeExecutor
|
||||
from runtime.node.executor.passthrough_executor import PassthroughNodeExecutor
|
||||
from runtime.node.executor.literal_executor import LiteralNodeExecutor
|
||||
from runtime.node.executor.python_executor import PythonNodeExecutor
|
||||
from runtime.node.executor.subgraph_executor import SubgraphNodeExecutor
|
||||
from runtime.node.executor.loop_counter_executor import LoopCounterNodeExecutor
|
||||
from runtime.node.executor.loop_timer_executor import LoopTimerNodeExecutor
|
||||
from runtime.node.registry import NodeCapabilities, register_node_type
|
||||
|
||||
|
||||
register_node_type(
|
||||
"agent",
|
||||
config_cls=AgentConfig,
|
||||
executor_cls=AgentNodeExecutor,
|
||||
capabilities=NodeCapabilities(
|
||||
default_role_field="role",
|
||||
exposes_tools=True,
|
||||
),
|
||||
summary="Agent execution node backed by configured LLM/tool providers with support for tooling, memory, and thinking extensions.",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"human",
|
||||
config_cls=HumanConfig,
|
||||
executor_cls=HumanNodeExecutor,
|
||||
capabilities=NodeCapabilities(
|
||||
resource_key="node_type:human",
|
||||
resource_limit=1,
|
||||
),
|
||||
summary="Pauses graph and waits for human operator response",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"subgraph",
|
||||
config_cls=SubgraphConfig,
|
||||
executor_cls=SubgraphNodeExecutor,
|
||||
capabilities=NodeCapabilities(),
|
||||
executor_factory=lambda context, subgraphs=None: SubgraphNodeExecutor(
|
||||
context, subgraphs or {}
|
||||
),
|
||||
summary="Embeds (through file path or inline config) and runs another named subgraph within the current workflow",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"python",
|
||||
config_cls=PythonRunnerConfig,
|
||||
executor_cls=PythonNodeExecutor,
|
||||
capabilities=NodeCapabilities(
|
||||
resource_key="node_type:python",
|
||||
resource_limit=1,
|
||||
),
|
||||
summary="Executes repository Python snippets",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"passthrough",
|
||||
config_cls=PassthroughConfig,
|
||||
executor_cls=PassthroughNodeExecutor,
|
||||
capabilities=NodeCapabilities(),
|
||||
summary="Forwards prior node output downstream without modification",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"literal",
|
||||
config_cls=LiteralNodeConfig,
|
||||
executor_cls=LiteralNodeExecutor,
|
||||
capabilities=NodeCapabilities(),
|
||||
summary="Emits the configured text message every time it is triggered",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"loop_counter",
|
||||
config_cls=LoopCounterConfig,
|
||||
executor_cls=LoopCounterNodeExecutor,
|
||||
capabilities=NodeCapabilities(),
|
||||
summary="Blocks downstream edges until the configured iteration limit is reached, then emits a message to release the loop.",
|
||||
)
|
||||
|
||||
register_node_type(
|
||||
"loop_timer",
|
||||
config_cls=LoopTimerConfig,
|
||||
executor_cls=LoopTimerNodeExecutor,
|
||||
capabilities=NodeCapabilities(),
|
||||
summary="Blocks downstream edges until the configured time limit is reached, then emits a message to release the loop.",
|
||||
)
|
||||
|
||||
# Register subgraph source types (file-based and inline config)
|
||||
register_subgraph_source(
|
||||
"config",
|
||||
config_cls=SubgraphInlineConfig,
|
||||
description="Inline subgraph definition embedded directly in the YAML graph",
|
||||
)
|
||||
register_subgraph_source(
|
||||
"file",
|
||||
config_cls=SubgraphFileConfig,
|
||||
description="Reference an external YAML file containing the subgraph",
|
||||
)
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
"""Node executor module.
|
||||
|
||||
Implements different execution strategies for each node type.
|
||||
"""
|
||||
|
||||
from runtime.node.executor.base import NodeExecutor, ExecutionContext
|
||||
from runtime.node.executor.agent_executor import AgentNodeExecutor
|
||||
from runtime.node.executor.human_executor import HumanNodeExecutor
|
||||
from runtime.node.executor.subgraph_executor import SubgraphNodeExecutor
|
||||
from runtime.node.executor.passthrough_executor import PassthroughNodeExecutor
|
||||
from runtime.node.executor.factory import NodeExecutorFactory
|
||||
|
||||
__all__ = [
|
||||
"NodeExecutor",
|
||||
"ExecutionContext",
|
||||
"AgentNodeExecutor",
|
||||
"HumanNodeExecutor",
|
||||
"SubgraphNodeExecutor",
|
||||
"PassthroughNodeExecutor",
|
||||
"NodeExecutorFactory",
|
||||
]
|
||||
Executable
+1336
File diff suppressed because it is too large
Load Diff
Executable
+146
@@ -0,0 +1,146 @@
|
||||
"""Abstract base classes for node executors.
|
||||
|
||||
Defines the interfaces that every node executor must implement.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, List
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.messages import Message, MessageContent, MessageRole, serialize_messages
|
||||
from runtime.node.agent import MemoryManager
|
||||
from runtime.node.agent import ThinkingManagerBase
|
||||
from runtime.node.agent import ToolManager
|
||||
from utils.function_manager import FunctionManager
|
||||
from utils.human_prompt import HumanPromptService
|
||||
from utils.log_manager import LogManager
|
||||
from utils.token_tracker import TokenTracker
|
||||
from utils.exceptions import WorkflowCancelledError
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Node execution context that bundles every service and state the executor needs.
|
||||
|
||||
Attributes:
|
||||
tool_manager: Tool manager shared by executors
|
||||
function_manager: Function manager registry
|
||||
log_manager: Structured log manager
|
||||
memory_managers: Mapping of node_id to ``MemoryManager`` instances
|
||||
thinking_managers: Mapping of node_id to ``ThinkingManagerBase`` instances
|
||||
token_tracker: Token tracker used for accounting
|
||||
global_state: Shared global state dictionary
|
||||
"""
|
||||
tool_manager: ToolManager
|
||||
function_manager: FunctionManager
|
||||
log_manager: LogManager
|
||||
memory_managers: Dict[str, MemoryManager] = field(default_factory=dict)
|
||||
thinking_managers: Dict[str, ThinkingManagerBase] = field(default_factory=dict)
|
||||
token_tracker: Optional[TokenTracker] = None
|
||||
global_state: Dict[str, Any] = field(default_factory=dict)
|
||||
workspace_hook: Optional[Any] = None
|
||||
human_prompt_service: Optional[HumanPromptService] = None
|
||||
cancel_event: Optional[Any] = None
|
||||
|
||||
def get_memory_manager(self, node_id: str) -> Optional[MemoryManager]:
|
||||
"""Return the memory manager for a given node."""
|
||||
return self.memory_managers.get(node_id)
|
||||
|
||||
def get_thinking_manager(self, node_id: str) -> Optional[ThinkingManagerBase]:
|
||||
"""Return the thinking manager for a given node."""
|
||||
return self.thinking_managers.get(node_id)
|
||||
|
||||
def get_token_tracker(self) -> Optional[TokenTracker]:
|
||||
"""Return the configured token tracker."""
|
||||
return self.token_tracker
|
||||
|
||||
def get_human_prompt_service(self) -> Optional[HumanPromptService]:
|
||||
"""Return the interactive human prompt service."""
|
||||
return self.human_prompt_service
|
||||
|
||||
|
||||
class NodeExecutor(ABC):
|
||||
"""Abstract base class for node executors.
|
||||
|
||||
Every concrete executor must inherit from this class and implement ``execute``.
|
||||
"""
|
||||
|
||||
def __init__(self, context: ExecutionContext):
|
||||
"""Initialize the executor with the shared execution context.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
"""
|
||||
self.context = context
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
"""Execute the node logic.
|
||||
|
||||
Args:
|
||||
node: Node definition to execute
|
||||
inputs: Input queue for the node
|
||||
|
||||
Returns:
|
||||
List of payload messages produced by the node. Empty list when the
|
||||
node intentionally suppresses downstream propagation. Standard nodes
|
||||
return a single-element list.
|
||||
|
||||
Raises:
|
||||
Exception: Raised when execution fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def tool_manager(self) -> ToolManager:
|
||||
"""Return the shared tool manager."""
|
||||
return self.context.tool_manager
|
||||
|
||||
@property
|
||||
def function_manager(self) -> FunctionManager:
|
||||
"""Return the shared function manager."""
|
||||
return self.context.function_manager
|
||||
|
||||
@property
|
||||
def log_manager(self) -> LogManager:
|
||||
"""Return the structured log manager."""
|
||||
return self.context.log_manager
|
||||
|
||||
def _inputs_to_text(self, inputs: List[Message]) -> str:
|
||||
if not inputs:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for message in inputs:
|
||||
source = message.metadata.get("source", "UNKNOWN")
|
||||
parts.append(
|
||||
f"=== INPUT FROM {source} ({message.role.value}) ===\n\n{message.text_content()}"
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _inputs_to_message_json(self, inputs: List[Message]) -> str | None:
|
||||
if not inputs:
|
||||
return None
|
||||
return serialize_messages(inputs)
|
||||
|
||||
def _build_message(
|
||||
self,
|
||||
role: MessageRole,
|
||||
content: MessageContent,
|
||||
*,
|
||||
source: str | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
preserve_role: bool = False,
|
||||
) -> Message:
|
||||
meta = dict(metadata or {})
|
||||
if source:
|
||||
meta.setdefault("source", source)
|
||||
return Message(role=role, content=content, metadata=meta, preserve_role=preserve_role)
|
||||
|
||||
def _clone_messages(self, messages: List[Message]) -> List[Message]:
|
||||
return [message.clone() for message in messages]
|
||||
|
||||
def _ensure_not_cancelled(self) -> None:
|
||||
event = getattr(self.context, "cancel_event", None)
|
||||
if event is not None and event.is_set():
|
||||
raise WorkflowCancelledError("Workflow execution cancelled")
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
"""Factory helpers for node executors.
|
||||
|
||||
Create and manage executors for different node types.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from runtime.node.executor.base import NodeExecutor, ExecutionContext
|
||||
from runtime.node.registry import iter_node_registrations
|
||||
|
||||
|
||||
class NodeExecutorFactory:
|
||||
"""Factory class that instantiates executors for every node type."""
|
||||
|
||||
@staticmethod
|
||||
def create_executors(context: ExecutionContext, subgraphs: dict = None) -> Dict[str, NodeExecutor]:
|
||||
"""Create executors for every registered node type.
|
||||
|
||||
Args:
|
||||
context: Shared execution context
|
||||
subgraphs: Mapping of subgraph nodes (used by Subgraph executors)
|
||||
|
||||
Returns:
|
||||
Mapping from node type to executor instance
|
||||
"""
|
||||
subgraphs = subgraphs or {}
|
||||
|
||||
executors: Dict[str, NodeExecutor] = {}
|
||||
for name, registration in iter_node_registrations().items():
|
||||
executors[name] = registration.build_executor(context, subgraphs=subgraphs)
|
||||
return executors
|
||||
|
||||
@staticmethod
|
||||
def create_executor(
|
||||
node_type: str,
|
||||
context: ExecutionContext,
|
||||
subgraphs: dict = None
|
||||
) -> NodeExecutor:
|
||||
"""Create an executor for the requested node type.
|
||||
|
||||
Args:
|
||||
node_type: Registered node type name
|
||||
context: Shared execution context
|
||||
subgraphs: Mapping of subgraph nodes (used by Subgraph executors)
|
||||
|
||||
Returns:
|
||||
Executor instance for the requested type
|
||||
|
||||
Raises:
|
||||
ValueError: If the node type is not supported
|
||||
"""
|
||||
subgraphs = subgraphs or {}
|
||||
|
||||
registrations = iter_node_registrations()
|
||||
if node_type not in registrations:
|
||||
raise ValueError(f"Unsupported node type: {node_type}")
|
||||
return registrations[node_type].build_executor(context, subgraphs=subgraphs)
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
"""Executor for Human nodes.
|
||||
|
||||
Runs the human-in-the-loop interaction nodes.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.human import HumanConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
|
||||
|
||||
class HumanNodeExecutor(NodeExecutor):
|
||||
"""Executor used for human interaction nodes."""
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
"""Execute a human node.
|
||||
|
||||
Args:
|
||||
node: Human node definition
|
||||
inputs: Input messages
|
||||
|
||||
Returns:
|
||||
Result supplied by the human reviewer
|
||||
"""
|
||||
self._ensure_not_cancelled()
|
||||
if node.node_type != "human":
|
||||
raise ValueError(f"Node {node.id} is not a human node")
|
||||
|
||||
human_config = node.as_config(HumanConfig)
|
||||
if not human_config:
|
||||
raise ValueError(f"Node {node.id} has no human configuration")
|
||||
|
||||
human_task_description = human_config.description
|
||||
# Use prompt-style preview so humans see the same flattened text format
|
||||
# instead of raw message JSON.
|
||||
input_data = self._inputs_to_text(inputs)
|
||||
|
||||
prompt_service = self.context.get_human_prompt_service()
|
||||
if prompt_service is None:
|
||||
raise RuntimeError("HumanPromptService is not configured; cannot execute human node")
|
||||
|
||||
prompt_result = prompt_service.request(
|
||||
node.id,
|
||||
human_task_description or "",
|
||||
inputs=input_data,
|
||||
metadata={"node_type": "human"},
|
||||
)
|
||||
|
||||
return [self._build_message(
|
||||
MessageRole.USER,
|
||||
prompt_result.as_message_content(),
|
||||
source=node.id,
|
||||
)]
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
"""Literal node executor."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.literal import LiteralNodeConfig
|
||||
from entity.messages import Message
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
|
||||
|
||||
class LiteralNodeExecutor(NodeExecutor):
|
||||
"""Emit the configured literal message whenever triggered."""
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
if node.node_type != "literal":
|
||||
raise ValueError(f"Node {node.id} is not a literal node")
|
||||
|
||||
config = node.as_config(LiteralNodeConfig)
|
||||
if config is None:
|
||||
raise ValueError(f"Node {node.id} missing literal configuration")
|
||||
|
||||
self._ensure_not_cancelled()
|
||||
return [self._build_message(
|
||||
role=config.role,
|
||||
content=config.content,
|
||||
source=node.id,
|
||||
preserve_role=True,
|
||||
)]
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"""Loop counter guard node executor."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.loop_counter import LoopCounterConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
|
||||
|
||||
class LoopCounterNodeExecutor(NodeExecutor):
|
||||
"""Track loop iterations and emit output only after hitting the limit."""
|
||||
|
||||
STATE_KEY = "loop_counter"
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
config = node.as_config(LoopCounterConfig)
|
||||
if config is None:
|
||||
raise ValueError(f"Node {node.id} missing loop_counter configuration")
|
||||
|
||||
state = self._get_state()
|
||||
counter = state.setdefault(node.id, {"count": 0})
|
||||
counter["count"] += 1
|
||||
count = counter["count"]
|
||||
|
||||
if count < config.max_iterations:
|
||||
self.log_manager.debug(
|
||||
f"LoopCounter {node.id}: iteration {count}/{config.max_iterations} (suppress downstream)"
|
||||
)
|
||||
return []
|
||||
|
||||
if config.reset_on_emit:
|
||||
counter["count"] = 0
|
||||
|
||||
content = config.message or f"Loop limit reached ({config.max_iterations})"
|
||||
metadata = {
|
||||
"loop_counter": {
|
||||
"count": count,
|
||||
"max": config.max_iterations,
|
||||
"reset_on_emit": config.reset_on_emit,
|
||||
}
|
||||
}
|
||||
|
||||
self.log_manager.debug(
|
||||
f"LoopCounter {node.id}: iteration {count}/{config.max_iterations} reached limit, releasing output"
|
||||
)
|
||||
|
||||
return [Message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)]
|
||||
|
||||
def _get_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
return self.context.global_state.setdefault(self.STATE_KEY, {})
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Loop timer guard node executor."""
|
||||
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.loop_timer import LoopTimerConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
|
||||
|
||||
class LoopTimerNodeExecutor(NodeExecutor):
|
||||
"""Track loop duration and emit output only after hitting the time limit.
|
||||
|
||||
Supports two modes:
|
||||
1. Standard Mode (passthrough=False): Suppresses input until time limit, then emits message
|
||||
2. Terminal Gate Mode (passthrough=True): Acts as a sequential switch
|
||||
- Before limit: Pass input through unchanged
|
||||
- At limit: Emit configured message, suppress original input
|
||||
- After limit: Transparent gate, pass all subsequent messages through
|
||||
"""
|
||||
|
||||
STATE_KEY = "loop_timer"
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
config = node.as_config(LoopTimerConfig)
|
||||
if config is None:
|
||||
raise ValueError(f"Node {node.id} missing loop_timer configuration")
|
||||
|
||||
state = self._get_state()
|
||||
timer_state = state.setdefault(node.id, {})
|
||||
|
||||
# Initialize timer on first execution
|
||||
current_time = time.time()
|
||||
if "start_time" not in timer_state:
|
||||
timer_state["start_time"] = current_time
|
||||
timer_state["emitted"] = False
|
||||
|
||||
start_time = timer_state["start_time"]
|
||||
elapsed_time = current_time - start_time
|
||||
|
||||
# Convert max_duration to seconds based on unit
|
||||
max_duration_seconds = self._convert_to_seconds(
|
||||
config.max_duration, config.duration_unit
|
||||
)
|
||||
|
||||
# Check if time limit has been reached
|
||||
limit_reached = elapsed_time >= max_duration_seconds
|
||||
|
||||
# Terminal Gate Mode (passthrough=True)
|
||||
if config.passthrough:
|
||||
if not limit_reached:
|
||||
# Before limit: pass input through unchanged
|
||||
self.log_manager.debug(
|
||||
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
|
||||
f"(passthrough mode: forwarding input)"
|
||||
)
|
||||
return inputs
|
||||
elif not timer_state["emitted"]:
|
||||
# At limit: emit configured message, suppress original input
|
||||
timer_state["emitted"] = True
|
||||
if config.reset_on_emit:
|
||||
timer_state["start_time"] = current_time
|
||||
|
||||
content = (
|
||||
config.message
|
||||
or f"Time limit reached ({config.max_duration} {config.duration_unit})"
|
||||
)
|
||||
metadata = {
|
||||
"loop_timer": {
|
||||
"elapsed_time": elapsed_time,
|
||||
"max_duration": config.max_duration,
|
||||
"duration_unit": config.duration_unit,
|
||||
"reset_on_emit": config.reset_on_emit,
|
||||
"passthrough": True,
|
||||
}
|
||||
}
|
||||
|
||||
self.log_manager.debug(
|
||||
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
|
||||
f"(passthrough mode: emitting limit message)"
|
||||
)
|
||||
|
||||
return [
|
||||
Message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
]
|
||||
else:
|
||||
# After limit: transparent gate, pass all subsequent messages through
|
||||
self.log_manager.debug(
|
||||
f"LoopTimer {node.id}: {elapsed_time:.1f}s (passthrough mode: transparent gate)"
|
||||
)
|
||||
return inputs
|
||||
|
||||
# Standard Mode (passthrough=False)
|
||||
if not limit_reached:
|
||||
self.log_manager.debug(
|
||||
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
|
||||
f"(suppress downstream)"
|
||||
)
|
||||
return []
|
||||
|
||||
if config.reset_on_emit and not timer_state["emitted"]:
|
||||
timer_state["start_time"] = current_time
|
||||
|
||||
timer_state["emitted"] = True
|
||||
|
||||
content = (
|
||||
config.message
|
||||
or f"Time limit reached ({config.max_duration} {config.duration_unit})"
|
||||
)
|
||||
metadata = {
|
||||
"loop_timer": {
|
||||
"elapsed_time": elapsed_time,
|
||||
"max_duration": config.max_duration,
|
||||
"duration_unit": config.duration_unit,
|
||||
"reset_on_emit": config.reset_on_emit,
|
||||
"passthrough": False,
|
||||
}
|
||||
}
|
||||
|
||||
self.log_manager.debug(
|
||||
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
|
||||
f"reached limit, releasing output"
|
||||
)
|
||||
|
||||
return [
|
||||
Message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
]
|
||||
|
||||
def _get_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
return self.context.global_state.setdefault(self.STATE_KEY, {})
|
||||
|
||||
def _convert_to_seconds(self, duration: float, unit: str) -> float:
|
||||
"""Convert duration to seconds based on unit."""
|
||||
unit_multipliers = {
|
||||
"seconds": 1.0,
|
||||
"minutes": 60.0,
|
||||
"hours": 3600.0,
|
||||
}
|
||||
return duration * unit_multipliers.get(unit, 1.0)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
"""Passthrough node executor."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.passthrough import PassthroughConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
|
||||
|
||||
class PassthroughNodeExecutor(NodeExecutor):
|
||||
"""Forward input messages without modifications."""
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
if node.node_type != "passthrough":
|
||||
raise ValueError(f"Node {node.id} is not a passthrough node")
|
||||
|
||||
config = node.as_config(PassthroughConfig)
|
||||
if config is None:
|
||||
raise ValueError(f"Node {node.id} missing passthrough configuration")
|
||||
|
||||
if not inputs:
|
||||
warning_msg = f"Passthrough node '{node.id}' triggered without inputs"
|
||||
self.log_manager.warning(warning_msg, node_id=node.id, details={"input_count": 0})
|
||||
return [Message(content="", role=MessageRole.USER)]
|
||||
|
||||
if config.only_last_message:
|
||||
if len(inputs) > 1:
|
||||
self.log_manager.debug(
|
||||
f"Passthrough node '{node.id}' received {len(inputs)} inputs; forwarding the latest entry",
|
||||
node_id=node.id,
|
||||
details={"input_count": len(inputs)},
|
||||
)
|
||||
return [inputs[-1].clone()]
|
||||
else:
|
||||
return [msg.clone() for msg in inputs]
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
"""Executor for Python code runner nodes."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.python_runner import PythonRunnerConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
|
||||
|
||||
_CODE_BLOCK_RE = re.compile(r"```(?P<lang>[a-zA-Z0-9_+-]*)?\s*\n(?P<code>.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ExecutionResult:
|
||||
success: bool
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PythonNodeExecutor(NodeExecutor):
|
||||
"""Execute inline Python code passed to the node."""
|
||||
|
||||
WORKSPACE_KEY = "python_workspace_root"
|
||||
COUNTER_KEY = "python_node_run_counters"
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
if node.node_type != "python":
|
||||
raise ValueError(f"Node {node.id} is not a python node")
|
||||
|
||||
workspace = self._ensure_workspace_root()
|
||||
last_message = inputs[-1] if inputs else None
|
||||
code_payload = self._extract_code(last_message)
|
||||
if not code_payload:
|
||||
return [self._build_failure_message(
|
||||
node,
|
||||
workspace,
|
||||
error_text="No executable code segment found",
|
||||
)]
|
||||
|
||||
script_path = self._write_script_file(node, workspace, code_payload)
|
||||
config = node.as_config(PythonRunnerConfig)
|
||||
if not config:
|
||||
raise ValueError(f"Node {node.id} missing PythonRunnerConfig")
|
||||
|
||||
result = self._run_process(config, script_path, workspace, node)
|
||||
metadata = {
|
||||
"workspace": str(workspace),
|
||||
"script_path": str(script_path),
|
||||
}
|
||||
if result.success:
|
||||
if result.stderr:
|
||||
self.log_manager.debug(
|
||||
f"Python node {node.id} stderr", node_id=node.id, details={"stderr": result.stderr}
|
||||
)
|
||||
return [self._build_message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=result.stdout,
|
||||
source=node.id,
|
||||
metadata=metadata,
|
||||
)]
|
||||
|
||||
error_text = result.error or "Script execution failed"
|
||||
return [self._build_failure_message(
|
||||
node,
|
||||
workspace,
|
||||
error_text=error_text,
|
||||
exit_code=result.exit_code,
|
||||
stderr=result.stderr,
|
||||
script_path=script_path,
|
||||
)]
|
||||
|
||||
def _ensure_workspace_root(self) -> Path:
|
||||
root = self.context.global_state.setdefault(self.WORKSPACE_KEY, None)
|
||||
if root is None:
|
||||
graph_dir = self.context.global_state.get("graph_directory")
|
||||
if not graph_dir:
|
||||
raise RuntimeError("graph_directory missing from execution context")
|
||||
root = (Path(graph_dir) / "code_workspace").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.context.global_state[self.WORKSPACE_KEY] = str(root)
|
||||
else:
|
||||
root = Path(root).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
def _extract_code(self, message: Message | None) -> str:
|
||||
if not message:
|
||||
return ""
|
||||
raw = message.text_content()
|
||||
if not raw or not raw.strip():
|
||||
return ""
|
||||
match = _CODE_BLOCK_RE.search(raw)
|
||||
code = match.group("code") if match else raw
|
||||
return textwrap.dedent(code).strip()
|
||||
|
||||
def _write_script_file(self, node: Node, workspace: Path, code: str) -> Path:
|
||||
counters = self.context.global_state.setdefault(self.COUNTER_KEY, {})
|
||||
safe_node_id = re.sub(r"[^0-9A-Za-z_\-]", "_", node.id)
|
||||
run_count = counters.get(node.id, 0) + 1
|
||||
counters[node.id] = run_count
|
||||
suffix = f"_run-{run_count}" if run_count > 1 else ""
|
||||
filename = f"{safe_node_id}{suffix}.py"
|
||||
path = (workspace / filename).resolve()
|
||||
path.write_text(code + ("\n" if not code.endswith("\n") else ""), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def _run_process(
|
||||
self,
|
||||
config: PythonRunnerConfig,
|
||||
script_path: Path,
|
||||
workspace: Path,
|
||||
node: Node,
|
||||
) -> _ExecutionResult:
|
||||
cmd = [config.interpreter]
|
||||
if config.args:
|
||||
cmd.extend(config.args)
|
||||
cmd.append(str(script_path))
|
||||
env = os.environ.copy()
|
||||
env.update(config.env or {})
|
||||
env.update(
|
||||
{
|
||||
"MAC_CODE_WORKSPACE": str(workspace),
|
||||
"MAC_CODE_SCRIPT": str(script_path),
|
||||
"MAC_NODE_ID": node.id,
|
||||
}
|
||||
)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(workspace),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=config.timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return _ExecutionResult(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr=exc.stdout.decode(config.encoding, errors="replace") if exc.stdout else "",
|
||||
exit_code=None,
|
||||
error=f"Script did not finish within {config.timeout_seconds}s",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return _ExecutionResult(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=None,
|
||||
error=f"Interpreter {config.interpreter} not found",
|
||||
)
|
||||
stdout = completed.stdout.decode(config.encoding, errors="replace")
|
||||
stderr = completed.stderr.decode(config.encoding, errors="replace")
|
||||
return _ExecutionResult(
|
||||
success=completed.returncode == 0,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=completed.returncode,
|
||||
)
|
||||
|
||||
def _build_failure_message(
|
||||
self,
|
||||
node: Node,
|
||||
workspace: Path,
|
||||
*,
|
||||
error_text: str,
|
||||
exit_code: int | None = None,
|
||||
stderr: str | None = None,
|
||||
script_path: Path | None = None,
|
||||
) -> Message:
|
||||
metadata = {
|
||||
"workspace": str(workspace),
|
||||
}
|
||||
if script_path:
|
||||
metadata["script_path"] = str(script_path)
|
||||
if exit_code is not None:
|
||||
metadata["exit_code"] = exit_code
|
||||
if stderr:
|
||||
metadata["stderr"] = stderr
|
||||
|
||||
content_lines = ["==CODE EXECUTION FAILED==", error_text]
|
||||
if exit_code is not None:
|
||||
content_lines.append(f"exit_code={exit_code}")
|
||||
if stderr:
|
||||
content_lines.append(f"stderr:\n{stderr}")
|
||||
|
||||
return self._build_message(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content="\n".join(content_lines),
|
||||
source=node.id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# workspace hook handled via ExecutionContext.workspace_hook
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
"""Executor for subgraph nodes.
|
||||
|
||||
Runs nested graph nodes inside the parent workflow.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
import copy
|
||||
|
||||
from entity.configs import Node
|
||||
from entity.configs.node.subgraph import SubgraphConfig
|
||||
from runtime.node.executor.base import NodeExecutor
|
||||
from entity.messages import Message, MessageRole
|
||||
|
||||
|
||||
class SubgraphNodeExecutor(NodeExecutor):
|
||||
"""Subgraph node executor.
|
||||
|
||||
Note: this executor needs access to ``GraphContext.subgraphs``.
|
||||
"""
|
||||
|
||||
def __init__(self, context, subgraphs: dict):
|
||||
"""Initialize the executor.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
subgraphs: Mapping from node_id to ``GraphContext``
|
||||
"""
|
||||
super().__init__(context)
|
||||
self.subgraphs = subgraphs
|
||||
|
||||
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
"""Execute a subgraph node.
|
||||
|
||||
Args:
|
||||
node: Subgraph node definition
|
||||
inputs: Input messages list
|
||||
|
||||
Returns:
|
||||
Result produced by the subgraph
|
||||
"""
|
||||
if node.node_type != "subgraph":
|
||||
raise ValueError(f"Node {node.id} is not a subgraph node")
|
||||
|
||||
subgraph_config = node.as_config(SubgraphConfig)
|
||||
if not subgraph_config:
|
||||
raise ValueError(f"Node {node.id} has no subgraph configuration")
|
||||
|
||||
task_payload: List[Message] = self._clone_messages(inputs)
|
||||
if not task_payload:
|
||||
task_payload = [self._build_message(MessageRole.USER, "", source="SUBGRAPH")]
|
||||
|
||||
input_data = self._inputs_to_text(task_payload)
|
||||
|
||||
self.log_manager.debug(
|
||||
f"Subgraph processing for node {node.id}",
|
||||
node_id=node.id,
|
||||
details={
|
||||
"input_size": len(str(input_data)),
|
||||
"input_result": input_data
|
||||
}
|
||||
)
|
||||
|
||||
# Retrieve the subgraph context
|
||||
if node.id not in self.subgraphs:
|
||||
raise ValueError(f"Subgraph for node {node.id} not found")
|
||||
|
||||
subgraph = self.subgraphs[node.id]
|
||||
|
||||
# Deep copy the subgraph to ensure isolation during parallel execution
|
||||
# process. Nodes in the subgraph (e.g. Start) hold state (inputs/outputs)
|
||||
# that must not be shared across threads.
|
||||
subgraph = copy.deepcopy(subgraph)
|
||||
|
||||
# Execute the subgraph (requires importing ``GraphExecutor``)
|
||||
from workflow.graph import GraphExecutor
|
||||
|
||||
executor = GraphExecutor.execute_graph(subgraph, task_prompt=task_payload)
|
||||
result_messages = executor.get_final_output_messages()
|
||||
|
||||
final_results = []
|
||||
if not result_messages:
|
||||
# Fallback for no output
|
||||
fallback = self._build_message(
|
||||
MessageRole.ASSISTANT,
|
||||
"",
|
||||
source=node.id,
|
||||
)
|
||||
final_results.append(fallback)
|
||||
else:
|
||||
for msg in result_messages:
|
||||
result_message = msg.clone()
|
||||
meta = dict(result_message.metadata)
|
||||
meta.setdefault("source", node.id)
|
||||
result_message.metadata = meta
|
||||
final_results.append(result_message)
|
||||
|
||||
self.log_manager.debug(
|
||||
f"Subgraph processing completed for node {node.id}",
|
||||
node_id=node.id,
|
||||
details=executor.log_manager.logs_to_dict()
|
||||
)
|
||||
|
||||
return final_results
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
"""Registry helpers for pluggable workflow node types."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Type
|
||||
|
||||
from schema_registry import register_node_schema
|
||||
from utils.registry import Registry, RegistryEntry, RegistryError
|
||||
|
||||
|
||||
node_registry = Registry("node_type")
|
||||
_BUILTINS_LOADED = False
|
||||
|
||||
|
||||
def _ensure_builtins_loaded() -> None:
|
||||
global _BUILTINS_LOADED
|
||||
if not _BUILTINS_LOADED:
|
||||
from importlib import import_module
|
||||
|
||||
import_module("runtime.node.builtin_nodes")
|
||||
_BUILTINS_LOADED = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NodeCapabilities:
|
||||
default_role_field: str | None = None
|
||||
exposes_tools: bool = False
|
||||
resource_key: str | None = None
|
||||
resource_limit: int | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NodeRegistration:
|
||||
name: str
|
||||
config_cls: Type[Any]
|
||||
executor_cls: Type[Any]
|
||||
capabilities: NodeCapabilities = field(default_factory=NodeCapabilities)
|
||||
executor_factory: Callable[..., Any] | None = None
|
||||
summary: str | None = None
|
||||
|
||||
def build_executor(self, context: Any, *, subgraphs: Dict[str, Any] | None = None) -> Any:
|
||||
if self.executor_factory:
|
||||
return self.executor_factory(context, subgraphs=subgraphs)
|
||||
return self.executor_cls(context)
|
||||
|
||||
|
||||
def register_node_type(
|
||||
name: str,
|
||||
*,
|
||||
config_cls: Type[Any],
|
||||
executor_cls: Type[Any],
|
||||
capabilities: NodeCapabilities | None = None,
|
||||
executor_factory: Callable[..., Any] | None = None,
|
||||
summary: str | None = None,
|
||||
) -> None:
|
||||
if name in node_registry.names():
|
||||
raise RegistryError(f"Node type '{name}' already registered")
|
||||
|
||||
entry = NodeRegistration(
|
||||
name=name,
|
||||
config_cls=config_cls,
|
||||
executor_cls=executor_cls,
|
||||
capabilities=capabilities or NodeCapabilities(),
|
||||
executor_factory=executor_factory,
|
||||
summary=summary,
|
||||
)
|
||||
node_registry.register(name, target=entry)
|
||||
register_node_schema(name, config_cls=config_cls, summary=summary)
|
||||
|
||||
|
||||
def get_node_registration(name: str) -> NodeRegistration:
|
||||
_ensure_builtins_loaded()
|
||||
entry: RegistryEntry = node_registry.get(name)
|
||||
registration = entry.load()
|
||||
if not isinstance(registration, NodeRegistration):
|
||||
raise RegistryError(f"Registry entry '{name}' is not a NodeRegistration")
|
||||
return registration
|
||||
|
||||
|
||||
def iter_node_registrations() -> Dict[str, NodeRegistration]:
|
||||
_ensure_builtins_loaded()
|
||||
return {name: entry.load() for name, entry in node_registry.items()}
|
||||
Executable
+289
@@ -0,0 +1,289 @@
|
||||
"""Split strategies for dynamic node execution.
|
||||
|
||||
Provides different methods to split input messages into execution units.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from entity.configs.dynamic_base import SplitConfig, RegexSplitConfig, JsonPathSplitConfig
|
||||
from entity.messages import Message, MessageRole
|
||||
|
||||
|
||||
class Splitter(ABC):
|
||||
"""Abstract base class for input splitters."""
|
||||
|
||||
@abstractmethod
|
||||
def split(self, inputs: List[Message]) -> List[List[Message]]:
|
||||
"""Split inputs into execution units.
|
||||
|
||||
Args:
|
||||
inputs: Input messages to split
|
||||
|
||||
Returns:
|
||||
List of message groups, each group is one execution unit
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MessageSplitter(Splitter):
|
||||
"""Split by message - each message becomes one execution unit."""
|
||||
|
||||
def split(self, inputs: List[Message]) -> List[List[Message]]:
|
||||
"""Each input message becomes a separate unit."""
|
||||
return [[msg] for msg in inputs]
|
||||
|
||||
|
||||
class RegexSplitter(Splitter):
|
||||
"""Split by regex pattern matches."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern: str,
|
||||
*,
|
||||
group: str | int | None = None,
|
||||
case_sensitive: bool = True,
|
||||
multiline: bool = False,
|
||||
dotall: bool = False,
|
||||
on_no_match: str = "pass",
|
||||
):
|
||||
"""Initialize with regex pattern and options.
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to match
|
||||
group: Capture group name or index. Defaults to entire match (0).
|
||||
case_sensitive: Whether the regex should be case sensitive.
|
||||
multiline: Enable multiline mode (re.MULTILINE).
|
||||
dotall: Enable dotall mode (re.DOTALL).
|
||||
on_no_match: Behavior when no match is found ('pass' or 'empty').
|
||||
"""
|
||||
flags = 0
|
||||
if not case_sensitive:
|
||||
flags |= re.IGNORECASE
|
||||
if multiline:
|
||||
flags |= re.MULTILINE
|
||||
if dotall:
|
||||
flags |= re.DOTALL
|
||||
|
||||
self.pattern = re.compile(pattern, flags)
|
||||
self.group = group
|
||||
self.on_no_match = on_no_match
|
||||
|
||||
def split(self, inputs: List[Message]) -> List[List[Message]]:
|
||||
"""Split by finding all regex matches across all inputs."""
|
||||
units: List[List[Message]] = []
|
||||
|
||||
for msg in inputs:
|
||||
text = msg.text_content()
|
||||
|
||||
# Find all matches
|
||||
matches = list(self.pattern.finditer(text))
|
||||
|
||||
if not matches:
|
||||
# Handle no match case
|
||||
if self.on_no_match == "pass":
|
||||
units.append([msg])
|
||||
elif self.on_no_match == "empty":
|
||||
# Return empty content
|
||||
unit_msg = Message(
|
||||
role=msg.role,
|
||||
content="",
|
||||
metadata={**msg.metadata, "split_source": "regex", "split_no_match": True},
|
||||
)
|
||||
units.append([unit_msg])
|
||||
continue
|
||||
|
||||
for match in matches:
|
||||
# Extract the appropriate group
|
||||
if self.group is not None:
|
||||
try:
|
||||
match_text = match.group(self.group)
|
||||
except (IndexError, re.error):
|
||||
match_text = match.group(0)
|
||||
else:
|
||||
match_text = match.group(0)
|
||||
|
||||
if match_text is None:
|
||||
match_text = ""
|
||||
|
||||
unit_msg = Message(
|
||||
role=msg.role,
|
||||
content=match_text,
|
||||
metadata={**msg.metadata, "split_source": "regex"},
|
||||
)
|
||||
units.append([unit_msg])
|
||||
|
||||
return units if units else [[msg] for msg in inputs]
|
||||
|
||||
|
||||
class JsonPathSplitter(Splitter):
|
||||
"""Split by JSON array path extraction."""
|
||||
|
||||
def __init__(self, json_path: str):
|
||||
"""Initialize with JSON path.
|
||||
|
||||
Args:
|
||||
json_path: Simple dot-notation path to array (e.g., 'items', 'data.results')
|
||||
"""
|
||||
self.json_path = json_path
|
||||
|
||||
def _extract_array(self, data: Any) -> List[Any]:
|
||||
"""Extract array from data using simple dot notation path."""
|
||||
if not self.json_path:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return [data]
|
||||
|
||||
parts = self.json_path.split(".")
|
||||
current = data
|
||||
|
||||
for part in parts:
|
||||
if isinstance(current, dict):
|
||||
current = current.get(part)
|
||||
elif isinstance(current, list) and part.isdigit():
|
||||
idx = int(part)
|
||||
current = current[idx] if idx < len(current) else None
|
||||
else:
|
||||
return []
|
||||
|
||||
if current is None:
|
||||
return []
|
||||
|
||||
if isinstance(current, list):
|
||||
return current
|
||||
return [current]
|
||||
|
||||
def split(self, inputs: List[Message]) -> List[List[Message]]:
|
||||
"""Split by extracting array items from JSON content."""
|
||||
units: List[List[Message]] = []
|
||||
|
||||
for msg in inputs:
|
||||
text = msg.text_content()
|
||||
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
data = json.loads(text)
|
||||
items = self._extract_array(data)
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, (dict, list)):
|
||||
content = json.dumps(item, ensure_ascii=False)
|
||||
else:
|
||||
content = str(item)
|
||||
|
||||
unit_msg = Message(
|
||||
role=msg.role,
|
||||
content=content,
|
||||
metadata={**msg.metadata, "split_source": "json_path"},
|
||||
)
|
||||
units.append([unit_msg])
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# If not valid JSON, treat as single unit
|
||||
units.append([msg])
|
||||
|
||||
return units if units else [[msg] for msg in inputs]
|
||||
|
||||
|
||||
def create_splitter(
|
||||
split_type: str,
|
||||
pattern: Optional[str] = None,
|
||||
json_path: Optional[str] = None,
|
||||
*,
|
||||
group: str | int | None = None,
|
||||
case_sensitive: bool = True,
|
||||
multiline: bool = False,
|
||||
dotall: bool = False,
|
||||
on_no_match: str = "pass",
|
||||
) -> Splitter:
|
||||
"""Factory function to create appropriate splitter.
|
||||
|
||||
Args:
|
||||
split_type: One of 'message', 'regex', 'json_path'
|
||||
pattern: Regex pattern (required for 'regex' type)
|
||||
json_path: JSON path (required for 'json_path' type)
|
||||
group: Capture group for regex (optional)
|
||||
case_sensitive: Case sensitivity for regex (default True)
|
||||
multiline: Multiline mode for regex (default False)
|
||||
dotall: Dotall mode for regex (default False)
|
||||
on_no_match: Behavior when no regex match ('pass' or 'empty')
|
||||
|
||||
Returns:
|
||||
Configured Splitter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If required arguments are missing
|
||||
"""
|
||||
if split_type == "message":
|
||||
return MessageSplitter()
|
||||
elif split_type == "regex":
|
||||
if not pattern:
|
||||
raise ValueError("regex splitter requires 'pattern' argument")
|
||||
return RegexSplitter(
|
||||
pattern,
|
||||
group=group,
|
||||
case_sensitive=case_sensitive,
|
||||
multiline=multiline,
|
||||
dotall=dotall,
|
||||
on_no_match=on_no_match,
|
||||
)
|
||||
elif split_type == "json_path":
|
||||
if not json_path:
|
||||
raise ValueError("json_path splitter requires 'json_path' argument")
|
||||
return JsonPathSplitter(json_path)
|
||||
else:
|
||||
raise ValueError(f"Unknown split type: {split_type}")
|
||||
|
||||
|
||||
def create_splitter_from_config(split_config: "SplitConfig") -> Splitter:
|
||||
"""Create a splitter from a SplitConfig object.
|
||||
|
||||
Args:
|
||||
split_config: The split configuration
|
||||
|
||||
Returns:
|
||||
Configured Splitter instance
|
||||
"""
|
||||
if split_config.type == "message":
|
||||
return MessageSplitter()
|
||||
elif split_config.type == "regex":
|
||||
regex_config = split_config.as_split_config(RegexSplitConfig)
|
||||
if not regex_config:
|
||||
raise ValueError("Invalid regex split configuration")
|
||||
return RegexSplitter(
|
||||
regex_config.pattern,
|
||||
group=regex_config.group,
|
||||
case_sensitive=regex_config.case_sensitive,
|
||||
multiline=regex_config.multiline,
|
||||
dotall=regex_config.dotall,
|
||||
on_no_match=regex_config.on_no_match,
|
||||
)
|
||||
elif split_config.type == "json_path":
|
||||
json_config = split_config.as_split_config(JsonPathSplitConfig)
|
||||
if not json_config:
|
||||
raise ValueError("Invalid json_path split configuration")
|
||||
return JsonPathSplitter(json_config.json_path)
|
||||
else:
|
||||
raise ValueError(f"Unknown split type: {split_config.type}")
|
||||
|
||||
|
||||
def group_messages(messages: List[Message], group_size: int) -> List[List[Message]]:
|
||||
"""Group messages into batches for tree reduction.
|
||||
|
||||
Args:
|
||||
messages: Messages to group
|
||||
group_size: Target size per group
|
||||
|
||||
Returns:
|
||||
List of message groups. Last group may have fewer items.
|
||||
"""
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
groups: List[List[Message]] = []
|
||||
for i in range(0, len(messages), group_size):
|
||||
groups.append(messages[i:i + group_size])
|
||||
|
||||
return groups
|
||||
Reference in New Issue
Block a user