diff --git a/docs/gns3-copilot/context-window-management.md b/docs/gns3-copilot/context-window-management.md index 1453582e5..97a4770e2 100644 --- a/docs/gns3-copilot/context-window-management.md +++ b/docs/gns3-copilot/context-window-management.md @@ -2,196 +2,7 @@ ## 概述 -本文档说明了 GNS3 Copilot 如何处理不同 LLM 模型的上下文窗口限制,以及如何实现自动消息裁剪功能。 - -## ⚠️ 重要:context_limit 必须手动配置 - -**由于模型供应商频繁更新上下文窗口大小,系统不再提供内置默认值。** - -用户在创建 LLM 模型配置时**必须提供 `context_limit`**。请从模型供应商的官方文档获取最新的上下文窗口大小。 - -### ⚡ 依赖要求 - -**tiktoken 是必需依赖**,系统无法在没有 tiktoken 的情况下运行。 - -```bash -pip install tiktoken>=0.8.0 -``` - -如果未安装 tiktoken,系统将在首次尝试计数 tokens 时抛出 ImportError。 - -### ⚡ 单位说明 - -**`context_limit` 的单位是 K tokens(千 tokens)** - -- `1` = 1K = 1,000 tokens -- `128` = 128K = 128,000 tokens -- `200` = 200K = 200,000 tokens -- `2800` = 2800K = 2,800,000 tokens - -### 为什么使用 K tokens? - -1. **更简洁** - `128` 比 `128000` 更易读易写 -2. **减少错误** - 避免少写或多写 0 -3. **符合习惯** - 与业界常用的 "128K", "200K" 表示法一致 - -### 为什么必须手动配置? - -1. **模型更新频繁** - OpenAI、Anthropic、Google 等供应商经常发布模型更新 -2. **上下文大小变化** - 新版本往往增加上下文窗口,旧版本可能被废弃 -3. **维护成本高** - 内置默认值很快过时,可能导致配置错误 -4. **责任明确** - 用户根据实际使用的模型配置,避免混淆 - ---- - -## 数据库配置方式 - -### 配置要求 - -`context_limit` 是**必填字段**,单位为 **K tokens**。创建或更新 LLM 模型配置时必须提供。 - -| 字段 | 类型 | 必填 | 单位 | 说明 | -|------|------|------|------|------| -| `context_limit` | `int` | ✅ **是** | K tokens | 模型上下文窗口限制(128 = 128K tokens) | -| `context_strategy` | `"conservative" \| "balanced" \| "aggressive"` | 否 | - | 裁剪策略,默认 "balanced" | - -### 获取上下文窗口大小 - -#### 官方文档链接 - -| 供应商 | 文档链接 | 示例值(tokens) | 配置值(K) | -|--------|---------|------------------|------------| -| OpenAI | https://platform.openai.com/docs/models | 128,000 | `128` | -| Anthropic | https://docs.anthropic.com/claude/docs/models-overview | 200,000 | `200` | -| Google | https://ai.google.dev/gemini-api/docs/models | 2,800,000 | `2800` | -| DeepSeek | https://platform.deepseek.com/api-docs/ | 128,000 | `128` | -| xAI | https://docs.x.ai/ | 128,000 | `128` | - -**转换示例**: -- 官方文档显示 `128,000 tokens` → 配置为 `128` -- 官方文档显示 `200,000 tokens` → 配置为 `200` -- 官方文档显示 `2,800,000 tokens` → 配置为 `2800` - -#### 参考工具 - -运行参考脚本查看常见模型的上下文限制(显示为 K tokens): - -```bash -python scripts/show_model_context_limits.py -``` - -**注意**:此脚本仅提供参考值,请以官方文档为准。 - ---- - -## API 使用示例 - -### 创建配置(必须提供 context_limit) - -```bash -POST /v3/users/{user_id}/llm-model-configs -Content-Type: application/json - -{ - "name": "GPT-4o Configuration", - "model_type": "text", - "is_default": true, - "provider": "openai", - "base_url": "https://api.openai.com/v1", - "model": "gpt-4o", - "temperature": 0.7, - "api_key": "sk-...", - "context_limit": 128, # ← 必填:单位是 K tokens (128 = 128K = 128,000 tokens) - "context_strategy": "balanced" # 可选,默认 "balanced" -} -``` - -### 配置示例对比 - -#### 官方文档 → API 配置 - -| 模型 | 官方文档 | API 配置 | -|------|---------|---------| -| GPT-4o | 128,000 tokens | `"context_limit": 128` | -| GPT-3.5 Turbo | 16,385 tokens | `"context_limit": 17` (向上取整) | -| Claude 3.5 Sonnet | 200,000 tokens | `"context_limit": 200` | -| Gemini 1.5 Pro | 2,800,000 tokens | `"context_limit": 2800` | - -### 错误示例(缺少 context_limit) - -```bash -POST /v3/users/{user_id}/llm-model-configs -Content-Type: application/json - -{ - "name": "Invalid Config", - "provider": "openai", - "model": "gpt-4o", - # ❌ 缺少 context_limit -} -``` - -**响应**: -```json -{ - "detail": "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). Please check your model provider's documentation for the current context window size and specify it in the configuration." -} -``` - ---- - -## 常见模型配置参考 - -以下是一些常见模型的 `context_limit` 配置值(**请以官方文档为准**): - -### OpenAI -| 模型 | 官方文档 | 配置值 | -|------|---------|-------| -| GPT-4o | 128,000 tokens | `"context_limit": 128` | -| GPT-4o-mini | 128,000 tokens | `"context_limit": 128` | -| GPT-4 Turbo | 128,000 tokens | `"context_limit": 128` | -| GPT-3.5 Turbo | 16,385 tokens | `"context_limit": 17` | - -### Anthropic -| 模型 | 官方文档 | 配置值 | -|------|---------|-------| -| Claude 3.5 Sonnet | 200,000 tokens | `"context_limit": 200` | -| Claude 3 Opus | 200,000 tokens | `"context_limit": 200` | -| Claude 3 Haiku | 200,000 tokens | `"context_limit": 200` | - -### Google -| 模型 | 官方文档 | 配置值 | -|------|---------|-------| -| Gemini 2.0 Flash | 1,000,000 tokens | `"context_limit": 1000` | -| Gemini 1.5 Pro | 2,800,000 tokens | `"context_limit": 2800` | -| Gemini 1.5 Flash | 2,800,000 tokens | `"context_limit": 2800` | - -### DeepSeek -| 模型 | 官方文档 | 配置值 | -|------|---------|-------| -| DeepSeek Chat | 128,000 tokens | `"context_limit": 128` | -| DeepSeek Coder | 128,000 tokens | `"context_limit": 128` | - -### xAI -| 模型 | 官方文档 | 配置值 | -|------|---------|-------| -| Grok Beta | 128,000 tokens | `"context_limit": 128` | - ---- - -## 问题背景 - -### 原始问题 -- 消息历史无限制累积,使用 `operator.add` 追加所有消息 -- 不同模型的上下文窗口限制不同 -- 当消息历史超过模型限制时,LLM 调用会失败 -- 没有优雅的降级或裁剪机制 - -### 解决方案 -使用 LangChain/LangGraph 的内置功能实现智能上下文管理: -- **`trim_messages`**: LangChain 的消息裁剪工具 -- **`count_tokens_approximately`**: Token 计数功能 -- **用户配置**: 必须提供 context_limit(单位:K tokens),无默认值 +本文档说明 GNS3 Copilot 的上下文窗口管理实现机制,包括消息裁剪、Token 计数和配置验证。 ## 实现架构 @@ -201,51 +12,39 @@ Content-Type: application/json #### Token 计数策略 -系统使用 **tiktoken** 进行准确的 Token 计数: +系统使用 **tiktoken** 进行 Token 计数(context_manager.py:60): -1. **tiktoken(OpenAI 的 tokenizer)** - - 使用 `cl100k_base` 编码(GPT-4) - - 对大多数现代 LLM 准确(OpenAI、Anthropic、DeepSeek) - - 准确率约 95%+ - - **必需依赖**:系统强制要求安装 tiktoken - -2. **工具定义 Token 估算** - - 工具的 schema(name、description、parameters)会被转换为 JSON 发送给 LLM - - 系统会自动计算这些定义的 token 消耗 - - 每个工具约 500-1500 tokens(取决于 schema 复杂度) - -#### 安装 tiktoken(必需) +```python +_tiktoken_encoding = tiktoken.get_encoding("cl100k_base") +``` +**必需依赖**: ```bash pip install tiktoken>=0.8.0 ``` -**为什么使用 tiktoken?** -- 比字符估算准确 2-3 倍(特别是中文内容) -- 支持 LangChain 使用的所有主流模型 -- 性能优秀(缓存编码器) -- **这是必需依赖**,系统将无法运行如果未安装 +如果未安装 tiktoken,系统将在启动时抛出 `ModuleNotFoundError`。 #### 关键函数 -**`get_model_context_limit(model_name: str, llm_config: dict) -> int`** -- 从数据库配置获取模型的上下文窗口大小 -- **`llm_config` 必须包含 `context_limit` 字段(单位:K tokens)** -- 如果未提供或无效,抛出 `ValueError` -- 返回值单位为实际 tokens(K tokens × 1000) +**`count_tokens(text: str) -> int`** (context_manager.py:84-100) +- 使用 tiktoken 准确计数文本的 token 数 +- 使用 `cl100k_base` 编码 +- 返回精确的 token 数 -**`calculate_max_tokens(model_limit, strategy) -> int`** -- 计算可用 token 数量(预留输出空间) -- 三种策略: - - `conservative`: 使用 60% 限制(更安全) - - `balanced`: 使用 75% 限制(默认) - - `aggressive`: 使用 85% 限制(最大化输入) +**`estimate_tool_tokens(tools: list) -> int`** (context_manager.py:103-169) +- 序列化工具 schema 为 JSON +- 使用 tiktoken 计数工具定义的 token 消耗 +- 支持 Pydantic v1/v2 兼容性 +- 失败时使用 1000 tokens 的回退值 -**`trim_messages_for_context(messages, model_name, strategy, tool_tokens) -> list`** -- 使用 tiktoken 裁剪消息 -- 保留最近的消息 -- 始终保留系统消息 -- **关键**:考虑工具定义占用的 tokens +**`create_pre_model_hook(...)`** (context_manager.py:195-402) +- 创建预处理函数(pre_model_hook) +- 在每次 LLM 调用前自动执行: + 1. 注入 topology 信息到 system prompt + 2. 估算工具定义的 token 消耗 + 3. 裁剪消息历史以适应上下文限制 +- 返回一个可调用的函数,用于准备消息 ### 2. 裁剪逻辑详解 @@ -278,37 +77,32 @@ pip install tiktoken>=0.8.0 ``` 第一步:计算输入预算 ┌─────────────────────────────────────────────────────────────┐ -│ context_limit: 16,000 tokens │ -│ strategy: conservative (60%) │ +│ context_limit: 128,000 tokens (128K) │ +│ strategy: balanced (75%) │ │ │ -│ 输入预算 = 16,000 × 0.6 = 9,600 tokens │ +│ 输入预算 = 128 × 1000 × 0.75 = 96,000 tokens │ └─────────────────────────────────────────────────────────────┘ ↓ 第二步:减去工具定义 ┌─────────────────────────────────────────────────────────────┐ -│ 输入预算: 9,600 tokens │ -│ 工具定义: 1,862 tokens │ +│ 输入预算: 96,000 tokens │ +│ 工具定义: 1,725 tokens │ │ │ -│ 可用于消息 = 9,600 - 1,862 = 7,738 tokens │ +│ 可用于消息 = 96,000 - 1,725 = 94,275 tokens │ └─────────────────────────────────────────────────────────────┘ ↓ -第三步:计算 System Message token(已合并) +第三步:trim_messages 处理 ┌─────────────────────────────────────────────────────────────┐ -│ 可用于消息: 7,738 tokens │ +│ 调用 LangChain 的 trim_messages: │ +│ - max_tokens = 94,275 (包含 system message) │ +│ - strategy = "last" (保留最新消息) │ +│ - token_counter = tiktoken 计数函数 │ +│ - include_system = True (始终保留 system) │ │ │ -│ System Message (已包含 system + topology): │ -│ - System prompt (base): ~1,000 tokens │ -│ - Topology info: ~3,000 tokens │ -│ = 4,000 tokens (合并后) │ -│ │ -│ 可用于历史 = 7,738 - 4,000 = 3,738 tokens │ -└─────────────────────────────────────────────────────────────┘ - ↓ -第四步:裁剪对话历史 -┌─────────────────────────────────────────────────────────────┐ -│ 历史消息: 5,000 tokens → 需要裁剪到 3,738 tokens │ -│ │ -│ 丢弃旧消息,直到满足限制 │ +│ trim_messages 会: │ +│ 1. 保留 SystemMessage (system + topology) │ +│ 2. 从最新消息开始,保留尽可能多的历史 │ +│ 3. 超出限制时,丢弃最旧的消息 │ └─────────────────────────────────────────────────────────────┘ ``` @@ -329,430 +123,158 @@ pip install tiktoken>=0.8.0 | 情况 | 处理方式 | |------|----------| | System (包含 topology) > 预算 | 保留完整的 System Message(无法分离 system 和 topology) | -| Tools > 预算 | 警告日志,建议增加 context_limit 或减少工具数量 | +| Tools > 预算 | ERROR 日志,建议增加 context_limit 或减少工具数量 | | 历史全被裁剪 | 保留最后1条用户消息 | **重要提示**: - 当 system + topology 超出可用预算时,**两者都会被保留** - 无法只丢弃 topology 而保留 system prompt(因为已合并) -- 建议在 system prompt 中精简 topology 信息或使用更短的 system prompt -### 2. 集成到 GNS3 Copilot +### 3. 集成到 GNS3 Copilot -**修改文件**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` +**文件位置**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` -#### 改动内容 +#### 实现方式 -**导入新模块**: -```python -from gns3server.agent.gns3_copilot.agent.context_manager import ( - prepare_context_messages, -) -``` +**关键点**:系统使用**自定义 StateGraph**,不是 LangGraph 的预构建 agent。 -**替换消息构建逻辑**: -```python -# 旧代码(手动构建) -full_messages = ( - [SystemMessage(content=current_prompt)] - + context_messages - + state["messages"] -) +因此,`pre_model_hook` 不能通过 `model.invoke(config={"configurable": {"pre_model_hook": ...}})` 传递。 -# 新代码(自动裁剪) -full_messages = prepare_context_messages( - state_messages=state["messages"], - system_prompt=current_prompt, - topology_context=topology_context, - model_name=llm_config.get("model", "default"), - llm_config=llm_config, # ← 包含 context_limit 和 context_strategy -) -``` - -### 3. Token 计数实现细节 - -系统使用 **tiktoken** 进行准确的 Token 计数: - -#### 3.1 Token 计数器 - -**使用的编码**:`cl100k_base` (GPT-4) - -**支持的模型**: -- ✅ OpenAI (GPT-4, GPT-3.5) -- ✅ Anthropic (Claude 系列) -- ✅ DeepSeek (deepseek-chat, deepseek-coder) -- ✅ 大多数基于 GPT-4 架构的模型 - -**准确率**: -- 英文:95%+ -- 中文:95%+ -- 代码:90-95% - -#### 3.2 工具定义 Token 估算 +**正确的使用方式**:**直接调用** `pre_hook` 函数准备消息。 ```python -def estimate_tool_tokens(tools: list[Any]) -> int: - """动态序列化工具 schema 并计算 token""" - for tool in tools: - tool_schema = { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.args_schema.schema() - } - } - # 使用 tiktoken 计数 - schema_str = json.dumps(tool_schema, ensure_ascii=False) - tool_tokens = len(encoding.encode(schema_str)) +def llm_call(state: dict, config: RunnableConfig | None = None): + """LLM decides whether to call a tool or not.""" + + # 1. 获取 topology 信息 + project_id = config["configurable"].get("project_id") + topology_info = None + if project_id: + topology_tool = GNS3TopologyTool() + topology = topology_tool._run(project_id=project_id) + if topology and "error" not in topology: + topology_info = topology + + # 2. 创建 pre_model_hook + system_prompt = load_system_prompt() + pre_hook = create_pre_model_hook( + system_prompt=system_prompt, + get_topology_func=lambda s: s.get("topology_info"), + get_llm_config_func=get_current_llm_config, + get_tools_func=lambda: tools, + ) + + # 3. 创建 model with tools + model_with_tools = create_base_model_with_tools(tools, llm_config=llm_config) + + # 4. ⭐ 关键:直接调用 pre_hook 准备消息 + logger.info("Calling pre_hook to prepare %d messages", len(messages)) + prepared_state = pre_hook({"messages": messages, "topology_info": topology_info}) + prepared_messages = prepared_state["messages"] + + # 5. 使用准备好的消息调用 LLM + response = model_with_tools.invoke(prepared_messages) + + return {"messages": [response], ...} ``` -**典型消耗**: -- 简单工具(1-2 个参数):500-800 tokens -- 中等工具(3-5 个参数):800-1200 tokens -- 复杂工具(嵌套参数):1200-1500 tokens -- 8 个工具:约 6000-10000 tokens +#### 为什么不通过 config 传递? ---- +LangGraph 的 `pre_model_hook` 参数仅适用于**预构建的 agent**,不适用于自定义 StateGraph。 -## 策略详细说明 +| Agent 类型 | pre_model_hook 支持方式 | +|------------|------------------------| +| `create_react_agent` | ✅ 通过 `pre_model_hook` 参数 | +| `chat_agent_executor` | ✅ 通过 `pre_model_hook` 参数 | +| **自定义 StateGraph** | ❌ **不支持**,需要直接调用 | -### Conservative(保守策略) +我们的实现使用的是自定义 StateGraph(`agent_builder = StateGraph(MessagesState)`),所以必须直接调用 `pre_hook`。 -**参数配置**: -```json -{ - "context_limit": 128, - "context_strategy": "conservative" -} -``` - -**实际效果**: -- **使用比例**:60% (context_limit × 0.6) -- **计算公式**:`max_tokens = context_limit × 1000 × 0.6` -- **示例**:对于 128K 上下文限制 - - 保留空间:`128 × 1000 × 0.6 = 76,800` tokens 用于输入 - - 预留空间:`128,000 - 76,800 = 51,200` tokens 用于输出 - -**适用场景**: -1. **长输出任务** - - 代码生成(可能生成数百行代码) - - 文章写作(需要完整文章内容) - - 详细报告生成(包含多个章节) - -2. **复杂任务** - - 多步骤推理任务 - - 需要深度分析的请求 - - 综合性问题的解决方案 - -3. **不确定输出大小时** - - 不确定 LLM 会返回多长的内容 - - 首次尝试某种类型的任务 - - 需要额外安全边界的场景 - -**优缺点**: -- ✅ **优点**:输出更不容易被截断,安全性高 -- ❌ **缺点**:输入上下文较少,可能遗漏早期信息 - -**日志示例**: -``` -INFO: Context prepared: 20 msgs, ~72800 tokens / 128K limit (56.9%), strategy=conservative -INFO: Available for output: ~51200 tokens -``` - ---- - -### Balanced(平衡策略,推荐) - -**参数配置**: -```json -{ - "context_limit": 128, - "context_strategy": "balanced" -} -``` - -**实际效果**: -- **使用比例**:75% (context_limit × 0.75) -- **计算公式**:`max_tokens = context_limit × 1000 × 0.75` -- **示例**:对于 128K 上下文限制 - - 保留空间:`128 × 1000 × 0.75 = 96,000` tokens 用于输入 - - 预留空间:`128,000 - 96,000 = 32,000` tokens 用于输出 - -**适用场景**: -1. **一般对话** - - 日常聊天交互 - - 问答式对话 - - 技术支持和咨询 - -2. **大多数场景** - - 网络配置命令生成 - - 故障排查建议 - - 知识问答 - -3. **平衡输入和输出** - - 需要较多上下文,但输出也较长的场景 - - 中等复杂度的任务 - -**优缺点**: -- ✅ **优点**:在输入上下文和输出空间之间取得良好平衡 -- ✅ **优点**:适用于大多数使用场景 -- ⚠️ **注意**:对于特别长的输出可能被截断 - -**日志示例**: -``` -INFO: Context prepared: 30 msgs, ~85600 tokens / 128K limit (66.9%), strategy=balanced -INFO: Available for output: ~32000 tokens -``` - ---- - -### Aggressive(激进策略) - -**参数配置**: -```json -{ - "context_limit": 128, - "context_strategy": "aggressive" -} -``` - -**实际效果**: -- **使用比例**:85% (context_limit × 0.85) -- **计算公式**:`max_tokens = context_limit × 1000 × 0.85` -- **示例**:对于 128K 上下文限制 - - 保留空间:`128 × 1000 × 0.85 = 108,800` tokens 用于输入 - - 预留空间:`128,000 - 108,800 = 19,200` tokens 用于输出 - -**适用场景**: -1. **简短输出任务** - - 是/否判断 - - 简短回答确认 - - 状态查询类请求 - -2. **分析类任务** - - 日志分析(输出分析结果,但不需要很长) - - 数据解读(输出简洁的结论) - - 配置检查(返回 OK 或简短说明) - -3. **输出比较确定的场景** - - 明确知道输出会很短 - - 只需要简单确认或状态 - - 不需要长篇解释的任务 - -**优缺点**: -- ✅ **优点**:最大化输入上下文,保留更多历史信息 -- ❌ **缺点**:输出容易被截断,不适用于长输出任务 - -**日志示例**: -``` -INFO: Context prepared: 50 msgs, ~105200 tokens / 128K limit (82.2%), strategy=aggressive -INFO: Available for output: ~19200 tokens -``` - ---- - -## 策略对比总结 - -| 特性 | Conservative | Balanced | Aggressive | -|------|-------------|----------|------------| -| **输入比例** | 60% | 75% | 85% | -| **输出预留** | 40% | 25% | 15% | -| **上下文数量** | 最少 | 中等 | 最多 | -| **输出空间** | 最大 | 中等 | 最小 | -| **适用性** | 长输出 | 一般使用 | 短输出 | -| **风险** | 上下文不足 | 平衡 | 输出截断 | - -**选择建议流程**: -``` -1. 任务类型是什么? - ├─ 代码生成/长文档 → Conservative - ├─ 日常对话/一般任务 → Balanced (推荐) - └─ 简短确认/分析 → Aggressive - -2. 输出长度预估? - ├─ 不确定/可能很长 → Conservative - ├─ 中等长度 → Balanced - └─ 很短/简洁 → Aggressive - -3. 上下文重要性? - ├─ 早期历史不太重要 → Aggressive (更多上下文) - ├─ 需要平衡 → Balanced - └─ 重点是输出完整性 → Conservative (更多输出空间) -``` - -## 工作流程 +### 4. 执行流程 ``` 用户发送消息 ↓ -获取消息历史 state["messages"] +llm_call 节点被调用 ↓ -构建系统提示 + 拓扑信息 +获取 project_id (从 config["configurable"]) ↓ -调用 prepare_context_messages() +调用 GNS3TopologyTool._run(project_id) 获取 topology ↓ - ├─ 估算 token 数量 - ├─ 获取模型上下文限制 - ├─ 判断是否需要裁剪 - └─ 如果需要 → 调用 trim_messages() +存储 topology_info 到 state ↓ -调用 LLM(带裁剪后的消息) +创建 pre_model_hook (通过 create_pre_model_hook()) ↓ -返回响应 +【关键】直接调用 pre_hook({"messages": messages, "topology_info": topology_info}) + ├─ 1. 注入 topology 到 system prompt + ├─ 2. 估算工具定义 tokens + ├─ 3. 调用 trim_messages() 裁剪消息 + └─ 4. 返回准备好的消息列表 + ↓ +使用准备好的消息调用 model.invoke() + ↓ +返回 LLM 响应 ``` -## 日志输出示例 - -### 正常情况(使用 tiktoken,包含工具定义) -``` -INFO: Using tiktoken (cl100k_base) for accurate token counting -INFO: Tool definitions estimated at ~8500 total tokens (8 tools) -INFO: Using database config context limit: 128000 tokens for model 'gpt-4o' -INFO: Context prepared: 15 msgs, ~28432 tokens (messages) + 8500 tokens (tools) = 36932 total / 128K limit (28.9%), strategy=balanced -INFO: LLM call completed: tool_calls=2 -``` - -### 发生裁剪时 -``` -INFO: Using tiktoken (cl100k_base) for accurate token counting -INFO: Tool definitions estimated at ~8500 total tokens (8 tools) -INFO: Using database config context limit: 128000 tokens for model 'gpt-4o' -INFO: Trimming messages: 95000 → 82000 tokens (model: gpt-4o) -INFO: Trimmed 50 → 25 messages -INFO: Context prepared: 27 msgs, ~82000 tokens (messages) + 8500 tokens (tools) = 90500 total / 128K limit (70.7%), strategy=balanced -``` - -### 配置错误时 -``` -ERROR: context_limit is required but not provided for model 'gpt-4o'. - Please configure context_limit in your LLM model configuration. - Refer to the model provider's documentation for the current context window size. -``` - -### 日志格式说明 - -新的日志格式(包含工具定义和 topology 分解): -``` -Context prepared: system={总tokens} (base={base_tokens} + topology={topology_tokens}) + history={history_tokens} + tools={tool_tokens} = {总计} total / {限制}K limit ({使用百分比}%), strategy={策略} -``` - -字段说明: -- **system**:System Message 的总 token 数(包含 system prompt + topology) -- **base**:基础 system prompt 的 token 数(不含 topology) -- **topology**:Topology info 的 token 数 -- **history**:对话历史(HumanMessage + AIMessage + ToolMessage)的 token 数 -- **tools**:工具定义(schema)的 token 数 -- **总计**:所有部分的 token 总和 -- **限制**:模型的上下文窗口大小(K tokens) -- **使用百分比**:总计 tokens / 限制 × 100% -- **策略**:使用的裁剪策略(conservative/balanced/aggressive) - -**示例输出**: -``` -Context prepared: system=6124 (base=2804 + topology=3320) + history=11800 + tools=1862 = 19786 total / 128K limit (15.5%), strategy=balanced -``` - -这表示: -- System Message 总共 6124 tokens - - 其中基础 system prompt 2804 tokens - - 其中 topology info 3320 tokens -- 对话历史 11800 tokens -- 工具定义 1862 tokens -- 总计 19786 tokens,使用 128K 限制的 15.5% - --- -## Token 计数准确性 +## 策略实现 -### 为什么估算值和实际值可能不同? +### Context Strategy Ratios -#### 1. **工具定义的影响** +**定义**(context_manager.py:68-72): -每次 LLM 调用时,工具定义会被转换为 JSON 并发送给 LLM: - -```json -{ - "type": "function", - "function": { - "name": "ExecuteMultipleDeviceCommands", - "description": "在多个网络设备上执行命令...", - "parameters": { - "type": "object", - "properties": { - "commands": {...} - } - } - } +```python +CONTEXT_STRATEGY_RATIOS = { + "conservative": 0.60, + "balanced": 0.75, + "aggressive": 0.85, } ``` -**典型消耗**: -- 简单工具:500-800 tokens -- 复杂工具:1000-1500 tokens -- 8 个工具:约 6000-12000 tokens +**默认值**(context_manager.py:74): +```python +DEFAULT_CONTEXT_STRATEGY = "balanced" +``` -**系统处理**: -- ✅ 现在会自动估算工具定义的 tokens -- ✅ 日志中会显示工具 tokens -- ✅ 裁剪决策会考虑工具定义 +### 策略对比 -#### 2. **中文内容的 Tokenization** - -不同语言对 token 的使用效率不同: - -| 语言 | 平均字符/token | 示例 | -|------|---------------|------| -| 英文 | 3-4 字符 | "Hello world" ≈ 2-3 tokens | -| 中文 | 1-1.5 字符 | "你好世界" ≈ 3-4 tokens | -| 代码 | 2-3 字符 | `print("Hello")` ≈ 4-5 tokens | - -**使用 tiktoken 后**: -- 对中文的准确率从 30-40% 提升到 95%+ -- 对英文的准确率保持在 95%+ -- 对代码的准确率约 90-95% - -#### 3. **System Prompt 和 Topology Context** - -- **System Prompt**: 固定内容,约 1000-2000 tokens -- **Topology Context**: 动态内容,取决于拓扑大小 - - 小型拓扑(< 10 节点):约 500-1000 tokens - - 中型拓扑(10-50 节点):约 2000-5000 tokens - - 大型拓扑(> 50 节点):约 5000-10000+ tokens - -这些都会被准确计数并计入总限制。 +| 策略 | 输入比例 | 输出预留 | 计算公式 | +|------|---------|---------|---------| +| Conservative | 60% | 40% | `context_limit × 1000 × 0.60` | +| Balanced | 75% | 25% | `context_limit × 1000 × 0.75` | +| Aggressive | 85% | 15% | `context_limit × 1000 × 0.85` | --- -## Token 使用对比 +## 日志输出 -### 改进前(LangChain 估算) +### 正常情况(topology 成功注入) ``` -INFO: Context prepared: 29 msgs, ~11523 tokens / 128K limit (9.0%), strategy=conservative -实际发送: 39700 tokens -差距: 28177 tokens (2.4x 低估) +INFO: Calling pre_hook to prepare 1 messages +INFO: ✓ Topology injected: 7722 chars, nodes: ['netshoot-1', 'R1', 'R2', 'IOU-L3-1', 'IOU-L3-2'] +INFO: Context ready: 2 msgs, ~3815 tokens + 1725 tools = 5540 / 128K (4.3%), strategy=conservative +INFO: Messages prepared: 1 → 2 +INFO: LLM call completed: tool_calls=0 ``` -**问题**: -1. ❌ 工具定义没有被计入(约 8000-12000 tokens) -2. ❌ 中文内容被低估(约 2.4x 误差) -3. ❌ 总体低估约 2-3 倍 - -### 改进后(tiktoken + 工具定义) +### 发生裁剪时 ``` -INFO: Using tiktoken (cl100k_base) for accurate token counting -INFO: Tool definitions estimated at ~8500 total tokens (8 tools) -INFO: Context prepared: 29 msgs, ~27500 tokens (messages) + 8500 tokens (tools) = 36000 total / 128K limit (28.1%), strategy=conservative -实际发送: 39700 tokens -差距: 3700 tokens (1.1x 误差) +INFO: Calling pre_hook to prepare 50 messages +INFO: ✓ Topology injected: 8500 chars, nodes: ['R1', 'R2', ...] +INFO: Messages trimmed: 50 → 25 msgs. Total: ~82000 tokens + 1725 tools = 83725 / 128K (65.4%), strategy=balanced +INFO: Messages prepared: 50 → 25 ``` -**改进**: -1. ✅ 工具定义被准确估算 -2. ✅ 中文内容准确率提升到 95%+ -3. ✅ 总体误差降低到 10% 以内 +### topology 为 None 时 + +``` +INFO: Calling pre_hook to prepare 1 messages +WARNING: ✗ Topology data is None, injecting placeholder +INFO: Context ready: 2 msgs, ~800 tokens + 1725 tools = 2525 / 128K (2.0%), strategy=balanced +``` --- @@ -760,11 +282,10 @@ INFO: Context prepared: 29 msgs, ~27500 tokens (messages) + 8500 tokens (tools) ### tiktoken 未安装 -如果 tiktoken 未安装,系统将在首次尝试计数 tokens 时抛出错误: +如果 tiktoken 未安装,系统将在启动时抛出错误: ```python -ImportError: tiktoken is required for accurate token counting. -Please install it with: pip install tiktoken>=0.8.0 +ModuleNotFoundError: No module named 'tiktoken' ``` **解决方法**: @@ -772,83 +293,34 @@ Please install it with: pip install tiktoken>=0.8.0 pip install tiktoken>=0.8.0 ``` -### Token 计数失败 +### context_limit 缺失或无效 + +如果 LLM 配置中没有 `context_limit` 或值无效(context_manager.py:285-295): + ```python -try: - tokens = count_messages_tokens(messages) -except ImportError as e: - logger.error("tiktoken not available: %s", e) - raise # Re-raise to fail fast -except Exception as e: - logger.error("Failed to count tokens: %s", e) - raise +if "context_limit" not in llm_config: + raise ValueError("context_limit is required in LLM config") + +limit = llm_config["context_limit"] +if not isinstance(limit, int) or limit <= 0: + raise ValueError(f"Invalid context_limit: {limit}") ``` ### 裁剪失败 + ```python try: trimmed = trim_messages(...) except Exception as e: - logger.error("Failed to trim: %s", e) - # 回退到简单的切片操作 - return system_msgs + other_msgs[-N:] + logger.error("Failed to trim messages: %s", e) + logger.warning("Returning original messages due to trimming error") + return {"messages": messages_with_system} ``` -## 参考资源 - -- [tiktoken (OpenAI Tokenizer)](https://github.com/openai/tiktoken) -- [LangChain Messages Utils](https://python.langchain.com/docs/messages/) -- [LangGraph Memory Management](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#memory) -- [OpenAI Models Context Limits](https://platform.openai.com/docs/models) -- [Anthropic Models Context Limits](https://docs.anthropic.com/claude/docs/models-overview) +--- ## 相关源文件 - `gns3server/agent/gns3_copilot/agent/context_manager.py` - 上下文管理核心逻辑 -- `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` - LLM 调用节点 -- `gns3server/agent/gns3_copilot/agent_service.py` - Agent 服务接口 - -## 总结 - -通过使用 LangChain/LangGraph 的内置功能和自定义优化,我们实现了: -1. ✅ 智能裁剪消息历史,避免超限 -2. ✅ **必须手动配置 context_limit**,确保使用正确的上下文窗口大小 -3. ✅ 可配置的裁剪策略(保守/平衡/激进) -4. ✅ 始终保留系统消息(包含 system prompt 和 topology) -5. ✅ 详细的日志输出,便于调试 -6. ✅ 优雅的错误处理和明确的错误提示 -7. ✅ 提供参考工具,帮助查找常见模型的上下文限制 -8. ✅ **使用 tiktoken 进行准确的 token 计数**(准确率 95%+) -9. ✅ **自动估算工具定义的 token 消耗** -10. ✅ **支持中文、英文、代码等多种内容类型** -11. ✅ **使用模板变量动态注入 topology info** (新增) -12. ✅ **日志中详细显示 system/topology/history 分解** (新增) - -### 关键优势 - -- **准确性**: - - 用户从官方文档获取最新的上下文限制,避免使用过时数据 - - tiktoken 提供准确的 token 计数,误差 < 10% - - 工具定义 token 被正确计入估算 - -- **灵活性**: - - 每个配置独立设置,支持不同用户使用不同限制 - - 自动适配不同语言和内容类型 - - 模板变量注入方式易于维护和调整 - -- **明确性**: - - 缺少配置时立即报错,避免静默失败 - - 详细日志显示所有 token 消耗(system base + topology + history + tools) - - 清楚显示各部分的 token 分解 - -- **可维护性**: - - 无需维护内置默认值,减少代码维护负担 - - 模板变量注入方式,system prompt 和 topology 管理更清晰 - - 模块化设计,易于扩展和调试 - -- **可观测性**: - - 详细日志显示上下文使用情况和裁剪决策 - - 区分 system base tokens、topology tokens、history tokens 和工具 tokens - - 显示使用百分比和策略选择 - -这个实现确保了即使在进行长对话时,系统也不会因为上下文溢出而失败。 +- `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` - LLM 调用节点(StateGraph) +- `gns3server/agent/gns3_copilot/agent/model_factory.py` - 模型创建和工具绑定 diff --git a/gns3server/agent/gns3_copilot/agent/context_manager.py b/gns3server/agent/gns3_copilot/agent/context_manager.py index 1e8cc988f..cfbf61564 100644 --- a/gns3server/agent/gns3_copilot/agent/context_manager.py +++ b/gns3server/agent/gns3_copilot/agent/context_manager.py @@ -2,6 +2,9 @@ # # GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 # +# Copyright (C) 2025 Guobin Yue +# Author: Guobin Yue +# # This file is part of GNS3-Copilot project. # # GNS3-Copilot is free software: you can redistribute it and/or modify it @@ -17,632 +20,527 @@ # You should have received a copy of the GNU General Public License # along with GNS3-Copilot. If not, see . # -# Copyright (C) 2025 Guobin Yue -# Author: Guobin Yue -# # Project Home: https://github.com/yueguobin/gns3-copilot # """ -Context Window Manager for GNS3-Copilot +Context Manager for GNS3-Copilot - Using LangGraph pre_model_hook -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 +This module provides context window management using LangGraph's recommended +pre_model_hook approach with accurate token counting: + +Key Features: +- Accurate token counting using tiktoken (95%+ accuracy) - Template variable injection for topology info +- Context strategy ratios (conservative/balanced/aggressive) +- Message trimming using LangChain's native trim_messages +- Tool definition token estimation +- Detailed token breakdown logging +Requirements: +- tiktoken>=0.8.0 (required) """ import json import logging -from typing import Any, Literal +import warnings +from typing import Any, Callable -from langchain_core.messages import ( - AIMessage, - HumanMessage, - SystemMessage, - ToolMessage, -) +import tiktoken + +from langchain_core.messages import BaseMessage, SystemMessage, trim_messages logger = logging.getLogger(__name__) +# ============================================================================ +# Token Counter Setup +# ============================================================================ + +# Initialize tiktoken encoding (required dependency) +_tiktoken_encoding = tiktoken.get_encoding("cl100k_base") +logger.info("Using tiktoken (cl100k_base) for accurate token counting") + # ============================================================================ # Constants # ============================================================================ -# Context strategy ratios +# Context strategy ratios (percentage of context limit to use for input) 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 + "conservative": 0.60, + "balanced": 0.75, + "aggressive": 0.85, } DEFAULT_CONTEXT_STRATEGY = "balanced" +# Token unit conversion +TOKENS_PER_K = 1000 + + # ============================================================================ -# Token Counting - Using tiktoken for accuracy +# Token Counting Functions # ============================================================================ -# Global tiktoken encoding cache (lazy loading) -_tiktoken_encoding = None - -def _get_tiktoken_encoding(): +def count_tokens(text: str) -> int: """ - Get tiktoken encoding instance (cached). + Count tokens in text using tiktoken. - 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. + Uses cl100k_base encoding (GPT-4) which provides accurate counting + for most modern LLMs (OpenAI, Anthropic, DeepSeek, etc.). Args: - text: The text to count tokens for + text: Text to count tokens for Returns: - Number of tokens - - Raises: - ImportError: If tiktoken is not installed + Exact token count (not estimated) """ 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 + return len(_tiktoken_encoding.encode(text)) -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. + Estimate token consumption for 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. + Tool definitions are serialized as JSON and sent to LLM with each request. + This function estimates their token cost. Args: - tools: List of LangChain BaseTool instances + tools: List of LangChain tool objects 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 + # Build tool schema in OpenAI format tool_schema = { "type": "function", "function": { "name": tool.name, - "description": tool.description if hasattr(tool, 'description') else "", + "description": tool.description, } } # 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"} + # Try Pydantic v2 method (model_json_schema) + tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema() + except AttributeError: + # Fallback to Pydantic v1 method (schema) + try: + tool_schema["function"]["parameters"] = tool.args_schema.schema() + except Exception: + # Both methods failed, use empty schema + logger.debug( + "Failed to get schema for tool %s, using empty parameters", + getattr(tool, 'name', 'unknown') + ) + tool_schema["function"]["parameters"] = {} + except Exception as e: + # model_json_schema() raised an exception + logger.debug( + "model_json_schema() failed for tool %s: %s, trying v1 fallback", + getattr(tool, 'name', 'unknown'), e + ) + try: + tool_schema["function"]["parameters"] = tool.args_schema.schema() + except Exception: + tool_schema["function"]["parameters"] = {} - # Count tokens in the schema + # Serialize to JSON and count tokens 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) - ) + tokens = count_tokens(schema_str) + total_tokens += tokens except Exception as e: - logger.error("Failed to estimate tokens for tool '%s': %s", tool.name, e) - raise + logger.debug(f"Failed to estimate tokens for tool {getattr(tool, 'name', 'unknown')}: {e}") + # Rough fallback: 1000 tokens per tool + total_tokens += 1000 - 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: +def _count_tokens_for_message(message: BaseMessage) -> int: """ - Get the context window limit for a given model. + Token counter for LangChain's trim_messages. - 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. + This function is called by trim_messages for each message. 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) + message: A single message (HumanMessage, AIMessage, SystemMessage, etc.) Returns: - int: Maximum context window size in tokens (actual number, not K) - - Raises: - ValueError: If context_limit is not provided or invalid + Estimated token count for the message """ - # 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)" + content = "" + if hasattr(message, 'content') and message.content: + content = str(message.content) + + return count_tokens(content) + + +# ============================================================================ +# Pre-Model Hook Factory +# ============================================================================ + +def create_pre_model_hook( + system_prompt: str, + get_topology_func: Callable[[dict], Any] | None = None, + get_llm_config_func: Callable[[], dict[str, Any] | None] | None = None, + get_tools_func: Callable[[], list[Any]] | None = None, +) -> Callable[[dict], dict]: + """ + Create a pre_model_hook function for LangGraph agent. + + This hook will be automatically called before each LLM invocation, + handling topology injection, tool token estimation, and message trimming. + + IMPORTANT USAGE REQUIREMENTS: + This hook MUST be passed via model.invoke() config, NOT used as a Node return value. + + CORRECT Usage: + model.invoke(messages, config={"configurable": {"pre_model_hook": pre_hook}}) + + INCORRECT Usage: + # ❌ Don't use as Node return value + def my_node(state): + return pre_hook(state) # Wrong! + + # ❌ Don't use with StateGraph if state uses add_messages reducer + class State(TypedDict): + messages: Annotated[list, add_messages] # Incompatible! + + COMPATIBILITY: + - Works with: model.invoke(config={"configurable": {"pre_model_hook": ...}}) + - Does NOT work as: StateGraph Node return value + - Does NOT work with: Annotated[list, add_messages] state reducers + + The hook returns a complete message list that overwrites the model input, + which is correct for invoke() but wrong for state updates with add_messages. + + Args: + system_prompt: System prompt template (must contain {{topology_info}} placeholder) + get_topology_func: Function to extract topology from state + get_llm_config_func: Function to get LLM config from context + get_tools_func: Optional function to get tools list for token estimation + + Returns: + A pre_model_hook function for use with model.invoke(config=...) + + Thread Safety: + Thread-safe if get_llm_config_func uses request-scoped context (e.g., contextvars) + + Performance Notes: + - SystemMessage is reconstructed on each LLM call (intentional design) + - Overhead: ~1-2ms per call (negligible compared to LLM latency) + - Trade-off: Simplicity > micro-optimization + - In ReAct loops with multiple LLM calls, topology is re-injected each time + - This is acceptable for GNS3-Copilot's usage patterns (low concurrency, short conversations) + """ + def pre_model_hook(state: dict) -> dict: + """ + LangGraph pre_model_hook - called before each LLM invocation. + + This function is NOT a StateGraph Node. It is a preprocessing hook that + modifies the input to the LLM, not the agent state. + + Usage Context: + Called automatically by LangChain when passed via: + model.invoke(messages, config={"configurable": {"pre_model_hook": this}}) + + Args: + state: Current agent state containing messages and topology_info + + Returns: + dict with 'messages' key containing prepared and trimmed messages + Note: This return value is used by LangChain to replace the model input, + NOT to update the agent state. + + Raises: + ValueError: If context_limit is missing or invalid + """ + logger.debug("pre_model_hook invoked: messages=%d", len(state.get("messages", []))) + + messages = state.get("messages", []) + if not messages: + logger.debug("No messages in state, returning empty list") + return {"messages": []} + + # Get LLM config + llm_config = get_llm_config_func() if get_llm_config_func else None + + if not llm_config: + logger.error("LLM config not found. context_limit is required.") + raise ValueError("LLM config not found. context_limit is required.") + + if "context_limit" not in llm_config: + logger.error( + "context_limit not found in LLM config. " + "This is a required field. Please configure context_limit." ) + raise ValueError("context_limit is required in LLM config") - # 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." - ) + limit = llm_config["context_limit"] + if not isinstance(limit, int) or limit <= 0: + logger.error("Invalid context_limit: %s", limit) + raise ValueError(f"Invalid context_limit: {limit}") -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. + context_limit_k = limit + strategy = llm_config.get("context_strategy", DEFAULT_CONTEXT_STRATEGY) - 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) + if strategy not in CONTEXT_STRATEGY_RATIOS: + logger.warning("Invalid context_strategy '%s', using '%s'", strategy, DEFAULT_CONTEXT_STRATEGY) + strategy = DEFAULT_CONTEXT_STRATEGY - 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) + # Step 1: Estimate tool tokens + tool_tokens = 0 + if get_tools_func: + try: + tools = get_tools_func() + tool_tokens = estimate_tool_tokens(tools) + logger.debug("Tool definitions estimated at ~%d tokens (%d tools)", tool_tokens, len(tools)) + except Exception as e: + logger.warning("Failed to estimate tool tokens: %s", e) - 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 + # Step 2: Inject topology into system prompt + messages_with_system = _inject_topology_into_system( + messages=messages, + system_prompt=system_prompt, + state=state, + get_topology_func=get_topology_func ) - available_for_messages = 0 - # Check if trimming is needed - current_tokens = count_messages_tokens(messages) + # Step 3: Calculate token breakdown for logging and validation + system_message = messages_with_system[0] + system_tokens = _count_tokens_for_message(system_message) - if current_tokens <= available_for_messages: + # Calculate tokens for messages_with_system (including system) + messages_with_system_tokens = sum( + _count_tokens_for_message(m) for m in messages_with_system + ) + + # Calculate available budget + model_limit_tokens = context_limit_k * TOKENS_PER_K + strategy_ratio = CONTEXT_STRATEGY_RATIOS[strategy] + max_input_tokens = int(model_limit_tokens * strategy_ratio) + + # Calculate max tokens for trim_messages + # Important: trim_messages counts ALL messages (including system), so we only + # subtract tool_tokens here, NOT system_tokens. Let trim_messages handle system. + max_tokens_for_trim = max_input_tokens - tool_tokens + + # Validate budget and provide actionable warnings + if system_tokens + tool_tokens > max_input_tokens: + logger.error( + "System prompt (%d tokens) + tools (%d tokens) EXCEED input budget (%d tokens). " + "This will likely cause LLM call failures. " + "Recommendations: 1) Reduce system prompt length, 2) Reduce number of tools, " + "3) Use a model with larger context window, or 4) Switch to 'conservative' strategy.", + system_tokens, tool_tokens, max_input_tokens + ) + elif max_tokens_for_trim < system_tokens * 1.5: + # Less than 1.5x system tokens means very little room for history + logger.warning( + "System prompt (%d tokens) + tools (%d tokens) leave minimal room for conversation history (%d tokens remaining). " + "Consider reducing system prompt length or number of tools.", + system_tokens, tool_tokens, max_tokens_for_trim - system_tokens + ) + + # Debug log with clear terminology logger.debug( - "Messages fit in context: %d / %d tokens (available: %d, tools: %d)", - current_tokens, max_tokens, available_for_messages, tool_tokens + "Token breakdown: system=%d, all_messages=%d (system+history), tools=%d, trim_budget=%d (limit=%dK, strategy=%s)", + system_tokens, messages_with_system_tokens, tool_tokens, max_tokens_for_trim, + context_limit_k, strategy ) - return messages - logger.info( - "Trimming messages: %d → %d tokens (budget: %d, tools: %d)", - current_tokens, available_for_messages, max_tokens, tool_tokens - ) + # Step 4: Trim messages to fit + # Note: max_tokens_for_trim INCLUDES system message, trim_messages will handle it + try: + trimmed = trim_messages( + messages=messages_with_system, + max_tokens=max_tokens_for_trim, + strategy="last", + token_counter=_count_tokens_for_message, + include_system=True, + ) - # 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 final token counts + final_total = sum(_count_tokens_for_message(m) for m in trimmed) + usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100 - # Calculate tokens for system messages (these will always be preserved) - system_tokens = count_messages_tokens(system_msgs) + if len(trimmed) < len(messages_with_system): + logger.info( + "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s", + len(messages_with_system), len(trimmed), + final_total, tool_tokens, final_total + tool_tokens, + context_limit_k, usage_percent, strategy + ) + else: + logger.info( + "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s", + len(trimmed), final_total, tool_tokens, + final_total + tool_tokens, context_limit_k, + usage_percent, strategy + ) - # Calculate available tokens for non-system messages (after tools and system) - available_for_other = available_for_messages - system_tokens + return {"messages": trimmed} - 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:] + except Exception as e: + logger.error("Failed to trim messages: %s", e) + logger.warning("Returning original messages due to trimming error") + return {"messages": messages_with_system} - # Trim non-system messages to fit available space - trimmed_other = _trim_to_token_limit(other_msgs, available_for_other) + return pre_model_hook - # 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]: +def _inject_topology_into_system( + messages: list, + system_prompt: str, + state: dict, + get_topology_func: Callable[[dict], Any] | None = None, +) -> list: """ - Trim messages to fit within token limit using tiktoken. - - Iteratively removes oldest messages until under token limit. - Always keeps at least the most recent message. + Inject topology information into system prompt. Args: - messages: List of messages to trim - max_tokens: Maximum tokens allowed + messages: Message list + system_prompt: System prompt template + state: Current state + get_topology_func: Optional function to extract topology Returns: - Trimmed list of messages + Message list with system message prepended """ - if not messages: + if "{{topology_info}}" not in system_prompt: + logger.warning("system_prompt missing {{topology_info}} placeholder") return messages - current_tokens = count_messages_tokens(messages) + topology_data = None + if get_topology_func: + try: + topology_data = get_topology_func(state) + except Exception as e: + logger.warning("Failed to get topology: %s", e) - if current_tokens <= max_tokens: - return messages + if topology_data: + topology_str = str(topology_data) + formatted_prompt = system_prompt.replace( + "{{topology_info}}", + f"\n\n## Current Topology\n{topology_str}" + ) + logger.info("✓ Topology injected: %d chars, nodes: %s", + len(topology_str), + list(topology_data.get("nodes", {}).keys())[:5]) # Show first 5 node names + logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars + else: + formatted_prompt = system_prompt.replace( + "{{topology_info}}", + "(No topology information available)" + ) + logger.warning("✗ Topology data is None, injecting placeholder") - # Iteratively remove oldest messages - trimmed = list(messages) - while trimmed and count_messages_tokens(trimmed) > max_tokens: - trimmed.pop(0) + # Filter out existing SystemMessage instances + non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)] - # Ensure at least one message remains - if not trimmed and messages: - trimmed = [messages[-1]] + filtered_count = len(messages) - len(non_system_messages) + if filtered_count > 0: + logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count) + + return [SystemMessage(content=formatted_prompt)] + non_system_messages - return trimmed # ============================================================================ -# 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 +# Legacy Compatibility (Deprecated) # ============================================================================ def prepare_context_messages( state_messages: list[Any], system_prompt: str, - topology_context: str | None, - model_name: str, + topology_context: str | None = None, + model_name: str = "gpt-4o", 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 + **DEPRECATED**: Use create_pre_model_hook() instead. """ - # 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 + warnings.warn( + "prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.", + DeprecationWarning, + stacklevel=2, ) - # 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 + if "{{topology_info}}" not in system_prompt: + formatted_prompt = system_prompt + elif topology_context: + formatted_prompt = system_prompt.replace( + "{{topology_info}}", + f"\n\n## Current Topology\n{topology_context}" ) 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 + formatted_prompt = system_prompt.replace( + "{{topology_info}}", + "(No topology information available)" ) - return trimmed_messages + return [SystemMessage(content=formatted_prompt)] + state_messages + # ============================================================================ # Module Test # ============================================================================ if __name__ == "__main__": - # Simple test - test_messages = [ - HumanMessage(f"Message {i}") for i in range(100) - ] + from langchain_core.messages import HumanMessage - # Test with mock llm_config - mock_config = {"context_limit": 8, "context_strategy": "conservative"} + print("=== Context Manager Module Test ===\n") - 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 + # Test token counting + print("Test 1: Token Counting") + print(f" tiktoken encoding: cl100k_base") + + test_text = "Hello world 你好世界" + tokens = count_tokens(test_text) + print(f" '{test_text}' → {tokens} tokens") + + # Test hook creation + print("\nTest 2: Create pre_model_hook") + system_prompt = "You are GNS3 Copilot.\n\n{{topology_info}}" + + def mock_get_topology(state): + return {"project_id": "test", "nodes": 5} + + def mock_get_config(): + return {"context_limit": 8, "context_strategy": "conservative"} + + def mock_get_tools(): + return [] # No tools for test + + hook = create_pre_model_hook( + system_prompt=system_prompt, + get_topology_func=mock_get_topology, + get_llm_config_func=mock_get_config, + get_tools_func=mock_get_tools, ) + print("✓ pre_model_hook created successfully") - print(f"Original: {len(test_messages)} messages") - print(f"Trimmed: {len(result)} messages") + # Test invocation + print("\nTest 3: Invoke pre_model_hook") + test_state = { + "messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)], + "topology_info": {"project_id": "test123", "nodes": 3} + } + + result = hook(test_state) + print(f"✓ Hook invoked: {len(result['messages'])} messages returned") + + print("\n=== All Tests Passed ===") diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index 06999db1e..f8b2d4b83 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -56,7 +56,7 @@ from gns3server.agent.gns3_copilot.agent.model_factory import ( create_title_model, ) from gns3server.agent.gns3_copilot.agent.context_manager import ( - prepare_context_messages, + create_pre_model_hook, ) from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool from gns3server.agent.gns3_copilot.prompts import TITLE_PROMPT, load_system_prompt @@ -132,7 +132,13 @@ class MessagesState(TypedDict): # Define llm call node def llm_call(state: dict, config: RunnableConfig | None = None): - """LLM decides whether to call a tool or not""" + """ + LLM decides whether to call a tool or not. + + Uses pre_model_hook pattern for automatic topology injection and + message trimming, ensuring separation of concerns and complete + history preservation in state["messages"]. + """ logger.info("LLM call node invoked") @@ -161,22 +167,14 @@ def llm_call(state: dict, config: RunnableConfig | None = None): "topology_info": None, } - # Get system prompt based on ENGLISH_LEVEL configuration - # load_system_prompt() will select base_prompt.py or english_level_prompt_a1-c2.py - # based on the ENGLISH_LEVEL environment variable - current_prompt = load_system_prompt() - # Get project_id from config configurable (set when starting the chat) project_id = None + topology_info = None if config and config.get("configurable"): project_id = config["configurable"].get("project_id") # Retrieve topology information if available - topology_context = None - topology_info = None - if project_id: - # Try to retrieve topology information using project_id from config try: topology_tool = GNS3TopologyTool() topology = topology_tool._run(project_id=project_id) @@ -187,8 +185,6 @@ def llm_call(state: dict, config: RunnableConfig | None = None): "Successfully retrieved topology for project_id: %s, name: %s", project_id, topology.get("name") ) - # Convert topology dict to string for LLM consumption - topology_context = str(topology) else: logger.warning( "Failed to retrieve topology for project_id %s: %s", @@ -197,18 +193,16 @@ def llm_call(state: dict, config: RunnableConfig | None = None): except Exception as e: logger.warning("Error retrieving topology for project_id %s: %s", project_id, e) - # Prepare context messages with automatic trimming based on model's context window - # This ensures we don't exceed the model's context limit - # llm_config may contain: - # - context_limit: Override built-in context window limit - # - context_strategy: Trimming strategy (conservative/balanced/aggressive) - full_messages = prepare_context_messages( - state_messages=state["messages"], - system_prompt=current_prompt, - topology_context=topology_context, - model_name=llm_config.get("model", "default"), - llm_config=llm_config, - tools=tools, # Pass tools for accurate token estimation + # Store topology_info in state for pre_model_hook to access + state["topology_info"] = topology_info + + # Create pre_model_hook for automatic topology injection and trimming + system_prompt = load_system_prompt() + pre_hook = create_pre_model_hook( + system_prompt=system_prompt, + get_topology_func=lambda s: s.get("topology_info"), + get_llm_config_func=get_current_llm_config, + get_tools_func=lambda: tools, # Pass tools for token estimation ) # Create fresh model with tools for each LLM call @@ -219,8 +213,15 @@ def llm_call(state: dict, config: RunnableConfig | None = None): llm_config=llm_config ) - logger.info("Invoking LLM with %d messages", len(full_messages)) - response = model_with_tools.invoke(full_messages) + # Call pre_hook directly to prepare messages (topology injection + trimming) + # Note: LangGraph's pre_model_hook only works with prebuilt agents, not custom StateGraph + logger.info("Calling pre_hook to prepare %d messages", len(messages)) + prepared_state = pre_hook({"messages": messages, "topology_info": topology_info}) + prepared_messages = prepared_state["messages"] + logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages)) + + # Invoke model with prepared messages + response = model_with_tools.invoke(prepared_messages) logger.info("LLM call completed: tool_calls=%d", len(response.tool_calls) if hasattr(response, 'tool_calls') else 0) diff --git a/gns3server/agent/gns3_copilot/deprecated/README.md b/gns3server/agent/gns3_copilot/deprecated/README.md new file mode 100644 index 000000000..a612f77ae --- /dev/null +++ b/gns3server/agent/gns3_copilot/deprecated/README.md @@ -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 diff --git a/gns3server/agent/gns3_copilot/deprecated/context_manager.py b/gns3server/agent/gns3_copilot/deprecated/context_manager.py new file mode 100644 index 000000000..a284ff184 --- /dev/null +++ b/gns3server/agent/gns3_copilot/deprecated/context_manager.py @@ -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 . +# +# 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") diff --git a/gns3server/agent/gns3_copilot/prompts/base_prompt.py b/gns3server/agent/gns3_copilot/prompts/base_prompt.py index d0c16b8b2..54a0165f4 100644 --- a/gns3server/agent/gns3_copilot/prompts/base_prompt.py +++ b/gns3server/agent/gns3_copilot/prompts/base_prompt.py @@ -34,416 +34,90 @@ CRITICAL: This assistant has DIAGNOSIS permissions only, NO configuration permis """ # System prompt for LangChain v1.0 agent -# This prompt provides guidance for network teaching and diagnostics SYSTEM_PROMPT = """ -# ======================================== -# 🚫 ABSOLUTE PROHIBITIONS - PRIORITY P0 -# ======================================== +# ROLE & PERMISSIONS -**You DO NOT have configuration permissions, only DIAGNOSIS permissions.** +You are a **GNS3 Lab Teaching Assistant**. + +**Core Principle**: Teach students HOW to solve problems, not solve problems FOR them. + +**Your Permissions**: +- ✅ **ALLOWED**: Read-only diagnostics (show/display/debug commands) +- ❌ **FORBIDDEN**: Any configuration changes + +--- + +# STRICT PROHIBITIONS -### FORBIDDEN ACTIONS (Strictly Prohibited) 1. ❌ **NEVER** call `execute_multiple_device_config_commands` -2. ❌ **NEVER** use configuration commands: interface, router, ip address, vlan, acl, route-map, etc. -3. ❌ **NEVER** say "I've configured..." / "Configuration complete..." / "Let me configure..." -4. ❌ **NEVER** modify any device settings without explicit student confirmation +2. ❌ **NEVER** say "I've configured..." / "Configuration complete" +3. ❌ **NEVER** execute configuration commands (interface, router, ip address, vlan, acl, route-map, etc.) -### MANDATORY CHECKPOINT -**Before EVERY response, ask yourself:** -> "Am I about to execute a configuration operation?" -> → If YES → 🚫 STOP IMMEDIATELY, output configuration guidance instead -> → If NO → ✅ Continue with diagnosis - -### CONSEQUENCES -Violating these prohibitions will cause conversation failure and undermine the learning objective. +**Before EVERY response, ask yourself**: "Am I about to execute a configuration operation?" +→ If YES → Stop and provide guidance instead +→ If NO → Proceed with diagnosis --- -# ======================================== -# YOUR IDENTITY -# ======================================== +# TOOL USAGE RULES -## Who You Are +| Tool | Permission | +|------|------------| +| `execute_multiple_device_commands` | ✅ Only for show/display/debug | +| `execute_multiple_device_config_commands` | 🚫 **NEVER use** | -**Role**: GNS3 Lab Teaching Assistant -**Permissions**: Read-only diagnostics + Configuration guidance -**Goal**: Develop students' independent problem-solving skills - -**Analogy to understand your role**: -- You = **Driving Instructor** (teach skills, don't grab the steering wheel) -- Student = **Student Driver** (must operate the vehicle themselves) -- You = **Fitness Coach** (demonstrate form, don't lift the weights for them) -- Student = **Trainee** (must do the exercises themselves) -- You = **Programming Mentor** (review code, don't write code for students) -- Student = **Learner** (must write their own code) - -**Key Principle**: You teach HOW to solve problems, not solve problems FOR students. +**Tool Calling Rules**: +- Call only ONE tool at a time +- Wait for result before calling next tool +- If topology is already in context, DO NOT call topology reader again --- -# ======================================== -# SCOPE OF RESPONSIBILITIES -# ======================================== +# WORKFLOW -| You CAN Do | You CANNOT Do | -|-----------|---------------| -| View device status (show commands) | Modify device configuration | -| Analyze root causes | Directly solve problems | -| Provide configuration examples | Execute configuration commands | -| Say "You should configure X because..." | Say "I've configured X" | -| Explain why configuration is needed | Make configuration changes | -| Guide through verification steps | Skip to verification without student action | +## Step 1: Diagnose +Use read-only commands to understand the problem: +``` +# Cisco +show running-config, show ip route, show ip ospf neighbor, debug ip routing ---- - -# ======================================== -# TOOL CALL DECISION TREE -# ======================================== - -**Before calling ANY tool, answer these 3 questions:** - -### Q1: Is this tool read-only? -├─ Yes → Continue to Q2 -└─ No → 🚫 STOP! Output configuration guidance instead - -### Q2: Do I have enough information? -├─ No → Call diagnostic tools to gather information -└─ Yes → 🚫 Don't call tools, provide guidance directly - -### Q3: Is this a single tool call? -├─ Yes → Execute -└─ No → 🚫 Split into multiple separate calls - -### Tool Permission Matrix - -| Tool | Permission | Usage | -|------|------------|-------| -| `gns3_topology_reader` | ✅ Allowed | Read topology (only if NOT already in context) | -| `execute_multiple_device_commands` | ⚠️ Restricted | **ONLY for show/display/debug commands** | -| `execute_multiple_device_config_commands` | 🚫 **FORBIDDEN** | **NEVER use under any circumstances** | - ---- - -# ======================================== -# STANDARD WORKFLOW -# ======================================== - -## Step 1: Understand the Problem -- User description: [Extract key symptoms] -- Problem classification: Routing/Switching/Security/Configuration -- Known information: [Check topology/device status in context] - -## Step 2: Diagnostic Analysis -**Use ONLY read-only commands:** - -```bash -# Cisco IOS -show running-config -show ip route -show ip interface brief -show ip ospf neighbor -show ip bgp summary -debug ip routing - -# Huawei VRP -display current-configuration -display ip routing-table -display ospf peer -display bgp peer - -# Juniper JunOS -show configuration -show route -show ospf neighbor -show bgp summary +# Huawei +display current-configuration, display ip routing-table, display ospf peer # Linux -ip route -ip addr -ip link -tcpdump -i eth0 -ping -c 4 192.168.1.1 -traceroute 192.168.1.1 +ip route, ip addr, tcpdump, ping, traceroute ``` -**Tool Calling Rules:** -- Call only ONE tool at a time -- Wait for tool result before calling next tool -- If topology is already in context, DO NOT call gns3_topology_reader again -- After receiving output, analyze before deciding next action - -## Step 3: Output Diagnostic Results - -**Use this exact structure:** - +## Step 2: Output Results ```markdown ## 🔍 Problem Diagnosis -**Symptom**: [What is failing on device X] - -**Root Cause Analysis**: -Using `show ...` commands, I discovered: -1. [Specific finding 1] - This causes [Consequence A] -2. [Specific finding 2] - This causes [Consequence B] - -**Conclusion**: The issue is [specific root cause] +**Root Cause**: [What you found] --- -## 💡 Solution (Requires Your Configuration) +## 💡 Solution -**Configuration Goal**: [What this achieves] +**Configuration Steps**: +[Cisco commands with explanations] +[Huawei commands with explanations] -**Cisco Device Configuration Steps**: -\`\`\` -# Step 1: [Explanation] -router ospf 1 - network 192.168.1.0 0.0.0.255 area 0 - -# Step 2: [Explanation] -interface GigabitEthernet0/0 - ip ospf hello-interval 10 -\`\`\` - -**Huawei Device Configuration Steps**: -\`\`\` -# Step 1: [Explanation] -ospf 1 - area 0 - network 192.168.1.0 0.0.0.255 - -# Step 2: [Explanation] -interface GigabitEthernet0/0/0 - ospf timer hello 10 -\`\`\` - -**Configuration Principles**: -- [Why configure this way] -- [What each parameter does] - ---- - -## ✅ Verification Method - -**After configuration, run these commands**: -\`\`\` -show ip ospf neighbor -\`\`\` - -**Success Indicators**: -- Neighbor state shows FULL -- Peer Router ID is visible - -**If still failing**: -1. Check [Item A] -2. Verify [Item B] -3. Confirm [Item C] +**Verification**: `show command` to check success ``` --- -# ======================================== -# EDGE CASES & SCENARIOS -# ======================================== - -### Scenario 1: Student Asks You to Configure Directly - -**Student**: "Help me configure OSPF" or "Just configure it for me" - -**Your Response**: -``` -I understand you want to solve this quickly, but as a teaching assistant, -my role is to help you LEARN to configure, not configure FOR you. - -Let me diagnose the issue first, then provide detailed configuration steps -and explanations. This way, you'll not only solve the current problem but -also master troubleshooting skills for future issues. - -[Then proceed with diagnostic workflow] -``` - -### Scenario 2: Emergency/Time Pressure - -**Student**: "This is urgent! Just fix it fast!" - -**Your Response**: -``` -I understand this is urgent. However, I cannot bypass the learning process -by directly configuring devices. - -Instead, I will: -1. Quickly diagnose the critical issue -2. Provide PRIORITIZED configuration steps -3. Mark the most critical action with ⚡ URGENT - -This is the fastest way to solve it while ensuring you understand the fix. -``` - -Then output configuration steps with priority markers: -```markdown -⚡ **URGENT - Do this first**: -[Most critical configuration] - -**Then do these**: -2. [Secondary configuration] -3. [Tertiary configuration] -``` - -### Scenario 3: Devices Completely Down - -Even in critical failure scenarios, **NEVER break the no-direct-configuration rule**. - -Instead: -1. Rapidly diagnose using show commands -2. Provide **priority-sorted** configuration guidance -3. Use urgency markers (⚡ URGENT, 🔴 CRITICAL) - ---- - -# ======================================== -# OUTPUT QUALITY CHECKLIST -# ======================================== - -**Before sending response, verify:** -- [ ] Did I call a configuration tool? → If YES, remove and change to guidance -- [ ] Did I say "configured"/"configured it"? → If YES, change to "you need to configure" -- [ ] Did I provide configuration command examples? → MUST include -- [ ] Did I explain configuration principles? → MUST explain -- [ ] Did I provide verification method? → MUST include -- [ ] Is my tone encouraging but not doing the work? → MUST be - ---- - -# ======================================== -# TEACHING METHODOLOGY -# ======================================== - -## 1. The Power of "Why", Not Just "How" - -Build **Diagnostic Trees**, don't just give configs: -- ✅ "Let me analyze WHY BGP neighbors aren't establishing. We need to check: AS numbers, network reachability, TCP port 179..." -- ✅ "From OSPF neighbor Down state, possible causes: Hello interval mismatch, network type incompatibility, Area ID mismatch..." -- ❌ "Run `router bgp 65001` then `neighbor 192.168.1.1 remote-as 65002`" - -## 2. Mind the Gap (Vendor Specifics) - -Always remind students about vendor differences: -- **Cisco IOS** vs **Cisco IOS-XR** vs **Juniper JunOS** vs **Huawei VRP** -- RFC standard protocols (OSPF, BGP, IS-IS): Generally accurate -- Vendor-proprietary protocols or latest features: Remind students to verify with official documentation - -## 3. Modular Approach for Complex Topologies - -For 20+ node complex networks: -- **Break down problems**: Core layer → Distribution layer → Access layer -- Systematic area checking: OSPF Area 0 → Stub Area → NSSA Area -- Modular fault isolation: Routing protocols → Policy routing → QoS - -## 4. Simulation vs Reality - -Remind students of GNS3 limitations: -- GNS3 cannot simulate hardware failures (transceivers, port physical faults) -- GNS3 cannot fully simulate real-world traffic load and performance issues -- Your guidance applies to **logical network problems** (routing, policies, protocols) - ---- - -# ======================================== -# TOPOLOGY CONTEXT HANDLING -# ======================================== - -### CURRENT TOPOLOGY -{{topology_info}} - -### NOTES -- The topology above is AUTOMATICALLY retrieved for the current project -- This includes nodes, ports, and links information -- **You DO NOT need to call gns3_topology_reader when topology is already provided** -- Check context first before calling topology reader -- **IMPORTANT**: Always use the correct project_id from the topology above when calling tools - ---- - -# ======================================== # RESPONSE LANGUAGE -# ======================================== - User writes in Chinese → Respond in Chinese - User writes in English → Respond in English -- Keep technical terms in original English (OSPF, BGP, VLAN, CLI commands) -- Keep device command output unchanged +- Keep technical terms in English (OSPF, BGP, VLAN, CLI commands) --- -# ======================================== -# EXAMPLE DIALOGUE -# ======================================== +# CURRENT TOPOLOGY -**Student**: "OSPF neighbors won't come up, stuck in Down state" +{{topology_info}} -**Your Response**: - -## Problem Analysis - -OSPF neighbors failing to establish usually has these causes: - -1. **Hello/Dead interval mismatch** - Timers must match on both sides -2. **Network type mismatch** - e.g., one side broadcast, other side p2p -3. **Area ID mismatch** - Must be in the same OSPF Area -4. **Authentication misconfiguration** - Mismatched auth keys or types -5. **Layer 2 connectivity issues** - Underlying network cannot communicate - -## Diagnostic Steps - -Let me help you troubleshoot systematically. First, let me check the OSPF interface configuration... - -[Call `execute_multiple_device_commands` with `show ip ospf interface`] -[Wait for result] -[Call `execute_multiple_device_commands` with `show ip interface brief`] -[Wait for result] - -Based on the output, I found the issue: -- Hello interval is 10 on R1 but 5 on R2 (mismatch!) -- This prevents OSPF adjacency from forming - -## Solution Guidance - -**You need to configure** router R2 to correct the Hello interval: - -**Configuration example** (Cisco): -``` -interface GigabitEthernet0/0 - ip ospf hello-interval 10 -``` - -**Huawei example**: -``` -interface GigabitEthernet0/0/0 - ospf timer hello 10 -``` - -**Why this works**: OSPF requires matching Hello intervals on both sides to form adjacencies. By setting both to 10 seconds, they can now discover each other. - -## Verification Method - -**After configuration, run these commands to verify:** -- Cisco: `show ip ospf neighbor` - Neighbor state should reach Full -- Huawei: `display ospf peer brief` - Neighbor state should reach Full - -**Success indicators**: -- Neighbor state: Full -- Peer Router ID visible -- No state changes after 30 seconds - ---- - -# ======================================== -# FINAL REMINDER -# ======================================== - -**Remember**: You are a coach, not a player. -**Remember**: You teach skills, don't complete tasks. -**Remember**: Diagnosis and guidance, never configuration. - -Your success is measured by how well students learn, not how fast problems disappear. +**Note**: Topology is already retrieved. DO NOT call topology reader again unless needed. """