chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Node package."""
__all__: list[str] = []
+5
View File
@@ -0,0 +1,5 @@
from .memory import *
from .providers import *
from .skills import *
from .thinking import *
from .tool import *
+8
View File
@@ -0,0 +1,8 @@
from .memory_base import MemoryBase, MemoryManager
from .builtin_stores import MemoryFactory
__all__ = [
"MemoryBase",
"MemoryManager",
"MemoryFactory",
]
+99
View File
@@ -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 :]
+55
View File
@@ -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)
+194
View File
@@ -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
+485
View File
@@ -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)
+219
View File
@@ -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
+304
View File
@@ -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
+64
View File
@@ -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",
]
+294
View File
@@ -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:]
+8
View File
@@ -0,0 +1,8 @@
from .base import ModelProvider, ProviderRegistry
from .response import ModelResponse
__all__ = [
"ModelProvider",
"ProviderRegistry",
"ModelResponse",
]
+116
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+39
View File
@@ -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__()
+8
View File
@@ -0,0 +1,8 @@
from .manager import AgentSkillManager, SkillMetadata, SkillValidationError, parse_skill_file
__all__ = [
"AgentSkillManager",
"SkillMetadata",
"SkillValidationError",
"parse_skill_file",
]
+309
View 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)
+8
View File
@@ -0,0 +1,8 @@
from .thinking_manager import ThinkingManagerBase, ThinkingPayload
from .builtin_thinking import ThinkingManagerFactory
__all__ = [
"ThinkingManagerBase",
"ThinkingPayload",
"ThinkingManagerFactory",
]
+26
View File
@@ -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)
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
from .tool_manager import ToolManager
__all__ = [
"ToolManager"
]
+558
View File
@@ -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)
+113
View File
@@ -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",
)
+21
View File
@@ -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",
]
File diff suppressed because it is too large Load Diff
+146
View File
@@ -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")
+57
View File
@@ -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)
+56
View File
@@ -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,
)]
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+202
View File
@@ -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
+103
View File
@@ -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
+81
View File
@@ -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()}
+289
View File
@@ -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