Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

770 lines
26 KiB
Python

#!/usr/bin/env python
"""
Unified BaseAgent - Base class for all module agents.
This is the single source of truth for agent base functionality across:
- solve module
- research module
- co_writer module
- question module (unified in Jan 2026 refactor)
"""
from abc import ABC, abstractmethod
import inspect
import logging
import time
from typing import Any, AsyncGenerator, Awaitable, Callable
from deeptutor.config.settings import settings
from deeptutor.logging import LLMStats
from deeptutor.services.config import get_agent_params
from deeptutor.services.llm import complete as llm_complete
from deeptutor.services.llm import (
get_llm_config,
get_token_limit_kwargs,
prepare_multimodal_messages,
supports_response_format,
)
from deeptutor.services.llm import stream as llm_stream
from deeptutor.services.prompt import get_prompt_manager
class BaseAgent(ABC):
"""
Unified base class for all module agents.
This class provides:
- LLM configuration management (api_key, base_url, model)
- Agent parameters (temperature, max_tokens) from agents.yaml
- Prompt loading via PromptManager
- Unified LLM call interface
- Token tracking (supports TokenTracker, LLMStats, or singleton tracker)
- Logging
Subclasses must implement the `process()` method.
"""
# Shared LLMStats tracker for each module (class-level)
_shared_stats: dict[str, LLMStats] = {}
TraceCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
def __init__(
self,
module_name: str,
agent_name: str,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
api_version: str | None = None,
language: str = "zh",
binding: str | None = None,
config: dict[str, Any] | None = None,
token_tracker: Any | None = None,
log_dir: str | None = None,
):
"""
Initialize base Agent.
Args:
module_name: Module name (solve/research/co_writer/question)
agent_name: Agent name (e.g., "solve_agent", "note_agent")
api_key: API key (optional, defaults to environment variable)
base_url: API endpoint (optional, defaults to environment variable)
model: Model name (optional, defaults to environment variable)
api_version: API version for Azure OpenAI (optional)
language: Language setting ('zh' | 'en'), default 'zh'
binding: Provider binding type (optional, defaults to 'openai')
config: Optional configuration dictionary
token_tracker: Optional external TokenTracker instance
log_dir: Optional log directory path
"""
self.module_name = module_name
self.agent_name = agent_name
self.language = language
self._trace_callback: BaseAgent.TraceCallback | None = None
# Ensure config is always a dict (not a dataclass like LLMConfig)
if config is None:
self.config = {}
elif isinstance(config, dict):
self.config = config
else:
# If config is a dataclass (like LLMConfig), convert to empty dict
# The actual LLM config should be loaded via get_llm_config()
self.config = {}
# Load agent parameters from unified config (agents.yaml)
self._agent_params = get_agent_params(module_name)
# Load LLM configuration
try:
env_llm = get_llm_config()
self.api_key = api_key or env_llm.api_key
self.base_url = base_url or env_llm.base_url
self.model = model or env_llm.model
self.api_version = api_version or getattr(env_llm, "api_version", None)
self.binding = binding or getattr(env_llm, "binding", "openai")
except Exception:
self.api_key = api_key
self.base_url = base_url
self.model = model
self.api_version = api_version
self.binding = binding or "openai"
# Get Agent-specific configuration (if config provided)
self.agent_config = self.config.get("agents", {}).get(agent_name, {})
llm_cfg = self.config.get("llm", {})
# Ensure llm_config is always a dict (handle case where LLMConfig object is passed)
if hasattr(llm_cfg, "__dataclass_fields__"):
from dataclasses import asdict
self.llm_config = asdict(llm_cfg)
else:
self.llm_config = llm_cfg if isinstance(llm_cfg, dict) else {}
# Agent status
self.enabled = self.agent_config.get("enabled", True)
# Token tracker (external instance, optional)
self.token_tracker = token_tracker
# Initialize logger
logger_name = f"{module_name.capitalize()}.{agent_name}"
self.logger = logging.getLogger(f"deeptutor.{logger_name}")
# Load prompts using unified PromptManager
try:
self.prompts = get_prompt_manager().load_prompts(
module_name=module_name,
agent_name=agent_name,
language=language,
)
if self.prompts:
self.logger.debug(f"Prompts loaded: {agent_name} ({language})")
except Exception as e:
self.prompts = None
self.logger.warning(f"Failed to load prompts for {agent_name}: {e}")
# -------------------------------------------------------------------------
# Model and Parameter Getters
# -------------------------------------------------------------------------
def get_model(self) -> str:
"""
Get model name.
Priority: agent_config > llm_config > self.model > environment variable
Returns:
Model name
Raises:
ValueError: If model is not configured
"""
# 1. Try agent-specific config
if self.agent_config.get("model"):
return self.agent_config["model"]
# 2. Try general LLM config
if self.llm_config.get("model"):
return self.llm_config["model"]
# 3. Use instance model
if self.model:
return self.model
raise ValueError(
f"Model not configured for agent {self.agent_name}. "
"Please activate a model in Settings > Catalog."
)
def get_temperature(self) -> float:
"""
Get temperature parameter from unified config (agents.yaml).
Returns:
Temperature value
"""
return self._agent_params["temperature"]
def get_max_tokens(self) -> int:
"""
Get maximum token count from unified config (agents.yaml).
Returns:
Maximum token count
"""
return self._agent_params["max_tokens"]
def get_max_retries(self) -> int:
"""
Get maximum retry count.
Returns:
Retry count
"""
return self.agent_config.get("max_retries", settings.retry.max_retries)
def refresh_config(self) -> None:
"""
Refresh LLM configuration from the current active settings.
This method reloads the LLM configuration from the unified config service,
allowing agents to pick up configuration changes made by users in Settings
without needing to restart the server or recreate the agent instance.
Call this method before processing requests if you want to ensure
the agent uses the latest user-configured LLM settings.
"""
try:
llm_config = get_llm_config()
self.api_key = llm_config.api_key
self.base_url = llm_config.base_url
self.model = llm_config.model
self.api_version = getattr(llm_config, "api_version", None)
self.binding = getattr(llm_config, "binding", "openai")
self.logger.debug(
f"Config refreshed: model={self.model}, base_url={self.base_url[:30]}..."
if self.base_url
else f"Config refreshed: model={self.model}"
)
except Exception as e:
self.logger.warning(f"Failed to refresh config: {e}")
def set_trace_callback(self, callback: TraceCallback | None) -> None:
"""Register a trace callback that receives structured LLM call events."""
self._trace_callback = callback
async def _emit_trace_event(self, payload: dict[str, Any]) -> None:
callback = self._trace_callback
if callback is None:
return
try:
result = callback(payload)
if inspect.isawaitable(result):
await result
except Exception as exc:
self.logger.debug(f"Trace callback failed: {exc}")
# -------------------------------------------------------------------------
# Token Tracking
# -------------------------------------------------------------------------
@classmethod
def get_stats(cls, module_name: str) -> LLMStats:
"""
Get or create shared LLMStats tracker for a module.
Args:
module_name: Module name
Returns:
LLMStats instance
"""
if module_name not in cls._shared_stats:
cls._shared_stats[module_name] = LLMStats(module_name=module_name.capitalize())
return cls._shared_stats[module_name]
@classmethod
def reset_stats(cls, module_name: str | None = None):
"""
Reset shared stats.
Args:
module_name: Module name (if None, reset all)
"""
if module_name:
if module_name in cls._shared_stats:
cls._shared_stats[module_name].reset()
else:
for stats in cls._shared_stats.values():
stats.reset()
@classmethod
def print_stats(cls, module_name: str | None = None):
"""
Print stats summary.
Args:
module_name: Module name (if None, print all)
"""
if module_name:
if module_name in cls._shared_stats:
cls._shared_stats[module_name].print_summary()
else:
for stats in cls._shared_stats.values():
stats.print_summary()
def _track_tokens(
self,
model: str,
system_prompt: str,
user_prompt: str,
response: str,
stage: str | None = None,
):
"""
Track token usage using available tracker.
Supports:
1. External TokenTracker (if self.token_tracker is set)
2. Shared LLMStats (always available)
Args:
model: Model name
system_prompt: System prompt
user_prompt: User prompt
response: LLM response
stage: Stage name (optional)
"""
stage_label = stage or self.agent_name
# 1. Use external TokenTracker if provided
if self.token_tracker:
try:
self.token_tracker.add_usage(
agent_name=self.agent_name,
stage=stage_label,
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
response_text=response,
)
except Exception:
pass # Don't let tracking errors affect main flow
# 2. Always use shared LLMStats
stats = self.get_stats(self.module_name)
stats.add_call(
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
response=response,
)
# -------------------------------------------------------------------------
# LLM Call Interface
# -------------------------------------------------------------------------
async def call_llm(
self,
user_prompt: str,
system_prompt: str,
messages: list[dict[str, Any]] | None = None,
response_format: dict[str, str] | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model: str | None = None,
verbose: bool = True,
stage: str | None = None,
attachments: list[Any] | None = None,
trace_meta: dict[str, Any] | None = None,
) -> str:
"""
Unified interface for calling LLM (non-streaming).
Uses the LLM factory to route calls to the appropriate provider
(cloud or local) based on configuration.
Args:
user_prompt: User prompt (ignored if messages provided)
system_prompt: System prompt (ignored if messages provided)
messages: Pre-built messages array (optional, overrides prompt/system_prompt)
response_format: Response format (e.g., {"type": "json_object"})
temperature: Temperature parameter (optional, uses config by default)
max_tokens: Maximum tokens (optional, uses config by default)
model: Model name (optional, uses config by default)
verbose: Whether to print raw LLM output (default True)
stage: Stage marker for logging and tracking
attachments: Image/file attachments for multimodal input (optional)
Returns:
LLM response text
"""
model = model or self.get_model()
temperature = temperature if temperature is not None else self.get_temperature()
max_tokens = max_tokens if max_tokens is not None else self.get_max_tokens()
max_retries = self.get_max_retries()
# Record call start time
start_time = time.time()
# Build kwargs for LLM factory
kwargs = {
"temperature": temperature,
}
# Handle token limit for newer OpenAI models
if max_tokens:
kwargs.update(get_token_limit_kwargs(model, max_tokens))
# Handle response_format with capability check
if response_format:
try:
config = get_llm_config()
binding = getattr(config, "binding", None) or "openai"
except Exception:
binding = "openai"
if supports_response_format(binding, model):
kwargs["response_format"] = response_format
else:
self.logger.debug(f"response_format not supported for {binding}/{model}, skipping")
# Keep non-streaming calls aligned with stream_llm/chat: when images
# are attached, convert the final user message to multimodal content.
if attachments:
if not messages:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
mm_result = prepare_multimodal_messages(
messages, attachments, binding=self.binding, model=model
)
messages = mm_result.messages
if messages:
kwargs["messages"] = messages
# Log input
stage_label = stage or self.agent_name
trace_payload_base = {
"event": "llm_call",
"state": "running",
"agent_name": self.agent_name,
"stage": stage_label,
"model": model,
"temperature": temperature,
"max_tokens": max_tokens,
"streaming": False,
**(trace_meta or {}),
}
await self._emit_trace_event(trace_payload_base)
self.logger.debug(
"LLM input %s:%s model=%s system_chars=%d user_chars=%d",
self.agent_name,
stage_label,
model,
len(system_prompt),
len(user_prompt),
)
# Call LLM via factory (routes to cloud or local provider)
response = None
try:
response = await llm_complete(
prompt=user_prompt,
system_prompt=system_prompt,
model=model,
api_key=self.api_key,
base_url=self.base_url,
api_version=self.api_version,
binding=self.binding,
max_retries=max_retries,
**kwargs,
)
except Exception as e:
await self._emit_trace_event(
{
**trace_payload_base,
"state": "error",
"response": str(e),
}
)
self.logger.error(f"LLM call failed: {e}")
raise
# Calculate duration
call_duration = time.time() - start_time
# Track token usage
self._track_tokens(
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
response=response,
stage=stage_label,
)
# Log output
await self._emit_trace_event(
{
**trace_payload_base,
"state": "complete",
"response": response,
"duration": call_duration,
}
)
self.logger.debug(
"LLM output %s:%s chars=%d duration=%.2fs",
self.agent_name,
stage_label,
len(response),
call_duration,
)
# Verbose output
if verbose:
self.logger.debug(f"LLM response: model={model}, duration={call_duration:.2f}s")
return response
async def stream_llm(
self,
user_prompt: str,
system_prompt: str,
messages: list[dict[str, Any]] | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model: str | None = None,
response_format: dict[str, Any] | None = None,
stage: str | None = None,
attachments: list[Any] | None = None,
trace_meta: dict[str, Any] | None = None,
) -> AsyncGenerator[str, None]:
"""
Unified interface for streaming LLM responses.
Uses the LLM factory to route calls to the appropriate provider
(cloud or local) based on configuration.
Args:
user_prompt: User prompt (ignored if messages provided)
system_prompt: System prompt (ignored if messages provided)
messages: Pre-built messages array (optional, overrides prompt/system_prompt)
temperature: Temperature parameter (optional, uses config by default)
max_tokens: Maximum tokens (optional, uses config by default)
model: Model name (optional, uses config by default)
response_format: JSON schema for structured output (optional)
stage: Stage marker for logging
attachments: Image/file attachments for multimodal input (optional)
Yields:
Response chunks as strings
"""
model = model or self.get_model()
temperature = temperature if temperature is not None else self.get_temperature()
max_tokens = max_tokens if max_tokens is not None else self.get_max_tokens()
max_retries = self.get_max_retries()
# Build kwargs
kwargs = {
"temperature": temperature,
}
# Handle token limit for newer OpenAI models
if max_tokens:
kwargs.update(get_token_limit_kwargs(model, max_tokens))
# Handle response_format with capability check
if response_format:
try:
config = get_llm_config()
binding = getattr(config, "binding", None) or "openai"
except Exception:
binding = "openai"
if supports_response_format(binding, model):
kwargs["response_format"] = response_format
else:
self.logger.debug(f"response_format not supported for {binding}/{model}, skipping")
# Inject image attachments into messages when provided
if attachments:
if not messages:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
mm_result = prepare_multimodal_messages(
messages, attachments, binding=self.binding, model=model
)
messages = mm_result.messages
# Log input
stage_label = stage or self.agent_name
trace_payload_base = {
"event": "llm_call",
"state": "running",
"agent_name": self.agent_name,
"stage": stage_label,
"model": model,
"temperature": temperature,
"max_tokens": max_tokens,
"streaming": True,
**(trace_meta or {}),
}
await self._emit_trace_event(trace_payload_base)
self.logger.debug(
"LLM stream input %s:%s model=%s system_chars=%d user_chars=%d",
self.agent_name,
stage_label,
model,
len(system_prompt),
len(user_prompt),
)
# Track start time
start_time = time.time()
full_response = ""
try:
# Stream via factory (routes to cloud or local provider)
async for chunk in llm_stream(
prompt=user_prompt,
system_prompt=system_prompt,
model=model,
api_key=self.api_key,
base_url=self.base_url,
api_version=self.api_version,
binding=self.binding,
messages=messages,
max_retries=max_retries,
**kwargs,
):
full_response += chunk
await self._emit_trace_event(
{
**trace_payload_base,
"state": "streaming",
"chunk": chunk,
}
)
yield chunk
# Track token usage after streaming completes
self._track_tokens(
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
response=full_response,
stage=stage_label,
)
# Log output
call_duration = time.time() - start_time
await self._emit_trace_event(
{
**trace_payload_base,
"state": "complete",
"response": full_response,
"duration": call_duration,
}
)
self.logger.debug(
"LLM stream output %s:%s chars=%d duration=%.2fs",
self.agent_name,
stage_label,
len(full_response),
call_duration,
)
except Exception as e:
await self._emit_trace_event(
{
**trace_payload_base,
"state": "error",
"response": str(e),
}
)
self.logger.error(f"LLM streaming failed: {e}")
raise
# -------------------------------------------------------------------------
# Prompt Helpers
# -------------------------------------------------------------------------
def get_prompt(
self,
section_or_type: str = "system",
field_or_fallback: str | None = None,
fallback: str = "",
) -> str | None:
"""
Get prompt by type or section/field.
Supports two calling patterns:
1. get_prompt("system") - simple key lookup
2. get_prompt("section", "field", "fallback") - nested lookup (for research module)
Args:
section_or_type: Prompt type key or section name
field_or_fallback: Field name (if nested) or fallback value (if simple)
fallback: Fallback value if prompt not found (only used in nested mode)
Returns:
Prompt string or fallback
"""
if not self.prompts:
return (
fallback
if fallback
else (
field_or_fallback
if isinstance(field_or_fallback, str) and field_or_fallback
else None
)
)
# Check if this is a nested lookup (section.field pattern)
# If field_or_fallback is provided and section_or_type points to a dict, use nested lookup
section_value = self.prompts.get(section_or_type)
if isinstance(section_value, dict) and field_or_fallback is not None:
# Nested lookup: get_prompt("section", "field", "fallback")
result = section_value.get(field_or_fallback)
if result is not None:
return result
return fallback if fallback else None
else:
# Simple lookup: get_prompt("key") or get_prompt("key", "fallback")
if section_value is not None:
return section_value
# field_or_fallback acts as fallback in simple mode
return field_or_fallback if field_or_fallback else (fallback if fallback else None)
def has_prompts(self) -> bool:
"""Check if prompts have been loaded."""
return self.prompts is not None
# -------------------------------------------------------------------------
# Status
# -------------------------------------------------------------------------
def is_enabled(self) -> bool:
"""
Check if Agent is enabled.
Returns:
Whether enabled
"""
return self.enabled
# -------------------------------------------------------------------------
# Abstract Method
# -------------------------------------------------------------------------
@abstractmethod
async def process(self, *args, **kwargs) -> Any:
"""
Main processing logic of Agent (must be implemented by subclasses).
Returns:
Processing result
"""
# -------------------------------------------------------------------------
# String Representation
# -------------------------------------------------------------------------
def __repr__(self) -> str:
"""String representation of Agent."""
return (
f"{self.__class__.__name__}("
f"module={self.module_name}, "
f"name={self.agent_name}, "
f"enabled={self.enabled})"
)
__all__ = ["BaseAgent"]