mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-06 07:11:13 +02:00
docs: simplify context window management documentation
Removed extensive implementation details and configuration examples from the context window management documentation. The document now focuses on core permissions and prohibitions for tool usage, providing a clearer and more concise reference for allowed and forbidden actions. This streamlines the documentation to essential guidelines only.
This commit is contained in:
1 parent
a8044cc1c2
commit
8dc8facb13
6 files changed
+1462
-1575
No files matched your search
@@ -0,0 +1,112 @@
|
||||
# Deprecated Context Manager
|
||||
|
||||
This directory contains the **advanced context management implementation** that has been replaced with a simplified version.
|
||||
|
||||
## What's in Here
|
||||
|
||||
- **context_manager.py** (~650 lines)
|
||||
- Accurate token counting using tiktoken
|
||||
- Tool definition token estimation
|
||||
- Custom message trimming with AIMessage + ToolMessage pairing
|
||||
- Detailed logging and diagnostics
|
||||
|
||||
## Why Was It Moved?
|
||||
|
||||
The advanced implementation was **over-engineered** for the current use case:
|
||||
|
||||
| Feature | Complexity | Necessity | Current Status |
|
||||
|---------|-----------|-----------|----------------|
|
||||
| Template injection | Low | ✅ Essential | **Kept** (in new version) |
|
||||
| Token counting (tiktoken) | High | ❓ Optional | Moved here |
|
||||
| Message trimming | Medium | ❓ Optional | **Simplified** (LangChain native) |
|
||||
| Tool token estimation | High | ❓ Optional | Moved here |
|
||||
| AIMessage/ToolMessage pairing | High | ✅ Important | Moved here |
|
||||
| 3 strategies (conservative/balanced/aggressive) | Low | ✅ Useful | **Kept** (in new version) |
|
||||
|
||||
## When to Use This Implementation
|
||||
|
||||
### Use the deprecated version if:
|
||||
|
||||
1. **You need accurate token counting**
|
||||
- Your model has strict token limits
|
||||
- You need to know exact token usage
|
||||
- You're working with cost-sensitive applications
|
||||
|
||||
2. **You have many tools**
|
||||
- Tool definitions consume significant tokens (500-1500 per tool)
|
||||
- You need to account for tool tokens in context limit
|
||||
|
||||
3. **You need AIMessage + ToolMessage pairing**
|
||||
- Your LLM requires tool calls and results to stay together
|
||||
- You've encountered errors from orphaned ToolMessages
|
||||
|
||||
4. **You need detailed diagnostics**
|
||||
- Debugging context limit issues
|
||||
- Optimizing token usage
|
||||
- Fine-tuning context strategy
|
||||
|
||||
### Use the current simplified version if:
|
||||
|
||||
1. ✅ You just need topology injection
|
||||
2. ✅ Your model has large context (128K+ tokens)
|
||||
3. ✅ Conversations are typically short (<50 turns)
|
||||
4. ✅ You don't need exact token counts
|
||||
|
||||
## How to Restore the Advanced Version
|
||||
|
||||
If you find you need the advanced features:
|
||||
|
||||
```python
|
||||
# 1. Remove current simplified version
|
||||
rm gns3server/agent/gns3_copilot/agent/context_manager.py
|
||||
|
||||
# 2. Restore from deprecated
|
||||
cp gns3server/agent/gns3_copilot/deprecated/context_manager.py \
|
||||
gns3server/agent/gns3_copilot/agent/context_manager.py
|
||||
```
|
||||
|
||||
## Key Differences
|
||||
|
||||
### Simplified Version (Current)
|
||||
```python
|
||||
# ~200 lines
|
||||
- Uses LangChain's native trim_messages
|
||||
- Simple token estimation (char count / 4)
|
||||
- Template injection for topology
|
||||
- 3 strategies: conservative/balanced/aggressive
|
||||
```
|
||||
|
||||
### Advanced Version (Deprecated)
|
||||
```python
|
||||
# ~650 lines
|
||||
- Custom trimming with AIMessage/ToolMessage pairing
|
||||
- Accurate tiktoken-based token counting
|
||||
- Tool definition token estimation
|
||||
- Detailed logging with token breakdown
|
||||
- Template injection for topology
|
||||
- 3 strategies: conservative/balanced/aggressive
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Metric | Simplified | Advanced |
|
||||
|--------|-----------|----------|
|
||||
| Code size | ~200 lines | ~650 lines |
|
||||
| Token accuracy | ~80% (estimation) | ~95%+ (tiktoken) |
|
||||
| Trimming safety | Good | Excellent |
|
||||
| Execution speed | Fast | Slower (tiktoken overhead) |
|
||||
| Maintenance | Low | High |
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If the simplified version proves insufficient:
|
||||
1. Consider adding tiktoken back (but keep architecture simple)
|
||||
2. Use LangChain's more advanced trim_messages features
|
||||
3. Add optional tool token estimation
|
||||
4. Consider a hybrid approach: simple by default, advanced when needed
|
||||
|
||||
---
|
||||
|
||||
**Moved**: 2025-03-05
|
||||
**Reason**: Simplification for current use case
|
||||
**Status**: Available for future use if needed
|
||||
@@ -0,0 +1,730 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# This file is part of GNS3-Copilot project.
|
||||
#
|
||||
# GNS3-Copilot is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by the
|
||||
# Free Software Foundation, either version 3 of the License, or (at your
|
||||
# option) any later version.
|
||||
#
|
||||
# GNS3-Copilot is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
# for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Copyright (C) 2025 Guobin Yue
|
||||
# Author: Guobin Yue
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
Context Window Manager for GNS3-Copilot
|
||||
|
||||
This module provides intelligent context window management for LLM models,
|
||||
including:
|
||||
- Model-specific context window limits
|
||||
- Accurate token counting using tiktoken
|
||||
- Message trimming strategies (conservative/balanced/aggressive)
|
||||
- System message preservation
|
||||
- Tool definition token estimation
|
||||
- Template variable injection for topology info
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
# Context strategy ratios
|
||||
CONTEXT_STRATEGY_RATIOS = {
|
||||
"conservative": 0.60, # 60% for input, 40% reserved for output
|
||||
"balanced": 0.75, # 75% for input, 25% reserved for output
|
||||
"aggressive": 0.85, # 85% for input, 15% reserved for output
|
||||
}
|
||||
|
||||
DEFAULT_CONTEXT_STRATEGY = "balanced"
|
||||
|
||||
# ============================================================================
|
||||
# Token Counting - Using tiktoken for accuracy
|
||||
# ============================================================================
|
||||
|
||||
# Global tiktoken encoding cache (lazy loading)
|
||||
_tiktoken_encoding = None
|
||||
|
||||
def _get_tiktoken_encoding():
|
||||
"""
|
||||
Get tiktoken encoding instance (cached).
|
||||
|
||||
Uses cl100k_base encoding (GPT-4) which is a good approximation
|
||||
for most modern LLMs including OpenAI, Anthropic, and DeepSeek.
|
||||
|
||||
Returns:
|
||||
Encoding object
|
||||
|
||||
Raises:
|
||||
ImportError: If tiktoken is not installed
|
||||
"""
|
||||
global _tiktoken_encoding
|
||||
if _tiktoken_encoding is None:
|
||||
try:
|
||||
import tiktoken
|
||||
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
|
||||
logger.debug("Using tiktoken (cl100k_base) for accurate token counting")
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"tiktoken is required for accurate token counting. "
|
||||
"Please install it with: pip install tiktoken>=0.8.0"
|
||||
)
|
||||
return _tiktoken_encoding
|
||||
|
||||
def count_tokens_accurately(text: str) -> int:
|
||||
"""
|
||||
Count tokens in text accurately using tiktoken.
|
||||
|
||||
Args:
|
||||
text: The text to count tokens for
|
||||
|
||||
Returns:
|
||||
Number of tokens
|
||||
|
||||
Raises:
|
||||
ImportError: If tiktoken is not installed
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
encoding = _get_tiktoken_encoding()
|
||||
try:
|
||||
return len(encoding.encode(text))
|
||||
except Exception as e:
|
||||
logger.error("tiktoken encoding failed: %s", e)
|
||||
raise
|
||||
|
||||
def count_messages_tokens(messages: list[Any]) -> int:
|
||||
"""
|
||||
Count total tokens in a list of messages accurately.
|
||||
|
||||
Args:
|
||||
messages: List of LangChain messages
|
||||
|
||||
Returns:
|
||||
Total token count
|
||||
|
||||
Raises:
|
||||
ImportError: If tiktoken is not installed
|
||||
"""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if hasattr(msg, 'content') and msg.content:
|
||||
# Handle both string and complex content
|
||||
content = str(msg.content)
|
||||
total += count_tokens_accurately(content)
|
||||
return total
|
||||
|
||||
# ============================================================================
|
||||
# Tool Definition Token Estimation
|
||||
# ============================================================================
|
||||
|
||||
def estimate_tool_tokens(tools: list[Any]) -> int:
|
||||
"""
|
||||
Estimate the token count of tool definitions.
|
||||
|
||||
When tools are bound to an LLM, their schemas (name, description, parameters)
|
||||
are converted to JSON and sent to the LLM. This function estimates how many
|
||||
tokens those definitions will consume.
|
||||
|
||||
Args:
|
||||
tools: List of LangChain BaseTool instances
|
||||
|
||||
Returns:
|
||||
Estimated token count for all tool definitions
|
||||
|
||||
Raises:
|
||||
ImportError: If tiktoken is not installed
|
||||
"""
|
||||
if not tools:
|
||||
return 0
|
||||
|
||||
total_tokens = 0
|
||||
encoding = _get_tiktoken_encoding()
|
||||
|
||||
for tool in tools:
|
||||
try:
|
||||
# Build tool schema as it would be sent to LLM
|
||||
tool_schema = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description if hasattr(tool, 'description') else "",
|
||||
}
|
||||
}
|
||||
|
||||
# Add parameters schema if available
|
||||
if hasattr(tool, 'args_schema') and tool.args_schema:
|
||||
try:
|
||||
tool_schema["function"]["parameters"] = tool.args_schema.schema()
|
||||
except Exception:
|
||||
# If schema generation fails, use empty object
|
||||
tool_schema["function"]["parameters"] = {"type": "object"}
|
||||
|
||||
# Count tokens in the schema
|
||||
schema_str = json.dumps(tool_schema, ensure_ascii=False)
|
||||
tool_tokens = len(encoding.encode(schema_str))
|
||||
total_tokens += tool_tokens
|
||||
|
||||
logger.debug(
|
||||
"Tool '%s': ~%d tokens (schema size: %d chars)",
|
||||
tool.name, tool_tokens, len(schema_str)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to estimate tokens for tool '%s': %s", tool.name, e)
|
||||
raise
|
||||
|
||||
logger.info("Tool definitions estimated at ~%d total tokens (%d tools)", total_tokens, len(tools))
|
||||
return total_tokens
|
||||
|
||||
# ============================================================================
|
||||
# Model Context Limits
|
||||
# ============================================================================
|
||||
|
||||
# NOTE: Built-in model context limits have been removed.
|
||||
# Model providers frequently update context limits, and maintaining this list is not sustainable.
|
||||
# Users MUST configure context_limit explicitly in their LLM model configurations.
|
||||
#
|
||||
# For reference, common model context limits as of 2025:
|
||||
# - OpenAI GPT-4o: 128K tokens
|
||||
# - OpenAI GPT-4 Turbo: 128K tokens
|
||||
# - OpenAI GPT-3.5 Turbo: 16K tokens
|
||||
# - Anthropic Claude 3.5 Sonnet: 200K tokens
|
||||
# - Google Gemini 1.5 Pro: 2.8M tokens
|
||||
# - DeepSeek Chat: 128K tokens
|
||||
#
|
||||
# Always verify current limits from official provider documentation.
|
||||
|
||||
def get_model_context_limit(
|
||||
model_name: str,
|
||||
llm_config: dict[str, Any] | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Get the context window limit for a given model.
|
||||
|
||||
IMPORTANT: context_limit MUST be provided in llm_config.
|
||||
Model providers frequently update context limits, so built-in defaults are NOT used.
|
||||
Users must configure this value explicitly.
|
||||
|
||||
NOTE: context_limit unit is K tokens (1 K = 1000 tokens).
|
||||
Example: 128 means 128K = 128,000 tokens.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model (e.g., "gpt-4o", "deepseek-chat")
|
||||
llm_config: LLM config dict from database (must contain context_limit in K tokens)
|
||||
|
||||
Returns:
|
||||
int: Maximum context window size in tokens (actual number, not K)
|
||||
|
||||
Raises:
|
||||
ValueError: If context_limit is not provided or invalid
|
||||
"""
|
||||
# Check database config for context_limit
|
||||
if llm_config and "context_limit" in llm_config:
|
||||
db_limit_k = llm_config["context_limit"]
|
||||
if isinstance(db_limit_k, int) and db_limit_k > 0:
|
||||
# Convert K tokens to actual tokens
|
||||
actual_tokens = db_limit_k * 1000
|
||||
logger.debug(
|
||||
"Using database config context limit: %dK tokens (%d tokens) for model '%s'",
|
||||
db_limit_k, actual_tokens, model_name
|
||||
)
|
||||
return actual_tokens
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid context_limit in database config: {db_limit_k} "
|
||||
f"(type={type(db_limit_k).__name__}, expected positive integer in K tokens)"
|
||||
)
|
||||
|
||||
# No context_limit provided - this is a configuration error
|
||||
raise ValueError(
|
||||
f"context_limit is required but not provided for model '{model_name}'. "
|
||||
f"Please configure context_limit in your LLM model configuration (unit: K tokens). "
|
||||
f"Example: 128 means 128K = 128,000 tokens. "
|
||||
f"Refer to the model provider's documentation for the current context window size."
|
||||
)
|
||||
|
||||
def calculate_max_tokens(
|
||||
model_limit: int,
|
||||
strategy: Literal["conservative", "balanced", "aggressive"] = DEFAULT_CONTEXT_STRATEGY
|
||||
) -> int:
|
||||
"""
|
||||
Calculate the maximum tokens to use, reserving space for output.
|
||||
|
||||
Args:
|
||||
model_limit: Model's context window limit
|
||||
strategy: How aggressively to use the context window
|
||||
- "conservative": Use 60% of limit (safer, more reserved for output)
|
||||
- "balanced": Use 75% of limit (default)
|
||||
- "aggressive": Use 85% of limit (maximize input, minimal output reserve)
|
||||
|
||||
Returns:
|
||||
int: Maximum tokens for input messages
|
||||
"""
|
||||
ratio = CONTEXT_STRATEGY_RATIOS.get(strategy, CONTEXT_STRATEGY_RATIOS[DEFAULT_CONTEXT_STRATEGY])
|
||||
max_tokens = int(model_limit * ratio)
|
||||
|
||||
logger.debug(
|
||||
"Context limit: model=%d, strategy=%s, usable=%d tokens",
|
||||
model_limit, strategy, max_tokens
|
||||
)
|
||||
|
||||
return max_tokens
|
||||
|
||||
# ============================================================================
|
||||
# Message Trimming
|
||||
# ============================================================================
|
||||
|
||||
def trim_messages_for_context(
|
||||
messages: list[Any],
|
||||
model_name: str,
|
||||
llm_config: dict[str, Any] | None = None,
|
||||
strategy: Literal["conservative", "balanced", "aggressive"] = DEFAULT_CONTEXT_STRATEGY,
|
||||
preserve_system: bool = True,
|
||||
tool_tokens: int = 0,
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Trim messages to fit within model's context window.
|
||||
|
||||
This function uses tiktoken for accurate token counting and intelligently
|
||||
reduces message history while preserving conversation flow.
|
||||
|
||||
IMPORTANT: Tool definitions are sent separately by LangChain and count towards
|
||||
the context limit. This function accounts for tool tokens when making
|
||||
trimming decisions.
|
||||
|
||||
Args:
|
||||
messages: List of LangChain messages (HumanMessage, AIMessage, etc.)
|
||||
model_name: Name of the LLM model being used
|
||||
llm_config: Optional LLM config dict from database (may contain context_limit)
|
||||
strategy: How aggressively to use the context window
|
||||
preserve_system: Whether to always preserve system messages
|
||||
tool_tokens: Token count for tool definitions (these are sent separately by LangChain)
|
||||
|
||||
Returns:
|
||||
list: Trimmed list of messages that fit within context limit
|
||||
|
||||
Examples:
|
||||
>>> messages = [HumanMessage("Hello"), AIMessage("Hi there!")]
|
||||
>>> trimmed = trim_messages_for_context(messages, "gpt-4o", tool_tokens=1000)
|
||||
>>> len(trimmed) <= len(messages)
|
||||
True
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# Get model's context limit (from database config)
|
||||
model_limit = get_model_context_limit(model_name, llm_config)
|
||||
|
||||
# Calculate usable tokens (reserve space for output)
|
||||
max_tokens = calculate_max_tokens(model_limit, strategy)
|
||||
|
||||
# Account for tool tokens - these are sent separately by LangChain
|
||||
# and count towards the context limit
|
||||
available_for_messages = max_tokens - tool_tokens
|
||||
|
||||
if available_for_messages < 0:
|
||||
logger.warning(
|
||||
"Tool definitions (%d tokens) exceed input budget (%d tokens). "
|
||||
"Consider reducing context_limit or using fewer tools.",
|
||||
tool_tokens, max_tokens
|
||||
)
|
||||
available_for_messages = 0
|
||||
|
||||
# Check if trimming is needed
|
||||
current_tokens = count_messages_tokens(messages)
|
||||
|
||||
if current_tokens <= available_for_messages:
|
||||
logger.debug(
|
||||
"Messages fit in context: %d / %d tokens (available: %d, tools: %d)",
|
||||
current_tokens, max_tokens, available_for_messages, tool_tokens
|
||||
)
|
||||
return messages
|
||||
|
||||
logger.info(
|
||||
"Trimming messages: %d → %d tokens (budget: %d, tools: %d)",
|
||||
current_tokens, available_for_messages, max_tokens, tool_tokens
|
||||
)
|
||||
|
||||
# Manually separate and trim to ensure system messages are preserved
|
||||
# This is more reliable than using trim_messages with include_system
|
||||
system_msgs = [m for m in messages if isinstance(m, SystemMessage)]
|
||||
other_msgs = [m for m in messages if not isinstance(m, SystemMessage)]
|
||||
|
||||
# Calculate tokens for system messages (these will always be preserved)
|
||||
system_tokens = count_messages_tokens(system_msgs)
|
||||
|
||||
# Calculate available tokens for non-system messages (after tools and system)
|
||||
available_for_other = available_for_messages - system_tokens
|
||||
|
||||
if available_for_other <= 0:
|
||||
# Not enough space for system messages - keep only system messages
|
||||
logger.warning(
|
||||
"System messages (%d tokens) exceed available space (%d tokens), truncating to system only",
|
||||
system_tokens, available_for_messages
|
||||
)
|
||||
return system_msgs[:1] if system_msgs else messages[-1:]
|
||||
|
||||
# Trim non-system messages to fit available space
|
||||
trimmed_other = _trim_to_token_limit(other_msgs, available_for_other)
|
||||
|
||||
# Combine system messages with trimmed conversation
|
||||
trimmed = system_msgs + trimmed_other
|
||||
|
||||
logger.info(
|
||||
"Trimmed %d → %d messages (system: %d, history: %d → %d)",
|
||||
len(messages), len(trimmed),
|
||||
len(system_msgs), len(other_msgs), len(trimmed_other)
|
||||
)
|
||||
|
||||
return trimmed
|
||||
|
||||
def _trim_to_token_limit(messages: list[Any], max_tokens: int) -> list[Any]:
|
||||
"""
|
||||
Trim messages to fit within token limit using tiktoken.
|
||||
|
||||
Intelligently removes oldest message groups while preserving:
|
||||
- AIMessage + ToolMessage pairs (must stay together)
|
||||
- Conversation coherence
|
||||
|
||||
Always keeps at least the most recent message.
|
||||
|
||||
Args:
|
||||
messages: List of messages to trim
|
||||
max_tokens: Maximum tokens allowed
|
||||
|
||||
Returns:
|
||||
Trimmed list of messages
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
current_tokens = count_messages_tokens(messages)
|
||||
|
||||
if current_tokens <= max_tokens:
|
||||
return messages
|
||||
|
||||
# Build message groups to preserve AIMessage + ToolMessage pairs
|
||||
groups = _build_message_groups(messages)
|
||||
|
||||
# Remove oldest groups until under token limit
|
||||
trimmed_groups = list(groups)
|
||||
while trimmed_groups and _count_groups_tokens(trimmed_groups) > max_tokens:
|
||||
# Always remove from beginning (oldest)
|
||||
trimmed_groups.pop(0)
|
||||
|
||||
# Flatten groups back to message list
|
||||
trimmed = []
|
||||
for group in trimmed_groups:
|
||||
trimmed.extend(group)
|
||||
|
||||
# Ensure at least one message remains
|
||||
if not trimmed and messages:
|
||||
# Return only the last message if nothing else fits
|
||||
trimmed = [messages[-1]]
|
||||
|
||||
logger.debug(
|
||||
"Trimmed messages: removed %d groups, %d messages remain, %d → %d tokens",
|
||||
len(groups) - len(trimmed_groups),
|
||||
len(trimmed),
|
||||
current_tokens,
|
||||
_count_groups_tokens(trimmed_groups)
|
||||
)
|
||||
|
||||
return trimmed
|
||||
|
||||
|
||||
def _build_message_groups(messages: list[Any]) -> list[list[Any]]:
|
||||
"""
|
||||
Build message groups where AIMessage + ToolMessage pairs stay together.
|
||||
|
||||
Each group is either:
|
||||
- A standalone message (HumanMessage, SystemMessage)
|
||||
- An AIMessage with its following ToolMessages (must stay together)
|
||||
|
||||
Args:
|
||||
messages: List of messages
|
||||
|
||||
Returns:
|
||||
List of message groups
|
||||
"""
|
||||
groups = []
|
||||
i = 0
|
||||
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
|
||||
# If AIMessage with tool_calls, group it with all following ToolMessages
|
||||
if isinstance(msg, AIMessage) and hasattr(msg, 'tool_calls') and msg.tool_calls:
|
||||
group = [msg]
|
||||
i += 1
|
||||
|
||||
# Collect all following ToolMessages that match these tool_calls
|
||||
tool_call_ids = {tc['id'] for tc in msg.tool_calls}
|
||||
|
||||
while i < len(messages):
|
||||
next_msg = messages[i]
|
||||
if isinstance(next_msg, ToolMessage):
|
||||
# Check if this ToolMessage belongs to current AIMessage
|
||||
if hasattr(next_msg, 'tool_call_id') and next_msg.tool_call_id in tool_call_ids:
|
||||
group.append(next_msg)
|
||||
i += 1
|
||||
else:
|
||||
# ToolMessage belongs to a different AIMessage, stop
|
||||
break
|
||||
else:
|
||||
# Not a ToolMessage, stop grouping
|
||||
break
|
||||
|
||||
groups.append(group)
|
||||
else:
|
||||
# Standalone message (no tool_calls)
|
||||
groups.append([msg])
|
||||
i += 1
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def _count_groups_tokens(groups: list[list[Any]]) -> int:
|
||||
"""Count total tokens in all groups."""
|
||||
total = 0
|
||||
for group in groups:
|
||||
for msg in group:
|
||||
if hasattr(msg, 'content') and msg.content:
|
||||
total += count_tokens_accurately(str(msg.content))
|
||||
return total
|
||||
|
||||
# ============================================================================
|
||||
# Token Usage Summary
|
||||
# ============================================================================
|
||||
|
||||
def get_token_usage_summary(
|
||||
messages: list[Any],
|
||||
model_name: str,
|
||||
llm_config: dict[str, Any] | None = None,
|
||||
tool_tokens: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of token usage for the given messages.
|
||||
|
||||
Args:
|
||||
messages: List of LangChain messages
|
||||
model_name: Name of the LLM model
|
||||
llm_config: Optional LLM config dict from database (may contain context_limit in K tokens)
|
||||
tool_tokens: Optional token count for tool definitions
|
||||
|
||||
Returns:
|
||||
dict: Token usage summary including:
|
||||
- estimated_tokens: Estimated total tokens (messages only)
|
||||
- tool_tokens: Token count for tool definitions
|
||||
- total_tokens: Sum of messages and tools
|
||||
- model_limit_k: Model's context window limit in K tokens
|
||||
- model_limit_tokens: Model's context window limit in actual tokens
|
||||
- usage_percentage: Percentage of context used (excluding tools)
|
||||
- total_usage_percentage: Percentage including tools
|
||||
- message_count: Number of messages
|
||||
- needs_trimming: Whether messages exceed 80% of limit
|
||||
"""
|
||||
try:
|
||||
estimated_tokens = count_messages_tokens(messages)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to count tokens: %s", e)
|
||||
estimated_tokens = 0
|
||||
|
||||
model_limit_tokens = get_model_context_limit(model_name, llm_config)
|
||||
model_limit_k = model_limit_tokens // 1000
|
||||
usage_percentage = (estimated_tokens / model_limit_tokens * 100) if model_limit_tokens > 0 else 0
|
||||
total_tokens = estimated_tokens + tool_tokens
|
||||
total_usage_percentage = (total_tokens / model_limit_tokens * 100) if model_limit_tokens > 0 else 0
|
||||
|
||||
return {
|
||||
"estimated_tokens": estimated_tokens,
|
||||
"tool_tokens": tool_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"model_limit_k": model_limit_k,
|
||||
"model_limit_tokens": model_limit_tokens,
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
"total_usage_percentage": round(total_usage_percentage, 2),
|
||||
"message_count": len(messages),
|
||||
"needs_trimming": usage_percentage > 80,
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point - Context Preparation with Template Injection
|
||||
# ============================================================================
|
||||
|
||||
def prepare_context_messages(
|
||||
state_messages: list[Any],
|
||||
system_prompt: str,
|
||||
topology_context: str | None,
|
||||
model_name: str,
|
||||
llm_config: dict[str, Any] | None = None,
|
||||
tools: list[Any] | None = None,
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Prepare full context messages for LLM call with automatic trimming.
|
||||
|
||||
This is the main entry point for GNS3-Copilot to prepare messages
|
||||
before calling the LLM. It injects topology info into the system prompt
|
||||
using template variables and performs intelligent message trimming.
|
||||
|
||||
Template Variable Injection:
|
||||
The system_prompt must contain the {{topology_info}} placeholder.
|
||||
This function will replace it with actual topology information or
|
||||
a placeholder message if topology is not available.
|
||||
|
||||
Args:
|
||||
state_messages: Message history from conversation state
|
||||
system_prompt: System prompt text (must contain {{topology_info}} placeholder)
|
||||
topology_context: Optional topology information string
|
||||
model_name: Name of the LLM model
|
||||
llm_config: Optional LLM config dict from database (may contain context_limit and context_strategy)
|
||||
tools: Optional list of LangChain tools (for token estimation)
|
||||
|
||||
Returns:
|
||||
list: Prepared messages ready for LLM invocation
|
||||
|
||||
Examples:
|
||||
>>> messages = prepare_context_messages(
|
||||
... state_messages=[HumanMessage("Help me")],
|
||||
... system_prompt="You are a helpful assistant\\n\\n{{topology_info}}",
|
||||
... topology_context=None,
|
||||
... model_name="gpt-4o"
|
||||
... )
|
||||
>>> len(messages)
|
||||
2 # System message + Human message
|
||||
"""
|
||||
# Step 1: Get trimming strategy from config
|
||||
trim_strategy = DEFAULT_CONTEXT_STRATEGY
|
||||
if llm_config and "context_strategy" in llm_config:
|
||||
strategy = llm_config["context_strategy"]
|
||||
if strategy in CONTEXT_STRATEGY_RATIOS:
|
||||
trim_strategy = strategy
|
||||
logger.debug("Using context_strategy from config: %s", trim_strategy)
|
||||
else:
|
||||
logger.warning("Invalid context_strategy '%s', using '%s'", strategy, DEFAULT_CONTEXT_STRATEGY)
|
||||
|
||||
# Step 2: Estimate tool tokens (tools are sent with each LLM call)
|
||||
tool_tokens = estimate_tool_tokens(tools) if tools else 0
|
||||
|
||||
# Step 3: Inject topology info into system prompt using template variable
|
||||
# The system_prompt contains {{topology_info}} placeholder
|
||||
if topology_context:
|
||||
topology_formatted = f"Current Topology:\n{topology_context}"
|
||||
formatted_prompt = system_prompt.replace("{{topology_info}}", topology_formatted)
|
||||
else:
|
||||
# If no topology, use placeholder
|
||||
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
|
||||
|
||||
# Step 4: Calculate token breakdown
|
||||
system_prompt_tokens = count_tokens_accurately(system_prompt)
|
||||
topology_tokens = count_tokens_accurately(topology_formatted) if topology_context else 0
|
||||
formatted_tokens = count_tokens_accurately(formatted_prompt)
|
||||
|
||||
# Step 5: Build context messages (single system message with topology injected)
|
||||
context_messages = [SystemMessage(content=formatted_prompt)]
|
||||
|
||||
# Step 6: Calculate conversation history tokens
|
||||
history_tokens = count_messages_tokens(state_messages)
|
||||
|
||||
# Step 7: Combine with conversation history
|
||||
full_messages = context_messages + state_messages
|
||||
|
||||
# Step 8: Trim if needed
|
||||
trimmed_messages = trim_messages_for_context(
|
||||
full_messages,
|
||||
model_name=model_name,
|
||||
llm_config=llm_config,
|
||||
strategy=trim_strategy,
|
||||
preserve_system=True, # Always keep system prompts
|
||||
tool_tokens=tool_tokens, # Account for tool definitions
|
||||
)
|
||||
|
||||
# Step 9: Recalculate after trimming
|
||||
trimmed_history_tokens = count_messages_tokens([m for m in trimmed_messages if not isinstance(m, SystemMessage)])
|
||||
|
||||
# Step 10: Log summary with detailed breakdown
|
||||
model_limit_k = get_model_context_limit(model_name, llm_config) // 1000
|
||||
|
||||
if trimmed_history_tokens < history_tokens:
|
||||
# Trimming happened
|
||||
logger.info(
|
||||
"Context prepared (trimmed): system=%d (base=%d + topology=%d) + history=%d→%d + tools=%d = %d total / %dK limit (%.1f%%), strategy=%s",
|
||||
formatted_tokens,
|
||||
system_prompt_tokens,
|
||||
topology_tokens,
|
||||
history_tokens,
|
||||
trimmed_history_tokens,
|
||||
tool_tokens,
|
||||
formatted_tokens + trimmed_history_tokens + tool_tokens,
|
||||
model_limit_k,
|
||||
((formatted_tokens + trimmed_history_tokens) / (model_limit_k * 1000)) * 100,
|
||||
trim_strategy
|
||||
)
|
||||
else:
|
||||
# No trimming
|
||||
logger.info(
|
||||
"Context prepared: system=%d (base=%d + topology=%d) + history=%d + tools=%d = %d total / %dK limit (%.1f%%), strategy=%s",
|
||||
formatted_tokens,
|
||||
system_prompt_tokens,
|
||||
topology_tokens,
|
||||
history_tokens,
|
||||
tool_tokens,
|
||||
formatted_tokens + history_tokens + tool_tokens,
|
||||
model_limit_k,
|
||||
(formatted_tokens / (model_limit_k * 1000)) * 100,
|
||||
trim_strategy
|
||||
)
|
||||
|
||||
return trimmed_messages
|
||||
|
||||
# ============================================================================
|
||||
# Module Test
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test
|
||||
test_messages = [
|
||||
HumanMessage(f"Message {i}") for i in range(100)
|
||||
]
|
||||
|
||||
# Test with mock llm_config
|
||||
mock_config = {"context_limit": 8, "context_strategy": "conservative"}
|
||||
|
||||
result = prepare_context_messages(
|
||||
state_messages=test_messages,
|
||||
system_prompt="You are GNS3 Copilot.\n\n{{topology_info}}",
|
||||
topology_context='{"project_id": "test", "nodes": 5}',
|
||||
model_name="gpt-4o",
|
||||
llm_config=mock_config,
|
||||
tools=None
|
||||
)
|
||||
|
||||
print(f"Original: {len(test_messages)} messages")
|
||||
print(f"Trimmed: {len(result)} messages")
|
||||
Reference in new issue
Block a user