ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Conversational Agent - Focuses purely on conversation without direct memory management
|
||||
Memory updates are handled by a separate background process
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from openai import OpenAI
|
||||
from config import Config, openrouter_model_id, PROVIDER_DEFAULT_MODELS
|
||||
from conversation_history import ConversationHistory, ConversationTurn
|
||||
from memory_manager import create_memory_manager, BaseMemoryManager, MemoryMode
|
||||
|
||||
|
||||
def _reasoning_safe_temperature(model, requested=1.0):
|
||||
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
|
||||
Return 1 for those; otherwise the requested value so non-reasoning
|
||||
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
|
||||
m = str(model or "").lower().replace("/", "-")
|
||||
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationConfig:
|
||||
"""Configuration for the conversational agent"""
|
||||
enable_memory_context: bool = True # Include memory in context but don't update
|
||||
enable_conversation_history: bool = True
|
||||
max_memory_context: int = 10
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
|
||||
|
||||
class ConversationalAgent:
|
||||
"""
|
||||
Pure conversational agent that focuses on dialogue
|
||||
Reads memory for context but doesn't update it directly
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
user_id: str,
|
||||
api_key: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
config: Optional[ConversationConfig] = None,
|
||||
memory_mode: MemoryMode = MemoryMode.NOTES,
|
||||
verbose: bool = True):
|
||||
"""
|
||||
Initialize the conversational agent
|
||||
|
||||
Args:
|
||||
user_id: Unique user identifier
|
||||
api_key: API key (defaults to env based on provider)
|
||||
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
|
||||
model: Model name (defaults to provider's default)
|
||||
config: Agent configuration
|
||||
memory_mode: Memory storage mode
|
||||
verbose: Enable verbose logging
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.verbose = verbose
|
||||
self.config = config or ConversationConfig()
|
||||
self.memory_mode = memory_mode
|
||||
|
||||
# Determine provider
|
||||
self.provider = (provider or Config.PROVIDER).lower()
|
||||
self.provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(
|
||||
self.provider, self.provider
|
||||
)
|
||||
|
||||
# Get API key for provider
|
||||
api_key = api_key or Config.get_api_key(self.provider)
|
||||
|
||||
# Universal OpenRouter fallback: primary provider key absent but
|
||||
# OPENROUTER_API_KEY present -> route this agent through OpenRouter.
|
||||
if not api_key and self.provider != "openrouter" and Config.OPENROUTER_API_KEY:
|
||||
model = openrouter_model_id(model or PROVIDER_DEFAULT_MODELS.get(self.provider))
|
||||
self.provider = "openrouter"
|
||||
api_key = Config.OPENROUTER_API_KEY
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"API key required for provider '{self.provider}'. Set the "
|
||||
f"provider's key or OPENROUTER_API_KEY to use the OpenRouter fallback."
|
||||
)
|
||||
|
||||
# Configure client based on provider
|
||||
if self.provider == "dashscope":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=Config.DASHSCOPE_BASE_URL
|
||||
)
|
||||
self.model = model or PROVIDER_DEFAULT_MODELS["dashscope"]
|
||||
elif self.provider == "siliconflow":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.siliconflow.cn/v1"
|
||||
)
|
||||
self.model = model or "Qwen/Qwen3-235B-A22B-Thinking-2507"
|
||||
elif self.provider == "doubao":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3"
|
||||
)
|
||||
self.model = model or os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
|
||||
elif self.provider == "kimi" or self.provider == "moonshot":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.moonshot.cn/v1"
|
||||
)
|
||||
self.model = model or "kimi-k3"
|
||||
elif self.provider == "openrouter":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://openrouter.ai/api/v1"
|
||||
)
|
||||
# Default to Gemini 2.5 Pro, but allow any of the supported models
|
||||
self.model = model or "google/gemini-3.5-flash"
|
||||
# Supported models: google/gemini-3.5-flash, openai/gpt-5, anthropic/claude-sonnet-4
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {self.provider}. Use 'dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', or 'openrouter'")
|
||||
|
||||
# Initialize memory manager (read-only access)
|
||||
self.memory_manager = create_memory_manager(user_id, memory_mode)
|
||||
|
||||
# Initialize conversation history
|
||||
self.conversation_history = ConversationHistory(user_id) if self.config.enable_conversation_history else None
|
||||
|
||||
# Track current session
|
||||
self.session_id = self._generate_session_id()
|
||||
self.conversation = []
|
||||
|
||||
# Initialize system prompt
|
||||
self._init_system_prompt()
|
||||
|
||||
logger.info(f"ConversationalAgent initialized for user {user_id} with {self.provider} provider using {self.model}")
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate a unique session ID"""
|
||||
return f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _init_system_prompt(self):
|
||||
"""Initialize the system prompt"""
|
||||
system_content = """You are a helpful and personalized assistant. You have access to information about the user from previous conversations, which helps you provide personalized and contextual responses.
|
||||
|
||||
You MUST analyze the context, user's questions and memories in detail, and provide a comprehensive and detailed response.
|
||||
"""
|
||||
|
||||
self.conversation = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_content
|
||||
}
|
||||
]
|
||||
|
||||
def _get_memory_context(self) -> str:
|
||||
"""Get current memory context as a string"""
|
||||
if not self.config.enable_memory_context:
|
||||
return ""
|
||||
|
||||
context_parts = []
|
||||
|
||||
# The background processor writes memory through its own manager
|
||||
# instance; reload from disk so its updates are visible within the
|
||||
# session (same reason main.py reloads after processing, and the
|
||||
# same fix ConversationHistory got for issue #181).
|
||||
self.memory_manager.load_memory()
|
||||
|
||||
# Add memory summary
|
||||
memory_str = self.memory_manager.get_context_string()
|
||||
if memory_str:
|
||||
context_parts.append("=== USER CONTEXT ===")
|
||||
context_parts.append(memory_str)
|
||||
context_parts.append("")
|
||||
|
||||
# Keep raw conversation turns scoped to the active session. Persisted
|
||||
# turns from earlier sessions are input to the background memory
|
||||
# processor, but the conversational agent should learn about those
|
||||
# sessions only through the structured long-term memory above.
|
||||
if self.conversation_history:
|
||||
session_turns = self.conversation_history.get_session_turns(
|
||||
self.session_id
|
||||
)
|
||||
|
||||
if session_turns:
|
||||
context_parts.append("=== CURRENT SESSION HISTORY ===")
|
||||
context_parts.append(f"Total turns: {len(session_turns)}")
|
||||
context_parts.append("")
|
||||
|
||||
for turn in session_turns:
|
||||
context_parts.append(f"[Session: {turn.session_id}, Turn {turn.turn_number}, Time: {turn.timestamp}]")
|
||||
context_parts.append(f"User: {turn.user_message}")
|
||||
context_parts.append(f"Assistant: {turn.assistant_message}")
|
||||
context_parts.append("")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def get_conversation_context(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get the full conversation context for background memory processing
|
||||
|
||||
Returns:
|
||||
List of conversation messages
|
||||
"""
|
||||
# Return a copy of the conversation without system prompt
|
||||
return [msg for msg in self.conversation[1:] if msg.get('role') != 'system']
|
||||
|
||||
def chat(self, message: str) -> str:
|
||||
"""
|
||||
Have a conversation with the user
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
|
||||
Returns:
|
||||
Assistant response
|
||||
"""
|
||||
# Add memory context to the user message
|
||||
memory_context = self._get_memory_context()
|
||||
|
||||
if memory_context:
|
||||
full_message = f"{message}\n\n{memory_context}"
|
||||
else:
|
||||
full_message = message
|
||||
|
||||
# Log the full prompt if verbose
|
||||
if self.verbose:
|
||||
logger.info(f"User request: {message}")
|
||||
if memory_context:
|
||||
logger.info(f"Memory context added: {memory_context}")
|
||||
logger.info(f"Full prompt sent to API: {full_message}")
|
||||
|
||||
# Persist only the raw message; the memory/history context block is
|
||||
# sent transiently as this call's last message. Persisting
|
||||
# full_message would embed the entire history inside every user turn
|
||||
# of a conversation that already contains the previous turns natively,
|
||||
# so tokens per turn would grow O(N^2) across the session.
|
||||
self.conversation.append({"role": "user", "content": message})
|
||||
api_messages = self.conversation[:-1] + [{"role": "user", "content": full_message}]
|
||||
|
||||
try:
|
||||
# Call the model with streaming
|
||||
stream = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
temperature=_reasoning_safe_temperature(self.model, self.config.temperature),
|
||||
max_tokens=self.config.max_tokens,
|
||||
stream=True
|
||||
)
|
||||
|
||||
# Collect streamed response
|
||||
assistant_message = ""
|
||||
if self.verbose:
|
||||
logger.info("Streaming response...")
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
delta = chunk.choices[0].delta.content
|
||||
assistant_message += delta
|
||||
# Always stream output to show real-time response
|
||||
print(delta, end='', flush=True)
|
||||
|
||||
print() # New line after streaming
|
||||
|
||||
# Add assistant response to conversation
|
||||
self.conversation.append({
|
||||
"role": "assistant",
|
||||
"content": assistant_message
|
||||
})
|
||||
|
||||
# Save to conversation history
|
||||
if self.conversation_history:
|
||||
self.conversation_history.add_turn(
|
||||
session_id=self.session_id,
|
||||
user_message=message,
|
||||
assistant_message=assistant_message
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"User: {message}")
|
||||
logger.info(f"Assistant: {assistant_message}")
|
||||
|
||||
return assistant_message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error during conversation: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return f"I apologize, but I encountered an error: {str(e)}"
|
||||
|
||||
def reset_session(self):
|
||||
"""Start a new conversation session"""
|
||||
self.session_id = self._generate_session_id()
|
||||
self._init_system_prompt()
|
||||
logger.info(f"Started new session: {self.session_id}")
|
||||
|
||||
def get_session_id(self) -> str:
|
||||
"""Get the current session ID"""
|
||||
return self.session_id
|
||||
Reference in New Issue
Block a user