feat: 新增 core/agents 模块和 nanobot
- 新增 agents 模块,包含 agent、api、skills 等子模块 - 新增 nanobot 项目,支持多渠道集成 - 添加启动脚本 start-all.bat 和 start-all.sh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
core/agents/.env.example
Normal file
27
core/agents/.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# X-Agents Python Agent Environment Configuration
|
||||
|
||||
# API Settings
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8001
|
||||
|
||||
# Go Backend URL (for tool sync)
|
||||
GO_BACKEND_URL=http://localhost:8080
|
||||
|
||||
# LLM Provider (openai/anthropic)
|
||||
LLM_PROVIDER=openai
|
||||
|
||||
# LLM API Key (required for actual LLM calls)
|
||||
LLM_API_KEY=your-api-key-here
|
||||
|
||||
# LLM Model
|
||||
LLM_MODEL=gpt-4o
|
||||
|
||||
# Optional: Custom LLM Base URL (for proxy/alternative endpoints)
|
||||
# LLM_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# Workspace for agent files
|
||||
WORKSPACE=./workspace
|
||||
|
||||
# Agent settings
|
||||
MAX_ITERATIONS=10
|
||||
TEMPERATURE=0.7
|
||||
7
core/agents/__init__.py
Normal file
7
core/agents/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""X-Agents Agent Core Package."""
|
||||
|
||||
# 注意:不要在这里使用顶层导入,会导致循环依赖问题
|
||||
# 如需使用,请在使用时导入:
|
||||
# from core.agents.agent.loop import AgentLoop
|
||||
|
||||
__all__ = []
|
||||
7
core/agents/agent/__init__.py
Normal file
7
core/agents/agent/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""X-Agents Agent Module."""
|
||||
|
||||
from agents.agent.loop import AgentLoop
|
||||
from agents.agent.context import ContextBuilder
|
||||
from agents.agent.memory import AgentMemory, SessionMemory, RemoteMemoryClient
|
||||
|
||||
__all__ = ["AgentLoop", "ContextBuilder", "AgentMemory", "SessionMemory", "RemoteMemoryClient"]
|
||||
111
core/agents/agent/context.py
Normal file
111
core/agents/agent/context.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Context builder for assembling agent prompts."""
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the context (system prompt + messages) for the agent."""
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
"""Initialize the context builder.
|
||||
|
||||
Args:
|
||||
workspace: Workspace directory
|
||||
"""
|
||||
self.workspace = workspace
|
||||
|
||||
def build_system_prompt(self) -> str:
|
||||
"""Build the system prompt with identity and runtime info."""
|
||||
workspace_path = str(self.workspace.expanduser().resolve())
|
||||
system = platform.system()
|
||||
runtime = f"{system} {platform.machine()}"
|
||||
|
||||
return f"""# X-Agents Assistant
|
||||
|
||||
You are an AI assistant built on the X-Agents platform.
|
||||
|
||||
## Runtime
|
||||
{runtime}
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: {workspace_path}
|
||||
|
||||
## Guidelines
|
||||
- Be helpful and concise
|
||||
- Think step by step when needed
|
||||
- Ask for clarification when the request is ambiguous
|
||||
"""
|
||||
|
||||
def build_messages(
|
||||
self,
|
||||
history: list[dict[str, Any]],
|
||||
current_message: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call.
|
||||
|
||||
Args:
|
||||
history: Conversation history
|
||||
current_message: Current user message
|
||||
|
||||
Returns:
|
||||
List of messages for LLM
|
||||
"""
|
||||
return [
|
||||
{"role": "system", "content": self.build_system_prompt()},
|
||||
*history,
|
||||
{"role": "user", "content": current_message},
|
||||
]
|
||||
|
||||
def add_assistant_message(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
content: str | None,
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
reasoning_content: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add an assistant message to the message list.
|
||||
|
||||
Args:
|
||||
messages: Current message list
|
||||
content: Assistant message content
|
||||
tool_calls: Optional tool calls
|
||||
reasoning_content: Optional reasoning from model
|
||||
|
||||
Returns:
|
||||
Updated message list
|
||||
"""
|
||||
msg = {"role": "assistant", "content": content or ""}
|
||||
if tool_calls:
|
||||
msg["tool_calls"] = tool_calls
|
||||
if reasoning_content:
|
||||
msg["reasoning_content"] = reasoning_content
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
def add_tool_result(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add a tool result to the message list.
|
||||
|
||||
Args:
|
||||
messages: Current message list
|
||||
tool_call_id: ID of the tool call
|
||||
tool_name: Name of the tool
|
||||
result: Tool execution result
|
||||
|
||||
Returns:
|
||||
Updated message list
|
||||
"""
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"content": result,
|
||||
})
|
||||
return messages
|
||||
521
core/agents/agent/intelligent_memory.py
Normal file
521
core/agents/agent/intelligent_memory.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""Intelligent memory summarization and compression system."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SummarizationConfig:
|
||||
"""Configuration for memory summarization."""
|
||||
# Token thresholds
|
||||
context_window: int = 200000 # Model's context window
|
||||
reserve_tokens: int = 20000 # Reserved tokens for system prompt
|
||||
soft_threshold: int = 4000 # Trigger summarization before hitting limit
|
||||
|
||||
# Summary settings
|
||||
keep_recent_tokens: int = 20000 # Keep recent N tokens
|
||||
summary_prompt: str = (
|
||||
"Please summarize the following conversation, preserving key information, "
|
||||
"decisions, and important details. Focus on:\n"
|
||||
"- User preferences and requirements\n"
|
||||
"- Important decisions made\n"
|
||||
"- Technical details and configurations\n"
|
||||
"- Any follow-up tasks or action items\n\n"
|
||||
"Conversation:\n{content}\n\n"
|
||||
"Provide a concise summary:"
|
||||
)
|
||||
|
||||
# Evergreen settings
|
||||
evergreen_importance_threshold: int = 8 # Auto-mark high importance as evergreen
|
||||
|
||||
# Decay settings
|
||||
decay_days_no_activity: int = 30 # Days without activity before decay starts
|
||||
decay_factor: float = 0.9 # Importance decay factor per period
|
||||
|
||||
|
||||
class MemorySummarizer:
|
||||
"""LLM-based memory summarizer."""
|
||||
|
||||
def __init__(self, llm_provider=None, config: SummarizationConfig | None = None):
|
||||
"""Initialize memory summarizer.
|
||||
|
||||
Args:
|
||||
llm_provider: LLM provider for generating summaries
|
||||
config: Summarization configuration
|
||||
"""
|
||||
self.llm_provider = llm_provider
|
||||
self.config = config or SummarizationConfig()
|
||||
|
||||
async def summarize_conversation(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Summarize a conversation.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages
|
||||
|
||||
Returns:
|
||||
Summary string or None if failed
|
||||
"""
|
||||
if not self.llm_provider:
|
||||
logger.warning("No LLM provider configured for summarization")
|
||||
return None
|
||||
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Format messages for summarization
|
||||
content = self._format_messages(messages)
|
||||
|
||||
# Generate summary using LLM
|
||||
try:
|
||||
prompt = self.config.summary_prompt.format(content=content)
|
||||
response = await self.llm_provider.chat(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1024,
|
||||
temperature=0.5,
|
||||
)
|
||||
|
||||
if response and response.content:
|
||||
return response.content.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Summarization failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _format_messages(self, messages: list[dict[str, Any]]) -> str:
|
||||
"""Format messages for summarization prompt."""
|
||||
lines = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if content:
|
||||
lines.append(f"{role}: {content[:500]}") # Truncate long messages
|
||||
return "\n".join(lines)
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count (rough approximation).
|
||||
|
||||
Args:
|
||||
text: Text to estimate
|
||||
|
||||
Returns:
|
||||
Estimated token count
|
||||
"""
|
||||
# Rough estimate: ~4 characters per token
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
class ContextCompressor:
|
||||
"""Context compression manager for agent memory."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
summarizer: MemorySummarizer,
|
||||
config: SummarizationConfig | None = None,
|
||||
):
|
||||
"""Initialize context compressor.
|
||||
|
||||
Args:
|
||||
summarizer: Memory summarizer
|
||||
config: Summarization configuration
|
||||
"""
|
||||
self.summarizer = summarizer
|
||||
self.config = config or SummarizationConfig()
|
||||
self._compaction_count = 0
|
||||
|
||||
@property
|
||||
def flush_trigger_tokens(self) -> int:
|
||||
"""Calculate token threshold for triggering memory flush."""
|
||||
return (
|
||||
self.config.context_window
|
||||
- self.config.reserve_tokens
|
||||
- self.config.soft_threshold
|
||||
)
|
||||
|
||||
def should_flush(self, current_tokens: int) -> bool:
|
||||
"""Check if memory flush should be triggered.
|
||||
|
||||
Args:
|
||||
current_tokens: Current token count
|
||||
|
||||
Returns:
|
||||
True if flush should be triggered
|
||||
"""
|
||||
return current_tokens >= self.flush_trigger_tokens
|
||||
|
||||
async def compress_context(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
current_tokens: int,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Compress context when approaching token limit.
|
||||
|
||||
Args:
|
||||
messages: Current conversation messages
|
||||
current_tokens: Current token count
|
||||
|
||||
Returns:
|
||||
Tuple of (compressed messages, summary)
|
||||
"""
|
||||
if not self.should_flush(current_tokens):
|
||||
return messages, None
|
||||
|
||||
self._compaction_count += 1
|
||||
logger.info(f"Triggering context compression (count: {self._compaction_count})")
|
||||
|
||||
# Keep recent messages
|
||||
recent_messages = self._keep_recent_messages(
|
||||
messages,
|
||||
self.config.keep_recent_tokens,
|
||||
)
|
||||
|
||||
# Summarize older messages
|
||||
older_messages = self._get_older_messages(
|
||||
messages,
|
||||
self.config.keep_recent_tokens,
|
||||
)
|
||||
|
||||
if not older_messages:
|
||||
return recent_messages, None
|
||||
|
||||
summary = await self.summarizer.summarize_conversation(older_messages)
|
||||
|
||||
# Create compressed context
|
||||
compressed = recent_messages.copy()
|
||||
|
||||
if summary:
|
||||
# Add summary as a system message
|
||||
compressed.insert(0, {
|
||||
"role": "system",
|
||||
"content": f"[Previous conversation summary]\n{summary}",
|
||||
})
|
||||
|
||||
logger.info(f"Context compressed: {len(older_messages)} messages summarized")
|
||||
return compressed, summary
|
||||
|
||||
def _keep_recent_messages(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Keep recent messages within token limit."""
|
||||
result = []
|
||||
total_tokens = 0
|
||||
|
||||
# Process from newest to oldest
|
||||
for msg in reversed(messages):
|
||||
content = msg.get("content", "")
|
||||
tokens = self.summarizer.estimate_tokens(content)
|
||||
|
||||
if total_tokens + tokens > max_tokens:
|
||||
break
|
||||
|
||||
result.insert(0, msg)
|
||||
total_tokens += tokens
|
||||
|
||||
return result
|
||||
|
||||
def _get_older_messages(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
keep_tokens: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get older messages that should be summarized."""
|
||||
result = []
|
||||
total_tokens = 0
|
||||
|
||||
# Process from oldest to newest
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
tokens = self.summarizer.estimate_tokens(content)
|
||||
|
||||
if total_tokens + tokens > keep_tokens:
|
||||
result.append(msg)
|
||||
total_tokens += tokens
|
||||
|
||||
return result
|
||||
|
||||
def get_compaction_count(self) -> int:
|
||||
"""Get number of compactions performed."""
|
||||
return self._compaction_count
|
||||
|
||||
|
||||
class MemoryDecayManager:
|
||||
"""Memory importance decay manager."""
|
||||
|
||||
def __init__(self, config: SummarizationConfig | None = None):
|
||||
"""Initialize decay manager.
|
||||
|
||||
Args:
|
||||
config: Summarization configuration
|
||||
"""
|
||||
self.config = config or SummarizationConfig()
|
||||
|
||||
def calculate_decay(
|
||||
self,
|
||||
importance: int,
|
||||
last_accessed: datetime,
|
||||
is_evergreen: bool = False,
|
||||
) -> int:
|
||||
"""Calculate decayed importance.
|
||||
|
||||
Args:
|
||||
importance: Original importance (1-10)
|
||||
last_accessed: Last access timestamp
|
||||
is_evergreen: Whether memory is marked as evergreen
|
||||
|
||||
Returns:
|
||||
Decayed importance
|
||||
"""
|
||||
if is_evergreen:
|
||||
return importance
|
||||
|
||||
# Calculate days since last access
|
||||
days_since = (datetime.now() - last_accessed).days
|
||||
|
||||
if days_since < self.config.decay_days_no_activity:
|
||||
return importance
|
||||
|
||||
# Calculate decay periods
|
||||
decay_periods = (
|
||||
days_since - self.config.decay_days_no_activity
|
||||
) // self.config.decay_days_no_activity
|
||||
|
||||
# Apply decay
|
||||
decay_factor = self.config.decay_factor ** decay_periods
|
||||
decayed = int(importance * decay_factor)
|
||||
|
||||
# Ensure minimum importance of 1
|
||||
return max(1, decayed)
|
||||
|
||||
def should_archive(self, importance: int, last_accessed: datetime) -> bool:
|
||||
"""Check if memory should be archived.
|
||||
|
||||
Args:
|
||||
importance: Current importance
|
||||
last_accessed: Last access timestamp
|
||||
|
||||
Returns:
|
||||
True if should be archived
|
||||
"""
|
||||
# Archive if importance has decayed to 1 and no recent access
|
||||
decayed = self.calculate_decay(importance, last_accessed)
|
||||
days_since = (datetime.now() - last_accessed).days
|
||||
|
||||
return decayed == 1 and days_since > self.config.decay_days_no_activity * 3
|
||||
|
||||
|
||||
class EvergreenManager:
|
||||
"""Evergreen (persistent) memory manager."""
|
||||
|
||||
def __init__(self, config: SummarizationConfig | None = None):
|
||||
"""Initialize evergreen manager.
|
||||
|
||||
Args:
|
||||
config: Summarization configuration
|
||||
"""
|
||||
self.config = config or SummarizationConfig()
|
||||
|
||||
def should_mark_evergreen(
|
||||
self,
|
||||
importance: int,
|
||||
memory_type: str,
|
||||
content: str,
|
||||
) -> bool:
|
||||
"""Determine if memory should be marked as evergreen.
|
||||
|
||||
Args:
|
||||
importance: Importance score
|
||||
memory_type: Type of memory
|
||||
content: Memory content
|
||||
|
||||
Returns:
|
||||
True if should be evergreen
|
||||
"""
|
||||
# High importance memories are evergreen
|
||||
if importance >= self.config.evergreen_importance_threshold:
|
||||
return True
|
||||
|
||||
# Certain memory types are typically evergreen
|
||||
evergreen_types = {"preference", "identity", "configuration"}
|
||||
if memory_type in evergreen_types:
|
||||
return True
|
||||
|
||||
# Check for evergreen keywords in content
|
||||
evergreen_keywords = [
|
||||
"always", "never", "permanent", "fixed",
|
||||
"my name is", "i am", "preference",
|
||||
]
|
||||
content_lower = content.lower()
|
||||
if any(kw in content_lower for kw in evergreen_keywords):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def format_evergreen_prompt(self, memories: list[dict[str, Any]]) -> str:
|
||||
"""Format evergreen memories for system prompt.
|
||||
|
||||
Args:
|
||||
memories: List of evergreen memories
|
||||
|
||||
Returns:
|
||||
Formatted prompt
|
||||
"""
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["[Evergreen Memories]"]
|
||||
for mem in memories:
|
||||
content = mem.get("content", "")
|
||||
memory_type = mem.get("memory_type", "general")
|
||||
lines.append(f"- [{memory_type}] {content}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class IntelligentMemorySystem:
|
||||
"""Complete intelligent memory management system."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm_provider=None,
|
||||
config: SummarizationConfig | None = None,
|
||||
):
|
||||
"""Initialize intelligent memory system.
|
||||
|
||||
Args:
|
||||
llm_provider: LLM provider for summarization
|
||||
config: System configuration
|
||||
"""
|
||||
self.config = config or SummarizationConfig()
|
||||
|
||||
# Initialize components
|
||||
self.summarizer = MemorySummarizer(llm_provider, self.config)
|
||||
self.compressor = ContextCompressor(self.summarizer, self.config)
|
||||
self.decay_manager = MemoryDecayManager(self.config)
|
||||
self.evergreen_manager = EvergreenManager(self.config)
|
||||
|
||||
async def process_message(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
current_tokens: int,
|
||||
agent_id: str,
|
||||
user_id: str = "default",
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
||||
"""Process incoming message with intelligent memory management.
|
||||
|
||||
Args:
|
||||
messages: Current conversation messages
|
||||
current_tokens: Current token count
|
||||
agent_id: Agent ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Tuple of (processed messages, memory to save)
|
||||
"""
|
||||
# Check if compression needed
|
||||
processed_messages, summary = await self.compressor.compress_context(
|
||||
messages,
|
||||
current_tokens,
|
||||
)
|
||||
|
||||
memory_to_save = None
|
||||
if summary:
|
||||
memory_to_save = {
|
||||
"content": f"[Conversation Summary]\n{summary}",
|
||||
"agent_id": agent_id,
|
||||
"user_id": user_id,
|
||||
"memory_type": "summary",
|
||||
"importance": 5,
|
||||
}
|
||||
|
||||
return processed_messages, memory_to_save
|
||||
|
||||
def get_evergreen_context(
|
||||
self,
|
||||
memories: list[dict[str, Any]],
|
||||
) -> str:
|
||||
"""Get evergreen memories formatted for context.
|
||||
|
||||
Args:
|
||||
memories: List of all memories
|
||||
|
||||
Returns:
|
||||
Formatted evergreen context
|
||||
"""
|
||||
evergreen = [
|
||||
m for m in memories
|
||||
if m.get("is_evergreen", False)
|
||||
or self.evergreen_manager.should_mark_evergreen(
|
||||
m.get("importance", 5),
|
||||
m.get("memory_type", ""),
|
||||
m.get("content", ""),
|
||||
)
|
||||
]
|
||||
return self.evergreen_manager.format_evergreen_prompt(evergreen)
|
||||
|
||||
def apply_decay(
|
||||
self,
|
||||
memories: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Apply decay to memories.
|
||||
|
||||
Args:
|
||||
memories: List of memories
|
||||
|
||||
Returns:
|
||||
Memories with updated importance
|
||||
"""
|
||||
updated = []
|
||||
for mem in memories:
|
||||
last_accessed = mem.get("last_accessed_at")
|
||||
if isinstance(last_accessed, str):
|
||||
last_accessed = datetime.fromisoformat(last_accessed)
|
||||
elif not last_accessed:
|
||||
last_accessed = datetime.now()
|
||||
|
||||
is_evergreen = mem.get("is_evergreen", False)
|
||||
|
||||
new_importance = self.decay_manager.calculate_decay(
|
||||
mem.get("importance", 5),
|
||||
last_accessed,
|
||||
is_evergreen,
|
||||
)
|
||||
|
||||
mem["importance"] = new_importance
|
||||
mem["should_archive"] = self.decay_manager.should_archive(
|
||||
new_importance,
|
||||
last_accessed,
|
||||
)
|
||||
updated.append(mem)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def create_intelligent_memory_system(
|
||||
llm_provider=None,
|
||||
context_window: int = 200000,
|
||||
reserve_tokens: int = 20000,
|
||||
) -> IntelligentMemorySystem:
|
||||
"""Create intelligent memory system with configuration.
|
||||
|
||||
Args:
|
||||
llm_provider: LLM provider
|
||||
context_window: Model context window size
|
||||
reserve_tokens: Reserved tokens
|
||||
|
||||
Returns:
|
||||
Configured IntelligentMemorySystem
|
||||
"""
|
||||
config = SummarizationConfig(
|
||||
context_window=context_window,
|
||||
reserve_tokens=reserve_tokens,
|
||||
)
|
||||
return IntelligentMemorySystem(llm_provider=llm_provider, config=config)
|
||||
463
core/agents/agent/loop.py
Normal file
463
core/agents/agent/loop.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""Agent run loop - complete implementation."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Awaitable, AsyncGenerator
|
||||
|
||||
from agents.agent.context import ContextBuilder
|
||||
from agents.agent.memory import AgentMemory
|
||||
from agents.llm import LLMProvider, LLMResponse, ProviderFactory
|
||||
from agents.tools import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentLoop:
|
||||
"""Agent loop with message processing, LLM calls, tool execution, and streaming."""
|
||||
|
||||
_TOOL_RESULT_MAX_CHARS = 10000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
workspace: Path | None = None,
|
||||
max_iterations: int = 10,
|
||||
tools: ToolRegistry | None = None,
|
||||
):
|
||||
"""Initialize the agent loop.
|
||||
|
||||
Args:
|
||||
provider: LLM provider (OpenAI, Anthropic, etc.)
|
||||
model: Model name to use
|
||||
workspace: Workspace directory for memory and configs
|
||||
max_iterations: Maximum tool call iterations
|
||||
tools: Tool registry (creates default if None)
|
||||
"""
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.workspace = workspace or Path.cwd()
|
||||
self.max_iterations = max_iterations
|
||||
self.tools = tools
|
||||
|
||||
self.context = ContextBuilder(self.workspace)
|
||||
self.memory = AgentMemory(self.workspace)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
message: str,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
session_key: str = "default",
|
||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
||||
model_id: str | None = None,
|
||||
model_name: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
use_xbot: bool = False,
|
||||
) -> str:
|
||||
"""Process a chat message and return the response.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
history: Conversation history
|
||||
session_key: Session identifier
|
||||
on_progress: Optional callback for progress updates
|
||||
model_id: Model ID (optional)
|
||||
model_name: Model name (optional)
|
||||
model_provider: Model provider (optional)
|
||||
api_key: API key (optional)
|
||||
base_url: Custom API base URL (optional)
|
||||
use_xbot: Use xbot mode (optional)
|
||||
|
||||
Returns:
|
||||
Agent response content
|
||||
"""
|
||||
history = history or []
|
||||
|
||||
# Check if dynamic provider parameters are provided
|
||||
if api_key or model_provider:
|
||||
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
||||
# Create temporary provider with dynamic parameters
|
||||
temp_provider = ProviderFactory.create(
|
||||
provider=model_provider or "openai",
|
||||
api_key=api_key,
|
||||
api_base=base_url,
|
||||
)
|
||||
# Use temporary provider and model
|
||||
temp_model = model_name or temp_provider.get_default_model()
|
||||
logger.info(f"Created temp provider with model: {temp_model}")
|
||||
return await self._chat_with_provider(
|
||||
message=message,
|
||||
history=history,
|
||||
session_key=session_key,
|
||||
on_progress=on_progress,
|
||||
provider=temp_provider,
|
||||
model=temp_model,
|
||||
)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=message,
|
||||
)
|
||||
|
||||
# Log which provider is being used
|
||||
logger.info(f"Using static provider: {type(self.provider).__name__}, model={self.model}")
|
||||
|
||||
# Run the agent loop
|
||||
final_content, tools_used, all_messages = await self._run_loop(
|
||||
messages, on_progress
|
||||
)
|
||||
|
||||
# Save to history
|
||||
self._save_history(session_key, all_messages, len(history))
|
||||
|
||||
return final_content or "No response generated."
|
||||
|
||||
async def _chat_with_provider(
|
||||
self,
|
||||
message: str,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
session_key: str = "default",
|
||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
) -> str:
|
||||
"""Chat with a specific provider (used for dynamic provider support).
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
history: Conversation history
|
||||
session_key: Session identifier
|
||||
on_progress: Optional callback for progress updates
|
||||
provider: LLM provider to use
|
||||
model: Model name to use
|
||||
|
||||
Returns:
|
||||
Agent response content
|
||||
"""
|
||||
history = history or []
|
||||
provider = provider or self.provider
|
||||
model = model or self.model
|
||||
|
||||
# Build messages for LLM
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=message,
|
||||
)
|
||||
|
||||
# Run the agent loop with custom provider
|
||||
final_content, tools_used, all_messages = await self._run_loop(
|
||||
messages, on_progress, provider=provider, model=model
|
||||
)
|
||||
|
||||
# Save to history
|
||||
self._save_history(session_key, all_messages, len(history))
|
||||
|
||||
return final_content or "No response generated."
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
message: str,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
session_key: str = "default",
|
||||
model_id: str | None = None,
|
||||
model_name: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
use_xbot: bool = False,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Process a chat message with streaming response.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
history: Conversation history
|
||||
session_key: Session identifier
|
||||
model_id: Model ID (optional)
|
||||
model_name: Model name (optional)
|
||||
model_provider: Model provider (optional)
|
||||
api_key: API key (optional)
|
||||
base_url: Custom API base URL (optional)
|
||||
use_xbot: Use xbot mode (optional)
|
||||
|
||||
Yields:
|
||||
Response content chunks
|
||||
"""
|
||||
history = history or []
|
||||
|
||||
# Check if dynamic provider parameters are provided
|
||||
if api_key or model_provider:
|
||||
logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
||||
# Create temporary provider with dynamic parameters
|
||||
temp_provider = ProviderFactory.create(
|
||||
provider=model_provider or "openai",
|
||||
api_key=api_key,
|
||||
api_base=base_url,
|
||||
)
|
||||
# Use temporary provider and model
|
||||
temp_model = model_name or temp_provider.get_default_model()
|
||||
logger.info(f"[stream] Created temp provider with model: {temp_model}")
|
||||
async for chunk in self._chat_stream_with_provider(
|
||||
message=message,
|
||||
history=history,
|
||||
session_key=session_key,
|
||||
provider=temp_provider,
|
||||
model=temp_model,
|
||||
):
|
||||
yield chunk
|
||||
return
|
||||
|
||||
# Build messages for LLM
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=message,
|
||||
)
|
||||
|
||||
# Stream the response
|
||||
async for chunk in self._run_loop_stream(messages):
|
||||
yield chunk
|
||||
|
||||
async def _chat_stream_with_provider(
|
||||
self,
|
||||
message: str,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
session_key: str = "default",
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat with a specific provider (used for dynamic provider support).
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
history: Conversation history
|
||||
session_key: Session identifier
|
||||
provider: LLM provider to use
|
||||
model: Model name to use
|
||||
|
||||
Yields:
|
||||
Response content chunks
|
||||
"""
|
||||
history = history or []
|
||||
provider = provider or self.provider
|
||||
model = model or self.model
|
||||
|
||||
# Build messages for LLM
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=message,
|
||||
)
|
||||
|
||||
# Stream the response with custom provider
|
||||
async for chunk in self._run_loop_stream(messages, provider=provider, model=model):
|
||||
yield chunk
|
||||
|
||||
async def _run_loop(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str | None, list[str], list[dict]]:
|
||||
"""Run the agent iteration loop.
|
||||
|
||||
Args:
|
||||
initial_messages: Initial message list
|
||||
on_progress: Progress callback
|
||||
provider: Optional LLM provider to use (defaults to self.provider)
|
||||
model: Optional model name to use (defaults to self.model)
|
||||
|
||||
Returns:
|
||||
Tuple of (final_content, tools_used, all_messages)
|
||||
"""
|
||||
messages = initial_messages
|
||||
iteration = 0
|
||||
final_content = None
|
||||
tools_used: list[str] = []
|
||||
provider = provider or self.provider
|
||||
model = model or self.model
|
||||
|
||||
tool_defs = self.tools.get_definitions() if self.tools else []
|
||||
|
||||
while iteration < self.max_iterations:
|
||||
iteration += 1
|
||||
|
||||
# Call LLM
|
||||
response = await provider.chat_with_retry(
|
||||
messages=messages,
|
||||
tools=tool_defs if tool_defs else None,
|
||||
model=model,
|
||||
)
|
||||
|
||||
if response.has_tool_calls:
|
||||
# Progress callback for tool calls
|
||||
if on_progress:
|
||||
thought = self._strip_think(response.content)
|
||||
if thought:
|
||||
await on_progress(thought)
|
||||
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
|
||||
|
||||
# Add assistant message with tool calls
|
||||
tool_call_dicts = [tc.to_dict() for tc in response.tool_calls]
|
||||
messages = self.context.add_assistant_message(
|
||||
messages,
|
||||
response.content,
|
||||
tool_call_dicts,
|
||||
reasoning_content=response.reasoning_content,
|
||||
)
|
||||
|
||||
# Execute tools
|
||||
for tool_call in response.tool_calls:
|
||||
tools_used.append(tool_call.name)
|
||||
args = tool_call.arguments
|
||||
logger.info(f"Tool call: {tool_call.name}({args})")
|
||||
|
||||
# Execute tool
|
||||
result = await self._execute_tool(tool_call.name, args)
|
||||
|
||||
# Truncate large results
|
||||
if len(result) > self._TOOL_RESULT_MAX_CHARS:
|
||||
result = result[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
|
||||
|
||||
# Add tool result
|
||||
messages = self.context.add_tool_result(
|
||||
messages, tool_call.id, tool_call.name, result
|
||||
)
|
||||
else:
|
||||
# No tool calls - return the response
|
||||
clean = self._strip_think(response.content)
|
||||
|
||||
# Handle errors
|
||||
if response.finish_reason == "error":
|
||||
logger.error(f"LLM error: {clean}")
|
||||
final_content = clean or "Sorry, I encountered an error calling the AI model."
|
||||
break
|
||||
|
||||
messages = self.context.add_assistant_message(
|
||||
messages, clean, reasoning_content=response.reasoning_content
|
||||
)
|
||||
final_content = clean
|
||||
break
|
||||
|
||||
if final_content is None and iteration >= self.max_iterations:
|
||||
logger.warning(f"Max iterations ({self.max_iterations}) reached")
|
||||
final_content = (
|
||||
f"I reached the maximum number of iterations ({self.max_iterations}) "
|
||||
"without completing the task."
|
||||
)
|
||||
|
||||
return final_content, tools_used, messages
|
||||
|
||||
async def _run_loop_stream(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Run the agent loop with streaming response.
|
||||
|
||||
Args:
|
||||
initial_messages: Initial message list
|
||||
provider: Optional LLM provider to use (defaults to self.provider)
|
||||
model: Optional model name to use (defaults to self.model)
|
||||
|
||||
Yields:
|
||||
Response content chunks
|
||||
"""
|
||||
provider = provider or self.provider
|
||||
model = model or self.model
|
||||
tool_defs = self.tools.get_definitions() if self.tools else []
|
||||
|
||||
# First call to check for tool calls
|
||||
response = await provider.chat_with_retry(
|
||||
messages=initial_messages,
|
||||
tools=tool_defs if tool_defs else None,
|
||||
model=model,
|
||||
)
|
||||
|
||||
if response.has_tool_calls:
|
||||
# Execute tools first
|
||||
for tool_call in response.tool_calls:
|
||||
logger.info(f"Tool call: {tool_call.name}")
|
||||
result = await self._execute_tool(tool_call.name, tool_call.arguments)
|
||||
|
||||
# Add to messages
|
||||
initial_messages = self.context.add_tool_result(
|
||||
initial_messages, tool_call.id, tool_call.name, result
|
||||
)
|
||||
|
||||
# Recursive call after tool execution
|
||||
async for chunk in self._run_loop_stream(initial_messages, provider=provider, model=model):
|
||||
yield chunk
|
||||
else:
|
||||
# Stream the content
|
||||
content = self._strip_think(response.content)
|
||||
if content:
|
||||
yield content
|
||||
|
||||
async def _execute_tool(self, tool_name: str, args: dict) -> str:
|
||||
"""Execute a tool.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute
|
||||
args: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
"""
|
||||
if self.tools:
|
||||
return await self.tools.execute(tool_name, args)
|
||||
return json.dumps({"error": "No tools registered"})
|
||||
|
||||
@staticmethod
|
||||
def _strip_think(text: str | None) -> str | None:
|
||||
"""Strip think blocks that some models embed in content."""
|
||||
if not text:
|
||||
return None
|
||||
import re
|
||||
# Match content between [/INST] or [/CONTINUE] tags commonly used in thinking
|
||||
patterns = [
|
||||
r"<think>[\s\S]*?</think>",
|
||||
r"<\/?think>",
|
||||
]
|
||||
for pattern in patterns:
|
||||
text = re.sub(pattern, "", text)
|
||||
return text.strip() or None
|
||||
|
||||
@staticmethod
|
||||
def _tool_hint(tool_calls: list) -> str:
|
||||
"""Format tool calls as concise hint."""
|
||||
def _fmt(tc):
|
||||
args = tc.arguments or {}
|
||||
val = next(iter(args.values()), None) if isinstance(args, dict) else None
|
||||
if not isinstance(val, str):
|
||||
return tc.name
|
||||
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
|
||||
return ", ".join(_fmt(tc) for tc in tool_calls)
|
||||
|
||||
def _save_history(
|
||||
self,
|
||||
session_key: str,
|
||||
messages: list[dict],
|
||||
skip: int = 0,
|
||||
) -> None:
|
||||
"""Save messages to history.
|
||||
|
||||
Args:
|
||||
session_key: Session identifier
|
||||
messages: Messages to save
|
||||
skip: Number of messages to skip
|
||||
"""
|
||||
for m in messages[skip:]:
|
||||
role = m.get("role")
|
||||
content = m.get("content")
|
||||
|
||||
if role == "user" and content:
|
||||
self.memory.add_to_history("user", str(content)[:1000], session_key)
|
||||
elif role == "assistant" and content:
|
||||
self.memory.add_to_history("assistant", str(content)[:1000], session_key)
|
||||
939
core/agents/agent/memory.py
Normal file
939
core/agents/agent/memory.py
Normal file
@@ -0,0 +1,939 @@
|
||||
"""Memory management for agent sessions."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionMemory:
|
||||
"""短期会话记忆 - 内存中的会话消息存储,支持 Markdown 持久化"""
|
||||
|
||||
def __init__(self, max_messages: int = 50, workspace: Path | str | None = None):
|
||||
"""初始化会话记忆
|
||||
|
||||
Args:
|
||||
max_messages: 每个会话保留的最大消息数
|
||||
workspace: 工作区目录,用于持久化会话文件
|
||||
"""
|
||||
self.max_messages = max_messages
|
||||
self._sessions: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
|
||||
# 持久化支持
|
||||
self.workspace = Path(workspace) if workspace else None
|
||||
self.sessions_dir = None
|
||||
if self.workspace:
|
||||
self.sessions_dir = self.workspace / "sessions"
|
||||
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 启动时加载所有会话
|
||||
self._load_all_sessions()
|
||||
|
||||
def _get_session_file(self, session_id: str) -> Path | None:
|
||||
"""获取会话文件路径"""
|
||||
if not self.sessions_dir:
|
||||
return None
|
||||
# 清理 session_id 中的非法文件名字符
|
||||
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
|
||||
return self.sessions_dir / f"{safe_id}.md"
|
||||
|
||||
def _load_all_sessions(self) -> None:
|
||||
"""启动时加载所有会话文件"""
|
||||
if not self.sessions_dir or not self.sessions_dir.exists():
|
||||
return
|
||||
|
||||
for session_file in self.sessions_dir.glob("*.md"):
|
||||
session_id = session_file.stem
|
||||
self._load_session(session_id)
|
||||
logger.info(f"Loaded session from file: {session_id}")
|
||||
|
||||
def _load_session(self, session_id: str) -> list[dict[str, Any]]:
|
||||
"""从文件加载单个会话
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
session_file = self._get_session_file(session_id)
|
||||
if not session_file or not session_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
content = session_file.read_text(encoding="utf-8")
|
||||
messages = []
|
||||
lines = content.strip().split("\n")
|
||||
|
||||
current_message = {}
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# 解析 "## 消息 N" 格式
|
||||
if line.startswith("## 消息"):
|
||||
# 保存上一条消息
|
||||
if current_message:
|
||||
messages.append(current_message)
|
||||
|
||||
current_message = {
|
||||
"role": "",
|
||||
"timestamp": "",
|
||||
"content": "",
|
||||
}
|
||||
continue
|
||||
|
||||
# 解析 "角色: xxx"
|
||||
if line.startswith("角色:") and current_message is not None:
|
||||
current_message["role"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
# 解析 "时间: xxx"
|
||||
if line.startswith("时间:") and current_message is not None:
|
||||
current_message["timestamp"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
# 解析 "内容: xxx"
|
||||
if line.startswith("内容:") and current_message is not None:
|
||||
current_message["content"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
# 保存最后一条消息
|
||||
if current_message and current_message.get("role"):
|
||||
messages.append(current_message)
|
||||
|
||||
# 加载到内存
|
||||
if messages:
|
||||
self._sessions[session_id] = messages[-self.max_messages:]
|
||||
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading session {session_id}: {e}")
|
||||
return []
|
||||
|
||||
def _save_session(self, session_id: str) -> None:
|
||||
"""将会话保存到文件
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
"""
|
||||
session_file = self._get_session_file(session_id)
|
||||
if not session_file:
|
||||
return
|
||||
|
||||
messages = self._sessions.get(session_id, [])
|
||||
if not messages:
|
||||
# 如果会话为空,删除文件
|
||||
if session_file.exists():
|
||||
session_file.unlink()
|
||||
return
|
||||
|
||||
# 构建 Markdown 内容(使用产品经理指定的格式)
|
||||
created_time = messages[0].get("timestamp", datetime.now().isoformat()) if messages else datetime.now().isoformat()
|
||||
created_time_str = created_time.replace("T", " ") if "T" in created_time else created_time
|
||||
|
||||
lines = [
|
||||
f"# 会话: {session_id}",
|
||||
f"创建时间: {created_time_str}",
|
||||
"",
|
||||
]
|
||||
|
||||
for i, msg in enumerate(messages, 1):
|
||||
role = msg.get("role", "unknown")
|
||||
timestamp = msg.get("timestamp", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# 格式化时间
|
||||
if "T" in timestamp:
|
||||
timestamp = timestamp.replace("T", " ")
|
||||
|
||||
lines.append(f"## 消息 {i}")
|
||||
lines.append(f"角色: {role}")
|
||||
lines.append(f"时间: {timestamp}")
|
||||
lines.append(f"内容: {content}")
|
||||
lines.append("")
|
||||
|
||||
try:
|
||||
session_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving session {session_id}: {e}")
|
||||
|
||||
def add_message(self, session_id: str, role: str, content: str, metadata: dict | None = None) -> None:
|
||||
"""添加消息到会话
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
role: 消息角色 (user/assistant/system)
|
||||
content: 消息内容
|
||||
metadata: 附加元数据
|
||||
"""
|
||||
message = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
if metadata:
|
||||
message["metadata"] = metadata
|
||||
|
||||
session_messages = self._sessions[session_id]
|
||||
session_messages.append(message)
|
||||
|
||||
# 超过最大消息数时,移除最旧的消息
|
||||
if len(session_messages) > self.max_messages:
|
||||
self._sessions[session_id] = session_messages[-self.max_messages:]
|
||||
|
||||
# 持久化到文件
|
||||
self._save_session(session_id)
|
||||
|
||||
def get_history(self, session_id: str, max_messages: int = 0) -> list[dict[str, Any]]:
|
||||
"""获取会话历史
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
max_messages: 返回的最大消息数,0表示全部
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
# 如果内存中没有,尝试从文件加载
|
||||
if session_id not in self._sessions:
|
||||
self._load_session(session_id)
|
||||
|
||||
messages = self._sessions.get(session_id, [])
|
||||
if max_messages > 0 and len(messages) > max_messages:
|
||||
return messages[-max_messages:]
|
||||
return messages
|
||||
|
||||
def clear_session(self, session_id: str) -> None:
|
||||
"""清除会话记忆
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
"""
|
||||
if session_id in self._sessions:
|
||||
del self._sessions[session_id]
|
||||
|
||||
# 删除会话文件
|
||||
session_file = self._get_session_file(session_id)
|
||||
if session_file and session_file.exists():
|
||||
session_file.unlink()
|
||||
|
||||
def get_session_count(self) -> int:
|
||||
"""获取当前会话数量"""
|
||||
return len(self._sessions)
|
||||
|
||||
def list_sessions(self) -> list[str]:
|
||||
"""列出所有会话ID"""
|
||||
return list(self._sessions.keys())
|
||||
|
||||
|
||||
class RemoteMemoryClient:
|
||||
"""与Go端Memory API对接的客户端"""
|
||||
|
||||
def __init__(self, base_url: str, agent_id: str, user_id: str = "default"):
|
||||
"""初始化远程记忆客户端
|
||||
|
||||
Args:
|
||||
base_url: Go服务端地址
|
||||
agent_id: Agent ID
|
||||
user_id: 用户ID
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.agent_id = agent_id
|
||||
self.user_id = user_id
|
||||
self._session = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""获取或创建aiohttp session"""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭session"""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def create_memory(
|
||||
self,
|
||||
content: str,
|
||||
memory_type: str = "conversation",
|
||||
importance: int = 5,
|
||||
) -> dict[str, Any] | None:
|
||||
"""创建记忆
|
||||
|
||||
Args:
|
||||
content: 记忆内容
|
||||
memory_type: 记忆类型 (conversation/experience/lessons)
|
||||
importance: 重要性评分 1-10
|
||||
|
||||
Returns:
|
||||
创建的记忆对象
|
||||
"""
|
||||
url = f"{self.base_url}/api/agent/{self.agent_id}/memories"
|
||||
payload = {
|
||||
"agent_id": self.agent_id,
|
||||
"user_id": self.user_id,
|
||||
"content": content,
|
||||
"memory_type": memory_type,
|
||||
"importance": importance,
|
||||
}
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
logger.warning(f"Failed to create memory: {response.status}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating memory: {e}")
|
||||
return None
|
||||
|
||||
async def get_memories(
|
||||
self,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
memory_type: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""获取记忆列表
|
||||
|
||||
Args:
|
||||
limit: 返回数量限制
|
||||
offset: 偏移量
|
||||
memory_type: 记忆类型筛选
|
||||
category: 分类筛选
|
||||
|
||||
Returns:
|
||||
记忆列表
|
||||
"""
|
||||
url = f"{self.base_url}/api/agent/{self.agent_id}/memories"
|
||||
params = {
|
||||
"user_id": self.user_id,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
if memory_type:
|
||||
params["memory_type"] = memory_type
|
||||
if category:
|
||||
params["category"] = category
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
return result if isinstance(result, list) else result.get("list", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memories: {e}")
|
||||
return []
|
||||
|
||||
async def search_memories(
|
||||
self,
|
||||
keyword: str,
|
||||
tags: str | None = None,
|
||||
category: str | None = None,
|
||||
memory_type: str | None = None,
|
||||
min_score: int = 0,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""搜索记忆(关键词搜索)
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词
|
||||
tags: 标签筛选
|
||||
category: 分类筛选
|
||||
memory_type: 记忆类型筛选
|
||||
min_score: 最低重要性分数
|
||||
limit: 返回数量限制
|
||||
offset: 偏移量
|
||||
|
||||
Returns:
|
||||
记忆列表
|
||||
"""
|
||||
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/search"
|
||||
payload = {
|
||||
"agent_id": self.agent_id,
|
||||
"user_id": self.user_id,
|
||||
"keyword": keyword,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
if tags:
|
||||
payload["tags"] = tags
|
||||
if category:
|
||||
payload["category"] = category
|
||||
if memory_type:
|
||||
payload["memory_type"] = memory_type
|
||||
if min_score > 0:
|
||||
payload["min_score"] = min_score
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
return result.get("list", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching memories: {e}")
|
||||
return []
|
||||
|
||||
async def get_categories(self) -> list[str]:
|
||||
"""获取记忆分类列表
|
||||
|
||||
Returns:
|
||||
分类列表
|
||||
"""
|
||||
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/categories"
|
||||
params = {"user_id": self.user_id}
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
return result.get("categories", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting categories: {e}")
|
||||
return []
|
||||
|
||||
async def get_tags(self) -> list[str]:
|
||||
"""获取记忆标签列表
|
||||
|
||||
Returns:
|
||||
标签列表
|
||||
"""
|
||||
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/tags"
|
||||
params = {"user_id": self.user_id}
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
return result.get("tags", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tags: {e}")
|
||||
return []
|
||||
|
||||
async def delete_memory(self, memory_id: str) -> bool:
|
||||
"""删除记忆
|
||||
|
||||
Args:
|
||||
memory_id: 记忆ID
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/{memory_id}"
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.delete(url) as response:
|
||||
return response.status == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting memory: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""Manages agent memory and session history."""
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
"""Initialize the memory manager.
|
||||
|
||||
Args:
|
||||
workspace: Workspace directory for storing memory
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.memory_dir = workspace / "memory"
|
||||
self.memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.long_term_file = self.memory_dir / "MEMORY.md"
|
||||
|
||||
# Session-specific history
|
||||
self.sessions_dir = self.memory_dir / "sessions"
|
||||
self.sessions_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Legacy history file (for backward compatibility)
|
||||
self.history_file = self.memory_dir / "HISTORY.md"
|
||||
|
||||
def _get_session_file(self, session_key: str) -> Path:
|
||||
"""Get session file path."""
|
||||
# Sanitize session_key for filename
|
||||
safe_key = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_key)
|
||||
return self.sessions_dir / f"{safe_key}.md"
|
||||
|
||||
def get_memory_context(self) -> str:
|
||||
"""Get long-term memory content.
|
||||
|
||||
Returns:
|
||||
Memory context string
|
||||
"""
|
||||
if self.long_term_file.exists():
|
||||
return self.long_term_file.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
def add_to_memory(self, content: str) -> None:
|
||||
"""Add content to long-term memory.
|
||||
|
||||
Args:
|
||||
content: Content to add to memory
|
||||
"""
|
||||
with open(self.long_term_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"\n{content}")
|
||||
|
||||
def add_to_history(self, role: str, content: str, session_key: str | None = None) -> None:
|
||||
"""Add an entry to conversation history.
|
||||
|
||||
Args:
|
||||
role: Message role (user/assistant)
|
||||
content: Message content
|
||||
session_key: Session identifier for session-specific history
|
||||
"""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# If session_key provided, save to session file
|
||||
if session_key:
|
||||
self._add_to_session_history(session_key, role, content, timestamp)
|
||||
else:
|
||||
# Legacy: save to global history file
|
||||
legacy_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
entry = f"[{legacy_timestamp}] {role}: {content}\n"
|
||||
with open(self.history_file, "a", encoding="utf-8") as f:
|
||||
f.write(entry)
|
||||
|
||||
def _add_to_session_history(self, session_key: str, role: str, content: str, timestamp: str) -> None:
|
||||
"""Add message to session-specific history file."""
|
||||
session_file = self._get_session_file(session_key)
|
||||
|
||||
# Format timestamp for display
|
||||
display_timestamp = timestamp.replace("T", " ") if "T" in timestamp else timestamp
|
||||
|
||||
# Determine header format based on whether file exists
|
||||
header = ""
|
||||
if not session_file.exists():
|
||||
header = f"# 会话: {session_key}\n创建时间: {display_timestamp}\n\n"
|
||||
|
||||
# Count existing messages to determine message number
|
||||
msg_count = 1
|
||||
if session_file.exists():
|
||||
try:
|
||||
existing = session_file.read_text(encoding="utf-8")
|
||||
msg_count = existing.count("## 消息") + 1
|
||||
except:
|
||||
pass
|
||||
|
||||
# Format as Markdown (产品经理指定格式)
|
||||
entry = f"## 消息 {msg_count}\n角色: {role}\n时间: {display_timestamp}\n内容: {content}\n\n"
|
||||
|
||||
with open(session_file, "a", encoding="utf-8") as f:
|
||||
if header:
|
||||
f.write(header)
|
||||
f.write(entry)
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
session_key: str | None = None,
|
||||
max_messages: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get conversation history.
|
||||
|
||||
Args:
|
||||
session_key: Optional session key for session-specific history
|
||||
max_messages: Maximum number of messages to return
|
||||
|
||||
Returns:
|
||||
List of history messages
|
||||
"""
|
||||
# If session_key provided, load from session file
|
||||
if session_key:
|
||||
return self._get_session_history(session_key, max_messages)
|
||||
|
||||
# Legacy: load from global history file
|
||||
return self._get_legacy_history(max_messages)
|
||||
|
||||
def _get_session_history(self, session_key: str, max_messages: int) -> list[dict[str, Any]]:
|
||||
"""Get history from session file."""
|
||||
session_file = self._get_session_file(session_key)
|
||||
if not session_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
content = session_file.read_text(encoding="utf-8")
|
||||
lines = content.strip().split("\n")
|
||||
messages = []
|
||||
|
||||
current_message = {}
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip headers
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Parse "## 消息 N"
|
||||
if line.startswith("## 消息"):
|
||||
# Save previous message
|
||||
if current_message and current_message.get("role"):
|
||||
messages.append(current_message)
|
||||
|
||||
current_message = {
|
||||
"role": "",
|
||||
"timestamp": "",
|
||||
"content": "",
|
||||
}
|
||||
continue
|
||||
|
||||
# Parse "角色: xxx"
|
||||
if line.startswith("角色:") and current_message is not None:
|
||||
current_message["role"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
# Parse "时间: xxx"
|
||||
if line.startswith("时间:") and current_message is not None:
|
||||
current_message["timestamp"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
# Parse "内容: xxx"
|
||||
if line.startswith("内容:") and current_message is not None:
|
||||
current_message["content"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
# Content line
|
||||
if current_message:
|
||||
if current_message["content"]:
|
||||
current_message["content"] += "\n" + line
|
||||
else:
|
||||
current_message["content"] = line
|
||||
|
||||
# Save last message
|
||||
if current_message:
|
||||
messages.append(current_message)
|
||||
|
||||
# Return most recent messages
|
||||
if max_messages > 0 and len(messages) > max_messages:
|
||||
return messages[-max_messages:]
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading session history: {e}")
|
||||
return []
|
||||
|
||||
def _get_legacy_history(self, max_messages: int) -> list[dict[str, Any]]:
|
||||
"""Get history from legacy history file."""
|
||||
if not self.history_file.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
content = self.history_file.read_text(encoding="utf-8")
|
||||
lines = content.strip().split("\n")
|
||||
messages = []
|
||||
|
||||
for line in lines[-max_messages * 2:]:
|
||||
if ": " in line:
|
||||
try:
|
||||
_, rest = line.split("] ", 1)
|
||||
role, content = rest.split(": ", 1)
|
||||
messages.append({"role": role, "content": content})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return messages[-max_messages:] if max_messages > 0 else messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading legacy history: {e}")
|
||||
return []
|
||||
|
||||
def clear_session(self, session_key: str) -> None:
|
||||
"""Clear a specific session's history.
|
||||
|
||||
Args:
|
||||
session_key: Session key to clear
|
||||
"""
|
||||
session_file = self._get_session_file(session_key)
|
||||
if session_file.exists():
|
||||
session_file.unlink()
|
||||
|
||||
for line in lines[-max_messages * 2:]:
|
||||
if ": " in line:
|
||||
# Skip timestamp prefix
|
||||
try:
|
||||
_, rest = line.split("] ", 1)
|
||||
role, content = rest.split(": ", 1)
|
||||
messages.append({"role": role, "content": content})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return messages[-max_messages:]
|
||||
|
||||
return []
|
||||
|
||||
def clear_session(self, session_key: str) -> None:
|
||||
"""Clear a specific session's history.
|
||||
|
||||
Args:
|
||||
session_key: Session key to clear
|
||||
"""
|
||||
# In a full implementation, you'd handle session-specific storage
|
||||
pass
|
||||
|
||||
|
||||
# Vector memory integration
|
||||
try:
|
||||
from .vector_memory import (
|
||||
VectorMemoryStore,
|
||||
HybridMemorySearch,
|
||||
EmbeddingProvider,
|
||||
create_vector_memory_store,
|
||||
)
|
||||
VECTOR_MEMORY_AVAILABLE = True
|
||||
except ImportError:
|
||||
VectorMemoryStore = None
|
||||
HybridMemorySearch = None
|
||||
EmbeddingProvider = None
|
||||
create_vector_memory_store = None
|
||||
VECTOR_MEMORY_AVAILABLE = False
|
||||
|
||||
|
||||
class EnhancedAgentMemory(AgentMemory):
|
||||
"""Enhanced agent memory with vector search capabilities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Path,
|
||||
enable_vector_search: bool = False,
|
||||
vector_persist_dir: str | None = None,
|
||||
embedding_provider: str = "openai",
|
||||
embedding_model: str = "text-embedding-3-small",
|
||||
):
|
||||
"""Initialize enhanced memory manager.
|
||||
|
||||
Args:
|
||||
workspace: Workspace directory for storing memory
|
||||
enable_vector_search: Enable vector search (requires dependencies)
|
||||
vector_persist_dir: Directory for vector store persistence
|
||||
embedding_provider: Provider type (openai, anthropic, local)
|
||||
embedding_model: Model name for embeddings
|
||||
"""
|
||||
super().__init__(workspace)
|
||||
|
||||
self.enable_vector_search = enable_vector_search and VECTOR_MEMORY_AVAILABLE
|
||||
self.vector_store = None
|
||||
self.hybrid_search = None
|
||||
self._embedding_provider_type = embedding_provider
|
||||
self._embedding_model = embedding_model
|
||||
|
||||
if self.enable_vector_search:
|
||||
try:
|
||||
self.vector_store = create_vector_memory_store(
|
||||
persist_dir=vector_persist_dir,
|
||||
provider_type=embedding_provider,
|
||||
model=embedding_model,
|
||||
)
|
||||
if self.vector_store:
|
||||
self.hybrid_search = HybridMemorySearch(self.vector_store)
|
||||
logger.info(f"Vector search enabled for agent memory (provider: {embedding_provider})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize vector store: {e}")
|
||||
self.enable_vector_search = False
|
||||
|
||||
async def add_memory_with_embedding(
|
||||
self,
|
||||
content: str,
|
||||
agent_id: str,
|
||||
user_id: str = "default",
|
||||
memory_type: str = "conversation",
|
||||
importance: int = 5,
|
||||
) -> str | None:
|
||||
"""Add memory with automatic embedding.
|
||||
|
||||
Args:
|
||||
content: Memory content
|
||||
agent_id: Agent ID
|
||||
user_id: User ID
|
||||
memory_type: Type of memory
|
||||
importance: Importance score (1-10)
|
||||
|
||||
Returns:
|
||||
Memory ID if vector search enabled
|
||||
"""
|
||||
# Also save to markdown file (base class behavior)
|
||||
self.add_to_memory(content)
|
||||
|
||||
# Add to vector store if enabled
|
||||
if self.vector_store:
|
||||
return await self.vector_store.add_memory(
|
||||
content=content,
|
||||
agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
memory_type=memory_type,
|
||||
importance=importance,
|
||||
)
|
||||
return None
|
||||
|
||||
async def search_memories(
|
||||
self,
|
||||
query: str,
|
||||
agent_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
n_results: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search memories by semantic similarity.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
agent_id: Filter by agent ID
|
||||
user_id: Filter by user ID
|
||||
n_results: Number of results
|
||||
|
||||
Returns:
|
||||
List of matching memories
|
||||
"""
|
||||
if not self.hybrid_search:
|
||||
logger.warning("Vector search not enabled")
|
||||
return []
|
||||
|
||||
return await self.hybrid_search.search(
|
||||
query=query,
|
||||
agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
n_results=n_results,
|
||||
)
|
||||
|
||||
|
||||
# Intelligent memory system integration
|
||||
try:
|
||||
from .intelligent_memory import (
|
||||
IntelligentMemorySystem,
|
||||
MemorySummarizer,
|
||||
ContextCompressor,
|
||||
MemoryDecayManager,
|
||||
EvergreenManager,
|
||||
SummarizationConfig,
|
||||
create_intelligent_memory_system,
|
||||
)
|
||||
INTELLIGENT_MEMORY_AVAILABLE = True
|
||||
except ImportError:
|
||||
IntelligentMemorySystem = None
|
||||
MemorySummarizer = None
|
||||
ContextCompressor = None
|
||||
MemoryDecayManager = None
|
||||
EvergreenManager = None
|
||||
SummarizationConfig = None
|
||||
create_intelligent_memory_system = None
|
||||
INTELLIGENT_MEMORY_AVAILABLE = False
|
||||
|
||||
|
||||
class CompleteAgentMemory:
|
||||
"""Complete agent memory with all features."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Path,
|
||||
llm_provider=None,
|
||||
enable_vector_search: bool = False,
|
||||
vector_persist_dir: str | None = None,
|
||||
embedding_provider: str = "openai",
|
||||
embedding_model: str = "text-embedding-3-small",
|
||||
context_window: int = 200000,
|
||||
):
|
||||
"""Initialize complete memory manager.
|
||||
|
||||
Args:
|
||||
workspace: Workspace directory
|
||||
llm_provider: LLM provider for summarization
|
||||
enable_vector_search: Enable vector search
|
||||
vector_persist_dir: Vector store persistence directory
|
||||
embedding_provider: Embedding provider type
|
||||
embedding_model: Embedding model name
|
||||
context_window: Model context window size
|
||||
"""
|
||||
# Base memory
|
||||
self.base = AgentMemory(workspace)
|
||||
|
||||
# Enhanced memory with vector search
|
||||
self.enhanced = None
|
||||
if enable_vector_search and VECTOR_MEMORY_AVAILABLE:
|
||||
self.enhanced = EnhancedAgentMemory(
|
||||
workspace=workspace,
|
||||
enable_vector_search=True,
|
||||
vector_persist_dir=vector_persist_dir,
|
||||
embedding_provider=embedding_provider,
|
||||
embedding_model=embedding_model,
|
||||
)
|
||||
|
||||
# Intelligent memory system
|
||||
self.intelligent = None
|
||||
if INTELLIGENT_MEMORY_AVAILABLE:
|
||||
self.intelligent = create_intelligent_memory_system(
|
||||
llm_provider=llm_provider,
|
||||
context_window=context_window,
|
||||
)
|
||||
|
||||
# Delegate base methods
|
||||
def get_memory_context(self) -> str:
|
||||
return self.base.get_memory_context()
|
||||
|
||||
def add_to_memory(self, content: str) -> None:
|
||||
self.base.add_to_memory(content)
|
||||
|
||||
def add_to_history(self, role: str, content: str) -> None:
|
||||
self.base.add_to_history(role, content)
|
||||
|
||||
def get_history(self, session_key: str | None = None, max_messages: int = 10):
|
||||
return self.base.get_history(session_key, max_messages)
|
||||
|
||||
# Delegate enhanced methods
|
||||
async def add_memory_with_embedding(self, *args, **kwargs):
|
||||
if self.enhanced:
|
||||
return await self.enhanced.add_memory_with_embedding(*args, **kwargs)
|
||||
return None
|
||||
|
||||
async def search_memories(self, *args, **kwargs):
|
||||
if self.enhanced:
|
||||
return await self.enhanced.search_memories(*args, **kwargs)
|
||||
return []
|
||||
|
||||
# Intelligent methods
|
||||
async def process_message(
|
||||
self,
|
||||
messages: list[dict],
|
||||
current_tokens: int,
|
||||
agent_id: str,
|
||||
user_id: str = "default",
|
||||
):
|
||||
"""Process message with intelligent memory management."""
|
||||
if not self.intelligent:
|
||||
return messages, None
|
||||
|
||||
return await self.intelligent.process_message(
|
||||
messages, current_tokens, agent_id, user_id
|
||||
)
|
||||
|
||||
def get_evergreen_context(self, memories: list[dict]) -> str:
|
||||
"""Get evergreen memories for context."""
|
||||
if not self.intelligent:
|
||||
return ""
|
||||
return self.intelligent.get_evergreen_context(memories)
|
||||
|
||||
def apply_decay(self, memories: list[dict]) -> list[dict]:
|
||||
"""Apply decay to memories."""
|
||||
if not self.intelligent:
|
||||
return memories
|
||||
return self.intelligent.apply_decay(memories)
|
||||
225
core/agents/agent/team_agent.py
Normal file
225
core/agents/agent/team_agent.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Team agent for multi-agent collaboration."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamAgent:
|
||||
"""Team agent that manages multiple agents for collaborative problem solving.
|
||||
|
||||
Supports different strategies:
|
||||
- parallel: All agents respond in parallel, results are aggregated
|
||||
- sequential: Agents respond one by one in sequence
|
||||
- supervisor: A supervisor agent coordinates the work
|
||||
"""
|
||||
|
||||
def __init__(self, provider: Any, model: str, workspace: Any):
|
||||
"""Initialize the team agent.
|
||||
|
||||
Args:
|
||||
provider: LLM provider
|
||||
model: Model name to use
|
||||
workspace: Workspace path
|
||||
"""
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.workspace = workspace
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
message: str,
|
||||
session_id: str = "default",
|
||||
supervisor_agent_id: int = 0,
|
||||
member_agent_ids: list[int] | None = None,
|
||||
strategy: str = "parallel",
|
||||
) -> dict[str, Any]:
|
||||
"""Process a team chat message.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
session_id: Session identifier
|
||||
supervisor_agent_id: Supervisor agent ID (for future use)
|
||||
member_agent_ids: List of member agent IDs to involve
|
||||
strategy: Collaboration strategy (parallel/sequential/supervisor)
|
||||
|
||||
Returns:
|
||||
Dict with response and subtask_results
|
||||
"""
|
||||
member_agent_ids = member_agent_ids or []
|
||||
|
||||
logger.info(f"Team chat: strategy={strategy}, members={member_agent_ids}, message={message[:50]}...")
|
||||
|
||||
if strategy == "parallel":
|
||||
return await self._parallel_chat(message, member_agent_ids, session_id)
|
||||
elif strategy == "sequential":
|
||||
return await self._sequential_chat(message, member_agent_ids, session_id)
|
||||
else:
|
||||
# Default to parallel
|
||||
return await self._parallel_chat(message, member_agent_ids, session_id)
|
||||
|
||||
async def _parallel_chat(
|
||||
self,
|
||||
message: str,
|
||||
member_agent_ids: list[int],
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute parallel chat with multiple agents.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
member_agent_ids: List of member agent IDs
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
Aggregated response from all agents
|
||||
"""
|
||||
if not member_agent_ids:
|
||||
return {
|
||||
"response": "No member agents specified for team chat.",
|
||||
"subtask_results": [],
|
||||
}
|
||||
|
||||
# Create tasks for each agent
|
||||
tasks = []
|
||||
for agent_id in member_agent_ids:
|
||||
task = self._call_agent(agent_id, message, session_id)
|
||||
tasks.append(task)
|
||||
|
||||
# Execute all tasks in parallel
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Aggregate results
|
||||
subtask_results = []
|
||||
responses = []
|
||||
|
||||
for i, result in enumerate(results):
|
||||
agent_id = member_agent_ids[i]
|
||||
|
||||
if isinstance(result, Exception):
|
||||
error_msg = f"Agent {agent_id} error: {str(result)}"
|
||||
logger.error(error_msg)
|
||||
subtask_results.append({
|
||||
"agent_id": agent_id,
|
||||
"status": "error",
|
||||
"result": str(result),
|
||||
})
|
||||
else:
|
||||
subtask_results.append({
|
||||
"agent_id": agent_id,
|
||||
"status": "success",
|
||||
"result": result,
|
||||
})
|
||||
responses.append(result)
|
||||
|
||||
# Combine responses
|
||||
if responses:
|
||||
combined_response = self._aggregate_responses(responses)
|
||||
else:
|
||||
combined_response = "All agents failed to respond."
|
||||
|
||||
return {
|
||||
"response": combined_response,
|
||||
"subtask_results": subtask_results,
|
||||
}
|
||||
|
||||
async def _sequential_chat(
|
||||
self,
|
||||
message: str,
|
||||
member_agent_ids: list[int],
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute sequential chat with multiple agents.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
member_agent_ids: List of member agent IDs
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
Aggregated response from all agents
|
||||
"""
|
||||
if not member_agent_ids:
|
||||
return {
|
||||
"response": "No member agents specified for team chat.",
|
||||
"subtask_results": [],
|
||||
}
|
||||
|
||||
subtask_results = []
|
||||
responses = []
|
||||
|
||||
for agent_id in member_agent_ids:
|
||||
try:
|
||||
result = await self._call_agent(agent_id, message, session_id)
|
||||
subtask_results.append({
|
||||
"agent_id": agent_id,
|
||||
"status": "success",
|
||||
"result": result,
|
||||
})
|
||||
responses.append(result)
|
||||
except Exception as e:
|
||||
error_msg = f"Agent {agent_id} error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
subtask_results.append({
|
||||
"agent_id": agent_id,
|
||||
"status": "error",
|
||||
"result": str(e),
|
||||
})
|
||||
|
||||
# Combine responses
|
||||
if responses:
|
||||
combined_response = self._aggregate_responses(responses)
|
||||
else:
|
||||
combined_response = "All agents failed to respond."
|
||||
|
||||
return {
|
||||
"response": combined_response,
|
||||
"subtask_results": subtask_results,
|
||||
}
|
||||
|
||||
async def _call_agent(
|
||||
self,
|
||||
agent_id: int,
|
||||
message: str,
|
||||
session_id: str,
|
||||
) -> str:
|
||||
"""Call an individual agent.
|
||||
|
||||
For now, this is a placeholder that simulates agent responses.
|
||||
In a real implementation, this would call the actual agent.
|
||||
|
||||
Args:
|
||||
agent_id: Agent ID
|
||||
message: User message
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
Agent response
|
||||
"""
|
||||
# Simulate agent processing delay
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Return a simulated response
|
||||
return f"Agent {agent_id} processed: {message[:30]}..."
|
||||
|
||||
def _aggregate_responses(self, responses: list[str]) -> str:
|
||||
"""Aggregate multiple agent responses into a single response.
|
||||
|
||||
Args:
|
||||
responses: List of individual agent responses
|
||||
|
||||
Returns:
|
||||
Combined response
|
||||
"""
|
||||
if len(responses) == 1:
|
||||
return responses[0]
|
||||
|
||||
header = f"【团队协作结果】共 {len(responses)} 位智能体参与了讨论:\n\n"
|
||||
body = ""
|
||||
|
||||
for i, resp in enumerate(responses, 1):
|
||||
body += f"--- 智能体 {i} ---\n{resp}\n\n"
|
||||
|
||||
return header + body
|
||||
504
core/agents/agent/vector_memory.py
Normal file
504
core/agents/agent/vector_memory.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""Vector-based memory retrieval with embedding search."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import optional dependencies
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
CHROMADB_AVAILABLE = True
|
||||
except ImportError:
|
||||
CHROMADB_AVAILABLE = False
|
||||
logger.warning("chromadb not available, vector search disabled")
|
||||
|
||||
|
||||
class EmbeddingProvider(ABC):
|
||||
"""Abstract base class for embedding providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings for texts."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def embed_single(self, text: str) -> list[float]:
|
||||
"""Generate embedding for a single text."""
|
||||
pass
|
||||
|
||||
|
||||
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
"""OpenAI embedding provider using API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
model: str = "text-embedding-3-small",
|
||||
):
|
||||
"""Initialize OpenAI embedding provider.
|
||||
|
||||
Args:
|
||||
api_key: OpenAI API key
|
||||
api_base: Custom API base URL
|
||||
model: Embedding model name
|
||||
"""
|
||||
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
||||
self.api_base = api_base or os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
self.model = model
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazy load OpenAI client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.api_base,
|
||||
)
|
||||
except ImportError:
|
||||
raise RuntimeError("openai package required: pip install openai")
|
||||
return self._client
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings using OpenAI API."""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = await self.client.embeddings.create(
|
||||
model=self.model,
|
||||
input=texts,
|
||||
)
|
||||
return [data.embedding for data in response.data]
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI embedding error: {e}")
|
||||
raise
|
||||
|
||||
async def embed_single(self, text: str) -> list[float]:
|
||||
"""Generate embedding for a single text."""
|
||||
result = await self.embed([text])
|
||||
return result[0]
|
||||
|
||||
|
||||
class AnthropicEmbeddingProvider(EmbeddingProvider):
|
||||
"""Anthropic embedding provider using API (via Cohere)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
model: str = "embed-english-v3.0",
|
||||
):
|
||||
"""Initialize Anthropic embedding provider.
|
||||
|
||||
Note: Anthropic doesn't have native embeddings, this uses Cohere as alternative.
|
||||
"""
|
||||
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
||||
self.cohere_key = os.getenv("COHERE_API_KEY")
|
||||
self.model = model
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazy load Cohere client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
import cohere
|
||||
self._client = cohere.AsyncClient(self.cohere_key)
|
||||
except ImportError:
|
||||
raise RuntimeError("cohere package required: pip install cohere")
|
||||
return self._client
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings using Cohere API."""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = await self.client.embed(
|
||||
texts=texts,
|
||||
model=self.model,
|
||||
)
|
||||
return response.embeddings
|
||||
except Exception as e:
|
||||
logger.error(f"Cohere embedding error: {e}")
|
||||
raise
|
||||
|
||||
async def embed_single(self, text: str) -> list[float]:
|
||||
"""Generate embedding for a single text."""
|
||||
result = await self.embed([text])
|
||||
return result[0]
|
||||
|
||||
|
||||
class LocalEmbeddingProvider(EmbeddingProvider):
|
||||
"""Local embedding provider using sentence-transformers (optional)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str = "all-MiniLM-L6-v2",
|
||||
device: str = "cpu",
|
||||
):
|
||||
"""Initialize local embedding provider.
|
||||
|
||||
Args:
|
||||
model_name: Model name for sentence-transformers
|
||||
device: Device to use (cpu/cuda)
|
||||
"""
|
||||
self.model_name = model_name
|
||||
self.device = device
|
||||
self._model = None
|
||||
self._sentence_transformers_available = False
|
||||
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self._SentenceTransformer = SentenceTransformer
|
||||
self._sentence_transformers_available = True
|
||||
except ImportError:
|
||||
logger.warning("sentence-transformers not available")
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
"""Lazy load the embedding model."""
|
||||
if self._model is None:
|
||||
if not self._sentence_transformers_available:
|
||||
raise RuntimeError("sentence-transformers not installed")
|
||||
logger.info(f"Loading embedding model: {self.model_name}")
|
||||
self._model = self._SentenceTransformer(self.model_name, device=self.device)
|
||||
return self._model
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings for texts."""
|
||||
if not texts:
|
||||
return []
|
||||
# Run in executor to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
embeddings = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.model.encode(texts, convert_to_numpy=True)
|
||||
)
|
||||
return embeddings.tolist()
|
||||
|
||||
async def embed_single(self, text: str) -> list[float]:
|
||||
"""Generate embedding for a single text."""
|
||||
result = await self.embed([text])
|
||||
return result[0]
|
||||
|
||||
|
||||
def create_embedding_provider(
|
||||
provider_type: str = "openai",
|
||||
**kwargs,
|
||||
) -> EmbeddingProvider:
|
||||
"""Create an embedding provider.
|
||||
|
||||
Args:
|
||||
provider_type: Type of provider (openai, anthropic/cohere, local)
|
||||
**kwargs: Additional arguments for the provider
|
||||
|
||||
Returns:
|
||||
EmbeddingProvider instance
|
||||
"""
|
||||
provider_type = provider_type.lower()
|
||||
|
||||
if provider_type == "openai":
|
||||
return OpenAIEmbeddingProvider(**kwargs)
|
||||
elif provider_type in ("anthropic", "cohere"):
|
||||
return AnthropicEmbeddingProvider(**kwargs)
|
||||
elif provider_type == "local":
|
||||
return LocalEmbeddingProvider(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
|
||||
class VectorMemoryStore:
|
||||
"""Vector-based memory store using ChromaDB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
persist_directory: Path | str | None = None,
|
||||
collection_name: str = "agent_memories",
|
||||
embedding_provider: EmbeddingProvider | None = None,
|
||||
):
|
||||
"""Initialize vector memory store.
|
||||
|
||||
Args:
|
||||
persist_directory: Directory to persist ChromaDB data
|
||||
collection_name: Name of the collection
|
||||
embedding_provider: Custom embedding provider
|
||||
"""
|
||||
if not CHROMADB_AVAILABLE:
|
||||
raise RuntimeError("chromadb not installed: pip install chromadb")
|
||||
|
||||
self.persist_directory = Path(persist_directory) if persist_directory else None
|
||||
self.collection_name = collection_name
|
||||
|
||||
# Default to OpenAI provider if not specified
|
||||
self.embedding_provider = embedding_provider or OpenAIEmbeddingProvider()
|
||||
|
||||
# Initialize ChromaDB client
|
||||
chroma_settings = Settings(
|
||||
anonymized_telemetry=False,
|
||||
allow_reset=True,
|
||||
)
|
||||
|
||||
if self.persist_directory:
|
||||
self.persist_directory.mkdir(parents=True, exist_ok=True)
|
||||
self._client = chromadb.PersistentClient(
|
||||
path=str(self.persist_directory),
|
||||
settings=chroma_settings,
|
||||
)
|
||||
else:
|
||||
self._client = chromadb.InMemoryClient(settings=chroma_settings)
|
||||
|
||||
# Get or create collection
|
||||
self._collection = self._client.get_or_create_collection(
|
||||
name=collection_name,
|
||||
metadata={"description": "Agent memory embeddings"},
|
||||
)
|
||||
|
||||
logger.info(f"Vector memory store initialized: {collection_name}")
|
||||
|
||||
def _generate_id(self, content: str, agent_id: str) -> str:
|
||||
"""Generate unique ID for a memory entry."""
|
||||
raw = f"{agent_id}:{content}:{datetime.now().isoformat()}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()
|
||||
|
||||
async def add_memory(
|
||||
self,
|
||||
content: str,
|
||||
agent_id: str,
|
||||
user_id: str = "default",
|
||||
memory_type: str = "conversation",
|
||||
importance: int = 5,
|
||||
) -> str:
|
||||
"""Add a memory to the vector store.
|
||||
|
||||
Args:
|
||||
content: Memory content
|
||||
agent_id: Agent ID
|
||||
user_id: User ID
|
||||
memory_type: Type of memory
|
||||
importance: Importance score (1-10)
|
||||
|
||||
Returns:
|
||||
Memory ID
|
||||
"""
|
||||
memory_id = self._generate_id(content, agent_id)
|
||||
embedding = await self.embedding_provider.embed_single(content)
|
||||
|
||||
self._collection.add(
|
||||
ids=[memory_id],
|
||||
embeddings=[embedding],
|
||||
documents=[content],
|
||||
metadatas=[{
|
||||
"agent_id": agent_id,
|
||||
"user_id": user_id,
|
||||
"memory_type": memory_type,
|
||||
"importance": importance,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}],
|
||||
)
|
||||
|
||||
logger.info(f"Added memory: {memory_id}")
|
||||
return memory_id
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
agent_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
n_results: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search memories by semantic similarity.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
agent_id: Filter by agent ID
|
||||
user_id: Filter by user ID
|
||||
n_results: Number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching memories with scores
|
||||
"""
|
||||
query_embedding = await self.embedding_provider.embed_single(query)
|
||||
|
||||
# Build where filter
|
||||
where = {}
|
||||
if agent_id:
|
||||
where["agent_id"] = agent_id
|
||||
if user_id:
|
||||
where["user_id"] = user_id
|
||||
|
||||
results = self._collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=n_results,
|
||||
where=where if where else None,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
|
||||
memories = []
|
||||
if results["ids"] and results["ids"][0]:
|
||||
for i, mem_id in enumerate(results["ids"][0]):
|
||||
memories.append({
|
||||
"id": mem_id,
|
||||
"content": results["documents"][0][i],
|
||||
"metadata": results["metadatas"][0][i],
|
||||
"distance": results["distances"][0][i],
|
||||
"score": 1.0 - results["distances"][0][i], # Convert distance to similarity
|
||||
})
|
||||
|
||||
return memories
|
||||
|
||||
def delete_memory(self, memory_id: str) -> bool:
|
||||
"""Delete a memory by ID.
|
||||
|
||||
Args:
|
||||
memory_id: Memory ID
|
||||
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
try:
|
||||
self._client.delete_collection(name=self.collection_name)
|
||||
self._collection = self._client.get_or_create_collection(
|
||||
name=self.collection_name,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting memory: {e}")
|
||||
return False
|
||||
|
||||
def get_count(self) -> int:
|
||||
"""Get total number of memories.
|
||||
|
||||
Returns:
|
||||
Memory count
|
||||
"""
|
||||
return self._collection.count()
|
||||
|
||||
def clear(self, agent_id: str | None = None) -> int:
|
||||
"""Clear memories.
|
||||
|
||||
Args:
|
||||
agent_id: If provided, only clear memories for this agent
|
||||
|
||||
Returns:
|
||||
Number of memories cleared
|
||||
"""
|
||||
try:
|
||||
if agent_id:
|
||||
# Get all IDs for this agent
|
||||
results = self._collection.get(where={"agent_id": agent_id})
|
||||
if results["ids"]:
|
||||
self._collection.delete(ids=results["ids"])
|
||||
return len(results["ids"])
|
||||
else:
|
||||
self._client.delete_collection(name=self.collection_name)
|
||||
self._collection = self._client.get_or_create_collection(
|
||||
name=self.collection_name,
|
||||
)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing memories: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
class HybridMemorySearch:
|
||||
"""Hybrid search combining vector and keyword search."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vector_store: VectorMemoryStore,
|
||||
keyword_weight: float = 0.3,
|
||||
vector_weight: float = 0.7,
|
||||
):
|
||||
"""Initialize hybrid search.
|
||||
|
||||
Args:
|
||||
vector_store: Vector memory store
|
||||
keyword_weight: Weight for keyword search (0-1)
|
||||
vector_weight: Weight for vector search (0-1)
|
||||
"""
|
||||
self.vector_store = vector_store
|
||||
self.keyword_weight = keyword_weight
|
||||
self.vector_weight = vector_weight
|
||||
|
||||
# Normalize weights
|
||||
total = keyword_weight + vector_weight
|
||||
self.keyword_weight /= total
|
||||
self.vector_weight /= total
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
agent_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
n_results: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search with hybrid approach.
|
||||
|
||||
For now, this is a simplified implementation using only vector search.
|
||||
Keyword search (BM25) can be added later with rank_bm25 library.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
agent_id: Filter by agent ID
|
||||
user_id: Filter by user ID
|
||||
n_results: Number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching memories with combined scores
|
||||
"""
|
||||
# Use vector search as primary method
|
||||
results = await self.vector_store.search(
|
||||
query=query,
|
||||
agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
n_results=n_results,
|
||||
)
|
||||
|
||||
# For future BM25 integration, would merge scores here
|
||||
return results
|
||||
|
||||
|
||||
def create_vector_memory_store(
|
||||
persist_dir: str | None = None,
|
||||
provider_type: str = "openai",
|
||||
**provider_kwargs,
|
||||
) -> VectorMemoryStore | None:
|
||||
"""Create a vector memory store with default settings.
|
||||
|
||||
Args:
|
||||
persist_dir: Directory to persist data
|
||||
provider_type: Type of embedding provider (openai, anthropic, local)
|
||||
**provider_kwargs: Additional arguments for the provider
|
||||
|
||||
Returns:
|
||||
VectorMemoryStore instance or None if dependencies missing
|
||||
"""
|
||||
if not CHROMADB_AVAILABLE:
|
||||
logger.warning(
|
||||
"Vector memory requires chromadb. "
|
||||
"Install with: pip install chromadb"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
provider = create_embedding_provider(provider_type, **provider_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create embedding provider: {e}")
|
||||
return None
|
||||
|
||||
return VectorMemoryStore(
|
||||
persist_directory=persist_dir,
|
||||
embedding_provider=provider,
|
||||
)
|
||||
5
core/agents/api/__init__.py
Normal file
5
core/agents/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""X-Agents API Module."""
|
||||
|
||||
from agents.api.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
316
core/agents/api/routes.py
Normal file
316
core/agents/api/routes.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""FastAPI routes for agent communication with Go backend."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Request/Response models - aligned with Go backend
|
||||
class ChatRequest(BaseModel):
|
||||
"""Chat request from Go backend.
|
||||
|
||||
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
|
||||
"""
|
||||
agent_id: int
|
||||
message: str
|
||||
user_id: int = 0
|
||||
session_id: str | None = None
|
||||
model_id: str | None = None
|
||||
model_name: str | None = None
|
||||
model_provider: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
use_xbot: bool = False
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""Chat response to Go backend.
|
||||
|
||||
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
|
||||
"""
|
||||
agent_id: int
|
||||
response: str
|
||||
tool_calls: list = []
|
||||
tokens_used: int = 0
|
||||
duration_ms: int = 0
|
||||
session_id: str
|
||||
|
||||
|
||||
class TeamChatRequest(BaseModel):
|
||||
"""Team chat request from Go backend.
|
||||
|
||||
Fields aligned with server/internal/service/agent_service.go::TeamChatRequest
|
||||
"""
|
||||
supervisor_agent_id: int
|
||||
member_agent_ids: list[int]
|
||||
message: str
|
||||
user_id: int = 0
|
||||
session_id: str | None = None
|
||||
strategy: str = "parallel"
|
||||
|
||||
|
||||
class TeamChatResponse(BaseModel):
|
||||
"""Team chat response to Go backend.
|
||||
|
||||
Fields aligned with server/internal/service/agent_service.go::TeamChatResponse
|
||||
"""
|
||||
supervisor_agent_id: int
|
||||
response: str
|
||||
subtask_results: list = []
|
||||
strategy: str = "parallel"
|
||||
duration_ms: int = 0
|
||||
session_id: str
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
status: str
|
||||
version: str = "0.1.0"
|
||||
|
||||
|
||||
# Global agent instance (to be initialized by main)
|
||||
_agent = None
|
||||
_team_agent = None
|
||||
|
||||
|
||||
def set_agent(agent: Any) -> None:
|
||||
"""Set the global agent instance.
|
||||
|
||||
Args:
|
||||
agent: Agent loop instance
|
||||
"""
|
||||
global _agent
|
||||
_agent = agent
|
||||
|
||||
|
||||
def set_team_agent(team_agent: Any) -> None:
|
||||
"""Set the global team agent instance.
|
||||
|
||||
Args:
|
||||
team_agent: Team agent instance
|
||||
"""
|
||||
global _team_agent
|
||||
_team_agent = team_agent
|
||||
|
||||
|
||||
def add_cors(app) -> None:
|
||||
"""Add CORS middleware to allow Go backend cross-origin requests.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
"""
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health_check() -> HealthResponse:
|
||||
"""Health check endpoint."""
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
@router.post("/agent/chat", response_model=ChatResponse)
|
||||
async def chat(request: ChatRequest) -> ChatResponse:
|
||||
"""Handle chat requests from Go backend.
|
||||
|
||||
Path: POST /agent/chat
|
||||
Aligned with Go backend server/internal/service/agent_service.go
|
||||
|
||||
Args:
|
||||
request: Chat request with agent_id, message, user_id, etc.
|
||||
|
||||
Returns:
|
||||
Chat response with agent_id, response, tool_calls, tokens_used, duration_ms, session_id
|
||||
|
||||
Raises:
|
||||
HTTPException: If agent is not initialized or processing fails
|
||||
"""
|
||||
if _agent is None:
|
||||
raise HTTPException(status_code=500, detail="Agent not initialized")
|
||||
|
||||
start_time = time.time()
|
||||
session_id = request.session_id or f"session_{request.agent_id}_{int(start_time)}"
|
||||
|
||||
try:
|
||||
# Prepare kwargs for agent.chat()
|
||||
kwargs = {
|
||||
"message": request.message,
|
||||
"session_key": session_id,
|
||||
}
|
||||
|
||||
# Add optional model configuration
|
||||
if request.model_id:
|
||||
kwargs["model_id"] = request.model_id
|
||||
if request.model_name:
|
||||
kwargs["model_name"] = request.model_name
|
||||
if request.model_provider:
|
||||
kwargs["model_provider"] = request.model_provider
|
||||
if request.api_key:
|
||||
kwargs["api_key"] = request.api_key
|
||||
if request.base_url:
|
||||
kwargs["base_url"] = request.base_url
|
||||
if request.use_xbot:
|
||||
kwargs["use_xbot"] = request.use_xbot
|
||||
|
||||
# Process the message
|
||||
logger.info(f"[chat] kwargs: model_provider={kwargs.get('model_provider')}, model_name={kwargs.get('model_name')}, api_key={'set' if kwargs.get('api_key') else 'not set'}")
|
||||
result = await _agent.chat(**kwargs)
|
||||
logger.info(f"[chat] result type={type(result).__name__}, content={str(result)[:100]}")
|
||||
|
||||
# Extract response content
|
||||
if isinstance(result, dict):
|
||||
response_text = result.get("response", result.get("content", str(result)))
|
||||
tool_calls = result.get("tool_calls", [])
|
||||
tokens_used = result.get("tokens_used", 0)
|
||||
else:
|
||||
response_text = str(result)
|
||||
tool_calls = []
|
||||
tokens_used = 0
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
return ChatResponse(
|
||||
agent_id=request.agent_id,
|
||||
response=response_text,
|
||||
tool_calls=tool_calls,
|
||||
tokens_used=tokens_used,
|
||||
duration_ms=duration_ms,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing chat: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/agent/chat/stream")
|
||||
async def chat_stream(request: ChatRequest):
|
||||
"""Handle streaming chat requests from Go backend.
|
||||
|
||||
Path: POST /agent/chat/stream
|
||||
Returns streaming response using SSE format.
|
||||
|
||||
Args:
|
||||
request: Chat request with agent_id, message, user_id, etc.
|
||||
|
||||
Yields:
|
||||
Streaming response chunks in SSE format
|
||||
"""
|
||||
if _agent is None:
|
||||
raise HTTPException(status_code=500, detail="Agent not initialized")
|
||||
|
||||
session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}"
|
||||
|
||||
async def generate() -> AsyncGenerator[str, None]:
|
||||
"""Generate streaming response."""
|
||||
try:
|
||||
# Prepare kwargs for agent.chat()
|
||||
kwargs = {
|
||||
"message": request.message,
|
||||
"session_key": session_id,
|
||||
}
|
||||
|
||||
if request.model_id:
|
||||
kwargs["model_id"] = request.model_id
|
||||
if request.model_name:
|
||||
kwargs["model_name"] = request.model_name
|
||||
if request.model_provider:
|
||||
kwargs["model_provider"] = request.model_provider
|
||||
if request.api_key:
|
||||
kwargs["api_key"] = request.api_key
|
||||
if request.base_url:
|
||||
kwargs["base_url"] = request.base_url
|
||||
if request.use_xbot:
|
||||
kwargs["use_xbot"] = request.use_xbot
|
||||
|
||||
# Process with streaming
|
||||
async for chunk in _agent.chat_stream(**kwargs):
|
||||
# SSE format: "data: <json>\n\n"
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
|
||||
# Send final message
|
||||
yield f"data: {json.dumps({'done': True, 'session_id': session_id})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in streaming chat: {e}")
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no-cache", # Disable nginx buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agent/team/chat", response_model=TeamChatResponse)
|
||||
async def team_chat(request: TeamChatRequest) -> TeamChatResponse:
|
||||
"""Handle team chat requests from Go backend.
|
||||
|
||||
Path: POST /agent/team/chat
|
||||
Aligned with Go backend server/internal/service/agent_service.go::TeamChat
|
||||
|
||||
Args:
|
||||
request: Team chat request with supervisor_agent_id, member_agent_ids, message, etc.
|
||||
|
||||
Returns:
|
||||
Team chat response with supervisor_agent_id, response, subtask_results, strategy, duration_ms, session_id
|
||||
|
||||
Raises:
|
||||
HTTPException: If team agent is not initialized or processing fails
|
||||
"""
|
||||
if _team_agent is None:
|
||||
raise HTTPException(status_code=500, detail="Team agent not initialized")
|
||||
|
||||
start_time = time.time()
|
||||
session_id = request.session_id or f"team_session_{request.supervisor_agent_id}_{int(start_time)}"
|
||||
|
||||
try:
|
||||
# Process the team chat message
|
||||
result = await _team_agent.chat(
|
||||
message=request.message,
|
||||
session_id=session_id,
|
||||
supervisor_agent_id=request.supervisor_agent_id,
|
||||
member_agent_ids=request.member_agent_ids,
|
||||
strategy=request.strategy,
|
||||
)
|
||||
|
||||
# Extract response content
|
||||
if isinstance(result, dict):
|
||||
response_text = result.get("response", str(result))
|
||||
subtask_results = result.get("subtask_results", [])
|
||||
else:
|
||||
response_text = str(result)
|
||||
subtask_results = []
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
return TeamChatResponse(
|
||||
supervisor_agent_id=request.supervisor_agent_id,
|
||||
response=response_text,
|
||||
subtask_results=subtask_results,
|
||||
strategy=request.strategy,
|
||||
duration_ms=duration_ms,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing team chat: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
56
core/agents/config.py
Normal file
56
core/agents/config.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Configuration for X-Agents."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 尝试加载 .env 文件
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
# 查找 .env 文件:从当前目录向上查找
|
||||
env_paths = [
|
||||
Path(__file__).parent.parent.parent / ".env", # X-Agents/.env
|
||||
Path(__file__).parent.parent / ".env", # core/.env
|
||||
Path(__file__).parent / ".env", # agents/.env
|
||||
]
|
||||
for env_path in env_paths:
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
break
|
||||
except ImportError:
|
||||
pass # python-dotenv 未安装时跳过
|
||||
|
||||
|
||||
class Config:
|
||||
"""X-Agents configuration."""
|
||||
|
||||
# API settings
|
||||
API_HOST: str = os.getenv("PYTHON_HOST", os.getenv("API_HOST", "0.0.0.0"))
|
||||
API_PORT: int = int(os.getenv("PYTHON_PORT", os.getenv("API_PORT", "8001")))
|
||||
|
||||
# LLM settings
|
||||
LLM_PROVIDER: str = os.getenv("PYTHON_LLM_PROVIDER", os.getenv("LLM_PROVIDER", "openai"))
|
||||
LLM_MODEL: str = os.getenv("PYTHON_LLM_MODEL", os.getenv("LLM_MODEL", "gpt-4o"))
|
||||
LLM_API_KEY: str = os.getenv("PYTHON_LLM_API_KEY", os.getenv("LLM_API_KEY", ""))
|
||||
LLM_BASE_URL: str | None = os.getenv("PYTHON_LLM_BASE_URL", os.getenv("LLM_BASE_URL", None))
|
||||
|
||||
# Workspace
|
||||
WORKSPACE: Path = Path(os.getenv("PYTHON_WORKSPACE", os.getenv("WORKSPACE", "./workspace")))
|
||||
|
||||
# Agent settings
|
||||
MAX_ITERATIONS: int = int(os.getenv("PYTHON_MAX_ITERATIONS", os.getenv("MAX_ITERATIONS", "10")))
|
||||
TEMPERATURE: float = float(os.getenv("PYTHON_TEMPERATURE", os.getenv("TEMPERATURE", "0.7")))
|
||||
|
||||
def __init__(self, **kwargs: Any):
|
||||
"""Initialize config with overrides.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration overrides
|
||||
"""
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
# Default config instance
|
||||
config = Config()
|
||||
482
core/agents/llm.py
Normal file
482
core/agents/llm.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""LLM Provider base classes and implementations."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallRequest:
|
||||
"""A tool call request from the LLM."""
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to dict."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"arguments": json.dumps(self.arguments, ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Response from an LLM provider."""
|
||||
content: str | None
|
||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||
finish_reason: str = "stop"
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
reasoning_content: str | None = None # For reasoning models
|
||||
|
||||
@property
|
||||
def has_tool_calls(self) -> bool:
|
||||
"""Check if response contains tool calls."""
|
||||
return len(self.tool_calls) > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationSettings:
|
||||
"""Default generation parameters for LLM calls."""
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
"""Abstract base class for LLM providers."""
|
||||
|
||||
_CHAT_RETRY_DELAYS = (1, 2, 4)
|
||||
_TRANSIENT_ERROR_MARKERS = (
|
||||
"429", "rate limit", "500", "502", "503", "504",
|
||||
"overloaded", "timeout", "timed out", "connection",
|
||||
"server error", "temporarily unavailable",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.generation = GenerationSettings()
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Sanitize messages to remove empty content that causes provider errors."""
|
||||
result = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and not content:
|
||||
clean = dict(msg)
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
clean["content"] = None
|
||||
else:
|
||||
clean["content"] = "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
stream: bool = False,
|
||||
) -> LLMResponse | AsyncGenerator[str, None]:
|
||||
"""Send a chat completion request."""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _is_transient_error(cls, content: str | None) -> bool:
|
||||
err = (content or "").lower()
|
||||
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
|
||||
|
||||
async def chat_with_retry(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Call chat() with retry on transient provider failures."""
|
||||
max_tokens = max_tokens or self.generation.max_tokens
|
||||
temperature = temperature or self.generation.temperature
|
||||
|
||||
messages = self._sanitize_messages(messages)
|
||||
|
||||
for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1):
|
||||
try:
|
||||
response = await self.chat(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
response = LLMResponse(
|
||||
content=f"Error calling LLM: {exc}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
if response.finish_reason != "error":
|
||||
return response
|
||||
if not self._is_transient_error(response.content):
|
||||
return response
|
||||
|
||||
logger.warning(
|
||||
"LLM transient error (attempt {}/{}), retrying in {}s",
|
||||
attempt,
|
||||
len(self._CHAT_RETRY_DELAYS),
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Last attempt
|
||||
try:
|
||||
return await self.chat(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: {exc}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_default_model(self) -> str:
|
||||
"""Get the default model for this provider."""
|
||||
pass
|
||||
|
||||
|
||||
# OpenAI Provider
|
||||
class OpenAIProvider(LLMProvider):
|
||||
"""OpenAI LLM provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazy load OpenAI client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.api_base,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError("openai package required: pip install openai")
|
||||
return self._client
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
stream: bool = False,
|
||||
) -> LLMResponse:
|
||||
model = model or self.get_default_model()
|
||||
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
params["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(**params)
|
||||
|
||||
choice = response.choices[0]
|
||||
msg = choice.message
|
||||
|
||||
tool_calls = []
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
args = tc.function.arguments
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
arguments=args,
|
||||
))
|
||||
|
||||
return LLMResponse(
|
||||
content=msg.content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=choice.finish_reason,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
|
||||
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"OpenAI API error: {exc}")
|
||||
return LLMResponse(
|
||||
content=f"Error: {exc}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completions."""
|
||||
model = model or self.get_default_model()
|
||||
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(**params)
|
||||
async for chunk in response:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
except Exception as exc:
|
||||
yield f"Error: {exc}"
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "gpt-4o"
|
||||
|
||||
|
||||
# Anthropic Provider
|
||||
class AnthropicProvider(LLMProvider):
|
||||
"""Anthropic Claude LLM provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazy load Anthropic client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
from anthropic import AsyncAnthropic
|
||||
self._client = AsyncAnthropic(
|
||||
api_key=self.api_key,
|
||||
base_url=self.api_base,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError("anthropic package required: pip install anthropic")
|
||||
return self._client
|
||||
|
||||
def _convert_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert messages to Anthropic format."""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
# Anthropic puts system in first user message
|
||||
content = msg.get("content", "")
|
||||
if converted and converted[0].get("role") == "user":
|
||||
converted[0]["content"] = f"{content}\n\n{converted[0].content}"
|
||||
else:
|
||||
converted.append({"role": "user", "content": f"{content}"})
|
||||
else:
|
||||
# Handle tool results
|
||||
if role == "tool":
|
||||
converted.append({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.get("tool_call_id"),
|
||||
"content": msg.get("content", ""),
|
||||
}
|
||||
],
|
||||
})
|
||||
else:
|
||||
converted.append(msg)
|
||||
return converted
|
||||
|
||||
def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert OpenAI-style tools to Anthropic format."""
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
func = tool.get("function", {})
|
||||
anthropic_tools.append({
|
||||
"name": func.get("name"),
|
||||
"description": func.get("description"),
|
||||
"input_schema": func.get("parameters", {}),
|
||||
})
|
||||
return anthropic_tools
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
stream: bool = False,
|
||||
) -> LLMResponse:
|
||||
model = model or self.get_default_model()
|
||||
|
||||
params = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"messages": self._convert_messages(messages),
|
||||
}
|
||||
|
||||
if tools:
|
||||
params["tools"] = self._convert_tools(tools)
|
||||
|
||||
try:
|
||||
response = await self.client.messages.create(**params)
|
||||
|
||||
tool_calls = []
|
||||
for tc in response.tool_calls:
|
||||
args = tc.input
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=tc.id,
|
||||
name=tc.name,
|
||||
arguments=args,
|
||||
))
|
||||
|
||||
# Get content text
|
||||
content_text = ""
|
||||
thinking = None
|
||||
if response.content:
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
content_text = block.text
|
||||
elif block.type == "thinking":
|
||||
thinking = block.thinking
|
||||
|
||||
return LLMResponse(
|
||||
content=content_text,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason="stop" if not tool_calls else "tool_use",
|
||||
reasoning_content=thinking,
|
||||
usage={
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Anthropic API error: {exc}")
|
||||
return LLMResponse(
|
||||
content=f"Error: {exc}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completions."""
|
||||
model = model or self.get_default_model()
|
||||
|
||||
params = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"messages": self._convert_messages(messages),
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
params["tools"] = self._convert_tools(tools)
|
||||
|
||||
try:
|
||||
async with self.client.messages.stream(**params) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield text
|
||||
except Exception as exc:
|
||||
yield f"Error: {exc}"
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
# Provider factory
|
||||
class ProviderFactory:
|
||||
"""Factory for creating LLM providers."""
|
||||
|
||||
_PROVIDERS = {
|
||||
"openai": OpenAIProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> LLMProvider:
|
||||
"""Create an LLM provider instance.
|
||||
|
||||
Args:
|
||||
provider: Provider name (openai, anthropic)
|
||||
api_key: API key
|
||||
api_base: Optional base URL for API
|
||||
|
||||
Returns:
|
||||
LLM provider instance
|
||||
"""
|
||||
provider_cls = cls._PROVIDERS.get(provider.lower())
|
||||
if not provider_cls:
|
||||
raise ValueError(f"Unknown provider: {provider}. Available: {list(cls._PROVIDERS.keys())}")
|
||||
return provider_cls(api_key=api_key, api_base=api_base)
|
||||
165
core/agents/main.py
Normal file
165
core/agents/main.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Main entry point for X-Agents agent service."""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path (parent of core directory)
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
core_dir = project_root / "core"
|
||||
sys.path.insert(0, str(project_root)) # for X-Agents root
|
||||
sys.path.insert(0, str(core_dir)) # for core
|
||||
sys.path.insert(0, str(core_dir / "nanobot")) # for nanobot
|
||||
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
|
||||
from agents.config import Config
|
||||
from agents.api.routes import router, set_agent, set_team_agent, add_cors
|
||||
from agents.agent.loop import AgentLoop
|
||||
from agents.agent.team_agent import TeamAgent
|
||||
from agents.llm import ProviderFactory
|
||||
from agents.tools import create_default_registry
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleProvider:
|
||||
"""Simple LLM provider placeholder for testing without API keys."""
|
||||
|
||||
def __init__(self, api_key: str = "", base_url: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
async def chat(self, messages: list[dict], model: str, **kwargs) -> dict:
|
||||
"""Simulate LLM chat response.
|
||||
|
||||
Args:
|
||||
messages: Message list
|
||||
model: Model name
|
||||
|
||||
Returns:
|
||||
Simulated response
|
||||
"""
|
||||
from agents.llm import LLMResponse
|
||||
|
||||
user_msg = ""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
user_msg = msg.get("content", "")
|
||||
break
|
||||
|
||||
return LLMResponse(
|
||||
content=f"I received your message: {user_msg[:50]}... (LLM integration pending)",
|
||||
tool_calls=[],
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
async def chat_with_retry(self, *args, **kwargs):
|
||||
return await self.chat(*args, **kwargs)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "simple"
|
||||
|
||||
|
||||
def create_app(config: Config | None = None) -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
Args:
|
||||
config: Configuration instance
|
||||
|
||||
Returns:
|
||||
Configured FastAPI app
|
||||
"""
|
||||
config = config or Config()
|
||||
|
||||
app = FastAPI(
|
||||
title="X-Agents API",
|
||||
description="Agent API for X-Agents platform",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Include routers with /api/v1 prefix (aligned with Go backend paths: /api/agent/chat, /api/agent/chat/stream)
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
# Add CORS middleware to allow Go backend cross-origin requests
|
||||
add_cors(app)
|
||||
|
||||
# Initialize LLM provider
|
||||
if config.LLM_API_KEY:
|
||||
try:
|
||||
provider = ProviderFactory.create(
|
||||
provider=config.LLM_PROVIDER,
|
||||
api_key=config.LLM_API_KEY,
|
||||
api_base=config.LLM_BASE_URL,
|
||||
)
|
||||
logger.info(f"Using {config.LLM_PROVIDER} provider with model {config.LLM_MODEL}")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Failed to import provider package: {e}, using placeholder")
|
||||
provider = SimpleProvider(api_key=config.LLM_API_KEY)
|
||||
else:
|
||||
logger.warning("No LLM_API_KEY provided, using placeholder provider")
|
||||
provider = SimpleProvider()
|
||||
|
||||
# Create tool registry
|
||||
tools = create_default_registry()
|
||||
|
||||
# Initialize agent
|
||||
agent = AgentLoop(
|
||||
provider=provider,
|
||||
model=config.LLM_MODEL,
|
||||
workspace=config.WORKSPACE,
|
||||
max_iterations=config.MAX_ITERATIONS,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
set_agent(agent)
|
||||
|
||||
# Initialize team agent for multi-agent collaboration
|
||||
team_agent = TeamAgent(
|
||||
provider=provider,
|
||||
model=config.LLM_MODEL,
|
||||
workspace=config.WORKSPACE,
|
||||
)
|
||||
set_team_agent(team_agent)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("X-Agents starting up...")
|
||||
logger.info(f"Model: {config.LLM_MODEL}")
|
||||
logger.info(f"Provider: {config.LLM_PROVIDER}")
|
||||
logger.info(f"Workspace: {config.WORKSPACE}")
|
||||
logger.info(f"Tools: {tools.tool_names}")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("X-Agents shutting down...")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the agent service."""
|
||||
config = Config()
|
||||
|
||||
# Ensure workspace exists
|
||||
config.WORKSPACE.mkdir(exist_ok=True)
|
||||
|
||||
app = create_app(config)
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=config.API_HOST,
|
||||
port=config.API_PORT,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
core/agents/providers/__init__.py
Normal file
7
core/agents/providers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""LLM Provider abstraction for X-Agents."""
|
||||
|
||||
from agents.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from agents.providers.openai_provider import OpenAIProvider
|
||||
from agents.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
__all__ = ["LLMProvider", "LLMResponse", "ToolCallRequest", "OpenAIProvider", "AnthropicProvider"]
|
||||
241
core/agents/providers/anthropic_provider.py
Normal file
241
core/agents/providers/anthropic_provider.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Anthropic LLM provider implementation."""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
from agents.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
"""Generate a 9-char alphanumeric ID for tool calls."""
|
||||
return "".join(secrets.choice(_ALNUM) for _ in range(9))
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
"""Anthropic LLM provider using Claude API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
default_model: str = "claude-sonnet-4-20250514",
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _convert_messages_to_anthropic(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert messages to Anthropic API format."""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
|
||||
# Handle tool calls in assistant messages
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
# Anthropic doesn't support tool_calls in the same way, convert to text
|
||||
tool_calls_text = "\n".join([
|
||||
f"Tool call: {tc.get('name')}({json.dumps(tc.get('arguments', {}))})"
|
||||
for tc in msg["tool_calls"]
|
||||
])
|
||||
if content:
|
||||
content = f"{content}\n\n{tool_calls_text}"
|
||||
else:
|
||||
content = tool_calls_text
|
||||
|
||||
# Handle tool results
|
||||
if role == "tool":
|
||||
# Convert tool result to Anthropic format
|
||||
tool_use_id = msg.get("tool_call_id", _short_tool_id())
|
||||
converted.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content or "(empty)",
|
||||
})
|
||||
continue
|
||||
|
||||
# Skip system messages - they'll be handled separately
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
# Convert content to Anthropic format
|
||||
if isinstance(content, str):
|
||||
converted.append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
})
|
||||
elif isinstance(content, list):
|
||||
# Handle list content
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "tool_use":
|
||||
# This shouldn't happen in input, but handle it
|
||||
text_parts.append(f"[tool_use: {item.get('name')}]")
|
||||
elif item.get("type") == "tool_result":
|
||||
text_parts.append(item.get("content", ""))
|
||||
converted.append({
|
||||
"role": role,
|
||||
"content": "\n".join(text_parts),
|
||||
})
|
||||
else:
|
||||
converted.append({
|
||||
"role": role,
|
||||
"content": str(content) if content else "(empty)",
|
||||
})
|
||||
|
||||
return converted
|
||||
|
||||
def _get_system_message(self, messages: list[dict[str, Any]]) -> str | None:
|
||||
"""Extract system message from messages."""
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
return msg.get("content")
|
||||
return None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
"""Send a chat completion request to Anthropic API."""
|
||||
model = model or self.default_model
|
||||
api_base = self.api_base or "https://api.anthropic.com"
|
||||
url = f"{api_base}/v1/messages"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["x-api-key"] = self.api_key
|
||||
|
||||
# Get system message and convert other messages
|
||||
system = self._get_system_message(messages)
|
||||
anthropic_messages = self._convert_messages_to_anthropic(messages)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
if system:
|
||||
payload["system"] = system
|
||||
|
||||
# Convert tools to Anthropic format if provided
|
||||
if tools:
|
||||
anthropic_tools = self._convert_tools(tools)
|
||||
payload["tools"] = anthropic_tools
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
try:
|
||||
error_json = json.loads(error_text)
|
||||
error_msg = error_json.get("error", {}).get("message", error_text)
|
||||
except json.JSONDecodeError:
|
||||
error_msg = error_text
|
||||
return LLMResponse(
|
||||
content=f"Anthropic API error (status {resp.status}): {error_msg}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
return self._parse_response(data, tools is not None)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
return LLMResponse(
|
||||
content=f"Anthropic API connection error: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
except Exception as e:
|
||||
return LLMResponse(
|
||||
content=f"Error calling Anthropic: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert OpenAI-style tools to Anthropic format."""
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
func = tool.get("function", {})
|
||||
anthropic_tools.append({
|
||||
"name": func.get("name", ""),
|
||||
"description": func.get("description", ""),
|
||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}}),
|
||||
})
|
||||
return anthropic_tools
|
||||
|
||||
def _parse_response(self, data: dict[str, Any], has_tools: bool = False) -> LLMResponse:
|
||||
"""Parse Anthropic API response into our standard format."""
|
||||
content = data.get("content", [])
|
||||
|
||||
# Extract text content
|
||||
text_content = ""
|
||||
tool_calls = []
|
||||
for block in content:
|
||||
if block.get("type") == "text":
|
||||
text_content += block.get("text", "")
|
||||
elif block.get("type") == "tool_use" and has_tools:
|
||||
# Convert Anthropic tool_use to our format
|
||||
args = block.get("input", {})
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=block.get("id", _short_tool_id()),
|
||||
name=block.get("name", ""),
|
||||
arguments=args,
|
||||
))
|
||||
|
||||
# Determine finish reason
|
||||
stop_reason = data.get("stop_reason", "end_turn")
|
||||
if stop_reason == "tool_use":
|
||||
finish_reason = "tool_calls"
|
||||
elif stop_reason == "max_tokens":
|
||||
finish_reason = "length"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
|
||||
# Parse usage
|
||||
usage = data.get("usage", {})
|
||||
usage_dict = {
|
||||
"prompt_tokens": usage.get("input_tokens", 0),
|
||||
"completion_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
|
||||
}
|
||||
|
||||
return LLMResponse(
|
||||
content=text_content if text_content else None,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
usage=usage_dict,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
"""Get the default model."""
|
||||
return self.default_model
|
||||
225
core/agents/providers/base.py
Normal file
225
core/agents/providers/base.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Base LLM provider interface."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallRequest:
|
||||
"""A tool call request from the LLM."""
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
provider_specific_fields: dict[str, Any] | None = None
|
||||
|
||||
def to_openai_tool_call(self) -> dict[str, Any]:
|
||||
"""Serialize to an OpenAI-style tool_call payload."""
|
||||
tool_call = {
|
||||
"id": self.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"arguments": json.dumps(self.arguments, ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
if self.provider_specific_fields:
|
||||
tool_call["provider_specific_fields"] = self.provider_specific_fields
|
||||
return tool_call
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Response from an LLM provider."""
|
||||
content: str | None
|
||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||
finish_reason: str = "stop"
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
reasoning_content: str | None = None # For reasoning models
|
||||
|
||||
@property
|
||||
def has_tool_calls(self) -> bool:
|
||||
"""Check if response contains tool calls."""
|
||||
return len(self.tool_calls) > 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationSettings:
|
||||
"""Default generation parameters for LLM calls."""
|
||||
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
"""
|
||||
Abstract base class for LLM providers.
|
||||
|
||||
Implementations should handle the specifics of each provider's API
|
||||
while maintaining a consistent interface.
|
||||
"""
|
||||
|
||||
_CHAT_RETRY_DELAYS = (1, 2, 4)
|
||||
_TRANSIENT_ERROR_MARKERS = (
|
||||
"429",
|
||||
"rate limit",
|
||||
"500",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
"overloaded",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"connection",
|
||||
"server error",
|
||||
"temporarily unavailable",
|
||||
)
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.generation: GenerationSettings = GenerationSettings()
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Replace empty text content that causes provider 400 errors."""
|
||||
result: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
|
||||
if isinstance(content, str) and not content:
|
||||
clean = dict(msg)
|
||||
clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
if isinstance(content, list):
|
||||
filtered = [
|
||||
item for item in content
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") in ("text", "input_text", "output_text")
|
||||
and not item.get("text")
|
||||
)
|
||||
]
|
||||
if len(filtered) != len(content):
|
||||
clean = dict(msg)
|
||||
if filtered:
|
||||
clean["content"] = filtered
|
||||
elif msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
clean["content"] = None
|
||||
else:
|
||||
clean["content"] = "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
if isinstance(content, dict):
|
||||
clean = dict(msg)
|
||||
clean["content"] = [content]
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Send a chat completion request.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'.
|
||||
tools: Optional list of tool definitions.
|
||||
model: Model identifier (provider-specific).
|
||||
max_tokens: Maximum tokens in response.
|
||||
temperature: Sampling temperature.
|
||||
|
||||
Returns:
|
||||
LLMResponse with content and/or tool calls.
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _is_transient_error(cls, content: str | None) -> bool:
|
||||
err = (content or "").lower()
|
||||
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
|
||||
|
||||
async def chat_with_retry(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: object = _SENTINEL,
|
||||
temperature: object = _SENTINEL,
|
||||
) -> LLMResponse:
|
||||
"""Call chat() with retry on transient provider failures."""
|
||||
if max_tokens is self._SENTINEL:
|
||||
max_tokens = self.generation.max_tokens
|
||||
if temperature is self._SENTINEL:
|
||||
temperature = self.generation.temperature
|
||||
|
||||
for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1):
|
||||
try:
|
||||
response = await self.chat(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
response = LLMResponse(
|
||||
content=f"Error calling LLM: {exc}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
if response.finish_reason != "error":
|
||||
return response
|
||||
if not self._is_transient_error(response.content):
|
||||
return response
|
||||
|
||||
err = (response.content or "").lower()
|
||||
logger.warning(
|
||||
"LLM transient error (attempt {}/{}), retrying in {}s: {}",
|
||||
attempt,
|
||||
len(self._CHAT_RETRY_DELAYS),
|
||||
delay,
|
||||
err[:120],
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
return await self.chat(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: {exc}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_default_model(self) -> str:
|
||||
"""Get the default model for this provider."""
|
||||
pass
|
||||
150
core/agents/providers/openai_provider.py
Normal file
150
core/agents/providers/openai_provider.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""OpenAI LLM provider implementation."""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
from agents.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
"""Generate a 9-char alphanumeric ID for tool calls."""
|
||||
return "".join(secrets.choice(_ALNUM) for _ in range(9))
|
||||
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
"""OpenAI LLM provider using OpenAI API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
default_model: str = "gpt-4o",
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
"""Send a chat completion request to OpenAI API."""
|
||||
model = model or self.default_model
|
||||
api_base = self.api_base or "https://api.openai.com/v1"
|
||||
url = f"{api_base}/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
# Sanitize messages
|
||||
messages = self._sanitize_empty_content(messages)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
return LLMResponse(
|
||||
content=f"OpenAI API error (status {resp.status}): {error_text}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
return self._parse_response(data)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
return LLMResponse(
|
||||
content=f"OpenAI API connection error: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
except Exception as e:
|
||||
return LLMResponse(
|
||||
content=f"Error calling OpenAI: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
def _parse_response(self, data: dict[str, Any]) -> LLMResponse:
|
||||
"""Parse OpenAI API response into our standard format."""
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
return LLMResponse(content="", finish_reason="stop")
|
||||
|
||||
choice = choices[0]
|
||||
message = choice.get("message", {})
|
||||
content = message.get("content")
|
||||
finish_reason = choice.get("finish_reason", "stop")
|
||||
|
||||
# Parse tool calls
|
||||
tool_calls = []
|
||||
raw_tool_calls = message.get("tool_calls", [])
|
||||
for tc in raw_tool_calls:
|
||||
func = tc.get("function", {})
|
||||
args_str = func.get("arguments", "{}")
|
||||
if isinstance(args_str, str):
|
||||
try:
|
||||
args = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
else:
|
||||
args = args_str
|
||||
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=tc.get("id", _short_tool_id()),
|
||||
name=func.get("name", ""),
|
||||
arguments=args,
|
||||
))
|
||||
|
||||
# Parse usage
|
||||
usage = data.get("usage", {})
|
||||
usage_dict = {
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
}
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
usage=usage_dict,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
"""Get the default model."""
|
||||
return self.default_model
|
||||
19
core/agents/requirements.txt
Normal file
19
core/agents/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# X-Agents Agent Core Dependencies
|
||||
|
||||
# Web framework
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
pydantic>=2.5.0
|
||||
|
||||
# LLM providers
|
||||
openai>=1.12.0
|
||||
anthropic>=0.18.0
|
||||
|
||||
# Async
|
||||
aiohttp>=3.9.0
|
||||
|
||||
# Vector search (optional)
|
||||
chromadb>=0.4.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
6
core/agents/skills/__init__.py
Normal file
6
core/agents/skills/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Skills module for X-Agents."""
|
||||
|
||||
from agents.skills.loader import SkillsLoader, Skill
|
||||
from agents.skills.executor import SkillExecutor
|
||||
|
||||
__all__ = ["SkillsLoader", "Skill", "SkillExecutor"]
|
||||
178
core/agents/skills/executor.py
Normal file
178
core/agents/skills/executor.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Skill executor for executing skills."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from agents.skills.loader import Skill, SkillsLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillContext:
|
||||
"""Execution context for a skill."""
|
||||
skill_id: str
|
||||
skill_name: str
|
||||
input_data: dict[str, Any]
|
||||
user_message: str
|
||||
|
||||
|
||||
class SkillExecutor:
|
||||
"""Executes skills based on user input."""
|
||||
|
||||
def __init__(self, skills_loader: SkillsLoader):
|
||||
"""Initialize skill executor.
|
||||
|
||||
Args:
|
||||
skills_loader: SkillsLoader instance for loading skills
|
||||
"""
|
||||
self.loader = skills_loader
|
||||
self._skills_prompt_cache: dict[str, str] = {}
|
||||
|
||||
async def find_matching_skills(self, user_message: str) -> list[Skill]:
|
||||
"""Find skills that match the user message.
|
||||
|
||||
Args:
|
||||
user_message: User's input message
|
||||
|
||||
Returns:
|
||||
List of matching skills (currently returns all active skills)
|
||||
"""
|
||||
# Get all active skills
|
||||
skills = await self.loader.list_skills()
|
||||
active_skills = [s for s in skills if s.status == "active"]
|
||||
return active_skills
|
||||
|
||||
async def execute_skill(
|
||||
self,
|
||||
skill_id: str,
|
||||
context: SkillContext,
|
||||
) -> str | None:
|
||||
"""Execute a skill with given context.
|
||||
|
||||
Args:
|
||||
skill_id: ID of skill to execute
|
||||
context: Execution context
|
||||
|
||||
Returns:
|
||||
Execution result as string, or None if failed
|
||||
"""
|
||||
skill = await self.loader.load_skill_with_content(skill_id)
|
||||
if not skill:
|
||||
logger.warning(f"Skill not found: {skill_id}")
|
||||
return None
|
||||
|
||||
if skill.status != "active":
|
||||
logger.warning(f"Skill is not active: {skill_id}")
|
||||
return None
|
||||
|
||||
# Extract prompt/instructions from skill content
|
||||
prompt = self._extract_skill_prompt(skill)
|
||||
|
||||
# Replace placeholders in prompt with context
|
||||
prompt = self._inject_context(prompt, context)
|
||||
|
||||
return prompt
|
||||
|
||||
def _extract_skill_prompt(self, skill: Skill) -> str:
|
||||
"""Extract main prompt/instructions from skill content.
|
||||
|
||||
Args:
|
||||
skill: Skill object with content
|
||||
|
||||
Returns:
|
||||
Extracted prompt
|
||||
"""
|
||||
content = skill.content
|
||||
|
||||
# Skip frontmatter if present
|
||||
lines = content.split("\n")
|
||||
start_line = 0
|
||||
if content.startswith("---"):
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
start_line = i + 1
|
||||
break
|
||||
|
||||
# Join remaining content
|
||||
main_content = "\n".join(lines[start_line:])
|
||||
|
||||
# Remove markdown headers but keep content
|
||||
prompt = re.sub(r"^#+\s+", "", main_content, flags=re.MULTILINE)
|
||||
|
||||
return prompt.strip()
|
||||
|
||||
def _inject_context(self, prompt: str, context: SkillContext) -> str:
|
||||
"""Inject context into prompt template.
|
||||
|
||||
Args:
|
||||
prompt: Prompt template
|
||||
context: Execution context
|
||||
|
||||
Returns:
|
||||
Prompt with context injected
|
||||
"""
|
||||
# Replace common placeholders
|
||||
replacements = {
|
||||
"{{user_message}}": context.user_message,
|
||||
"{{skill_name}}": context.skill_name,
|
||||
"{{input}}": str(context.input_data),
|
||||
}
|
||||
|
||||
result = prompt
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
return result
|
||||
|
||||
async def get_skill_system_prompt(self, skill_id: str) -> str | None:
|
||||
"""Get system prompt for a skill to be used in LLM context.
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID
|
||||
|
||||
Returns:
|
||||
System prompt for the skill, or None if not found
|
||||
"""
|
||||
# Check cache
|
||||
if skill_id in self._skills_prompt_cache:
|
||||
return self._skills_prompt_cache[skill_id]
|
||||
|
||||
skill = await self.loader.load_skill_with_content(skill_id)
|
||||
if not skill or skill.status != "active":
|
||||
return None
|
||||
|
||||
# Extract prompt
|
||||
prompt = self._extract_skill_prompt(skill)
|
||||
|
||||
# Cache it
|
||||
self._skills_prompt_cache[skill_id] = prompt
|
||||
|
||||
return prompt
|
||||
|
||||
def build_skills_context(self, skills: list[Skill]) -> str:
|
||||
"""Build context string from multiple skills.
|
||||
|
||||
Args:
|
||||
skills: List of skills
|
||||
|
||||
Returns:
|
||||
Combined context string
|
||||
"""
|
||||
if not skills:
|
||||
return ""
|
||||
|
||||
context_parts = ["## Available Skills\n"]
|
||||
for skill in skills:
|
||||
context_parts.append(f"### {skill.name}")
|
||||
context_parts.append(f"{skill.description}\n")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear prompt cache."""
|
||||
self._skills_prompt_cache.clear()
|
||||
252
core/agents/skills/loader.py
Normal file
252
core/agents/skills/loader.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Skills loader for loading and managing skills from Go backend."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skill:
|
||||
"""Skill data model."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
skill_type: str # system/user
|
||||
status: str # active/inactive
|
||||
path: str
|
||||
content: str = ""
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""Loads skills from Go backend API and local file system."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
"""Initialize skills loader.
|
||||
|
||||
Args:
|
||||
base_url: Go backend API base URL
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._session = None
|
||||
self._skills_cache: dict[str, Skill] = {}
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def list_skills(self, skill_type: str | None = None) -> list[Skill]:
|
||||
"""List all skills from Go backend.
|
||||
|
||||
Args:
|
||||
skill_type: Optional filter by skill type (system/user)
|
||||
|
||||
Returns:
|
||||
List of skills
|
||||
"""
|
||||
url = f"{self.base_url}/api/skill/list"
|
||||
params = {}
|
||||
if skill_type:
|
||||
params["type"] = skill_type
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
skills_list = result.get("list", [])
|
||||
skills = []
|
||||
for s in skills_list:
|
||||
skill = Skill(
|
||||
id=s.get("id", ""),
|
||||
name=s.get("skill_name", ""),
|
||||
description=s.get("skill_desc", ""),
|
||||
skill_type=s.get("skill_type", "user"),
|
||||
status=s.get("status", "inactive"),
|
||||
path=s.get("path", ""),
|
||||
)
|
||||
skills.append(skill)
|
||||
return skills
|
||||
logger.warning(f"Failed to list skills: {response.status}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing skills: {e}")
|
||||
return []
|
||||
|
||||
async def get_skill(self, skill_id: str) -> Skill | None:
|
||||
"""Get a skill by ID.
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID
|
||||
|
||||
Returns:
|
||||
Skill object or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if skill_id in self._skills_cache:
|
||||
return self._skills_cache[skill_id]
|
||||
|
||||
url = f"{self.base_url}/api/skill/{skill_id}"
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
skill_data = result.get("skill", {})
|
||||
skill = Skill(
|
||||
id=skill_data.get("id", ""),
|
||||
name=skill_data.get("skill_name", ""),
|
||||
description=skill_data.get("skill_desc", ""),
|
||||
skill_type=skill_data.get("skill_type", "user"),
|
||||
status=skill_data.get("status", "inactive"),
|
||||
path=skill_data.get("path", ""),
|
||||
)
|
||||
self._skills_cache[skill_id] = skill
|
||||
return skill
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting skill {skill_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_skill_content(self, skill_id: str) -> str | None:
|
||||
"""Get skill content (SKILL.md file content).
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID
|
||||
|
||||
Returns:
|
||||
Skill content as string, or None if failed
|
||||
"""
|
||||
url = f"{self.base_url}/api/skill/content"
|
||||
params = {"id": skill_id}
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status == 200:
|
||||
content = await response.text()
|
||||
return content
|
||||
logger.warning(f"Failed to get skill content: {response.status}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting skill content: {e}")
|
||||
return None
|
||||
|
||||
async def sync_skills(self) -> int:
|
||||
"""Manually trigger skills sync from file system.
|
||||
|
||||
Returns:
|
||||
Number of skills synced
|
||||
"""
|
||||
url = f"{self.base_url}/api/skill/sync"
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
count = result.get("count", 0)
|
||||
logger.info(f"Synced {count} skills")
|
||||
return count
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing skills: {e}")
|
||||
return 0
|
||||
|
||||
async def load_skill_with_content(self, skill_id: str) -> Skill | None:
|
||||
"""Load skill with its content.
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID
|
||||
|
||||
Returns:
|
||||
Skill object with content, or None if failed
|
||||
"""
|
||||
skill = await self.get_skill(skill_id)
|
||||
if skill:
|
||||
content = await self.get_skill_content(skill_id)
|
||||
if content:
|
||||
skill.content = content
|
||||
return skill
|
||||
|
||||
def load_skill_from_file(self, file_path: str | Path) -> Skill | None:
|
||||
"""Load skill from local file system.
|
||||
|
||||
Args:
|
||||
file_path: Path to SKILL.md file
|
||||
|
||||
Returns:
|
||||
Skill object or None if failed
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.warning(f"Skill file not found: {path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# Parse frontmatter
|
||||
name, description = self._parse_frontmatter(content)
|
||||
|
||||
return Skill(
|
||||
id="",
|
||||
name=name or path.stem,
|
||||
description=description or "",
|
||||
skill_type="user",
|
||||
status="active",
|
||||
path=str(path),
|
||||
content=content,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading skill from file: {e}")
|
||||
return None
|
||||
|
||||
def _parse_frontmatter(self, content: str) -> tuple[str | None, str | None]:
|
||||
"""Parse YAML frontmatter from skill content.
|
||||
|
||||
Args:
|
||||
content: Skill markdown content
|
||||
|
||||
Returns:
|
||||
Tuple of (name, description)
|
||||
"""
|
||||
import re
|
||||
|
||||
if not content.startswith("---"):
|
||||
return None, None
|
||||
|
||||
lines = content.split("\n")
|
||||
end_idx = 0
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx == 0:
|
||||
return None, None
|
||||
|
||||
yaml_content = "\n".join(lines[1:end_idx])
|
||||
|
||||
name_match = re.search(r"name:\s*(.+)", yaml_content)
|
||||
name = name_match.group(1).strip() if name_match else None
|
||||
|
||||
desc_match = re.search(r"description:\s*(.+)", yaml_content)
|
||||
description = desc_match.group(1).strip() if desc_match else None
|
||||
|
||||
return name, description
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear skills cache."""
|
||||
self._skills_cache.clear()
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,405 @@
|
||||
---
|
||||
name: openakita/skills@algorithmic-art
|
||||
description: Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
Algorithmic philosophies are computational aesthetic movements that are then expressed through code. Output .md files (philosophy), .html files (interactive viewer), and .js files (generative algorithms).
|
||||
|
||||
This happens in two steps:
|
||||
1. Algorithmic Philosophy Creation (.md file)
|
||||
2. Express by creating p5.js generative art (.html + .js files)
|
||||
|
||||
First, undertake this task:
|
||||
|
||||
## ALGORITHMIC PHILOSOPHY CREATION
|
||||
|
||||
To begin, create an ALGORITHMIC PHILOSOPHY (not static images or templates) that will be interpreted through:
|
||||
- Computational processes, emergent behavior, mathematical beauty
|
||||
- Seeded randomness, noise fields, organic systems
|
||||
- Particles, flows, fields, forces
|
||||
- Parametric variation and controlled chaos
|
||||
|
||||
### THE CRITICAL UNDERSTANDING
|
||||
- What is received: Some subtle input or instructions by the user to take into account, but use as a foundation; it should not constrain creative freedom.
|
||||
- What is created: An algorithmic philosophy/generative aesthetic movement.
|
||||
- What happens next: The same version receives the philosophy and EXPRESSES IT IN CODE - creating p5.js sketches that are 90% algorithmic generation, 10% essential parameters.
|
||||
|
||||
Consider this approach:
|
||||
- Write a manifesto for a generative art movement
|
||||
- The next phase involves writing the algorithm that brings it to life
|
||||
|
||||
The philosophy must emphasize: Algorithmic expression. Emergent behavior. Computational beauty. Seeded variation.
|
||||
|
||||
### HOW TO GENERATE AN ALGORITHMIC PHILOSOPHY
|
||||
|
||||
**Name the movement** (1-2 words): "Organic Turbulence" / "Quantum Harmonics" / "Emergent Stillness"
|
||||
|
||||
**Articulate the philosophy** (4-6 paragraphs - concise but complete):
|
||||
|
||||
To capture the ALGORITHMIC essence, express how this philosophy manifests through:
|
||||
- Computational processes and mathematical relationships?
|
||||
- Noise functions and randomness patterns?
|
||||
- Particle behaviors and field dynamics?
|
||||
- Temporal evolution and system states?
|
||||
- Parametric variation and emergent complexity?
|
||||
|
||||
**CRITICAL GUIDELINES:**
|
||||
- **Avoid redundancy**: Each algorithmic aspect should be mentioned once. Avoid repeating concepts about noise theory, particle dynamics, or mathematical principles unless adding new depth.
|
||||
- **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final algorithm should appear as though it took countless hours to develop, was refined with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted algorithm," "the product of deep computational expertise," "painstaking optimization," "master-level implementation."
|
||||
- **Leave creative space**: Be specific about the algorithmic direction, but concise enough that the next Claude has room to make interpretive implementation choices at an extremely high level of craftsmanship.
|
||||
|
||||
The philosophy must guide the next version to express ideas ALGORITHMICALLY, not through static images. Beauty lives in the process, not the final frame.
|
||||
|
||||
### PHILOSOPHY EXAMPLES
|
||||
|
||||
**"Organic Turbulence"**
|
||||
Philosophy: Chaos constrained by natural law, order emerging from disorder.
|
||||
Algorithmic expression: Flow fields driven by layered Perlin noise. Thousands of particles following vector forces, their trails accumulating into organic density maps. Multiple noise octaves create turbulent regions and calm zones. Color emerges from velocity and density - fast particles burn bright, slow ones fade to shadow. The algorithm runs until equilibrium - a meticulously tuned balance where every parameter was refined through countless iterations by a master of computational aesthetics.
|
||||
|
||||
**"Quantum Harmonics"**
|
||||
Philosophy: Discrete entities exhibiting wave-like interference patterns.
|
||||
Algorithmic expression: Particles initialized on a grid, each carrying a phase value that evolves through sine waves. When particles are near, their phases interfere - constructive interference creates bright nodes, destructive creates voids. Simple harmonic motion generates complex emergent mandalas. The result of painstaking frequency calibration where every ratio was carefully chosen to produce resonant beauty.
|
||||
|
||||
**"Recursive Whispers"**
|
||||
Philosophy: Self-similarity across scales, infinite depth in finite space.
|
||||
Algorithmic expression: Branching structures that subdivide recursively. Each branch slightly randomized but constrained by golden ratios. L-systems or recursive subdivision generate tree-like forms that feel both mathematical and organic. Subtle noise perturbations break perfect symmetry. Line weights diminish with each recursion level. Every branching angle the product of deep mathematical exploration.
|
||||
|
||||
**"Field Dynamics"**
|
||||
Philosophy: Invisible forces made visible through their effects on matter.
|
||||
Algorithmic expression: Vector fields constructed from mathematical functions or noise. Particles born at edges, flowing along field lines, dying when they reach equilibrium or boundaries. Multiple fields can attract, repel, or rotate particles. The visualization shows only the traces - ghost-like evidence of invisible forces. A computational dance meticulously choreographed through force balance.
|
||||
|
||||
**"Stochastic Crystallization"**
|
||||
Philosophy: Random processes crystallizing into ordered structures.
|
||||
Algorithmic expression: Randomized circle packing or Voronoi tessellation. Start with random points, let them evolve through relaxation algorithms. Cells push apart until equilibrium. Color based on cell size, neighbor count, or distance from center. The organic tiling that emerges feels both random and inevitable. Every seed produces unique crystalline beauty - the mark of a master-level generative algorithm.
|
||||
|
||||
*These are condensed examples. The actual algorithmic philosophy should be 4-6 substantial paragraphs.*
|
||||
|
||||
### ESSENTIAL PRINCIPLES
|
||||
- **ALGORITHMIC PHILOSOPHY**: Creating a computational worldview to be expressed through code
|
||||
- **PROCESS OVER PRODUCT**: Always emphasize that beauty emerges from the algorithm's execution - each run is unique
|
||||
- **PARAMETRIC EXPRESSION**: Ideas communicate through mathematical relationships, forces, behaviors - not static composition
|
||||
- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy algorithmically - provide creative implementation room
|
||||
- **PURE GENERATIVE ART**: This is about making LIVING ALGORITHMS, not static images with randomness
|
||||
- **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final algorithm must feel meticulously crafted, refined through countless iterations, the product of deep expertise by someone at the absolute top of their field in computational aesthetics
|
||||
|
||||
**The algorithmic philosophy should be 4-6 paragraphs long.** Fill it with poetic computational philosophy that brings together the intended vision. Avoid repeating the same points. Output this algorithmic philosophy as a .md file.
|
||||
|
||||
---
|
||||
|
||||
## DEDUCING THE CONCEPTUAL SEED
|
||||
|
||||
**CRITICAL STEP**: Before implementing the algorithm, identify the subtle conceptual thread from the original request.
|
||||
|
||||
**THE ESSENTIAL PRINCIPLE**:
|
||||
The concept is a **subtle, niche reference embedded within the algorithm itself** - not always literal, always sophisticated. Someone familiar with the subject should feel it intuitively, while others simply experience a masterful generative composition. The algorithmic philosophy provides the computational language. The deduced concept provides the soul - the quiet conceptual DNA woven invisibly into parameters, behaviors, and emergence patterns.
|
||||
|
||||
This is **VERY IMPORTANT**: The reference must be so refined that it enhances the work's depth without announcing itself. Think like a jazz musician quoting another song through algorithmic harmony - only those who know will catch it, but everyone appreciates the generative beauty.
|
||||
|
||||
---
|
||||
|
||||
## P5.JS IMPLEMENTATION
|
||||
|
||||
With the philosophy AND conceptual framework established, express it through code. Pause to gather thoughts before proceeding. Use only the algorithmic philosophy created and the instructions below.
|
||||
|
||||
### ⚠️ STEP 0: READ THE TEMPLATE FIRST ⚠️
|
||||
|
||||
**CRITICAL: BEFORE writing any HTML:**
|
||||
|
||||
1. **Read** `templates/viewer.html` using the Read tool
|
||||
2. **Study** the exact structure, styling, and Anthropic branding
|
||||
3. **Use that file as the LITERAL STARTING POINT** - not just inspiration
|
||||
4. **Keep all FIXED sections exactly as shown** (header, sidebar structure, Anthropic colors/fonts, seed controls, action buttons)
|
||||
5. **Replace only the VARIABLE sections** marked in the file's comments (algorithm, parameters, UI controls for parameters)
|
||||
|
||||
**Avoid:**
|
||||
- ❌ Creating HTML from scratch
|
||||
- ❌ Inventing custom styling or color schemes
|
||||
- ❌ Using system fonts or dark themes
|
||||
- ❌ Changing the sidebar structure
|
||||
|
||||
**Follow these practices:**
|
||||
- ✅ Copy the template's exact HTML structure
|
||||
- ✅ Keep Anthropic branding (Poppins/Lora fonts, light colors, gradient backdrop)
|
||||
- ✅ Maintain the sidebar layout (Seed → Parameters → Colors? → Actions)
|
||||
- ✅ Replace only the p5.js algorithm and parameter controls
|
||||
|
||||
The template is the foundation. Build on it, don't rebuild it.
|
||||
|
||||
---
|
||||
|
||||
To create gallery-quality computational art that lives and breathes, use the algorithmic philosophy as the foundation.
|
||||
|
||||
### TECHNICAL REQUIREMENTS
|
||||
|
||||
**Seeded Randomness (Art Blocks Pattern)**:
|
||||
```javascript
|
||||
// ALWAYS use a seed for reproducibility
|
||||
let seed = 12345; // or hash from user input
|
||||
randomSeed(seed);
|
||||
noiseSeed(seed);
|
||||
```
|
||||
|
||||
**Parameter Structure - FOLLOW THE PHILOSOPHY**:
|
||||
|
||||
To establish parameters that emerge naturally from the algorithmic philosophy, consider: "What qualities of this system can be adjusted?"
|
||||
|
||||
```javascript
|
||||
let params = {
|
||||
seed: 12345, // Always include seed for reproducibility
|
||||
// colors
|
||||
// Add parameters that control YOUR algorithm:
|
||||
// - Quantities (how many?)
|
||||
// - Scales (how big? how fast?)
|
||||
// - Probabilities (how likely?)
|
||||
// - Ratios (what proportions?)
|
||||
// - Angles (what direction?)
|
||||
// - Thresholds (when does behavior change?)
|
||||
};
|
||||
```
|
||||
|
||||
**To design effective parameters, focus on the properties the system needs to be tunable rather than thinking in terms of "pattern types".**
|
||||
|
||||
**Core Algorithm - EXPRESS THE PHILOSOPHY**:
|
||||
|
||||
**CRITICAL**: The algorithmic philosophy should dictate what to build.
|
||||
|
||||
To express the philosophy through code, avoid thinking "which pattern should I use?" and instead think "how to express this philosophy through code?"
|
||||
|
||||
If the philosophy is about **organic emergence**, consider using:
|
||||
- Elements that accumulate or grow over time
|
||||
- Random processes constrained by natural rules
|
||||
- Feedback loops and interactions
|
||||
|
||||
If the philosophy is about **mathematical beauty**, consider using:
|
||||
- Geometric relationships and ratios
|
||||
- Trigonometric functions and harmonics
|
||||
- Precise calculations creating unexpected patterns
|
||||
|
||||
If the philosophy is about **controlled chaos**, consider using:
|
||||
- Random variation within strict boundaries
|
||||
- Bifurcation and phase transitions
|
||||
- Order emerging from disorder
|
||||
|
||||
**The algorithm flows from the philosophy, not from a menu of options.**
|
||||
|
||||
To guide the implementation, let the conceptual essence inform creative and original choices. Build something that expresses the vision for this particular request.
|
||||
|
||||
**Canvas Setup**: Standard p5.js structure:
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1200, 1200);
|
||||
// Initialize your system
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Your generative algorithm
|
||||
// Can be static (noLoop) or animated
|
||||
}
|
||||
```
|
||||
|
||||
### CRAFTSMANSHIP REQUIREMENTS
|
||||
|
||||
**CRITICAL**: To achieve mastery, create algorithms that feel like they emerged through countless iterations by a master generative artist. Tune every parameter carefully. Ensure every pattern emerges with purpose. This is NOT random noise - this is CONTROLLED CHAOS refined through deep expertise.
|
||||
|
||||
- **Balance**: Complexity without visual noise, order without rigidity
|
||||
- **Color Harmony**: Thoughtful palettes, not random RGB values
|
||||
- **Composition**: Even in randomness, maintain visual hierarchy and flow
|
||||
- **Performance**: Smooth execution, optimized for real-time if animated
|
||||
- **Reproducibility**: Same seed ALWAYS produces identical output
|
||||
|
||||
### OUTPUT FORMAT
|
||||
|
||||
Output:
|
||||
1. **Algorithmic Philosophy** - As markdown or text explaining the generative aesthetic
|
||||
2. **Single HTML Artifact** - Self-contained interactive generative art built from `templates/viewer.html` (see STEP 0 and next section)
|
||||
|
||||
The HTML artifact contains everything: p5.js (from CDN), the algorithm, parameter controls, and UI - all in one file that works immediately in claude.ai artifacts or any browser. Start from the template file, not from scratch.
|
||||
|
||||
---
|
||||
|
||||
## INTERACTIVE ARTIFACT CREATION
|
||||
|
||||
**REMINDER: `templates/viewer.html` should have already been read (see STEP 0). Use that file as the starting point.**
|
||||
|
||||
To allow exploration of the generative art, create a single, self-contained HTML artifact. Ensure this artifact works immediately in claude.ai or any browser - no setup required. Embed everything inline.
|
||||
|
||||
### CRITICAL: WHAT'S FIXED VS VARIABLE
|
||||
|
||||
The `templates/viewer.html` file is the foundation. It contains the exact structure and styling needed.
|
||||
|
||||
**FIXED (always include exactly as shown):**
|
||||
- Layout structure (header, sidebar, main canvas area)
|
||||
- Anthropic branding (UI colors, fonts, gradients)
|
||||
- Seed section in sidebar:
|
||||
- Seed display
|
||||
- Previous/Next buttons
|
||||
- Random button
|
||||
- Jump to seed input + Go button
|
||||
- Actions section in sidebar:
|
||||
- Regenerate button
|
||||
- Reset button
|
||||
|
||||
**VARIABLE (customize for each artwork):**
|
||||
- The entire p5.js algorithm (setup/draw/classes)
|
||||
- The parameters object (define what the art needs)
|
||||
- The Parameters section in sidebar:
|
||||
- Number of parameter controls
|
||||
- Parameter names
|
||||
- Min/max/step values for sliders
|
||||
- Control types (sliders, inputs, etc.)
|
||||
- Colors section (optional):
|
||||
- Some art needs color pickers
|
||||
- Some art might use fixed colors
|
||||
- Some art might be monochrome (no color controls needed)
|
||||
- Decide based on the art's needs
|
||||
|
||||
**Every artwork should have unique parameters and algorithm!** The fixed parts provide consistent UX - everything else expresses the unique vision.
|
||||
|
||||
### REQUIRED FEATURES
|
||||
|
||||
**1. Parameter Controls**
|
||||
- Sliders for numeric parameters (particle count, noise scale, speed, etc.)
|
||||
- Color pickers for palette colors
|
||||
- Real-time updates when parameters change
|
||||
- Reset button to restore defaults
|
||||
|
||||
**2. Seed Navigation**
|
||||
- Display current seed number
|
||||
- "Previous" and "Next" buttons to cycle through seeds
|
||||
- "Random" button for random seed
|
||||
- Input field to jump to specific seed
|
||||
- Generate 100 variations when requested (seeds 1-100)
|
||||
|
||||
**3. Single Artifact Structure**
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- p5.js from CDN - always available -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js"></script>
|
||||
<style>
|
||||
/* All styling inline - clean, minimal */
|
||||
/* Canvas on top, controls below */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="canvas-container"></div>
|
||||
<div id="controls">
|
||||
<!-- All parameter controls -->
|
||||
</div>
|
||||
<script>
|
||||
// ALL p5.js code inline here
|
||||
// Parameter objects, classes, functions
|
||||
// setup() and draw()
|
||||
// UI handlers
|
||||
// Everything self-contained
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**CRITICAL**: This is a single artifact. No external files, no imports (except p5.js CDN). Everything inline.
|
||||
|
||||
**4. Implementation Details - BUILD THE SIDEBAR**
|
||||
|
||||
The sidebar structure:
|
||||
|
||||
**1. Seed (FIXED)** - Always include exactly as shown:
|
||||
- Seed display
|
||||
- Prev/Next/Random/Jump buttons
|
||||
|
||||
**2. Parameters (VARIABLE)** - Create controls for the art:
|
||||
```html
|
||||
<div class="control-group">
|
||||
<label>Parameter Name</label>
|
||||
<input type="range" id="param" min="..." max="..." step="..." value="..." oninput="updateParam('param', this.value)">
|
||||
<span class="value-display" id="param-value">...</span>
|
||||
</div>
|
||||
```
|
||||
Add as many control-group divs as there are parameters.
|
||||
|
||||
**3. Colors (OPTIONAL/VARIABLE)** - Include if the art needs adjustable colors:
|
||||
- Add color pickers if users should control palette
|
||||
- Skip this section if the art uses fixed colors
|
||||
- Skip if the art is monochrome
|
||||
|
||||
**4. Actions (FIXED)** - Always include exactly as shown:
|
||||
- Regenerate button
|
||||
- Reset button
|
||||
- Download PNG button
|
||||
|
||||
**Requirements**:
|
||||
- Seed controls must work (prev/next/random/jump/display)
|
||||
- All parameters must have UI controls
|
||||
- Regenerate, Reset, Download buttons must work
|
||||
- Keep Anthropic branding (UI styling, not art colors)
|
||||
|
||||
### USING THE ARTIFACT
|
||||
|
||||
The HTML artifact works immediately:
|
||||
1. **In claude.ai**: Displayed as an interactive artifact - runs instantly
|
||||
2. **As a file**: Save and open in any browser - no server needed
|
||||
3. **Sharing**: Send the HTML file - it's completely self-contained
|
||||
|
||||
---
|
||||
|
||||
## VARIATIONS & EXPLORATION
|
||||
|
||||
The artifact includes seed navigation by default (prev/next/random buttons), allowing users to explore variations without creating multiple files. If the user wants specific variations highlighted:
|
||||
|
||||
- Include seed presets (buttons for "Variation 1: Seed 42", "Variation 2: Seed 127", etc.)
|
||||
- Add a "Gallery Mode" that shows thumbnails of multiple seeds side-by-side
|
||||
- All within the same single artifact
|
||||
|
||||
This is like creating a series of prints from the same plate - the algorithm is consistent, but each seed reveals different facets of its potential. The interactive nature means users discover their own favorites by exploring the seed space.
|
||||
|
||||
---
|
||||
|
||||
## THE CREATIVE PROCESS
|
||||
|
||||
**User request** → **Algorithmic philosophy** → **Implementation**
|
||||
|
||||
Each request is unique. The process involves:
|
||||
|
||||
1. **Interpret the user's intent** - What aesthetic is being sought?
|
||||
2. **Create an algorithmic philosophy** (4-6 paragraphs) describing the computational approach
|
||||
3. **Implement it in code** - Build the algorithm that expresses this philosophy
|
||||
4. **Design appropriate parameters** - What should be tunable?
|
||||
5. **Build matching UI controls** - Sliders/inputs for those parameters
|
||||
|
||||
**The constants**:
|
||||
- Anthropic branding (colors, fonts, layout)
|
||||
- Seed navigation (always present)
|
||||
- Self-contained HTML artifact
|
||||
|
||||
**Everything else is variable**:
|
||||
- The algorithm itself
|
||||
- The parameters
|
||||
- The UI controls
|
||||
- The visual outcome
|
||||
|
||||
To achieve the best results, trust creativity and let the philosophy guide the implementation.
|
||||
|
||||
---
|
||||
|
||||
## RESOURCES
|
||||
|
||||
This skill includes helpful templates and documentation:
|
||||
|
||||
- **templates/viewer.html**: REQUIRED STARTING POINT for all HTML artifacts.
|
||||
- This is the foundation - contains the exact structure and Anthropic branding
|
||||
- **Keep unchanged**: Layout structure, sidebar organization, Anthropic colors/fonts, seed controls, action buttons
|
||||
- **Replace**: The p5.js algorithm, parameter definitions, and UI controls in Parameters section
|
||||
- The extensive comments in the file mark exactly what to keep vs replace
|
||||
|
||||
- **templates/generator_template.js**: Reference for p5.js best practices and code structure principles.
|
||||
- Shows how to organize parameters, use seeded randomness, structure classes
|
||||
- NOT a pattern menu - use these principles to build unique algorithms
|
||||
- Embed algorithms inline in the HTML artifact (don't create separate .js files)
|
||||
|
||||
**Critical reminder**:
|
||||
- The **template is the STARTING POINT**, not inspiration
|
||||
- The **algorithm is where to create** something unique
|
||||
- Don't copy the flow field example - build what the philosophy demands
|
||||
- But DO keep the exact UI structure and Anthropic branding from the template
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* P5.JS GENERATIVE ART - BEST PRACTICES
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* This file shows STRUCTURE and PRINCIPLES for p5.js generative art.
|
||||
* It does NOT prescribe what art you should create.
|
||||
*
|
||||
* Your algorithmic philosophy should guide what you build.
|
||||
* These are just best practices for how to structure your code.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 1. PARAMETER ORGANIZATION
|
||||
// ============================================================================
|
||||
// Keep all tunable parameters in one object
|
||||
// This makes it easy to:
|
||||
// - Connect to UI controls
|
||||
// - Reset to defaults
|
||||
// - Serialize/save configurations
|
||||
|
||||
let params = {
|
||||
// Define parameters that match YOUR algorithm
|
||||
// Examples (customize for your art):
|
||||
// - Counts: how many elements (particles, circles, branches, etc.)
|
||||
// - Scales: size, speed, spacing
|
||||
// - Probabilities: likelihood of events
|
||||
// - Angles: rotation, direction
|
||||
// - Colors: palette arrays
|
||||
|
||||
seed: 12345,
|
||||
// define colorPalette as an array -- choose whatever colors you'd like ['#d97757', '#6a9bcc', '#788c5d', '#b0aea5']
|
||||
// Add YOUR parameters here based on your algorithm
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 2. SEEDED RANDOMNESS (Critical for reproducibility)
|
||||
// ============================================================================
|
||||
// ALWAYS use seeded random for Art Blocks-style reproducible output
|
||||
|
||||
function initializeSeed(seed) {
|
||||
randomSeed(seed);
|
||||
noiseSeed(seed);
|
||||
// Now all random() and noise() calls will be deterministic
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. P5.JS LIFECYCLE
|
||||
// ============================================================================
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800);
|
||||
|
||||
// Initialize seed first
|
||||
initializeSeed(params.seed);
|
||||
|
||||
// Set up your generative system
|
||||
// This is where you initialize:
|
||||
// - Arrays of objects
|
||||
// - Grid structures
|
||||
// - Initial positions
|
||||
// - Starting states
|
||||
|
||||
// For static art: call noLoop() at the end of setup
|
||||
// For animated art: let draw() keep running
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Option 1: Static generation (runs once, then stops)
|
||||
// - Generate everything in setup()
|
||||
// - Call noLoop() in setup()
|
||||
// - draw() doesn't do much or can be empty
|
||||
|
||||
// Option 2: Animated generation (continuous)
|
||||
// - Update your system each frame
|
||||
// - Common patterns: particle movement, growth, evolution
|
||||
// - Can optionally call noLoop() after N frames
|
||||
|
||||
// Option 3: User-triggered regeneration
|
||||
// - Use noLoop() by default
|
||||
// - Call redraw() when parameters change
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. CLASS STRUCTURE (When you need objects)
|
||||
// ============================================================================
|
||||
// Use classes when your algorithm involves multiple entities
|
||||
// Examples: particles, agents, cells, nodes, etc.
|
||||
|
||||
class Entity {
|
||||
constructor() {
|
||||
// Initialize entity properties
|
||||
// Use random() here - it will be seeded
|
||||
}
|
||||
|
||||
update() {
|
||||
// Update entity state
|
||||
// This might involve:
|
||||
// - Physics calculations
|
||||
// - Behavioral rules
|
||||
// - Interactions with neighbors
|
||||
}
|
||||
|
||||
display() {
|
||||
// Render the entity
|
||||
// Keep rendering logic separate from update logic
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. PERFORMANCE CONSIDERATIONS
|
||||
// ============================================================================
|
||||
|
||||
// For large numbers of elements:
|
||||
// - Pre-calculate what you can
|
||||
// - Use simple collision detection (spatial hashing if needed)
|
||||
// - Limit expensive operations (sqrt, trig) when possible
|
||||
// - Consider using p5 vectors efficiently
|
||||
|
||||
// For smooth animation:
|
||||
// - Aim for 60fps
|
||||
// - Profile if things are slow
|
||||
// - Consider reducing particle counts or simplifying calculations
|
||||
|
||||
// ============================================================================
|
||||
// 6. UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
// Color utilities
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function colorFromPalette(index) {
|
||||
return params.colorPalette[index % params.colorPalette.length];
|
||||
}
|
||||
|
||||
// Mapping and easing
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin));
|
||||
}
|
||||
|
||||
function easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
// Constrain to bounds
|
||||
function wrapAround(value, max) {
|
||||
if (value < 0) return max;
|
||||
if (value > max) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 7. PARAMETER UPDATES (Connect to UI)
|
||||
// ============================================================================
|
||||
|
||||
function updateParameter(paramName, value) {
|
||||
params[paramName] = value;
|
||||
// Decide if you need to regenerate or just update
|
||||
// Some params can update in real-time, others need full regeneration
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
// Reinitialize your generative system
|
||||
// Useful when parameters change significantly
|
||||
initializeSeed(params.seed);
|
||||
// Then regenerate your system
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 8. COMMON P5.JS PATTERNS
|
||||
// ============================================================================
|
||||
|
||||
// Drawing with transparency for trails/fading
|
||||
function fadeBackground(opacity) {
|
||||
fill(250, 249, 245, opacity); // Anthropic light with alpha
|
||||
noStroke();
|
||||
rect(0, 0, width, height);
|
||||
}
|
||||
|
||||
// Using noise for organic variation
|
||||
function getNoiseValue(x, y, scale = 0.01) {
|
||||
return noise(x * scale, y * scale);
|
||||
}
|
||||
|
||||
// Creating vectors from angles
|
||||
function vectorFromAngle(angle, magnitude = 1) {
|
||||
return createVector(cos(angle), sin(angle)).mult(magnitude);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 9. EXPORT FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function exportImage() {
|
||||
saveCanvas('generative-art-' + params.seed, 'png');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REMEMBER
|
||||
// ============================================================================
|
||||
//
|
||||
// These are TOOLS and PRINCIPLES, not a recipe.
|
||||
// Your algorithmic philosophy should guide WHAT you create.
|
||||
// This structure helps you create it WELL.
|
||||
//
|
||||
// Focus on:
|
||||
// - Clean, readable code
|
||||
// - Parameterized for exploration
|
||||
// - Seeded for reproducibility
|
||||
// - Performant execution
|
||||
//
|
||||
// The art itself is entirely up to you!
|
||||
//
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,599 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
THIS IS A TEMPLATE THAT SHOULD BE USED EVERY TIME AND MODIFIED.
|
||||
WHAT TO KEEP:
|
||||
✓ Overall structure (header, sidebar, main content)
|
||||
✓ Anthropic branding (colors, fonts, layout)
|
||||
✓ Seed navigation section (always include this)
|
||||
✓ Self-contained artifact (everything inline)
|
||||
|
||||
WHAT TO CREATIVELY EDIT:
|
||||
✗ The p5.js algorithm (implement YOUR vision)
|
||||
✗ The parameters (define what YOUR art needs)
|
||||
✗ The UI controls (match YOUR parameters)
|
||||
|
||||
Let your philosophy guide the implementation.
|
||||
The world is your oyster - be creative!
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Generative Art Viewer</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Anthropic Brand Colors */
|
||||
:root {
|
||||
--anthropic-dark: #141413;
|
||||
--anthropic-light: #faf9f5;
|
||||
--anthropic-mid-gray: #b0aea5;
|
||||
--anthropic-light-gray: #e8e6dc;
|
||||
--anthropic-orange: #d97757;
|
||||
--anthropic-blue: #6a9bcc;
|
||||
--anthropic-green: #788c5d;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
background: linear-gradient(135deg, var(--anthropic-light) 0%, #f5f3ee 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--anthropic-dark);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(20, 20, 19, 0.1);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
font-family: 'Lora', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--anthropic-dark);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar .subtitle {
|
||||
color: var(--anthropic-mid-gray);
|
||||
font-size: 14px;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Control Sections */
|
||||
.control-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.control-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--anthropic-dark);
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-section h3::before {
|
||||
content: '•';
|
||||
color: var(--anthropic-orange);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Seed Controls */
|
||||
.seed-input {
|
||||
width: 100%;
|
||||
background: var(--anthropic-light);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--anthropic-light-gray);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.seed-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--anthropic-orange);
|
||||
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.seed-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.regen-button {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Parameter Controls */
|
||||
.control-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--anthropic-dark);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider-container input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--anthropic-light-gray);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.slider-container input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--anthropic-orange);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider-container input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
background: #c86641;
|
||||
}
|
||||
|
||||
.slider-container input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--anthropic-orange);
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.value-display {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--anthropic-mid-gray);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Color Pickers */
|
||||
.color-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.color-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--anthropic-mid-gray);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.color-picker-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-picker-container input[type="color"] {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--anthropic-mid-gray);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button {
|
||||
background: var(--anthropic-orange);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #c86641;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: var(--anthropic-blue);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: #5a8bb8;
|
||||
}
|
||||
|
||||
.button.tertiary {
|
||||
background: var(--anthropic-green);
|
||||
}
|
||||
|
||||
.button.tertiary:hover {
|
||||
background: #6b7b52;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-row .button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Canvas Area */
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#canvas-container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(20, 20, 19, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
#canvas-container canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: var(--anthropic-mid-gray);
|
||||
}
|
||||
|
||||
/* Responsive - Stack on mobile */
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Control Sidebar -->
|
||||
<div class="sidebar">
|
||||
<!-- Headers (CUSTOMIZE THIS FOR YOUR ART) -->
|
||||
<h1>TITLE - EDIT</h1>
|
||||
<div class="subtitle">SUBHEADER - EDIT</div>
|
||||
|
||||
<!-- Seed Section (ALWAYS KEEP THIS) -->
|
||||
<div class="control-section">
|
||||
<h3>Seed</h3>
|
||||
<input type="number" id="seed-input" class="seed-input" value="12345" onchange="updateSeed()">
|
||||
<div class="seed-controls">
|
||||
<button class="button secondary" onclick="previousSeed()">← Prev</button>
|
||||
<button class="button secondary" onclick="nextSeed()">Next →</button>
|
||||
</div>
|
||||
<button class="button tertiary regen-button" onclick="randomSeedAndUpdate()">↻ Random</button>
|
||||
</div>
|
||||
|
||||
<!-- Parameters Section (CUSTOMIZE THIS FOR YOUR ART) -->
|
||||
<div class="control-section">
|
||||
<h3>Parameters</h3>
|
||||
|
||||
<!-- Particle Count -->
|
||||
<div class="control-group">
|
||||
<label>Particle Count</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="particleCount" min="1000" max="10000" step="500" value="5000" oninput="updateParam('particleCount', this.value)">
|
||||
<span class="value-display" id="particleCount-value">5000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flow Speed -->
|
||||
<div class="control-group">
|
||||
<label>Flow Speed</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="flowSpeed" min="0.1" max="2.0" step="0.1" value="0.5" oninput="updateParam('flowSpeed', this.value)">
|
||||
<span class="value-display" id="flowSpeed-value">0.5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Noise Scale -->
|
||||
<div class="control-group">
|
||||
<label>Noise Scale</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="noiseScale" min="0.001" max="0.02" step="0.001" value="0.005" oninput="updateParam('noiseScale', this.value)">
|
||||
<span class="value-display" id="noiseScale-value">0.005</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trail Length -->
|
||||
<div class="control-group">
|
||||
<label>Trail Length</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="trailLength" min="2" max="20" step="1" value="8" oninput="updateParam('trailLength', this.value)">
|
||||
<span class="value-display" id="trailLength-value">8</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colors Section (OPTIONAL - CUSTOMIZE OR REMOVE) -->
|
||||
<div class="control-section">
|
||||
<h3>Colors</h3>
|
||||
|
||||
<!-- Color 1 -->
|
||||
<div class="color-group">
|
||||
<label>Primary Color</label>
|
||||
<div class="color-picker-container">
|
||||
<input type="color" id="color1" value="#d97757" onchange="updateColor('color1', this.value)">
|
||||
<span class="color-value" id="color1-value">#d97757</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color 2 -->
|
||||
<div class="color-group">
|
||||
<label>Secondary Color</label>
|
||||
<div class="color-picker-container">
|
||||
<input type="color" id="color2" value="#6a9bcc" onchange="updateColor('color2', this.value)">
|
||||
<span class="color-value" id="color2-value">#6a9bcc</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color 3 -->
|
||||
<div class="color-group">
|
||||
<label>Accent Color</label>
|
||||
<div class="color-picker-container">
|
||||
<input type="color" id="color3" value="#788c5d" onchange="updateColor('color3', this.value)">
|
||||
<span class="color-value" id="color3-value">#788c5d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section (ALWAYS KEEP THIS) -->
|
||||
<div class="control-section">
|
||||
<h3>Actions</h3>
|
||||
<div class="button-row">
|
||||
<button class="button" onclick="resetParameters()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Canvas Area -->
|
||||
<div class="canvas-area">
|
||||
<div id="canvas-container">
|
||||
<div class="loading">Initializing generative art...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// GENERATIVE ART PARAMETERS - CUSTOMIZE FOR YOUR ALGORITHM
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
let params = {
|
||||
seed: 12345,
|
||||
particleCount: 5000,
|
||||
flowSpeed: 0.5,
|
||||
noiseScale: 0.005,
|
||||
trailLength: 8,
|
||||
colorPalette: ['#d97757', '#6a9bcc', '#788c5d']
|
||||
};
|
||||
|
||||
let defaultParams = {...params}; // Store defaults for reset
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// P5.JS GENERATIVE ART ALGORITHM - REPLACE WITH YOUR VISION
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
let particles = [];
|
||||
let flowField = [];
|
||||
let cols, rows;
|
||||
let scl = 10; // Flow field resolution
|
||||
|
||||
function setup() {
|
||||
let canvas = createCanvas(1200, 1200);
|
||||
canvas.parent('canvas-container');
|
||||
|
||||
initializeSystem();
|
||||
|
||||
// Remove loading message
|
||||
document.querySelector('.loading').style.display = 'none';
|
||||
}
|
||||
|
||||
function initializeSystem() {
|
||||
// Seed the randomness for reproducibility
|
||||
randomSeed(params.seed);
|
||||
noiseSeed(params.seed);
|
||||
|
||||
// Clear particles and recreate
|
||||
particles = [];
|
||||
|
||||
// Initialize particles
|
||||
for (let i = 0; i < params.particleCount; i++) {
|
||||
particles.push(new Particle());
|
||||
}
|
||||
|
||||
// Calculate flow field dimensions
|
||||
cols = floor(width / scl);
|
||||
rows = floor(height / scl);
|
||||
|
||||
// Generate flow field
|
||||
generateFlowField();
|
||||
|
||||
// Clear background
|
||||
background(250, 249, 245); // Anthropic light background
|
||||
}
|
||||
|
||||
function generateFlowField() {
|
||||
// fill this in
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// fill this in
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PARTICLE SYSTEM - CUSTOMIZE FOR YOUR ALGORITHM
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class Particle {
|
||||
constructor() {
|
||||
// fill this in
|
||||
}
|
||||
// fill this in
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UI CONTROL HANDLERS - CUSTOMIZE FOR YOUR PARAMETERS
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function updateParam(paramName, value) {
|
||||
// fill this in
|
||||
}
|
||||
|
||||
function updateColor(colorId, value) {
|
||||
// fill this in
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SEED CONTROL FUNCTIONS - ALWAYS KEEP THESE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function updateSeedDisplay() {
|
||||
document.getElementById('seed-input').value = params.seed;
|
||||
}
|
||||
|
||||
function updateSeed() {
|
||||
let input = document.getElementById('seed-input');
|
||||
let newSeed = parseInt(input.value);
|
||||
if (newSeed && newSeed > 0) {
|
||||
params.seed = newSeed;
|
||||
initializeSystem();
|
||||
} else {
|
||||
// Reset to current seed if invalid
|
||||
updateSeedDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function previousSeed() {
|
||||
params.seed = Math.max(1, params.seed - 1);
|
||||
updateSeedDisplay();
|
||||
initializeSystem();
|
||||
}
|
||||
|
||||
function nextSeed() {
|
||||
params.seed = params.seed + 1;
|
||||
updateSeedDisplay();
|
||||
initializeSystem();
|
||||
}
|
||||
|
||||
function randomSeedAndUpdate() {
|
||||
params.seed = Math.floor(Math.random() * 999999) + 1;
|
||||
updateSeedDisplay();
|
||||
initializeSystem();
|
||||
}
|
||||
|
||||
function resetParameters() {
|
||||
params = {...defaultParams};
|
||||
|
||||
// Update UI elements
|
||||
document.getElementById('particleCount').value = params.particleCount;
|
||||
document.getElementById('particleCount-value').textContent = params.particleCount;
|
||||
document.getElementById('flowSpeed').value = params.flowSpeed;
|
||||
document.getElementById('flowSpeed-value').textContent = params.flowSpeed;
|
||||
document.getElementById('noiseScale').value = params.noiseScale;
|
||||
document.getElementById('noiseScale-value').textContent = params.noiseScale;
|
||||
document.getElementById('trailLength').value = params.trailLength;
|
||||
document.getElementById('trailLength-value').textContent = params.trailLength;
|
||||
|
||||
// Reset colors
|
||||
document.getElementById('color1').value = params.colorPalette[0];
|
||||
document.getElementById('color1-value').textContent = params.colorPalette[0];
|
||||
document.getElementById('color2').value = params.colorPalette[1];
|
||||
document.getElementById('color2-value').textContent = params.colorPalette[1];
|
||||
document.getElementById('color3').value = params.colorPalette[2];
|
||||
document.getElementById('color3-value').textContent = params.colorPalette[2];
|
||||
|
||||
updateSeedDisplay();
|
||||
initializeSystem();
|
||||
}
|
||||
|
||||
// Initialize UI on load
|
||||
window.addEventListener('load', function() {
|
||||
updateSeedDisplay();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: openakita/skills@code-reviewer
|
||||
description:
|
||||
Use this skill to review code. It supports both local changes (staged or working tree)
|
||||
and remote Pull Requests (by ID or URL). It focuses on correctness, maintainability,
|
||||
and adherence to project standards.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
This skill guides the agent in conducting professional and thorough code reviews for both local development and remote Pull Requests.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine Review Target
|
||||
* **Remote PR**: If the user provides a PR number or URL (e.g., "Review PR #123"), target that remote PR.
|
||||
* **Local Changes**: If no specific PR is mentioned, or if the user asks to "review my changes", target the current local file system states (staged and unstaged changes).
|
||||
|
||||
### 2. Preparation
|
||||
|
||||
#### For Remote PRs:
|
||||
1. **Checkout**: Use the GitHub CLI to checkout the PR.
|
||||
```bash
|
||||
gh pr checkout <PR_NUMBER>
|
||||
```
|
||||
2. **Preflight**: Execute the project's standard verification suite to catch automated failures early.
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
3. **Context**: Read the PR description and any existing comments to understand the goal and history.
|
||||
|
||||
#### For Local Changes:
|
||||
1. **Identify Changes**:
|
||||
* Check status: `git status`
|
||||
* Read diffs: `git diff` (working tree) and/or `git diff --staged` (staged).
|
||||
2. **Preflight (Optional)**: If the changes are substantial, ask the user if they want to run `npm run preflight` before reviewing.
|
||||
|
||||
### 3. In-Depth Analysis
|
||||
Analyze the code changes based on the following pillars:
|
||||
|
||||
* **Correctness**: Does the code achieve its stated purpose without bugs or logical errors?
|
||||
* **Maintainability**: Is the code clean, well-structured, and easy to understand and modify in the future? Consider factors like code clarity, modularity, and adherence to established design patterns.
|
||||
* **Readability**: Is the code well-commented (where necessary) and consistently formatted according to our project's coding style guidelines?
|
||||
* **Efficiency**: Are there any obvious performance bottlenecks or resource inefficiencies introduced by the changes?
|
||||
* **Security**: Are there any potential security vulnerabilities or insecure coding practices?
|
||||
* **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors?
|
||||
* **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness.
|
||||
|
||||
### 4. Provide Feedback
|
||||
|
||||
#### Structure
|
||||
* **Summary**: A high-level overview of the review.
|
||||
* **Findings**:
|
||||
* **Critical**: Bugs, security issues, or breaking changes.
|
||||
* **Improvements**: Suggestions for better code quality or performance.
|
||||
* **Nitpicks**: Formatting or minor style issues (optional).
|
||||
* **Conclusion**: Clear recommendation (Approved / Request Changes).
|
||||
|
||||
#### Tone
|
||||
* Be constructive, professional, and friendly.
|
||||
* Explain *why* a change is requested.
|
||||
* For approvals, acknowledge the specific value of the contribution.
|
||||
|
||||
### 5. Cleanup (Remote PRs only)
|
||||
* After the review, ask the user if they want to switch back to the default branch (e.g., `main` or `master`).
|
||||
202
core/agents/tools.py
Normal file
202
core/agents/tools.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Tool system for agent capabilities."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
"""Abstract base class for agent tools."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Tool name used in function calls."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Description of what the tool does."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
"""JSON Schema for tool parameters."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
"""Execute the tool with given parameters.
|
||||
|
||||
Returns:
|
||||
String result of the tool execution.
|
||||
"""
|
||||
pass
|
||||
|
||||
def to_schema(self) -> dict[str, Any]:
|
||||
"""Convert tool to function schema format."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Registry for managing agent tools."""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: dict[str, Tool] = {}
|
||||
|
||||
def register(self, tool: Tool) -> None:
|
||||
"""Register a tool.
|
||||
|
||||
Args:
|
||||
tool: Tool instance to register
|
||||
"""
|
||||
self._tools[tool.name] = tool
|
||||
logger.info(f"Registered tool: {tool.name}")
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Unregister a tool.
|
||||
|
||||
Args:
|
||||
name: Tool name to unregister
|
||||
"""
|
||||
if name in self._tools:
|
||||
del self._tools[name]
|
||||
logger.info(f"Unregistered tool: {name}")
|
||||
|
||||
def get(self, name: str) -> Tool | None:
|
||||
"""Get a tool by name.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Tool instance or None
|
||||
"""
|
||||
return self._tools.get(name)
|
||||
|
||||
def get_definitions(self) -> list[dict[str, Any]]:
|
||||
"""Get all tool definitions for LLM.
|
||||
|
||||
Returns:
|
||||
List of tool schemas
|
||||
"""
|
||||
return [tool.to_schema() for tool in self._tools.values()]
|
||||
|
||||
async def execute(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
"""Execute a tool.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
arguments: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
"""
|
||||
tool = self.get(name)
|
||||
if not tool:
|
||||
return f'{{"error": "Unknown tool: {name}"}}'
|
||||
|
||||
try:
|
||||
# Validate parameters
|
||||
validated = tool.cast_params(arguments)
|
||||
errors = tool.validate_params(validated)
|
||||
if errors:
|
||||
return f'{{"error": "Parameter validation failed: {errors}"}}'
|
||||
|
||||
# Execute with timeout
|
||||
result = await asyncio.wait_for(
|
||||
tool.execute(**validated),
|
||||
timeout=60.0,
|
||||
)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return f'{{"error": "Tool execution timed out: {name}"}}'
|
||||
except Exception as exc:
|
||||
logger.exception(f"Tool execution error: {name}")
|
||||
return f'{{"error": "Tool execution failed: {exc}"}}'
|
||||
|
||||
def list_tools(self) -> list[str]:
|
||||
"""List all registered tool names.
|
||||
|
||||
Returns:
|
||||
List of tool names
|
||||
"""
|
||||
return list(self._tools.keys())
|
||||
|
||||
|
||||
# Built-in placeholder tools
|
||||
class EchoTool(Tool):
|
||||
"""Echo tool for testing."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "echo"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Echo back the input text. Useful for testing."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to echo back",
|
||||
}
|
||||
},
|
||||
"required": ["text"],
|
||||
}
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
text = kwargs.get("text", "")
|
||||
return f'{{"echo": "{text}"}}'
|
||||
|
||||
|
||||
class TimeTool(Tool):
|
||||
"""Get current time tool."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "get_time"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Get the current date and time."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return f'{{"time": "{now.isoformat()}"}}'
|
||||
|
||||
|
||||
def create_default_registry() -> ToolRegistry:
|
||||
"""Create a tool registry with default tools.
|
||||
|
||||
Returns:
|
||||
Tool registry with built-in tools
|
||||
"""
|
||||
registry = ToolRegistry()
|
||||
registry.register(EchoTool())
|
||||
registry.register(TimeTool())
|
||||
return registry
|
||||
51
core/agents/tools/__init__.py
Normal file
51
core/agents/tools/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Tools module for X-Agents.
|
||||
|
||||
This module provides tool infrastructure for the agent system.
|
||||
It wraps and extends the nanobot tool implementation.
|
||||
"""
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
from agents.tools.builtin import (
|
||||
get_builtin_tools,
|
||||
ReadFileTool,
|
||||
WriteFileTool,
|
||||
ListDirectoryTool,
|
||||
SearchTool,
|
||||
WebSearchTool,
|
||||
CalculatorTool,
|
||||
GetTimeTool,
|
||||
BashTool,
|
||||
)
|
||||
from agents.tools.manager import ToolManager
|
||||
|
||||
|
||||
def create_default_registry() -> ToolRegistry:
|
||||
"""Create a tool registry with default tools.
|
||||
|
||||
Returns:
|
||||
Tool registry with built-in tools
|
||||
"""
|
||||
registry = ToolRegistry()
|
||||
# Register built-in tools
|
||||
for tool in get_builtin_tools():
|
||||
registry.register(tool)
|
||||
return registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Tool",
|
||||
"ToolRegistry",
|
||||
"ToolManager",
|
||||
"create_default_registry",
|
||||
"get_builtin_tools",
|
||||
"ReadFileTool",
|
||||
"WriteFileTool",
|
||||
"ListDirectoryTool",
|
||||
"SearchTool",
|
||||
"WebSearchTool",
|
||||
"CalculatorTool",
|
||||
"GetTimeTool",
|
||||
"BashTool",
|
||||
]
|
||||
431
core/agents/tools/builtin.py
Normal file
431
core/agents/tools/builtin.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""Built-in tools for X-Agents."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
|
||||
class ReadFileTool(Tool):
|
||||
"""Read file contents."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "read_file"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Read the contents of a file from the local filesystem."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "The file path to read"},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Line number to start reading from (1-indexed)",
|
||||
"default": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to read",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
}
|
||||
|
||||
async def execute(self, path: str, offset: int = 1, limit: int = 100, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = Path(path)
|
||||
if not file_path.is_absolute() and self._workspace:
|
||||
file_path = self._workspace / file_path
|
||||
|
||||
if not file_path.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
|
||||
if not file_path.is_file():
|
||||
return f"Error: Not a file: {path}"
|
||||
|
||||
lines = file_path.read_text(encoding="utf-8").split("\n")
|
||||
start = max(0, offset - 1)
|
||||
end = min(len(lines), start + limit)
|
||||
|
||||
result_lines = [f"{i+1:4d}| {line}" for i, line in enumerate(lines[start:end], start=start+1)]
|
||||
return f"File: {file_path}\nLines {start+1}-{end}/{len(lines)}\n\n" + "\n".join(result_lines)
|
||||
except Exception as e:
|
||||
return f"Error reading file: {str(e)}"
|
||||
|
||||
|
||||
class WriteFileTool(Tool):
|
||||
"""Write content to a file."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "write_file"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Write content to a file. Creates the file if it doesn't exist."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "The file path to write to"},
|
||||
"content": {"type": "string", "description": "Content to write to the file"},
|
||||
"append": {
|
||||
"type": "boolean",
|
||||
"description": "Append to existing file instead of overwriting",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
}
|
||||
|
||||
async def execute(self, path: str, content: str, append: bool = False, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = Path(path)
|
||||
if not file_path.is_absolute() and self._workspace:
|
||||
file_path = self._workspace / file_path
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mode = "a" if append else "w"
|
||||
with open(file_path, mode, encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return f"Successfully wrote to {file_path}"
|
||||
except Exception as e:
|
||||
return f"Error writing file: {str(e)}"
|
||||
|
||||
|
||||
class ListDirectoryTool(Tool):
|
||||
"""List directory contents."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "list_directory"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "List files and directories in a given path."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to list",
|
||||
"default": ".",
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"description": "List recursively",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def execute(self, path: str = ".", recursive: bool = False, **kwargs: Any) -> str:
|
||||
try:
|
||||
dir_path = Path(path)
|
||||
if not dir_path.is_absolute() and self._workspace:
|
||||
dir_path = self._workspace / dir_path
|
||||
|
||||
if not dir_path.exists():
|
||||
return f"Error: Path not found: {path}"
|
||||
|
||||
if not dir_path.is_dir():
|
||||
return f"Error: Not a directory: {path}"
|
||||
|
||||
if recursive:
|
||||
items = []
|
||||
for item in dir_path.rglob("*"):
|
||||
rel = item.relative_to(dir_path)
|
||||
prefix = "[D]" if item.is_dir() else "[F]"
|
||||
items.append(f"{prefix} {rel}")
|
||||
return "\n".join(sorted(items)) or "(empty)"
|
||||
else:
|
||||
items = []
|
||||
for item in dir_path.iterdir():
|
||||
prefix = "[D]" if item.is_dir() else "[F]"
|
||||
items.append(f"{prefix} {item.name}")
|
||||
return "\n".join(sorted(items)) or "(empty)"
|
||||
except Exception as e:
|
||||
return f"Error listing directory: {str(e)}"
|
||||
|
||||
|
||||
class SearchTool(Tool):
|
||||
"""Search for text in files."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "search"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Search for text patterns in files using regex."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path to search in",
|
||||
"default": ".",
|
||||
},
|
||||
"file_pattern": {
|
||||
"type": "string",
|
||||
"description": "File glob pattern (e.g., *.py)",
|
||||
"default": "*",
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"description": "Case sensitive search",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
file_pattern: str = "*",
|
||||
case_sensitive: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
try:
|
||||
search_path = Path(path)
|
||||
if not search_path.is_absolute() and self._workspace:
|
||||
search_path = self._workspace / search_path
|
||||
|
||||
if not search_path.exists():
|
||||
return f"Error: Path not found: {path}"
|
||||
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
regex = re.compile(pattern, flags)
|
||||
|
||||
results = []
|
||||
for file_path in search_path.rglob(file_pattern):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(content.split("\n"), 1):
|
||||
if regex.search(line):
|
||||
results.append(f"{file_path}:{i}: {line.strip()[:100]}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
return f"No matches found for: {pattern}"
|
||||
|
||||
return f"Found {len(results)} matches:\n" + "\n".join(results[:50])
|
||||
except Exception as e:
|
||||
return f"Error searching: {str(e)}"
|
||||
|
||||
|
||||
class WebSearchTool(Tool):
|
||||
"""Search the web for information."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "web_search"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Search the web for information using a search engine."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results",
|
||||
"default": 5,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
async def execute(self, query: str, max_results: int = 5, **kwargs: Any) -> str:
|
||||
# Placeholder for web search implementation
|
||||
# In production, this would use a search API (e.g., Google, Bing, SerpAPI)
|
||||
return f"Web search not implemented yet. Query: {query}"
|
||||
|
||||
|
||||
class CalculatorTool(Tool):
|
||||
"""Simple calculator tool."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "calculator"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Evaluate a mathematical expression."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string", "description": "Mathematical expression to evaluate"},
|
||||
},
|
||||
"required": ["expression"],
|
||||
}
|
||||
|
||||
async def execute(self, expression: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
# Safe evaluation - only allow basic math operators
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
|
||||
result = eval(expression) # Note: In production, use a safer parser
|
||||
return f"{expression} = {result}"
|
||||
except Exception as e:
|
||||
return f"Error evaluating expression: {str(e)}"
|
||||
|
||||
|
||||
class GetTimeTool(Tool):
|
||||
"""Get current time."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "get_time"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Get the current date and time."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Timezone (e.g., UTC, Asia/Shanghai)",
|
||||
"default": "UTC",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def execute(self, timezone: str = "UTC", **kwargs: Any) -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
if timezone.upper() != "UTC":
|
||||
# For non-UTC timezones, return simple result
|
||||
return f"Timezone '{timezone}' not supported. Current UTC time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
class BashTool(Tool):
|
||||
"""Execute bash commands."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "bash"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute a bash command and return its output."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Command to execute"},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds",
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
}
|
||||
|
||||
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str:
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
result = []
|
||||
if stdout:
|
||||
result.append(stdout.decode("utf-8"))
|
||||
if stderr:
|
||||
result.append(f"STDERR: {stderr.decode('utf-8')}")
|
||||
return "\n".join(result) or "Command completed with no output"
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return f"Error: Command timed out after {timeout} seconds"
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
|
||||
|
||||
def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
|
||||
"""Get list of all built-in tools.
|
||||
|
||||
Args:
|
||||
workspace: Optional workspace path for file operations
|
||||
|
||||
Returns:
|
||||
List of Tool instances
|
||||
"""
|
||||
return [
|
||||
ReadFileTool(workspace),
|
||||
WriteFileTool(workspace),
|
||||
ListDirectoryTool(workspace),
|
||||
SearchTool(workspace),
|
||||
WebSearchTool(),
|
||||
CalculatorTool(),
|
||||
GetTimeTool(),
|
||||
BashTool(workspace),
|
||||
]
|
||||
108
core/agents/tools/manager.py
Normal file
108
core/agents/tools/manager.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tool manager for loading and managing tools."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
from agents.tools.builtin import get_builtin_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""Manages tools for the agent."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None):
|
||||
"""Initialize tool manager.
|
||||
|
||||
Args:
|
||||
workspace: Optional workspace path
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.registry = ToolRegistry()
|
||||
self._load_builtin_tools()
|
||||
|
||||
def _load_builtin_tools(self) -> None:
|
||||
"""Load all built-in tools."""
|
||||
tools = get_builtin_tools(self.workspace)
|
||||
for tool in tools:
|
||||
self.registry.register(tool)
|
||||
logger.info(f"Loaded {len(tools)} built-in tools")
|
||||
|
||||
def register_tool(self, tool: Any) -> None:
|
||||
"""Register a custom tool.
|
||||
|
||||
Args:
|
||||
tool: Tool instance to register
|
||||
"""
|
||||
self.registry.register(tool)
|
||||
logger.info(f"Registered tool: {tool.name}")
|
||||
|
||||
def unregister_tool(self, name: str) -> None:
|
||||
"""Unregister a tool.
|
||||
|
||||
Args:
|
||||
name: Tool name to unregister
|
||||
"""
|
||||
self.registry.unregister(name)
|
||||
logger.info(f"Unregistered tool: {name}")
|
||||
|
||||
def get_tool(self, name: str) -> Any:
|
||||
"""Get a tool by name.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Tool instance or None
|
||||
"""
|
||||
return self.registry.get(name)
|
||||
|
||||
def has_tool(self, name: str) -> bool:
|
||||
"""Check if a tool is registered.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
True if tool exists
|
||||
"""
|
||||
return self.registry.has(name)
|
||||
|
||||
def list_tools(self) -> list[str]:
|
||||
"""List all registered tool names.
|
||||
|
||||
Returns:
|
||||
List of tool names
|
||||
"""
|
||||
return self.registry.tool_names
|
||||
|
||||
def get_tool_definitions(self) -> list[dict[str, Any]]:
|
||||
"""Get all tool definitions in OpenAI format.
|
||||
|
||||
Returns:
|
||||
List of tool schemas
|
||||
"""
|
||||
return self.registry.get_definitions()
|
||||
|
||||
async def execute_tool(self, name: str, params: dict[str, Any]) -> str:
|
||||
"""Execute a tool by name.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
"""
|
||||
return await self.registry.execute(name, params)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get number of registered tools."""
|
||||
return len(self.registry)
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
"""Check if tool is registered."""
|
||||
return name in self.registry
|
||||
107
core/agents/tools/sync.py
Normal file
107
core/agents/tools/sync.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tool synchronization between Python Agent and Go backend."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolSyncClient:
|
||||
"""Client for syncing tools to Go backend."""
|
||||
|
||||
def __init__(self, base_url: str, agent_id: str = "default"):
|
||||
"""Initialize tool sync client.
|
||||
|
||||
Args:
|
||||
base_url: Go backend base URL
|
||||
agent_id: Agent ID
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.agent_id = agent_id
|
||||
self._session = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def sync_tools(
|
||||
self,
|
||||
tools: list[dict[str, Any]],
|
||||
) -> tuple[int, str]:
|
||||
"""Sync tools to Go backend.
|
||||
|
||||
Args:
|
||||
tools: List of tool definitions
|
||||
|
||||
Returns:
|
||||
Tuple of (synced_count, message)
|
||||
"""
|
||||
url = f"{self.base_url}/tool/sync-from-python"
|
||||
|
||||
# Transform tools to match Go backend format
|
||||
python_tools = []
|
||||
for tool in tools:
|
||||
func = tool.get("function", {})
|
||||
python_tools.append({
|
||||
"name": func.get("name"),
|
||||
"description": func.get("description"),
|
||||
"parameters": func.get("parameters", "{}"),
|
||||
"category": "python", # Default category for Python tools
|
||||
})
|
||||
|
||||
payload = {"tools": python_tools}
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
count = result.get("synced_count", 0)
|
||||
return count, f"Synced {count} tools successfully"
|
||||
else:
|
||||
text = await response.text()
|
||||
return 0, f"Failed to sync tools: {response.status} - {text}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing tools: {e}")
|
||||
return 0, f"Error syncing tools: {e}"
|
||||
|
||||
|
||||
async def sync_registry_tools(
|
||||
registry,
|
||||
base_url: str,
|
||||
agent_id: str = "default",
|
||||
) -> tuple[int, str]:
|
||||
"""Sync tools from a ToolRegistry to Go backend.
|
||||
|
||||
Args:
|
||||
registry: ToolRegistry instance
|
||||
base_url: Go backend base URL
|
||||
agent_id: Agent ID
|
||||
|
||||
Returns:
|
||||
Tuple of (synced_count, message)
|
||||
"""
|
||||
client = ToolSyncClient(base_url, agent_id)
|
||||
|
||||
try:
|
||||
# Get all tool definitions
|
||||
tools = registry.get_definitions()
|
||||
|
||||
if not tools:
|
||||
return 0, "No tools to sync"
|
||||
|
||||
# Sync tools
|
||||
count, message = await client.sync_tools(tools)
|
||||
return count, message
|
||||
finally:
|
||||
await client.close()
|
||||
Reference in New Issue
Block a user