ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+525
View File
@@ -0,0 +1,525 @@
# Context Compression Strategies / 上下文压缩策略对比
> Companion material for *AI Agents in Depth*, Chapter 2 — **Experiment 2-10 ★★★: Comparison of context compression strategies**.
> 配套《深入理解 AI Agent》第 2 章 **实验 2-10 ★★★:上下文压缩策略对比**。
← [Chapter 2 index / 返回第 2 章目录](../README.md)
---
## Code map
- **Run first:** python experiment.py -s context_aware (or python quickstart.py for the menu).
- **Start here:** experiment.py::ExperimentRunner controls one strategy comparison.
- **Core behavior:** agent.py::ResearchAgent records the tool trajectory; compression_strategies.py::ContextCompressor applies the policy.
- **State / protocol:** AgentTrajectory, ToolCall and CompressionStrategy.
- **Verifier:** results/ JSON plus token/overflow counters; tests cover malformed tool results.
- **Experiment variable:** six compression strategies and the context-window budget.
- **Skip on first pass:** web search provider, streaming UI and plotting helpers.
## English
### Overview
Demonstrates and compares context compression strategies for LLM agents, using research on OpenAI co-founders current affiliations as the test task.
As context windows grow (128K+), efficient context management matters for:
- **Cost** — fewer tokens
- **Performance** — lower latency
- **Reliability** — fewer overflow errors
- **Relevance** — keep what matters
This lab implements and compares **6** strategies and their trade-offs.
### Compression strategies
#### 1. No compression
- Full webpage content into context
- Expected: fails after a few tool calls (overflow)
- Purpose: baseline problem
#### 2. Non-context-aware: individual summaries
- Summarize each page with LLM, then concatenate
- Preserves page-specific detail; may lose cross-page links
- Multiple LLM calls; good when sources are independent
#### 3. Non-context-aware: combined summary
- Concatenate all pages, then one summary
- Better overall picture; may lose per-page attribution
- One LLM call; may hit limits with many pages
#### 4. Context-aware summarization
- Query-focused summary over all search results
- Better relevance; extra LLM call
#### 5. Context-aware with citations
- Like #4 plus citations / source links
- Better for follow-ups; slightly larger
#### 6. Windowed context
- Full content for latest tool call; compress older history
- Balance detail vs efficiency
- Only compresses messages not already marked `[COMPRESSED]`
### Installation
```bash
# From the repository root: use the shared Chapter 2 environment
uv sync --locked --python 3.12 --extra ch2
# Activate it before changing directories:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# pip fallback when uv is not installed:
# python -m pip install -e ".[ch2]"
cd chapter2/context-compression
# Single-project compatibility path, still supported during migration:
# python -m pip install -r requirements.txt
cp env.example .env
# Edit .env with your API keys
```
**API keys:**
- `LLM_PROVIDER``kimi` (default), `dashscope`/`qwen`/`bailian`, or `openrouter`.
- `DASHSCOPE_API_KEY` — Alibaba Cloud Model Studio / Bailian key when using DashScope; default model is `qwen3.7-plus` (set `DASHSCOPE_BASE_URL` for international keys).
- `MOONSHOT_API_KEY` — Kimi/Moonshot for live runs. Book 实验 2-10 uses Kimi K3 (~1M real window); the demo **caps** the compression/overflow budget at `CONTEXT_WINDOW_SIZE` (default 128K) so overflow/compression is observable. Override model via `MODEL_NAME` or `-m/--model` (e.g. `kimi-k2.5`, `kimi-k3`, `moonshot-v1-128k`).
- `OPENROUTER_API_KEY` — fallback if Moonshot key unset (`kimi-*``moonshotai/kimi-k2`). Unchanged if `MOONSHOT_API_KEY` is set.
- `SERPER_API_KEY` — web search (optional; mock data if missing)
Keys: [Moonshot](https://platform.moonshot.cn/), [Serper free tier](https://serper.dev/)
### Scripts overview
| Script | Purpose | Output |
|--------|---------|--------|
| `main.py` | Interactive demo / single strategy | Console |
| `experiment.py` | Automated comparison (token / compression / success table) | `results/` |
| `run_all_strategies.py` | Strategies with detailed per-round logs | `logs/` |
| `quickstart.py` | Menu wrapper (env check + launcher) | Console |
CLIs use Chinese `--help`. Shared useful flags:
- `-s/--strategy` — one or more strategies (default all 6); see list or `--list-strategies`
- `-m/--model` — override `MODEL_NAME`
- `-n/--max-iterations` — max tool-call rounds per strategy
Strategy aliases: `no_compression`, `individual`, `combined`, `context_aware`, `citations`, `windowed`.
### Usage
#### Full experiment (comparison table + JSON)
```bash
python experiment.py # all 6 strategies + comparison table
python experiment.py -s context_aware # one strategy
python experiment.py -s individual combined # two non-task-aware strategies
python experiment.py -m moonshot-v1-128k -o results/run.json
python experiment.py --list-strategies
```
Runs selected strategies sequentially, researches co-founder affiliations, prints Success / Time / **Tokens** / Compression / Overflows, saves `results/experiment_TIMESTAMP.json` (or `-o`).
Key flags: `-s/--strategy`, `-m/--model`, `-o/--output`, `-n/--max-iterations`, `--streaming`, `--list-strategies`.
#### All strategies with logging
```bash
python run_all_strategies.py
python run_all_strategies.py -s windowed
python run_all_strategies.py --log-dir logs/k2 -m kimi-k2.5
```
- Sequential strategies
- Compression summaries to log file
- Streaming by default
- Logs: `<log-dir>/strategy_run_TIMESTAMP.log`
- JSON: `<log-dir>/strategy_results_TIMESTAMP.json`
- End comparison summary
Flags: `-s/--strategy`, `-m/--model`, `--log-dir`, `-n/--max-iterations`, `--list-strategies`.
#### Interactive demo
```bash
python main.py # choose strategy at prompt
python main.py -s citations
python main.py -s windowed --no-streaming
```
Streaming on by default; follow-ups useful for citation strategy.
#### Custom usage
```python
from agent import ResearchAgent
from compression_strategies import CompressionStrategy
agent = ResearchAgent(
api_key="your_api_key",
compression_strategy=CompressionStrategy.CONTEXT_AWARE_CITATIONS,
enable_streaming=True
)
result = agent.execute_research()
if result['success']:
print(result['final_answer'])
print(f"Tool calls: {len(result['trajectory'].tool_calls)}")
```
### Project structure
```
context-compression/
├── config.py
├── web_tools.py
├── compression_strategies.py
├── agent.py
├── experiment.py
├── run_all_strategies.py
├── main.py
├── quickstart.py
├── requirements.txt
├── env.example
├── logs/ # from run_all_strategies.py
└── results/ # experiment JSON
```
### Key components
- **web_tools.py:** `search_web` (Serper + crawl), `fetch_webpage`, mock data without key
- **compression_strategies.py:** `ContextCompressor`, `CompressedContent`, dynamic compression
- **agent.py:** streaming, tools, history, windowed compression
- **experiment.py:** automated runs, metrics, comparison table, JSON
### Metrics
Success rate, execution time, compression ratio (compressed/original size), context overflows, tool calls, final answer length.
### Expected results (qualitative)
1. No compression → overflow fail
2. Non-context-aware → may complete, miss detail
3. Context-aware → good size/relevance balance
4. With citations → best for follow-ups
5. Windowed → efficient for long multi-turn
### Measured results (real run)
Real end-to-end run (no mock): live Serper + Moonshot reasoning model.
- **Model:** `kimi-k3` (real window ~1M; demo budget `CONTEXT_WINDOW_SIZE = 128000`)
- **Search:** real Serper + page crawl
- **Task:** track current affiliations of ~11 OpenAI co-founders
- **Date:** 2026-07-18 · `MAX_ITERATIONS=15` · raw: `results/kimi_k3_real_20260718.json`
| # | Strategy | Success | Iterations | Tokens | Compress | Overflows | Time |
|---|----------|---------|-----------|--------|----------|-----------|------|
| 1 | `no_compression` | ❌ (overflow at 165,227 tok > 128K) | 5 | 166,043 | 102.1% | 1 | 107s |
| 2 | `non_context_aware_individual_summary` | ✅ | 12 | 276,608 | 10.9% | 4 | 2980s |
| 3 | `non_context_aware_combined_summary` | ✅ | 10 | 93,449 | 4.3% | 0 | 1189s |
| 4 | `context_aware_summary` | ✅ | 7 | 40,157 | 3.0% | 0 | 967s |
| 5 | `context_aware_with_citations` | ✅ | 10 | 222,992 | 4.1% | 3 | 1235s |
| 6 | `windowed_context` | ✅ | 7 | 174,601 | 102.4% | 4 | 867s |
Notes:
- **No compression** fails as designed past 128K (~5th iteration).
- **Context-aware summary (#4)** most token-efficient success (40,157 tokens, 3.0% char compression).
- **Individual summaries (#2)** slowest (~50 min): per-page summaries on a reasoning model.
- **Windowed (#6)** compresses only when usage crosses ~80% of budget; keeps recent full content → char “compression ratio” ~100% while still finishing fastest among compressing strategies.
- Single-run numbers vary; relative ordering is the takeaway.
### Configuration
`.env` or `config.py`:
- `MODEL_NAME` (default kimi-k3)
- `MODEL_TEMPERATURE` (default 0.3)
- `MAX_ITERATIONS` (default 50)
- `MAX_WEBPAGE_LENGTH` (default 50000)
- `SUMMARY_MAX_TOKENS` (default 500)
- `CONTEXT_WINDOW_SIZE` (default 128000; intentional cap vs K3s real ~1M window)
### Troubleshooting
- **No Serper key:** mock data still exercises compression logic
- **Overflow on non-baseline strategies:** lower `MAX_WEBPAGE_LENGTH` / `SUMMARY_MAX_TOKENS` / search `num_results`
- **Slow:** `--no-streaming`, lower `-n/--max-iterations`, mock search
### Research task
> “Find the current affiliations of all OpenAI co-founders”
Good because it needs many searches, accumulates text, stresses context management, and has checkable outcomes.
### Extending
New strategy: enum → `ContextCompressor``compress_search_results()` → experiment runner.
New task: system prompt in `agent.py`, mock data in `web_tools.py`, tool descriptions as needed.
---
## 中文
### 概述
演示并对比 LLM Agent 的多种上下文压缩策略,测试任务为调研 OpenAI 联合创始人当前职业归属。
上下文窗口越来越大(128K+)时,高效管理上下文关乎:
- **成本** — 减少 token
- **性能** — 更低延迟
- **可靠性** — 减少溢出错误
- **相关性** — 保留关键信息
本实验实现并对比 **6** 种策略及其取舍。
### 压缩策略
#### 1. 无压缩
- 网页原文直接进入上下文
- 预期:几次工具调用后溢出失败
- 目的:展示基线问题
#### 2. 非任务感知:逐页摘要
- 每页单独 LLM 摘要再拼接
- 保留页内细节,可能丢跨页关系
- 多次 LLM 调用;适合来源彼此独立
#### 3. 非任务感知:合并摘要
- 先拼接全部网页再做一次总摘要
- 更利把握全局,可能丢页级归属
- 单次 LLM 调用;页多时可能撞限
#### 4. 上下文感知摘要
- 结合查询对全部搜索结果做聚焦摘要
- 相关性更好;多一次 LLM 调用
#### 5. 带引用的上下文感知摘要
-#4 基础上加引用与来源链接
- 利于追问;上下文略大
#### 6. 窗口化上下文
- 最近一次工具调用保留全文,更早历史压缩
- 细节与效率折中
- 只压缩尚未标记 `[COMPRESSED]` 的消息
### 安装
```bash
# 在仓库根目录使用统一的第 2 章环境
uv sync --locked --python 3.12 --extra ch2
# 切换目录前先激活环境:
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell.venv\Scripts\Activate.ps1
# Windows cmd.venv\Scripts\activate.bat
# 未安装 uv 时可用 pip 兜底:
# python -m pip install -e ".[ch2]"
cd chapter2/context-compression
# 迁移期间仍支持单项目兼容路径:
# python -m pip install -r requirements.txt
cp env.example .env
# 编辑 .env 填入 API Key
```
**所需 Key**
- `LLM_PROVIDER``kimi`(默认)、`dashscope`/`qwen`/`bailian``openrouter`
- `DASHSCOPE_API_KEY`:使用阿里云百炼 / Model Studio 时的 Key,默认模型 `qwen3.7-plus`(国际区 Key 可设置 `DASHSCOPE_BASE_URL`)。
- `MOONSHOT_API_KEY`Kimi/Moonshot(在线跑必需)。书中实验 2-10 使用 Kimi K3(真实窗口约 1M);演示通过 `CONTEXT_WINDOW_SIZE`(默认 128K)**故意收紧**溢出/压缩预算以便观察。可用 `MODEL_NAME``-m/--model` 覆盖(如 `kimi-k2.5``kimi-k3``moonshot-v1-128k`)。
- `OPENROUTER_API_KEY`:未设置 Moonshot key 时的通用回退(`kimi-*``moonshotai/kimi-k2`)。设了 `MOONSHOT_API_KEY` 时行为不变。
- `SERPER_API_KEY`:联网搜索(可选;缺失则用 mock 数据)
获取:[Moonshot](https://platform.moonshot.cn/)、[Serper 免费档](https://serper.dev/)
### 脚本一览
| 脚本 | 作用 | 输出 |
|------|------|------|
| `main.py` | 交互演示 / 单策略 | 控制台 |
| `experiment.py` | 自动对比(token / 压缩 / 成功表) | `results/` |
| `run_all_strategies.py` | 带逐轮详细日志 | `logs/` |
| `quickstart.py` | 菜单封装(检查环境并启动) | 控制台 |
均提供中文 `--help`。共用常用参数:
- `-s/--strategy` — 一种或多种策略(默认全部 6 种);见列表或 `--list-strategies`
- `-m/--model` — 覆盖 `MODEL_NAME`
- `-n/--max-iterations` — 每策略最大工具调用轮数
策略别名:`no_compression``individual``combined``context_aware``citations``windowed`
### 用法
#### 完整实验(对比表 + JSON
```bash
python experiment.py # 运行全部 6 种策略并生成对比表
python experiment.py -s context_aware # 只运行「上下文感知压缩」
python experiment.py -s individual combined # 只对比两种非任务感知策略
python experiment.py -m moonshot-v1-128k -o results/run.json
python experiment.py --list-strategies
```
依次测试所选策略、调研联合创始人归属、打印 Success / Time / **Tokens** / Compression / Overflows,保存到 `results/experiment_TIMESTAMP.json`(或 `-o`)。
主要参数:`-s/--strategy``-m/--model``-o/--output``-n/--max-iterations``--streaming``--list-strategies`
#### 带日志跑全部策略
```bash
python run_all_strategies.py
python run_all_strategies.py -s windowed
python run_all_strategies.py --log-dir logs/k2 -m kimi-k2.5
```
- 顺序跑所选策略
- 压缩摘要写入日志
- 默认流式
- 日志:`<log-dir>/strategy_run_TIMESTAMP.log`
- JSON`<log-dir>/strategy_results_TIMESTAMP.json`
- 末尾对比摘要
参数:`-s/--strategy``-m/--model``--log-dir``-n/--max-iterations``--list-strategies`
#### 交互演示
```bash
python main.py # 提示选择策略
python main.py -s citations
python main.py -s windowed --no-streaming
```
默认开启流式;引用策略适合追问。
#### 编程调用
```python
from agent import ResearchAgent
from compression_strategies import CompressionStrategy
agent = ResearchAgent(
api_key="your_api_key",
compression_strategy=CompressionStrategy.CONTEXT_AWARE_CITATIONS,
enable_streaming=True
)
result = agent.execute_research()
if result['success']:
print(result['final_answer'])
print(f"Tool calls: {len(result['trajectory'].tool_calls)}")
```
### 项目结构
```
context-compression/
├── config.py
├── web_tools.py
├── compression_strategies.py
├── agent.py
├── experiment.py
├── run_all_strategies.py
├── main.py
├── quickstart.py
├── requirements.txt
├── env.example
├── logs/
└── results/
```
### 关键组件
- **web_tools.py** `search_web`Serper + 抓取)、`fetch_webpage`、无 Key 时 mock
- **compression_strategies.py** `ContextCompressor``CompressedContent`、动态压缩
- **agent.py:** 流式、工具、历史、窗口化压缩
- **experiment.py** 自动跑、指标、对比表、JSON
### 采集指标
成功率、执行时间、压缩比(压缩后/原始)、上下文溢出次数、工具调用次数、最终答案长度。
### 定性预期
1. 无压缩 → 溢出失败
2. 非任务感知 → 可能完成但丢细节
3. 上下文感知 → 体积与相关性较均衡
4. 带引用 → 最利于追问
5. 窗口化 → 长对话更高效
### 实测结果(真实运行)
真实端到端(无 mock):实时 Serper + Moonshot 推理模型。
- **模型:** `kimi-k3`(真实窗口约 1M;演示预算 `CONTEXT_WINDOW_SIZE = 128000`
- **搜索:** 真实 Serper + 页面抓取
- **任务:** 识别并追踪约 11 位 OpenAI 联合创始人的职业状态
- **日期:** 2026-07-18 · `MAX_ITERATIONS=15` · 原始 JSON`results/kimi_k3_real_20260718.json`
| # | Strategy | Success | Iterations | Tokens | Compress | Overflows | Time |
|---|----------|---------|-----------|--------|----------|-----------|------|
| 1 | `no_compression` | ❌ (overflow at 165,227 tok > 128K) | 5 | 166,043 | 102.1% | 1 | 107s |
| 2 | `non_context_aware_individual_summary` | ✅ | 12 | 276,608 | 10.9% | 4 | 2980s |
| 3 | `non_context_aware_combined_summary` | ✅ | 10 | 93,449 | 4.3% | 0 | 1189s |
| 4 | `context_aware_summary` | ✅ | 7 | 40,157 | 3.0% | 0 | 967s |
| 5 | `context_aware_with_citations` | ✅ | 10 | 222,992 | 4.1% | 3 | 1235s |
| 6 | `windowed_context` | ✅ | 7 | 174,601 | 102.4% | 4 | 867s |
说明:
- **无压缩**按设计在超过 128K 时失败(约第 5 轮)。
- **上下文感知摘要(#4** token 最省(40,157 tokens,字符压缩 3.0%)。
- **逐页摘要(#2)**最慢(约 50 分钟):推理模型对每页单独摘要。
- **窗口化(#6)**仅在用量跨过约 80% 预算时批量压缩未压缩工具消息;保留近期全文,字符「压缩比」约 100%,但在可完成任务的策略中总时间最短。
- 单次运行绝对值会波动;相对排序是关键 takeaway。
### 配置
`.env``config.py`
- `MODEL_NAME`(默认 kimi-k3
- `MODEL_TEMPERATURE`(默认 0.3
- `MAX_ITERATIONS`(默认 50
- `MAX_WEBPAGE_LENGTH`(默认 50000
- `SUMMARY_MAX_TOKENS`(默认 500
- `CONTEXT_WINDOW_SIZE`(默认 128000;相对 K3 真实 ~1M 的故意收紧)
### 故障排除
- **无 Serper Key** mock 仍可验证压缩逻辑
- **非基线策略仍溢出:** 降低 `MAX_WEBPAGE_LENGTH` / `SUMMARY_MAX_TOKENS` / 搜索 `num_results`
- **偏慢:** `--no-streaming`、减小 `-n/--max-iterations`、改用 mock 搜索
### 研究任务
> 「查找所有 OpenAI 联合创始人的当前职业归属」
适合原因:需多次搜索、内容量大、考验上下文累积管理,结果可核对。
### 扩展
新策略:枚举 → `ContextCompressor``compress_search_results()` → 实验 runner。
新任务:改 `agent.py` 系统提示、`web_tools.py` mock、工具描述。
---
## Notes / 说明
- The 128K budget is intentional so compression/overflow behavior is visible even on models with larger real windows.
- 128K 预算是故意收紧的,以便在真实窗口更大的模型上仍能观察到压缩与溢出行为。
+675
View File
@@ -0,0 +1,675 @@
"""
Context Compression Research Agent with Streaming Support
"""
import json
import logging
import time
import sys
from typing import List, Dict, Any, Optional, Generator, Tuple
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
from config import Config
from web_tools import WebTools
from compression_strategies import (
CompressionStrategy,
ContextCompressor,
CompressedContent
)
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
# Configure logging
logging.basicConfig(level=logging.INFO, format=Config.LOG_FORMAT)
logger = logging.getLogger(__name__)
@dataclass
class ToolCall:
"""Represents a single tool call"""
tool_name: str
arguments: Dict[str, Any]
result: Optional[Any] = None
compressed_result: Optional[CompressedContent] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
# Provider-side tool_call id, so a tool message in the history can be
# matched back to the call that produced it (used by windowed compression
# to recover the original query).
id: Optional[str] = None
@dataclass
class AgentTrajectory:
"""Tracks the agent's execution trajectory"""
tool_calls: List[ToolCall] = field(default_factory=list)
total_tokens_used: int = 0
prompt_tokens_used: int = 0
completion_tokens_used: int = 0
# Prompt tokens of the most recent API call = the current context size.
# prompt_tokens_used above is a cumulative COST counter (each call's
# prompt re-counts the shared prefix), so it must not be compared
# against the per-request context window.
last_prompt_tokens: int = 0
context_overflows: int = 0
compression_strategy: CompressionStrategy = CompressionStrategy.NO_COMPRESSION
start_time: float = field(default_factory=time.time)
end_time: Optional[float] = None
class ResearchAgent:
"""
AI Agent for researching with context compression
"""
def __init__(
self,
api_key: str,
compression_strategy: CompressionStrategy = CompressionStrategy.NO_COMPRESSION,
verbose: bool = False,
enable_streaming: bool = True
):
"""
Initialize the research agent
Args:
api_key: API key for Moonshot/Kimi
compression_strategy: Strategy for context compression
verbose: Enable verbose logging
enable_streaming: Enable streaming responses
"""
# Moonshot 官方 key 存在则直连;否则回退 OpenRouter(见 Config.resolve_llm)。
resolved_key, resolved_base_url, resolved_model = Config.resolve_llm()
self.client = OpenAI(
api_key=resolved_key,
base_url=resolved_base_url
)
self.model = resolved_model
self.compression_strategy = compression_strategy
self.verbose = verbose
self.enable_streaming = enable_streaming
# Initialize tools
self.web_tools = WebTools()
self.compressor = ContextCompressor(compression_strategy, api_key, enable_streaming)
# Initialize trajectory
self.trajectory = AgentTrajectory(compression_strategy=compression_strategy)
# Initialize conversation history
self.conversation_history = []
self._init_system_prompt()
logger.info(f"Agent initialized with compression strategy: {compression_strategy.value}")
def _init_system_prompt(self):
"""Initialize the system prompt for OpenAI co-founders research"""
# Get current date dynamically
from datetime import datetime
today = datetime.now()
date_string = today.strftime("%A, %B %d, %Y")
self.conversation_history = [
{
"role": "system",
"content": f"""You are a research assistant tasked with finding information about OpenAI co-founders.
Your task is to:
1. First, search for and identify ALL OpenAI co-founders
2. Then, search for EACH co-founder individually to find their CURRENT affiliations
3. Compile a comprehensive report with current status for each co-founder
Important instructions:
- Be thorough and systematic - search for each person individually
- Focus on CURRENT affiliations, not historical roles
- Include company names, positions, and any recent changes
- If someone left a position, note where they went
- When you have gathered all information, provide a FINAL ANSWER with a complete list
Available tools:
- search_web: Search the web for information
- fetch_webpage: Fetch specific webpage content
Start by searching for the complete list of OpenAI co-founders.
TODAY'S DATE: {date_string}"""
}
]
def _get_tools_description(self) -> List[Dict[str, Any]]:
"""Get tool descriptions for the model"""
return [
{
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for information. Returns multiple search results with content from each webpage.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"num_results": {
"type": "integer",
"description": "Number of results to return (default: 5)",
"default": 5
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "fetch_webpage",
"description": "Fetch and extract text content from a specific webpage URL",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL of the webpage to fetch"
}
},
"required": ["url"]
}
}
}
]
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Tuple[Any, Optional[CompressedContent]]:
"""
Execute a tool and return the result with optional compression
Args:
tool_name: Name of the tool to execute
arguments: Arguments for the tool
Returns:
Tuple of (tool result, compressed content if applicable)
"""
if not isinstance(arguments, dict):
arguments = {}
if tool_name == "search_web":
if "query" not in arguments:
return {"error": "Missing required argument 'query' for search_web"}, None
try:
result = self.web_tools.search_web(**arguments)
except Exception as e:
logger.error(f"Failed to execute search_web: {e}")
return {"error": f"Failed to execute search_web: {e}"}, None
# Apply compression strategy
query = arguments.get('query', '')
current_context = self._get_current_context_summary()
compressed = self.compressor.compress_search_results(
result,
query,
current_context
)
return result, compressed
elif tool_name == "fetch_webpage":
if "url" not in arguments:
return {"error": "Missing required argument 'url' for fetch_webpage"}, None
try:
result = self.web_tools.fetch_webpage(**arguments)
except Exception as e:
logger.error(f"Failed to execute fetch_webpage: {e}")
return {"error": f"Failed to execute fetch_webpage: {e}"}, None
# For fetch, we typically don't compress (used for follow-ups)
return result, None
else:
return {"error": f"Unknown tool: {tool_name}"}, None
def _get_current_context_summary(self) -> str:
"""Get a summary of current context for context-aware compression"""
if not self.trajectory.tool_calls:
return ""
# Get last few tool calls for context
recent_calls = self.trajectory.tool_calls[-3:]
context_parts = []
for call in recent_calls:
context_parts.append(f"Previous search: {call.arguments.get('query', 'N/A')}")
return " | ".join(context_parts)
def _handle_windowed_compression(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Apply windowed compression strategy to message history
Only compresses when context usage exceeds 80% threshold
Args:
messages: Current message history
Returns:
Messages with compressed history when needed
"""
if self.compression_strategy != CompressionStrategy.WINDOWED_CONTEXT:
return messages
# Check if we should start compressing (80% context usage).
# Use the LAST call's prompt size (current context), not the
# cumulative cost counter, which grows quadratically and would
# trigger compression long before the window is actually near full.
context_threshold = Config.CONTEXT_WINDOW_SIZE * 0.8
if self.trajectory.last_prompt_tokens <= context_threshold:
logger.debug(f"Windowed compression: Context usage below threshold ({self.trajectory.last_prompt_tokens:,}/{context_threshold:.0f} tokens)")
return messages # No compression needed yet
logger.info(f"⚠️ Context usage exceeds 80% threshold ({self.trajectory.last_prompt_tokens:,}/{Config.CONTEXT_WINDOW_SIZE} tokens) - Starting compression")
# Compression marker to identify already-compressed messages
COMPRESSION_MARKER = "[COMPRESSED]"
# First, count how many tool messages we have and how many need compression
tool_messages_to_compress = []
already_compressed_count = 0
for i, msg in enumerate(messages):
if msg.get('role') == 'tool':
original_content = msg.get('content', '')
if original_content.startswith(COMPRESSION_MARKER):
already_compressed_count += 1
else:
tool_messages_to_compress.append((i, msg))
total_tool_messages = already_compressed_count + len(tool_messages_to_compress)
if not tool_messages_to_compress:
logger.debug(f"Windowed compression: All {total_tool_messages} tool messages already compressed")
return messages # All tool messages already compressed
logger.info(f"📊 Compressing {len(tool_messages_to_compress)} uncompressed tool messages (out of {total_tool_messages} total)")
# Build the result with compression for all uncompressed tool messages
compressed_messages = []
compressed_in_this_pass = 0
for i, msg in enumerate(messages):
if msg.get('role') == 'tool':
original_content = msg.get('content', '')
# Check if already compressed
if original_content.startswith(COMPRESSION_MARKER):
# Already compressed, keep as is
compressed_messages.append(msg)
else:
# Compress this tool result
compressed_in_this_pass += 1
# Find the corresponding tool call to get context
tool_call_id = msg.get('tool_call_id')
query = "Information search" # Default
# Try to find the query from the tool call
for call in self.trajectory.tool_calls:
if call.id is not None and call.id == tool_call_id:
query = call.arguments.get('query', query)
break
logger.debug(f"Compressing tool message {compressed_in_this_pass}/{len(tool_messages_to_compress)} at index {i} (query: {query[:50]}...)")
compressed = self.compressor.compress_for_history(
original_content,
'search_web',
query,
preserve_citations=True
)
logger.debug(f"Compressed: {compressed.original_length:,}{compressed.compressed_length:,} chars")
# Mark as compressed with clear marker
compressed_content = (
f"{COMPRESSION_MARKER} "
f"[Original: {compressed.original_length:,} chars → Compressed: {compressed.compressed_length:,} chars]\n"
f"{compressed.content}"
)
compressed_messages.append({
**msg,
'content': compressed_content
})
else:
compressed_messages.append(msg)
logger.info(f"✅ Compressed {compressed_in_this_pass} tool messages in this pass")
return compressed_messages
def _stream_response(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Stream response from the model
Args:
messages: Conversation messages
Returns:
Complete message object with token usage
"""
try:
stream = self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=self._get_tools_description(),
tool_choice="auto",
temperature=_reasoning_safe_temperature(self.model, Config.MODEL_TEMPERATURE),
max_tokens=Config.MODEL_MAX_TOKENS,
stream=True,
stream_options={"include_usage": True} # Request token usage in stream
)
collected_chunks = []
collected_messages = []
current_tool_calls = []
usage_data = None
print("\n🤖 Assistant: ", end="", flush=True)
for chunk in stream:
collected_chunks.append(chunk)
# Capture usage data if present (might be in a chunk without choices)
if hasattr(chunk, 'usage') and chunk.usage is not None:
usage_data = chunk.usage
# Check if chunk has choices before accessing
if hasattr(chunk, 'choices') and chunk.choices and len(chunk.choices) > 0:
delta = chunk.choices[0].delta
# Handle content
if hasattr(delta, 'content') and delta.content:
content = delta.content
print(content, end="", flush=True)
collected_messages.append(content)
# Handle tool calls in streaming
if hasattr(delta, 'tool_calls') and delta.tool_calls:
for tool_call_delta in delta.tool_calls:
if tool_call_delta.index is not None:
# Ensure we have enough tool calls in the list
while len(current_tool_calls) <= tool_call_delta.index:
current_tool_calls.append({
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""}
})
if tool_call_delta.id:
current_tool_calls[tool_call_delta.index]["id"] = tool_call_delta.id
if tool_call_delta.function:
if tool_call_delta.function.name:
current_tool_calls[tool_call_delta.index]["function"]["name"] = tool_call_delta.function.name
if tool_call_delta.function.arguments:
current_tool_calls[tool_call_delta.index]["function"]["arguments"] += tool_call_delta.function.arguments
print("\n", flush=True)
# Log token usage if available
if usage_data:
prompt_tokens = usage_data.prompt_tokens if hasattr(usage_data, 'prompt_tokens') else 0
completion_tokens = usage_data.completion_tokens if hasattr(usage_data, 'completion_tokens') else 0
total_tokens = usage_data.total_tokens if hasattr(usage_data, 'total_tokens') else 0
logger.info(f"🔢 Kimi API Token Usage - Prompt: {prompt_tokens}, Completion: {completion_tokens}, Total: {total_tokens}")
# Update trajectory
self.trajectory.last_prompt_tokens = prompt_tokens
self.trajectory.prompt_tokens_used += prompt_tokens
self.trajectory.completion_tokens_used += completion_tokens
self.trajectory.total_tokens_used += total_tokens
# Construct the complete message
complete_message = {
"role": "assistant",
"content": "".join(collected_messages) if collected_messages else None
}
if current_tool_calls:
complete_message["tool_calls"] = current_tool_calls
return complete_message
except Exception as e:
logger.error(f"Error in streaming response: {str(e)}")
raise
def _non_streaming_response(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Get non-streaming response from the model
Args:
messages: Conversation messages
Returns:
Complete message object with token usage
"""
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=self._get_tools_description(),
tool_choice="auto",
temperature=_reasoning_safe_temperature(self.model, Config.MODEL_TEMPERATURE),
max_tokens=Config.MODEL_MAX_TOKENS,
stream=False
)
message = response.choices[0].message
# Log token usage
if hasattr(response, 'usage') and response.usage:
prompt_tokens = response.usage.prompt_tokens
completion_tokens = response.usage.completion_tokens
total_tokens = response.usage.total_tokens
logger.info(f"🔢 Kimi API Token Usage - Prompt: {prompt_tokens}, Completion: {completion_tokens}, Total: {total_tokens}")
# Update trajectory
self.trajectory.last_prompt_tokens = prompt_tokens
self.trajectory.prompt_tokens_used += prompt_tokens
self.trajectory.completion_tokens_used += completion_tokens
self.trajectory.total_tokens_used += total_tokens
# Convert to dict format
message_dict = {
"role": "assistant",
"content": message.content
}
if hasattr(message, 'tool_calls') and message.tool_calls:
message_dict["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in message.tool_calls
]
# Display the response
if message.content:
print(f"\n🤖 Assistant: {message.content}\n")
return message_dict
def execute_research(self, max_iterations: int = 15) -> Dict[str, Any]:
"""
Execute the research task
Args:
max_iterations: Maximum number of tool calls
Returns:
Research results
"""
# Add initial user message
self.conversation_history.append({
"role": "user",
"content": "Please research and find the current affiliations of all OpenAI co-founders."
})
messages = self.conversation_history.copy()
iteration = 0
final_answer = None
print("\n" + "="*60)
print(f"Starting research with {self.compression_strategy.value} strategy")
print("="*60)
while iteration < max_iterations:
iteration += 1
print(f"\n📍 Iteration {iteration}/{max_iterations}")
try:
# Apply windowed compression if needed
if self.compression_strategy == CompressionStrategy.WINDOWED_CONTEXT:
messages = self._handle_windowed_compression(messages)
# Display current token usage from trajectory
print(f"📊 Cumulative Token Usage - Prompt: {self.trajectory.prompt_tokens_used:,}, Completion: {self.trajectory.completion_tokens_used:,}, Total: {self.trajectory.total_tokens_used:,}")
# Check if we're approaching token limit based on actual usage
if self.trajectory.total_tokens_used > 0: # Only check after first call
# Compression demo uses a 128k context budget. Compare the
# LAST call's prompt size (the actual context) against the
# window — the cumulative counter re-counts the shared
# prefix every call and overstates usage quadratically.
if self.trajectory.last_prompt_tokens > Config.CONTEXT_WINDOW_SIZE * 0.8:
logger.warning(f"Approaching context limit: {self.trajectory.last_prompt_tokens:,} prompt tokens in last request")
self.trajectory.context_overflows += 1
if self.compression_strategy == CompressionStrategy.NO_COMPRESSION:
print("\n⚠️ Context overflow detected! This demonstrates the limitation of no compression.")
return {
"error": f"Context window exceeded - {self.trajectory.last_prompt_tokens:,} tokens in last request (limit: {Config.CONTEXT_WINDOW_SIZE})",
"trajectory": self.trajectory,
"iterations": iteration
}
# Get response from model
if self.enable_streaming:
message = self._stream_response(messages)
else:
message = self._non_streaming_response(messages)
# Handle tool calls
if message.get('tool_calls'):
messages.append(message)
if message.get('content'):
print(f"\n🤖 Assistant: {message['content']}")
for tool_call in message['tool_calls']:
function_name = tool_call['function']['name']
raw_args = tool_call['function'].get('arguments') or "{}"
try:
if isinstance(raw_args, dict):
function_args = raw_args
elif isinstance(raw_args, (bytes, bytearray)):
function_args = json.loads(raw_args.decode("utf-8"))
elif isinstance(raw_args, str):
function_args = json.loads(raw_args)
else:
function_args = json.loads(str(raw_args))
if not isinstance(function_args, dict):
logger.warning(
"Tool argument JSON is not an object, proceeding with empty object: %r",
raw_args,
)
function_args = {}
except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
# Tolerate bad tool-arg JSON; keep the loop alive.
function_args = {}
logger.warning(
"Tool argument is not valid JSON, proceeding with empty object: %r",
raw_args,
)
print(f"\n🔧 Executing: {function_name}")
print(f" Args: {function_args}")
# Execute the tool
result, compressed = self._execute_tool(function_name, function_args)
# Record the tool call
tool_call_record = ToolCall(
tool_name=function_name,
arguments=function_args,
result=result,
compressed_result=compressed,
id=tool_call['id']
)
self.trajectory.tool_calls.append(tool_call_record)
# Determine what content to add to messages
if compressed and self.compression_strategy != CompressionStrategy.NO_COMPRESSION:
# Use compressed content
tool_content = compressed.content
print(f" ✂️ Compressed: {compressed.original_length:,}{compressed.compressed_length:,} chars")
else:
# Use original content (for no compression or last message in windowed)
if function_name == "search_web":
# Format search results
tool_content = json.dumps(result, indent=2)
else:
tool_content = json.dumps(result)
# Add tool result to messages
tool_msg = {
"role": "tool",
"tool_call_id": tool_call['id'],
"content": tool_content
}
messages.append(tool_msg)
print(f" 📄 Result size: {len(tool_content):,} characters")
elif message.get('content'):
# No tool calls, just content
messages.append(message)
final_answer = message['content']
logger.info("Final answer found")
break
except Exception as e:
logger.error(f"Error during research: {str(e)}")
return {
"error": str(e),
"trajectory": self.trajectory,
"iterations": iteration
}
# Set end time
self.trajectory.end_time = time.time()
return {
"final_answer": final_answer,
"trajectory": self.trajectory,
"iterations": iteration,
"success": final_answer is not None,
"execution_time": self.trajectory.end_time - self.trajectory.start_time
}
def reset(self):
"""Reset the agent's state"""
self.trajectory = AgentTrajectory(compression_strategy=self.compression_strategy)
self._init_system_prompt()
self.web_tools.clear_cache()
logger.info("Agent state reset")
@@ -0,0 +1,339 @@
"""
Context Compression Benchmark Module.
Systematically benchmarks Summary, Truncation, Key-Sentence, and Observation-Filtering
compression strategies on long-context tasks. Measures compression ratio, Time-to-First-Token (TTFT),
token cost savings, and downstream QA retention accuracy.
"""
import math
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union, Tuple
def count_tokens(text: str) -> int:
"""Estimate token count for a given text string.
Uses tiktoken if available, with a reliable character/word-based fallback.
"""
if not text:
return 0
try:
import tiktoken
try:
encoding = tiktoken.encoding_for_model("gpt-4")
except Exception:
encoding = tiktoken.get_encoding("cl100k_base")
return len(encoding.encode(text))
except Exception:
# Fallback estimation: ~4 chars per token or ~0.75 words per token
words = len(text.split())
chars = len(text)
return max(1, int((words * 1.3 + chars / 4) / 2))
@dataclass
class StrategyMetrics:
"""Performance metrics for a context compression strategy."""
strategy: str
original_tokens: int
compressed_tokens: int
compression_ratio: float # compressed_tokens / original_tokens
ttft_ms: float # Time to first token in milliseconds
token_cost_savings: float # Cost savings ratio (0.0 to 1.0)
qa_retention_accuracy: float # Downstream QA accuracy (0.0 to 1.0)
def to_dict(self) -> Dict[str, Any]:
"""Convert metrics to a standard dictionary representation."""
return {
"strategy": self.strategy,
"original_tokens": self.original_tokens,
"compressed_tokens": self.compressed_tokens,
"compression_ratio": self.compression_ratio,
"ttft_ms": self.ttft_ms,
"token_cost_savings": self.token_cost_savings,
"qa_retention_accuracy": self.qa_retention_accuracy,
}
class ContextCompressionBenchmark:
"""Benchmark harness for evaluating context compression strategies."""
STRATEGIES = ["summary", "truncation", "key_sentence", "observation_filtering"]
def __init__(
self,
base_ttft_ms: float = 50.0,
per_token_ttft_ms: float = 0.05,
token_cost_per_1k: float = 0.0015,
target_max_tokens: int = 500,
):
"""Initialize the benchmark suite with configurable performance parameters."""
self.base_ttft_ms = base_ttft_ms
self.per_token_ttft_ms = per_token_ttft_ms
self.token_cost_per_1k = token_cost_per_1k
self.target_max_tokens = target_max_tokens
def compress_summary(self, context: str, query: str = "") -> str:
"""Summary Strategy: Condenses context into key abstract points."""
if not context:
return ""
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', context) if s.strip()]
if not sentences:
return context
if len(sentences) <= 3:
return context
# Extract beginning, middle, and end sentences to form a concise summary
step = max(1, len(sentences) // 3)
summary_sentences = [sentences[0]]
if step < len(sentences):
summary_sentences.append(sentences[step])
if len(sentences) - 1 > step:
summary_sentences.append(sentences[-1])
return " ".join(summary_sentences)
def compress_truncation(self, context: str, max_tokens: Optional[int] = None) -> str:
"""Truncation Strategy: Slices context to fit within strict token limits."""
if not context:
return ""
limit = self.target_max_tokens if max_tokens is None else max_tokens
if limit <= 0:
return ""
words = context.split()
if not words:
# No whitespace-separated words (e.g. CJK text): truncate by characters.
# CJK characters are roughly 1-2 tokens each, so use a conservative 1:1 ratio.
return context[:limit]
# Estimate max words corresponding to limit tokens (~0.75 words per token)
max_words = max(1, int(limit * 0.75))
truncated_words = words[:max_words]
return " ".join(truncated_words)
def compress_key_sentence(self, context: str, query: str = "") -> str:
"""Key-Sentence Strategy: Retains sentences with high query term match/relevance."""
if not context:
return ""
query = query or ""
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', context) if s.strip()]
if not sentences:
return context
if not query:
# Fallback to sentence length / position scoring if query is empty
scored = sorted(enumerate(sentences), key=lambda x: len(x[1]), reverse=True)
top_indices = sorted([idx for idx, _ in scored[:max(1, len(sentences) // 2)]])
return " ".join([sentences[i] for i in top_indices])
query_terms = set(re.findall(r'\w+', query.lower()))
scored_sentences = []
for idx, sentence in enumerate(sentences):
sentence_terms = set(re.findall(r'\w+', sentence.lower()))
overlap = len(query_terms.intersection(sentence_terms))
scored_sentences.append((overlap, idx, sentence))
# Sort by overlap descending, then by original position
scored_sentences.sort(key=lambda x: (-x[0], x[1]))
# Keep top half of sentences or those with overlap > 0
keep_count = max(1, math.ceil(len(sentences) * 0.5))
selected = scored_sentences[:keep_count]
# Sort selected back into original context order
selected.sort(key=lambda x: x[1])
return " ".join([s[2] for s in selected])
def compress_observation_filtering(self, context: str) -> str:
"""Observation-Filtering Strategy: Removes verbose system output, logs, hex, and JSON blobs."""
if not context:
return ""
lines = context.splitlines()
filtered_lines = []
for line in lines:
stripped = line.strip()
# Filter out JSON-like blobs, long hex hashes, trace logs, or repetitive debug markers
if (
re.match(r'^\s*[\{\[\}\]].*$', stripped) or
re.search(r'\b[0-9a-fA-F]{32,64}\b', stripped) or
re.search(r'^\s*(DEBUG|TRACE|INFO|VERBOSE)\b', stripped, re.IGNORECASE) or
re.search(r'^\s*<.*?>\s*$', stripped)
):
continue
filtered_lines.append(line)
result = "\n".join(filtered_lines).strip()
return result if result else context
def compress(self, strategy: str, context: str, query: str = "") -> str:
"""Apply a specific compression strategy to a given context string."""
strat = strategy.lower().replace("-", "_")
if strat == "summary":
return self.compress_summary(context, query)
elif strat == "truncation":
return self.compress_truncation(context)
elif strat in ("key_sentence", "keysentence"):
return self.compress_key_sentence(context, query)
elif strat in ("observation_filtering", "observationfiltering"):
return self.compress_observation_filtering(context)
else:
raise ValueError(f"Unknown compression strategy: {strategy}")
def evaluate_retention(self, compressed_text: str, task: Union[str, Dict[str, Any]]) -> Optional[float]:
"""Evaluate downstream QA retention accuracy on compressed context."""
compressed_text = compressed_text or ""
task = task or ""
query = task if isinstance(task, str) else (task.get("query", "") if isinstance(task, dict) else "")
expected = task.get("expected_answer", "") if isinstance(task, dict) else ""
if query is None:
query = ""
if expected is None:
expected = ""
# Only score against the expected answer, not the query.
# Using query words as fallback inflates scores because the question
# text often survives compression even when the answer is deleted.
target_text = expected.strip()
target_tokens = set(re.findall(r'\w+', target_text.lower()))
if not target_tokens:
# No expected answer to check against: cannot evaluate retention.
return None
compressed_tokens = set(re.findall(r'\w+', compressed_text.lower()))
matched = target_tokens.intersection(compressed_tokens)
# Calculate recall accuracy
accuracy = len(matched) / len(target_tokens)
return min(1.0, max(0.0, accuracy))
def evaluate_strategy(
self,
strategy: str,
contexts: List[str],
tasks: List[Union[str, Dict[str, Any]]],
) -> StrategyMetrics:
"""Benchmark a single compression strategy over multiple contexts and tasks."""
total_orig_tokens = 0
total_comp_tokens = 0
total_retention_acc = 0.0
retention_count = 0
sample_count = 0
start_time = time.perf_counter()
for idx, ctx in enumerate(contexts):
task = tasks[idx % len(tasks)] if tasks else ""
if task is None:
task = ""
query = task if isinstance(task, str) else (task.get("query", "") if isinstance(task, dict) else "")
query = query or ""
orig_tokens = count_tokens(ctx)
compressed_ctx = self.compress(strategy, ctx, query=query)
comp_tokens = count_tokens(compressed_ctx)
retention_acc = self.evaluate_retention(compressed_ctx, task)
total_orig_tokens += orig_tokens
total_comp_tokens += comp_tokens
if retention_acc is not None:
total_retention_acc += retention_acc
retention_count += 1
sample_count += 1
elapsed_ms = (time.perf_counter() - start_time) * 1000
avg_orig_tokens = total_orig_tokens / max(1, sample_count)
avg_comp_tokens = total_comp_tokens / max(1, sample_count)
avg_retention_acc = total_retention_acc / max(1, retention_count)
if avg_orig_tokens == 0:
ratio = 0.0
savings = 0.0
else:
ratio = avg_comp_tokens / avg_orig_tokens
savings = max(0.0, 1.0 - ratio)
# Simulate TTFT: Base TTFT + processing time + prefill latency based on compressed tokens
simulated_ttft = self.base_ttft_ms + (avg_comp_tokens * self.per_token_ttft_ms) + (elapsed_ms / max(1, sample_count))
# Format normalized strategy key
strat_key = strategy.lower().replace("-", "_")
return StrategyMetrics(
strategy=strat_key,
original_tokens=int(avg_orig_tokens),
compressed_tokens=int(avg_comp_tokens),
compression_ratio=round(ratio, 4),
ttft_ms=round(simulated_ttft, 2),
token_cost_savings=round(savings, 4),
qa_retention_accuracy=round(avg_retention_acc, 4),
)
def run_benchmark(
self,
contexts: Union[str, List[Union[str, Dict[str, Any]]]],
tasks: Union[str, List[Union[str, Dict[str, Any]]]],
) -> Dict[str, Any]:
"""Run systematic benchmark across all compression strategies.
Args:
contexts: Single context string, dict, or list of context strings/dicts.
tasks: Single task/query string, dict, or list of tasks/queries.
Returns:
Comparative metrics dictionary mapping strategy names to performance metrics dicts.
"""
# Standardize contexts into list of text strings
if isinstance(contexts, (str, dict)):
raw_contexts = [contexts]
else:
raw_contexts = list(contexts)
normalized_contexts = []
for c in raw_contexts:
if isinstance(c, str):
normalized_contexts.append(c)
elif isinstance(c, dict):
content = c.get("content")
if content is None:
content = c.get("text")
# Use the extracted content, or empty string if none found.
# Falling back to str(c) would treat the raw dict repr as
# context text, producing nonsensical benchmark metrics.
normalized_contexts.append(content if content is not None else "")
else:
normalized_contexts.append(str(c))
# Standardize tasks into list of queries/task objects
if isinstance(tasks, (str, dict)):
normalized_tasks = [tasks]
else:
normalized_tasks = list(tasks)
results: Dict[str, Any] = {}
for strategy in self.STRATEGIES:
metrics = self.evaluate_strategy(strategy, normalized_contexts, normalized_tasks)
metrics_dict = metrics.to_dict()
display_name = {
"summary": "Summary",
"truncation": "Truncation",
"key_sentence": "Key-Sentence",
"observation_filtering": "Observation-Filtering",
}.get(strategy, strategy)
metrics_dict["display_name"] = display_name
results[strategy] = metrics_dict
return results
def run_benchmark(
contexts: Union[str, List[Union[str, Dict[str, Any]]]],
tasks: Union[str, List[Union[str, Dict[str, Any]]]],
) -> Dict[str, Any]:
"""Module-level entrypoint for executing the compression benchmark.
Args:
contexts: Input contexts (strings or dicts).
tasks: Downstream QA tasks or queries.
Returns:
Dictionary of comparative performance metrics per compression strategy.
"""
benchmark = ContextCompressionBenchmark()
return benchmark.run_benchmark(contexts, tasks)
@@ -0,0 +1,694 @@
"""
Context Compression Strategies for the experiment
"""
import json
import logging
from typing import List, Dict, Any, Optional, Tuple
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
import tiktoken
from config import Config
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
def _reasoning_safe_max_tokens(model, requested, reasoning_budget=2048):
"""Reasoning models (Kimi K3, GPT-5, ...) spend part of the max_tokens
budget on reasoning_content *before* emitting the visible answer. If we
pass only the summary budget (e.g. 300-500), the reasoning trace can eat
into it and the summary comes back truncated or empty. Give reasoning
models extra headroom so the requested output budget is fully available
for the summary itself; non-reasoning models are unchanged."""
m = str(model or "").lower().replace("/", "-")
if "kimi-k3" in m or "gpt-5" in m:
return requested + reasoning_budget
return requested
# Configure logging
logging.basicConfig(level=logging.INFO, format=Config.LOG_FORMAT)
logger = logging.getLogger(__name__)
class CompressionStrategy(Enum):
"""Different context compression strategies"""
NO_COMPRESSION = "no_compression"
NON_CONTEXT_AWARE_INDIVIDUAL = "non_context_aware_individual_summary" # Summarize each page individually then concat
NON_CONTEXT_AWARE_COMBINED = "non_context_aware_combined_summary" # Concat all pages then summarize once
CONTEXT_AWARE = "context_aware_summary"
CONTEXT_AWARE_CITATIONS = "context_aware_with_citations"
WINDOWED_CONTEXT = "windowed_context"
@dataclass
class CompressedContent:
"""Represents compressed content"""
original_length: int
compressed_length: int
content: str
citations: List[Dict[str, str]] = field(default_factory=list)
strategy: CompressionStrategy = CompressionStrategy.NO_COMPRESSION
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
class ContextCompressor:
"""Handles different context compression strategies"""
def __init__(self, strategy: CompressionStrategy, api_key: str, enable_streaming: bool = True):
"""
Initialize the context compressor
Args:
strategy: Compression strategy to use
api_key: API key for LLM
enable_streaming: Whether to enable streaming for summarization
"""
self.strategy = strategy
self.enable_streaming = enable_streaming
# Moonshot 官方 key 存在则直连;否则回退 OpenRouter(见 Config.resolve_llm)。
resolved_key, resolved_base_url, resolved_model = Config.resolve_llm()
self.client = OpenAI(
api_key=resolved_key,
base_url=resolved_base_url
)
self.model = resolved_model
# Initialize tokenizer for token counting
try:
self.encoding = tiktoken.encoding_for_model("gpt-4")
except Exception:
self.encoding = tiktoken.get_encoding("cl100k_base")
logger.info(f"Context compressor initialized with strategy: {strategy.value}, streaming: {enable_streaming}")
def count_tokens(self, text: str) -> int:
"""Count the number of tokens in a text string."""
try:
return len(self.encoding.encode(text))
except Exception:
# Fallback to character-based estimation (1 token ≈ 4 chars)
return len(text) // 4
def compress_search_results(
self,
search_results: Dict[str, Any],
query: str,
current_context: Optional[str] = None
) -> CompressedContent:
"""
Compress search results based on the selected strategy
Args:
search_results: Raw search results from web tool
query: The original search query
current_context: Current conversation context (for context-aware strategies)
Returns:
Compressed content
"""
if self.strategy == CompressionStrategy.NO_COMPRESSION:
return self._no_compression(search_results)
elif self.strategy == CompressionStrategy.NON_CONTEXT_AWARE_INDIVIDUAL:
return self._non_context_aware_individual_summary(search_results)
elif self.strategy == CompressionStrategy.NON_CONTEXT_AWARE_COMBINED:
return self._non_context_aware_combined_summary(search_results)
elif self.strategy == CompressionStrategy.CONTEXT_AWARE:
return self._context_aware_summary(search_results, query, current_context)
elif self.strategy == CompressionStrategy.CONTEXT_AWARE_CITATIONS:
return self._context_aware_with_citations(search_results, query, current_context)
elif self.strategy == CompressionStrategy.WINDOWED_CONTEXT:
# For windowed context, return full content (compression happens later)
return self._no_compression(search_results)
else:
raise ValueError(f"Unknown compression strategy: {self.strategy}")
def compress_for_history(
self,
content: str,
tool_name: str,
query: str,
preserve_citations: bool = True
) -> CompressedContent:
"""
Compress content for message history (used in windowed context strategy)
Args:
content: Content to compress
tool_name: Name of the tool that generated the content
query: The query that triggered the tool call
preserve_citations: Whether to preserve citations
Returns:
Compressed content for history
"""
original_length = len(content)
try:
prompt = f"""Compress the following {tool_name} results into a concise summary that preserves key information.
Focus on information relevant to: {query}
Original content:
{content[:10000]}
Requirements:
1. Keep all important facts, names, dates, and affiliations
2. Remove redundant information
3. Maintain clarity and coherence
{"4. Include [Source: URL] citations for important facts" if preserve_citations else ""}
5. Maximum length: {Config.SUMMARY_MAX_TOKENS} tokens
Provide a focused summary:"""
# Log prompt length
prompt_tokens = self.count_tokens(prompt)
logger.info(f"Simple summary request - Prompt tokens: {prompt_tokens}, Prompt length: {len(prompt)} chars")
if self.enable_streaming:
# Stream the summary to console
print(f"\n📝 Creating simple summary...\n", flush=True)
stream = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates concise summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS),
stream=True
)
summary_parts = []
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
# NB: do not name this `content` — that shadows the
# `content` parameter (the original tool output) and
# breaks the truncation fallback below when the stream
# fails part-way through.
delta_text = chunk.choices[0].delta.content
print(delta_text, end="", flush=True)
summary_parts.append(delta_text)
print("\n") # New lines after streaming
compressed = "".join(summary_parts)
else:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates concise summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS)
)
compressed = response.choices[0].message.content
return CompressedContent(
original_length=original_length,
compressed_length=len(compressed),
content=compressed,
strategy=CompressionStrategy.WINDOWED_CONTEXT
)
except Exception as e:
logger.error(f"Error compressing for history: {str(e)}")
# Fallback to truncation
truncated = content[:2000] + "\n\n[Content truncated for history...]"
return CompressedContent(
original_length=original_length,
compressed_length=len(truncated),
content=truncated,
strategy=CompressionStrategy.WINDOWED_CONTEXT
)
def _no_compression(self, search_results: Dict[str, Any]) -> CompressedContent:
"""
Strategy 1: No compression - return all original content
"""
all_content = []
total_length = 0
for result in search_results.get('results', []):
content = f"""
===== Search Result =====
Title: {result.get('title', 'N/A')}
URL: {result.get('url', 'N/A')}
Snippet: {result.get('snippet', 'N/A')}
Full Content:
{result.get('content', 'No content available')}
========================
"""
all_content.append(content)
total_length += len(result.get('content') or '')
full_content = "\n\n".join(all_content)
return CompressedContent(
original_length=total_length,
compressed_length=len(full_content),
content=full_content,
strategy=CompressionStrategy.NO_COMPRESSION
)
def _non_context_aware_individual_summary(self, search_results: Dict[str, Any]) -> CompressedContent:
"""
Strategy 2A: Non-context-aware summarization - Summarize each page individually then concatenate
"""
summaries = []
total_original = 0
for result in search_results.get('results', []):
if not result.get('content'):
continue
original_content = result.get('content', '')
total_original += len(original_content)
try:
# Summarize each page independently
prompt = f"""Summarize the following webpage content in 2-3 paragraphs:
Title: {result.get('title', 'N/A')}
URL: {result.get('url', 'N/A')}
Content:
{original_content[:5000]}
Provide a concise summary:"""
# Log prompt length
prompt_tokens = self.count_tokens(prompt)
logger.info(f"Non-context-aware summary - Prompt tokens: {prompt_tokens}, Prompt length: {len(prompt)} chars")
if self.enable_streaming:
# Stream the summary to console
print(f"\n📝 Summarizing: {result.get('title', 'N/A')[:50]}...", end=" ", flush=True)
stream = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates concise summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, 300),
stream=True
)
summary_parts = []
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
print(content, end="", flush=True)
summary_parts.append(content)
print() # New line after streaming
summary = "".join(summary_parts)
else:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates concise summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, 300)
)
summary = response.choices[0].message.content
summaries.append(f"""
Source: {result.get('title', 'N/A')}
URL: {result.get('url', 'N/A')}
Summary: {summary}
""")
except Exception as e:
logger.error(f"Error summarizing page: {str(e)}")
# Fallback to snippet
summaries.append(f"""
Source: {result.get('title', 'N/A')}
URL: {result.get('url', 'N/A')}
Summary: {result.get('snippet', 'No summary available')}
""")
compressed_content = "\n".join(summaries)
return CompressedContent(
original_length=total_original,
compressed_length=len(compressed_content),
content=compressed_content,
strategy=CompressionStrategy.NON_CONTEXT_AWARE_INDIVIDUAL
)
def _non_context_aware_combined_summary(self, search_results: Dict[str, Any]) -> CompressedContent:
"""
Strategy 2B: Non-context-aware summarization - Concatenate all pages then summarize once
"""
# Combine all content first
all_content = []
total_original = 0
max_chars_per_page = 5000 # Limit each page to prevent token overflow
for result in search_results.get('results', []):
if result.get('content'):
original_content = result.get('content', '')
total_original += len(original_content)
# Limit each page's content
limited_content = original_content[:max_chars_per_page]
all_content.append(f"""
===== Page: {result.get('title', 'N/A')} =====
URL: {result.get('url', 'N/A')}
Content: {limited_content}
""")
if not all_content:
return CompressedContent(
original_length=0,
compressed_length=0,
content="No content available",
strategy=CompressionStrategy.NON_CONTEXT_AWARE_COMBINED
)
combined_content = "\n\n".join(all_content)
try:
# Create a single summary for all combined content
prompt = f"""Summarize the following combined webpage content comprehensively:
{combined_content}
Requirements:
1. Create a comprehensive summary covering all pages
2. Include key information from each source
3. Maintain factual accuracy
4. Maximum length: {Config.SUMMARY_MAX_TOKENS} tokens
Provide a comprehensive summary:"""
# Log prompt length
prompt_tokens = self.count_tokens(prompt)
logger.info(f"Non-context-aware combined summary - Prompt tokens: {prompt_tokens}, Prompt length: {len(prompt)} chars")
if self.enable_streaming:
# Stream the summary to console
print(f"\n📄 Creating combined summary for all {len(search_results.get('results', []))} pages...\n", flush=True)
stream = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates comprehensive summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS),
stream=True
)
summary_parts = []
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
print(content, end="", flush=True)
summary_parts.append(content)
print("\n") # New lines after streaming
summary = "".join(summary_parts)
else:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates comprehensive summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS)
)
summary = response.choices[0].message.content
return CompressedContent(
original_length=total_original,
compressed_length=len(summary),
content=summary,
strategy=CompressionStrategy.NON_CONTEXT_AWARE_COMBINED
)
except Exception as e:
logger.error(f"Error creating combined summary: {str(e)}")
# Fallback to concatenated snippets
fallback = "\n\n".join([
f"{r.get('title', 'N/A')}: {r.get('snippet', 'No summary available')}"
for r in search_results.get('results', [])
])
return CompressedContent(
original_length=total_original,
compressed_length=len(fallback),
content=fallback,
strategy=CompressionStrategy.NON_CONTEXT_AWARE_COMBINED
)
def _context_aware_summary(
self,
search_results: Dict[str, Any],
query: str,
current_context: Optional[str] = None
) -> CompressedContent:
"""
Strategy 3: Context-aware summarization considering the query
"""
# Combine all content with per-page limits
all_content = []
total_original = 0
max_chars_per_page = 5000 # Limit each page to prevent token overflow
for result in search_results.get('results', []):
if result.get('content'):
original_content = result.get('content', '')
total_original += len(original_content)
# Limit each page's content
limited_content = original_content[:max_chars_per_page]
all_content.append(f"""
Title: {result.get('title', 'N/A')}
URL: {result.get('url', 'N/A')}
Content: {limited_content}
""")
combined_content = "\n\n".join(all_content)
try:
# Create context-aware summary
prompt = f"""Given the search query: "{query}"
{f"Current context: {current_context[:1000]}" if current_context else ""}
Analyze the following search results and provide a focused summary that directly addresses the query.
Focus on extracting information most relevant to answering: {query}
Search Results:
{combined_content}
Requirements:
1. Focus only on information relevant to the query
2. Prioritize current/recent information
3. Include specific names, dates, and affiliations
4. Maximum length: {Config.SUMMARY_MAX_TOKENS} tokens
Provide a query-focused summary:"""
# Log prompt length
prompt_tokens = self.count_tokens(prompt)
logger.info(f"Context-aware summary - Prompt tokens: {prompt_tokens}, Prompt length: {len(prompt)} chars")
if self.enable_streaming:
# Stream the summary to console
print(f"\n🎯 Creating context-aware summary for query: '{query[:50]}...'\n", flush=True)
stream = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates focused, context-aware summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS),
stream=True
)
summary_parts = []
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
print(content, end="", flush=True)
summary_parts.append(content)
print("\n") # New lines after streaming
summary = "".join(summary_parts)
else:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates focused, context-aware summaries."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS)
)
summary = response.choices[0].message.content
return CompressedContent(
original_length=total_original,
compressed_length=len(summary),
content=summary,
strategy=CompressionStrategy.CONTEXT_AWARE
)
except Exception as e:
logger.error(f"Error creating context-aware summary: {str(e)}")
# Fallback to simple concatenation
fallback = "\n\n".join([r.get('snippet', '') for r in search_results.get('results', [])])
return CompressedContent(
original_length=total_original,
compressed_length=len(fallback),
content=fallback,
strategy=CompressionStrategy.CONTEXT_AWARE
)
def _context_aware_with_citations(
self,
search_results: Dict[str, Any],
query: str,
current_context: Optional[str] = None
) -> CompressedContent:
"""
Strategy 4: Context-aware summarization with citations
"""
# Track sources with per-page limits
sources = []
all_content = []
total_original = 0
max_chars_per_page = 5000 # Limit each page to prevent token overflow
for i, result in enumerate(search_results.get('results', [])):
if result.get('content'):
source_id = f"[{i+1}]"
original_content = result.get('content', '')
total_original += len(original_content)
# Limit each page's content
limited_content = original_content[:max_chars_per_page]
sources.append({
'id': source_id,
'title': result.get('title', 'N/A'),
'url': result.get('url', 'N/A')
})
all_content.append(f"""
{source_id} Title: {result.get('title', 'N/A')}
Content: {limited_content}
""")
combined_content = "\n\n".join(all_content)
try:
# Create context-aware summary with citations
prompt = f"""Given the search query: "{query}"
{f"Current context: {current_context[:1000]}" if current_context else ""}
Analyze the following search results and provide a focused summary with citations.
Search Results (with source IDs):
{combined_content}
Requirements:
1. Focus on information relevant to: {query}
2. Include inline citations using [1], [2], etc. for each fact
3. Prioritize current/recent information
4. Include specific names, dates, and affiliations with citations
5. Maximum length: {Config.SUMMARY_MAX_TOKENS} tokens
Provide a query-focused summary with citations:"""
# Log prompt length
prompt_tokens = self.count_tokens(prompt)
logger.info(f"Citation-based summary - Prompt tokens: {prompt_tokens}, Prompt length: {len(prompt)} chars")
if self.enable_streaming:
# Stream the summary to console
print(f"\n📚 Creating summary with citations for: '{query[:50]}...'\n", flush=True)
stream = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates focused summaries with proper citations."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS),
stream=True
)
summary_parts = []
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
print(content, end="", flush=True)
summary_parts.append(content)
print("\n") # New lines after streaming
summary = "".join(summary_parts)
else:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that creates focused summaries with proper citations."},
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=_reasoning_safe_max_tokens(self.model, Config.SUMMARY_MAX_TOKENS)
)
summary = response.choices[0].message.content
# Append source list
source_list = "\n\nSources:\n"
for source in sources:
source_list += f"{source['id']} {source['title']} - {source['url']}\n"
final_content = summary + source_list
return CompressedContent(
original_length=total_original,
compressed_length=len(final_content),
content=final_content,
citations=sources,
strategy=CompressionStrategy.CONTEXT_AWARE_CITATIONS
)
except Exception as e:
logger.error(f"Error creating summary with citations: {str(e)}")
# Fallback
fallback = "\n\n".join([
f"[{i+1}] {r.get('title', '')}: {r.get('snippet', '')}"
for i, r in enumerate(search_results.get('results', []))
])
return CompressedContent(
original_length=total_original,
compressed_length=len(fallback),
content=fallback,
citations=sources,
strategy=CompressionStrategy.CONTEXT_AWARE_CITATIONS
)
def estimate_tokens(self, text: str) -> int:
"""
Estimate token count for text (rough approximation)
Args:
text: Text to estimate tokens for
Returns:
Estimated token count
"""
# Rough approximation: 1 token ≈ 4 characters
return len(text) // 4
+121
View File
@@ -0,0 +1,121 @@
"""
Configuration module for Context Compression Experiment
"""
import os
from typing import Optional
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
class Config:
"""Configuration settings for the context compression experiment"""
# API Configuration
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "kimi").lower()
LLM_PROVIDER = {"qwen": "dashscope", "bailian": "dashscope"}.get(
LLM_PROVIDER, LLM_PROVIDER
)
DASHSCOPE_API_KEY: str = os.getenv("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE_URL: str = os.getenv(
"DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
)
MOONSHOT_API_KEY: str = os.getenv("MOONSHOT_API_KEY", "")
MOONSHOT_BASE_URL: str = "https://api.moonshot.cn/v1"
# Universal fallback: 当 MOONSHOT_API_KEY 缺失但设置了 OPENROUTER_API_KEY 时,
# 自动改走 OpenRouterkimi-* 模型名映射为 moonshotai/kimi-k2)。
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
SERPER_API_KEY: str = os.getenv("SERPER_API_KEY", "")
SERPER_BASE_URL: str = "https://google.serper.dev"
# Model Configuration
MODEL_NAME: str = os.getenv(
"MODEL_NAME", "qwen3.7-plus" if LLM_PROVIDER == "dashscope" else "kimi-k3"
)
MODEL_TEMPERATURE: float = float(os.getenv("MODEL_TEMPERATURE", "0.3"))
MODEL_MAX_TOKENS: int = int(os.getenv("MODEL_MAX_TOKENS", "8192"))
# Agent Configuration
MAX_ITERATIONS: int = int(os.getenv("MAX_ITERATIONS", "50"))
ENABLE_VERBOSE: bool = os.getenv("ENABLE_VERBOSE", "false").lower() == "true"
# Compression Configuration
MAX_WEBPAGE_LENGTH: int = int(os.getenv("MAX_WEBPAGE_LENGTH", "50000"))
SUMMARY_MAX_TOKENS: int = int(os.getenv("SUMMARY_MAX_TOKENS", "500"))
# Context Window Configuration
CONTEXT_WINDOW_SIZE: int = 128000 # 128K context budget for the compression demo (K3 supports up to 1M)
# Logging Configuration
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
LOG_FORMAT: str = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
# File paths
RESULTS_DIR: str = "results"
CACHE_DIR: str = "cache"
@classmethod
def validate(cls) -> bool:
"""
Validate required configuration
Returns:
True if configuration is valid
"""
try:
cls.resolve_llm()
except ValueError as exc:
print(f"ERROR: {exc}")
return False
if not cls.SERPER_API_KEY:
print("WARNING: SERPER_API_KEY is not set")
print("Web search functionality will be limited")
print("Get a free API key at: https://serper.dev")
return True
@classmethod
def resolve_llm(cls):
"""Return ``(api_key, base_url, model)`` for the configured provider.
Computed at call time so a runtime override of ``Config.MODEL_NAME``
(e.g. via ``--model``) is respected.
端点、接受的 key 变量与模型名映射由 agentbook 的 provider 注册表统一
维护。此处保持三元组返回值:调用方按 3 个字段解包,测试也按这个形状
打桩。
"""
from agentbook.providers import resolve_backend
backend = resolve_backend(cls.LLM_PROVIDER, model=cls.MODEL_NAME)
return backend.api_key, backend.base_url, backend.model
@classmethod
def create_directories(cls):
"""Create necessary directories if they don't exist"""
os.makedirs(cls.RESULTS_DIR, exist_ok=True)
os.makedirs(cls.CACHE_DIR, exist_ok=True)
@classmethod
def print_config(cls):
"""Print current configuration (hiding sensitive data)"""
print("\n" + "="*50)
print("CONFIGURATION")
print("="*50)
print(f"Model: {cls.MODEL_NAME}")
print(f"Temperature: {cls.MODEL_TEMPERATURE}")
print(f"Max Tokens: {cls.MODEL_MAX_TOKENS}")
print(f"Max Iterations: {cls.MAX_ITERATIONS}")
print(f"Context Window: {cls.CONTEXT_WINDOW_SIZE:,} tokens")
print(f"Max Webpage Length: {cls.MAX_WEBPAGE_LENGTH:,} chars")
print(f"Summary Max Tokens: {cls.SUMMARY_MAX_TOKENS}")
print(f"Provider: {cls.LLM_PROVIDER}")
print(f"DashScope API Key Set: {'Yes' if cls.DASHSCOPE_API_KEY else 'No'}")
print(f"Kimi API Key Set: {'Yes' if cls.MOONSHOT_API_KEY else 'No'}")
print(f"Serper API Key Set: {'Yes' if cls.SERPER_API_KEY else 'No'}")
print("="*50 + "\n")
+29
View File
@@ -0,0 +1,29 @@
# LLM provider: kimi (default), dashscope/qwen/bailian, or openrouter
LLM_PROVIDER=kimi
# Kimi API Configuration
MOONSHOT_API_KEY=your_moonshot_api_key_here
# Alibaba Cloud Model Studio / Bailian (Qwen)
# DASHSCOPE_API_KEY=your_dashscope_api_key_here
# DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
# 通用回退:未设置 MOONSHOT_API_KEY 时,若配置了 OPENROUTER_API_KEY,则自动改走
# OpenRouterkimi-* 会映射为 moonshotai/kimi-k2)。
# OPENROUTER_API_KEY=your-openrouter-api-key
# Search API Configuration (using Serper API - free tier available)
SERPER_API_KEY=your_serper_api_key_here
# Model Configuration
MODEL_NAME=kimi-k3
MODEL_TEMPERATURE=0.3
MODEL_MAX_TOKENS=8192
# Agent Configuration
MAX_ITERATIONS=15
ENABLE_VERBOSE=false
# Compression Configuration
MAX_WEBPAGE_LENGTH=50000
SUMMARY_MAX_TOKENS=500
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""
Context Compression Strategies Comparison Experiment
"""
import os
import sys
import json
import time
import argparse
from typing import Dict, Any, List, Optional
from datetime import datetime
from dataclasses import asdict
from colorama import init, Fore, Style
from tqdm import tqdm
from config import Config
from agent import ResearchAgent
from compression_strategies import CompressionStrategy
# Initialize colorama for colored output
init(autoreset=True)
# Short CLI aliases -> compression strategy (order matches the book's 实验 2-10)
STRATEGY_CHOICES = {
"no_compression": CompressionStrategy.NO_COMPRESSION,
"individual": CompressionStrategy.NON_CONTEXT_AWARE_INDIVIDUAL,
"combined": CompressionStrategy.NON_CONTEXT_AWARE_COMBINED,
"context_aware": CompressionStrategy.CONTEXT_AWARE,
"citations": CompressionStrategy.CONTEXT_AWARE_CITATIONS,
"windowed": CompressionStrategy.WINDOWED_CONTEXT,
}
ALL_STRATEGIES = list(STRATEGY_CHOICES.values())
class ExperimentRunner:
"""Runs experiments comparing different compression strategies"""
def __init__(self, api_key: str, results_file: Optional[str] = None,
enable_streaming: bool = False):
"""
Initialize the experiment runner
Args:
api_key: API key for Kimi/Moonshot
results_file: Optional explicit path for the results JSON (default: results/experiment_TIMESTAMP.json)
enable_streaming: Stream compression/model output to the console during the run
"""
self.api_key = api_key
self.results = []
self.enable_streaming = enable_streaming
# Create results directory
Config.create_directories()
# Results file
if results_file:
self.results_file = results_file
parent = os.path.dirname(self.results_file)
if parent:
os.makedirs(parent, exist_ok=True)
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.results_file = os.path.join(Config.RESULTS_DIR, f"experiment_{timestamp}.json")
def run_single_strategy(self, strategy: CompressionStrategy, verbose: bool = False) -> Dict[str, Any]:
"""
Run experiment with a single compression strategy
Args:
strategy: Compression strategy to test
verbose: Enable verbose output
Returns:
Experiment results
"""
print(f"\n{Fore.CYAN}{'='*70}")
print(f"{Fore.CYAN}Testing Strategy: {Fore.YELLOW}{strategy.value}")
print(f"{Fore.CYAN}{'='*70}{Style.RESET_ALL}")
# Create agent with the strategy
agent = ResearchAgent(
api_key=self.api_key,
compression_strategy=strategy,
verbose=verbose,
enable_streaming=self.enable_streaming # Off by default for cleaner experiment output
)
start_time = time.time()
try:
# Execute the research task
result = agent.execute_research(max_iterations=Config.MAX_ITERATIONS)
end_time = time.time()
execution_time = end_time - start_time
# Analyze results
trajectory = result.get('trajectory')
# Calculate metrics
metrics = {
'strategy': strategy.value,
'success': result.get('success', False),
'iterations': result.get('iterations', 0),
'tool_calls': len(trajectory.tool_calls) if trajectory else 0,
'context_overflows': trajectory.context_overflows if trajectory else 0,
'execution_time': execution_time,
'total_tokens': trajectory.total_tokens_used if trajectory else 0,
'error': result.get('error'),
'final_answer_length': len(result.get('final_answer', '')) if result.get('final_answer') else 0
}
# Calculate compression ratios
if trajectory and trajectory.tool_calls:
total_original = 0
total_compressed = 0
for call in trajectory.tool_calls:
if call.compressed_result:
total_original += call.compressed_result.original_length
total_compressed += call.compressed_result.compressed_length
elif call.result and call.tool_name == 'search_web':
# No compression - count full size
content = json.dumps(call.result)
total_original += len(content)
total_compressed += len(content)
if total_original > 0:
metrics['compression_ratio'] = round(total_compressed / total_original, 3)
metrics['total_original_size'] = total_original
metrics['total_compressed_size'] = total_compressed
else:
metrics['compression_ratio'] = 1.0
metrics['total_original_size'] = 0
metrics['total_compressed_size'] = 0
# Print summary
self._print_summary(metrics)
# Store full result
full_result = {
'metrics': metrics,
'final_answer': result.get('final_answer'),
'timestamp': datetime.now().isoformat()
}
return full_result
except Exception as e:
print(f"{Fore.RED}Error during experiment: {str(e)}{Style.RESET_ALL}")
return {
'metrics': {
'strategy': strategy.value,
'success': False,
'error': str(e),
'execution_time': time.time() - start_time
},
'timestamp': datetime.now().isoformat()
}
def _print_summary(self, metrics: Dict[str, Any]):
"""Print a summary of the metrics"""
print(f"\n{Fore.GREEN}📊 Results Summary:{Style.RESET_ALL}")
print(f" Success: {self._format_bool(metrics['success'])}")
print(f" Iterations: {metrics['iterations']}")
print(f" Tool Calls: {metrics['tool_calls']}")
print(f" Execution Time: {metrics['execution_time']:.2f}s")
print(f" Total Tokens: {metrics.get('total_tokens', 0):,}")
if 'compression_ratio' in metrics:
print(f" Compression Ratio: {metrics['compression_ratio']:.1%}")
print(f" Original Size: {metrics['total_original_size']:,} chars")
print(f" Compressed Size: {metrics['total_compressed_size']:,} chars")
if metrics.get('context_overflows', 0) > 0:
print(f" {Fore.YELLOW}Context Overflows: {metrics['context_overflows']}{Style.RESET_ALL}")
if metrics.get('error'):
print(f" {Fore.RED}Error: {metrics['error'][:100]}...{Style.RESET_ALL}")
def _format_bool(self, value: bool) -> str:
"""Format boolean value with color"""
if value:
return f"{Fore.GREEN}✓ Yes{Style.RESET_ALL}"
else:
return f"{Fore.RED}✗ No{Style.RESET_ALL}"
def run_all_strategies(self, strategies: Optional[List[CompressionStrategy]] = None) -> None:
"""Run experiments for the given compression strategies (default: all six)"""
if strategies is None:
strategies = list(ALL_STRATEGIES)
print(f"\n{Fore.MAGENTA}{'='*70}")
print(f"{Fore.MAGENTA}CONTEXT COMPRESSION STRATEGIES COMPARISON EXPERIMENT")
print(f"{Fore.MAGENTA}{'='*70}{Style.RESET_ALL}")
print(f"\nTesting {len(strategies)} compression strategies...")
print(f"Task: Research current affiliations of OpenAI co-founders")
# Run each strategy
for strategy in tqdm(strategies, desc="Running experiments"):
result = self.run_single_strategy(strategy)
self.results.append(result)
# Save intermediate results
self._save_results()
# Small delay between experiments
time.sleep(2)
# Print final comparison
self._print_comparison()
def _save_results(self):
"""Save results to JSON file"""
with open(self.results_file, 'w') as f:
json.dump(self.results, f, indent=2, default=str)
print(f"\n💾 Results saved to: {self.results_file}")
def _print_comparison(self):
"""Print comparison table of all strategies"""
print(f"\n{Fore.MAGENTA}{'='*70}")
print(f"{Fore.MAGENTA}FINAL COMPARISON")
print(f"{Fore.MAGENTA}{'='*70}{Style.RESET_ALL}")
# Create comparison table
print(f"\n{'Strategy':<38} {'Success':<9} {'Time':<9} {'Tokens':<11} {'Compress':<10} {'Overflows':<10}")
print("-" * 90)
for result in self.results:
metrics = result['metrics']
strategy = metrics['strategy'][:36]
success = "" if metrics['success'] else ""
time_str = f"{metrics.get('execution_time', 0):.1f}s"
tokens = f"{metrics.get('total_tokens', 0):,}" if metrics.get('total_tokens') else "N/A"
compress = f"{metrics.get('compression_ratio', 1.0):.1%}" if 'compression_ratio' in metrics else "N/A"
overflows = str(metrics.get('context_overflows', 0))
# Color code success
color = Fore.GREEN if metrics['success'] else Fore.RED
print(f"{color}{strategy:<38} {success:<9} {time_str:<9} {tokens:<11} {compress:<10} {overflows:<10}{Style.RESET_ALL}")
print("\n" + "="*90)
# Analysis summary
self._print_analysis()
def _print_analysis(self):
"""Print analysis of the results"""
print(f"\n{Fore.CYAN}📈 Analysis:{Style.RESET_ALL}")
successful = [r for r in self.results if r['metrics']['success']]
failed = [r for r in self.results if not r['metrics']['success']]
print(f"\n Successful Strategies: {len(successful)}/{len(self.results)}")
if successful:
# Find best performing
fastest = min(successful, key=lambda x: x['metrics']['execution_time'])
most_efficient = min(successful, key=lambda x: x['metrics'].get('total_compressed_size', float('inf')))
print(f" Fastest: {fastest['metrics']['strategy']} ({fastest['metrics']['execution_time']:.1f}s)")
print(f" Most Efficient: {most_efficient['metrics']['strategy']} ({most_efficient['metrics'].get('total_compressed_size', 0):,} chars)")
if failed:
print(f"\n Failed Strategies:")
for r in failed:
# error may be present-but-None when a strategy fails by hitting the
# iteration cap (rather than raising), so coalesce before slicing.
err = r['metrics'].get('error') or 'No final answer within max iterations'
print(f" - {r['metrics']['strategy']}: {err[:50]}...")
# Key findings
print(f"\n{Fore.CYAN}🔍 Key Findings:{Style.RESET_ALL}")
print(" 1. No Compression: Expected to fail with context overflow ✓")
print(" 2. Non-Context-Aware: May lose important context details")
print(" 3. Context-Aware: Better relevance preservation")
print(" 4. With Citations: Enables follow-up questions")
print(" 5. Windowed Context: Balance between detail and efficiency")
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器"""
parser = argparse.ArgumentParser(
prog="experiment.py",
description="上下文压缩策略对比实验(对应《深入理解 AI Agent》实验 2-10)。\n"
"对同一个研究任务(追踪 OpenAI 联合创始人的现状)分别运行多种压缩策略,"
"输出 token 用量 / 压缩率 / 成功率对比表,并保存 JSON 结果。",
epilog="示例:\n"
" python experiment.py # 运行全部 6 种策略并对比\n"
" python experiment.py -s context_aware # 只运行“上下文感知压缩”\n"
" python experiment.py -s individual combined # 只对比两种非任务感知策略\n"
" python experiment.py --model kimi-k3 -o results/k2.json\n"
" python experiment.py --list-strategies # 查看可选策略名",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-s", "--strategy", nargs="+", choices=list(STRATEGY_CHOICES.keys()), metavar="NAME",
help="要运行的压缩策略(可指定多个,默认运行全部 6 种)。可选值:"
+ ", ".join(STRATEGY_CHOICES.keys()),
)
parser.add_argument(
"-m", "--model", default=None,
help=f"覆盖使用的模型名称(默认读取环境变量 MODEL_NAME,当前为 {Config.MODEL_NAME}",
)
parser.add_argument(
"-o", "--output", default=None, metavar="PATH",
help="结果 JSON 的保存路径(默认 results/experiment_<时间戳>.json",
)
parser.add_argument(
"-n", "--max-iterations", type=int, default=None, metavar="N",
help=f"每个策略允许的最大迭代(工具调用轮数),默认 {Config.MAX_ITERATIONS}",
)
parser.add_argument(
"--streaming", action="store_true",
help="实时流式打印模型与压缩过程的输出(默认关闭,以获得更整洁的对比输出)",
)
parser.add_argument(
"--list-strategies", action="store_true",
help="列出所有可选的压缩策略名称后退出",
)
return parser
def main():
"""Main entry point"""
parser = build_parser()
args = parser.parse_args()
if args.list_strategies:
print("可选的压缩策略(--strategy 的取值):")
for alias, strat in STRATEGY_CHOICES.items():
print(f" {alias:<16} -> {strat.value}")
return
# Apply CLI overrides onto the shared Config
if args.model:
Config.MODEL_NAME = args.model
if args.max_iterations is not None:
Config.MAX_ITERATIONS = args.max_iterations
# Resolve which strategies to run
if args.strategy:
strategies = [STRATEGY_CHOICES[name] for name in args.strategy]
else:
strategies = list(ALL_STRATEGIES)
# Check configuration
if not Config.validate():
print(f"\n{Fore.RED}Configuration validation failed!{Style.RESET_ALL}")
print("\nPlease set up your .env file with:")
print(" MOONSHOT_API_KEY=your_api_key_here")
print(" SERPER_API_KEY=your_api_key_here (optional)")
sys.exit(1)
# Print configuration
Config.print_config()
# Create runner
runner = ExperimentRunner(
Config.MOONSHOT_API_KEY,
results_file=args.output,
enable_streaming=args.streaming,
)
# Run experiments
try:
runner.run_all_strategies(strategies)
print(f"\n{Fore.GREEN}✅ Experiment completed successfully!{Style.RESET_ALL}")
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}⚠️ Experiment interrupted by user{Style.RESET_ALL}")
except Exception as e:
print(f"\n{Fore.RED}❌ Experiment failed: {str(e)}{Style.RESET_ALL}")
sys.exit(1)
if __name__ == "__main__":
main()
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Interactive demo for context compression strategies
"""
import os
import sys
import argparse
from colorama import init, Fore, Style
from config import Config
from agent import ResearchAgent
from compression_strategies import CompressionStrategy
# Initialize colorama
init(autoreset=True)
# Short CLI aliases -> compression strategy (order matches the book's 实验 2-10)
STRATEGY_CHOICES = {
"no_compression": CompressionStrategy.NO_COMPRESSION,
"individual": CompressionStrategy.NON_CONTEXT_AWARE_INDIVIDUAL,
"combined": CompressionStrategy.NON_CONTEXT_AWARE_COMBINED,
"context_aware": CompressionStrategy.CONTEXT_AWARE,
"citations": CompressionStrategy.CONTEXT_AWARE_CITATIONS,
"windowed": CompressionStrategy.WINDOWED_CONTEXT,
}
def print_banner():
"""Print demo banner"""
print(f"\n{Fore.CYAN}{'='*70}")
print(f"{Fore.CYAN}CONTEXT COMPRESSION RESEARCH AGENT - INTERACTIVE DEMO")
print(f"{Fore.CYAN}{'='*70}{Style.RESET_ALL}")
print("\nThis demo allows you to test different compression strategies")
print("for researching OpenAI co-founders' current affiliations.\n")
def select_strategy() -> CompressionStrategy:
"""Let user select a compression strategy"""
print(f"{Fore.YELLOW}Available Compression Strategies:{Style.RESET_ALL}")
print("1. No Compression (expected to fail with large contexts)")
print("2. Non-Context-Aware: Individual Summaries (summarize each page, then concatenate)")
print("3. Non-Context-Aware: Combined Summary (concatenate all pages, then summarize once)")
print("4. Context-Aware Summarization")
print("5. Context-Aware with Citations")
print("6. Windowed Context (only compress when approaching context limit)")
while True:
try:
choice = input(f"\n{Fore.GREEN}Select strategy (1-6): {Style.RESET_ALL}")
strategies = [
CompressionStrategy.NO_COMPRESSION,
CompressionStrategy.NON_CONTEXT_AWARE_INDIVIDUAL,
CompressionStrategy.NON_CONTEXT_AWARE_COMBINED,
CompressionStrategy.CONTEXT_AWARE,
CompressionStrategy.CONTEXT_AWARE_CITATIONS,
CompressionStrategy.WINDOWED_CONTEXT
]
return strategies[int(choice) - 1]
except (ValueError, IndexError):
print(f"{Fore.RED}Invalid choice. Please enter 1-6.{Style.RESET_ALL}")
def run_demo(enable_streaming=True, strategy: CompressionStrategy = None):
"""Run the interactive demo
Args:
enable_streaming: Whether to enable streaming output (default: True)
strategy: Preselected compression strategy; if None, prompt the user interactively
"""
print_banner()
# Check configuration
if not Config.validate():
print(f"\n{Fore.RED}Configuration validation failed!{Style.RESET_ALL}")
print("\nPlease set up your .env file with:")
print(" DASHSCOPE_API_KEY=your_api_key_here (for LLM_PROVIDER=dashscope/qwen/bailian)")
print(" MOONSHOT_API_KEY=your_api_key_here")
print(" SERPER_API_KEY=your_api_key_here (optional, will use mock data)")
sys.exit(1)
# Select strategy (interactively unless one was passed on the command line)
if strategy is None:
strategy = select_strategy()
print(f"\n{Fore.CYAN}Selected: {strategy.value}{Style.RESET_ALL}")
# Display streaming status
streaming_status = "ENABLED" if enable_streaming else "DISABLED"
print(f"{Fore.YELLOW}Streaming output: {streaming_status}{Style.RESET_ALL}")
# Create agent
print(f"\n{Fore.YELLOW}Initializing agent...{Style.RESET_ALL}")
agent = ResearchAgent(
api_key=Config.resolve_llm()[0],
compression_strategy=strategy,
verbose=False,
enable_streaming=enable_streaming
)
print(f"\n{Fore.CYAN}Starting research task...{Style.RESET_ALL}")
print("Task: Find current affiliations of all OpenAI co-founders\n")
print("-" * 70)
try:
# Execute research
result = agent.execute_research(max_iterations=Config.MAX_ITERATIONS)
# Print results
print("\n" + "="*70)
print(f"{Fore.GREEN}RESEARCH COMPLETE{Style.RESET_ALL}")
print("="*70)
if result.get('success'):
print(f"\n{Fore.GREEN}✅ Success!{Style.RESET_ALL}")
print(f"\nFinal Answer:\n{result.get('final_answer', 'No answer found')}")
else:
print(f"\n{Fore.RED}❌ Failed{Style.RESET_ALL}")
if result.get('error'):
print(f"Error: {result['error']}")
# Print statistics
trajectory = result.get('trajectory')
if trajectory:
print(f"\n{Fore.CYAN}📊 Statistics:{Style.RESET_ALL}")
print(f" Tool Calls: {len(trajectory.tool_calls)}")
print(f" Context Overflows: {trajectory.context_overflows}")
print(f" Execution Time: {result.get('execution_time', 0):.2f}s")
print(f" Total Tokens Used: {trajectory.total_tokens_used:,}")
print(f" - Prompt Tokens: {trajectory.prompt_tokens_used:,}")
print(f" - Completion Tokens: {trajectory.completion_tokens_used:,}")
# Calculate compression stats
if trajectory.tool_calls:
total_original = 0
total_compressed = 0
for call in trajectory.tool_calls:
if call.compressed_result:
total_original += call.compressed_result.original_length
total_compressed += call.compressed_result.compressed_length
if total_original > 0:
ratio = total_compressed / total_original
print(f" Compression Ratio: {ratio:.1%}")
print(f" Space Saved: {total_original - total_compressed:,} chars")
# Follow-up question demo (for citation strategy)
if strategy == CompressionStrategy.CONTEXT_AWARE_CITATIONS and result.get('success'):
print(f"\n{Fore.YELLOW}This strategy supports follow-up questions!{Style.RESET_ALL}")
follow_up = input("\nAsk a follow-up question (or press Enter to skip): ")
if follow_up:
print(f"\n{Fore.CYAN}Processing follow-up...{Style.RESET_ALL}")
# Add follow-up to conversation
agent.conversation_history.append({"role": "user", "content": follow_up})
# Get response (simplified for demo)
messages = agent.conversation_history.copy()
if enable_streaming:
message = agent._stream_response(messages)
else:
message = agent._non_streaming_response(messages)
if message.get('content'):
print(f"\n{Fore.GREEN}Follow-up Answer:{Style.RESET_ALL}")
print(message['content'])
except KeyboardInterrupt:
print(f"\n\n{Fore.YELLOW}Demo interrupted by user{Style.RESET_ALL}")
except Exception as e:
print(f"\n{Fore.RED}Error: {str(e)}{Style.RESET_ALL}")
def main():
"""Main entry point"""
# Parse command line arguments
parser = argparse.ArgumentParser(
prog="main.py",
description="上下文压缩策略交互式演示:针对“追踪 OpenAI 联合创始人现状”这一研究任务,"
"单独运行某一种压缩策略并实时观察其执行与压缩过程。",
epilog="示例:\n"
" python main.py # 交互式选择策略\n"
" python main.py -s citations # 直接运行“带引用的上下文感知”策略\n"
" python main.py -s windowed --no-streaming\n"
"如需批量对比全部策略并生成对比表,请使用 experiment.py。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'-s', '--strategy', choices=list(STRATEGY_CHOICES.keys()), metavar="NAME",
help="直接指定压缩策略(跳过交互式选择)。可选值:" + ", ".join(STRATEGY_CHOICES.keys()),
)
parser.add_argument(
'-m', '--model', default=None,
help=f"覆盖使用的模型名称(默认读取环境变量 MODEL_NAME,当前为 {Config.MODEL_NAME}",
)
parser.add_argument(
'--no-streaming',
action='store_true',
help='关闭流式输出(默认开启流式)'
)
args = parser.parse_args()
if args.model:
Config.MODEL_NAME = args.model
# Determine streaming preference
enable_streaming = not args.no_streaming
preset_strategy = STRATEGY_CHOICES[args.strategy] if args.strategy else None
try:
run_demo(enable_streaming=enable_streaming, strategy=preset_strategy)
# Ask if user wants to try another strategy
while True:
again = input(f"\n{Fore.GREEN}Try another strategy? (y/n): {Style.RESET_ALL}")
if again.lower() == 'y':
run_demo(enable_streaming=enable_streaming)
else:
print(f"\n{Fore.CYAN}Thank you for using the demo!{Style.RESET_ALL}")
break
except KeyboardInterrupt:
print(f"\n\n{Fore.YELLOW}Goodbye!{Style.RESET_ALL}")
sys.exit(0)
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Quick start script to test the context compression experiment
"""
import os
import sys
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def check_environment():
"""Check if environment is properly configured"""
provider = os.getenv("LLM_PROVIDER", "kimi").lower()
provider_key = os.getenv("DASHSCOPE_API_KEY") if provider in {"dashscope", "qwen", "bailian"} else os.getenv("MOONSHOT_API_KEY")
serper_key = os.getenv("SERPER_API_KEY")
print("🔍 Checking environment configuration...")
print("-" * 40)
if provider_key:
print(f"✅ API key for {provider} is set")
else:
print(f"❌ API key for {provider} is NOT set")
print(" Please add it to your .env file")
print(" Set DASHSCOPE_API_KEY for dashscope/qwen/bailian or MOONSHOT_API_KEY for kimi")
return False
if serper_key:
print("✅ SERPER_API_KEY is set")
else:
print("⚠️ SERPER_API_KEY is NOT set")
print(" Web search will use mock data")
print(" Get free API key at: https://serper.dev/")
print("-" * 40)
return True
def quick_test():
"""Run a quick test with context-aware citations strategy"""
from agent import ResearchAgent
from compression_strategies import CompressionStrategy
from config import Config
print("\n🚀 Running quick test with Context-Aware Citations strategy...")
print("Task: Research OpenAI co-founders' current affiliations\n")
# Create agent
agent = ResearchAgent(
api_key=Config.resolve_llm()[0],
compression_strategy=CompressionStrategy.CONTEXT_AWARE_CITATIONS,
verbose=False,
enable_streaming=True
)
# Execute research
result = agent.execute_research(max_iterations=10)
# Print results
print("\n" + "="*60)
if result.get('success'):
print("✅ SUCCESS!")
print("\nFinal Answer:")
print(result.get('final_answer', 'No answer found'))
# Statistics
trajectory = result.get('trajectory')
if trajectory:
print(f"\n📊 Statistics:")
print(f" - Tool calls: {len(trajectory.tool_calls)}")
print(f" - Execution time: {result.get('execution_time', 0):.2f}s")
else:
print("❌ FAILED")
if result.get('error'):
print(f"Error: {result['error']}")
print("="*60)
def main():
"""Main entry point"""
print("\n" + "="*60)
print("CONTEXT COMPRESSION EXPERIMENT - QUICK START")
print("="*60 + "\n")
# Check environment
if not check_environment():
print("\n❌ Please configure your environment first!")
print("\n1. Copy env.example to .env:")
print(" cp env.example .env")
print("\n2. Edit .env and add your API keys")
print("\n3. Run this script again")
sys.exit(1)
# Menu
print("\n📋 What would you like to do?")
print("1. Run quick test (Context-Aware Citations)")
print("2. Run full experiment (all 6 strategies)")
print("3. Interactive demo (choose strategy)")
print("4. Exit")
try:
choice = input("\nSelect option (1-4): ")
if choice == "1":
quick_test()
elif choice == "2":
print("\n🔬 Starting full experiment...")
import experiment
experiment.main()
elif choice == "3":
print("\n🎮 Starting interactive demo...")
import main as demo
demo.main()
elif choice == "4":
print("\n👋 Goodbye!")
sys.exit(0)
else:
print("\n❌ Invalid choice")
sys.exit(1)
except KeyboardInterrupt:
print("\n\n⚠️ Interrupted by user")
sys.exit(0)
except Exception as e:
print(f"\n❌ Error: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,13 @@
# Shared provider resolver from the repository root. Run this requirements file
# from the experiment directory, as shown in the README.
-e ../..
openai>=1.0.0
requests>=2.31.0
beautifulsoup4>=4.12.0
html2text>=2020.1.16
python-dotenv>=1.0.0
lxml>=4.9.0
colorama>=0.4.6
tqdm>=4.66.0
tiktoken>=0.5.0
@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""
Script to run all compression strategies sequentially and save results to log
"""
import os
import sys
import json
import time
import argparse
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from agent import ResearchAgent
from compression_strategies import CompressionStrategy, ContextCompressor
from config import Config
from colorama import init, Fore, Style
# Initialize colorama
init(autoreset=True)
# Short CLI aliases -> compression strategy (order matches the book's 实验 2-10)
STRATEGY_CHOICES = {
"no_compression": CompressionStrategy.NO_COMPRESSION,
"individual": CompressionStrategy.NON_CONTEXT_AWARE_INDIVIDUAL,
"combined": CompressionStrategy.NON_CONTEXT_AWARE_COMBINED,
"context_aware": CompressionStrategy.CONTEXT_AWARE,
"citations": CompressionStrategy.CONTEXT_AWARE_CITATIONS,
"windowed": CompressionStrategy.WINDOWED_CONTEXT,
}
ALL_STRATEGIES = list(STRATEGY_CHOICES.values())
class StrategyRunner:
"""Runs all compression strategies and logs results"""
def __init__(self, log_dir: str = "logs"):
"""
Initialize the strategy runner
Args:
log_dir: Directory to save log files
"""
self.log_dir = log_dir
os.makedirs(log_dir, exist_ok=True)
# Create log file with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.log_file = os.path.join(log_dir, f"strategy_run_{timestamp}.log")
self.json_file = os.path.join(log_dir, f"strategy_results_{timestamp}.json")
# Configure logging
self.setup_logging()
# Results storage
self.results = []
def setup_logging(self):
"""Configure logging to file and console"""
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# File handler
file_handler = logging.FileHandler(self.log_file)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
# Console handler - with custom filter for cleaner output
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# Use a simpler format for console
console_formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%H:%M:%S'
)
console_handler.setFormatter(console_formatter)
# Configure root logger
self.logger = logging.getLogger('StrategyRunner')
self.logger.setLevel(logging.DEBUG)
self.logger.addHandler(file_handler)
self.logger.addHandler(console_handler)
def log_banner(self, message: str, char: str = "=", width: int = 70):
"""Log a banner message"""
border = char * width
self.logger.info(border)
self.logger.info(message.center(width))
self.logger.info(border)
def run_strategy(self, strategy: CompressionStrategy) -> Dict[str, Any]:
"""
Run a single compression strategy
Args:
strategy: The compression strategy to test
Returns:
Dictionary with results
"""
self.log_banner(f"Testing: {strategy.value}", char="-")
self.logger.info(f"Strategy: {strategy.value}")
result = {
'strategy': strategy.value,
'start_time': datetime.now().isoformat(),
'success': False,
'error': None,
'metrics': {}
}
try:
# Create agent with the strategy
self.logger.info("Creating agent...")
agent = ResearchAgent(
api_key=Config.MOONSHOT_API_KEY,
compression_strategy=strategy,
verbose=False,
enable_streaming=True # Enable streaming to see compressions
)
# Execute research task
self.logger.info("Starting research task...")
start_time = time.time()
# Custom stream handler to capture and log streaming output
class StreamCapture:
def __init__(self, logger, original_stdout):
self.logger = logger
self.original_stdout = original_stdout
self.buffer = []
self.current_line = []
def write(self, text):
# Accumulate text
self.current_line.append(text)
# If we have a newline, log the complete line
if '\n' in text:
full_line = ''.join(self.current_line)
lines = full_line.split('\n')
# Log all complete lines through logger (will go to both console and file)
for line in lines[:-1]:
if line.strip():
# Use INFO level for important summaries, DEBUG for other output
if any(keyword in line for keyword in ['📝', '🎯', '📚', '📄', 'Summarizing:', 'Creating']):
self.logger.info(f"[COMPRESSION] {line}")
else:
self.logger.debug(f"[AGENT] {line}")
self.buffer.append(line)
# Keep any partial line for next write
self.current_line = [lines[-1]] if lines[-1] else []
def flush(self):
# Flush any remaining partial line
if self.current_line:
remaining = ''.join(self.current_line)
if remaining.strip():
self.logger.debug(f"[AGENT] {remaining}")
self.buffer.append(remaining)
self.current_line = []
def get_output(self):
# Ensure any remaining content is flushed
self.flush()
return '\n'.join(self.buffer)
# Capture streaming output
original_stdout = sys.stdout
stream_capture = StreamCapture(self.logger, original_stdout)
try:
sys.stdout = stream_capture
research_result = agent.execute_research(max_iterations=Config.MAX_ITERATIONS)
finally:
sys.stdout = original_stdout
execution_time = time.time() - start_time
# Get the complete captured output for storage
output = stream_capture.get_output()
# Store output in result for later analysis
result['agent_output'] = output
# Process results
trajectory = research_result.get('trajectory')
if research_result.get('success'):
result['success'] = True
result['final_answer'] = research_result.get('final_answer', 'No answer found')
self.logger.info("✅ Strategy completed successfully")
else:
result['error'] = research_result.get('error', 'Unknown error')
self.logger.warning(f"⚠️ Strategy failed: {result['error']}")
# Collect metrics
if trajectory:
result['metrics'] = {
'execution_time': execution_time,
'tool_calls': len(trajectory.tool_calls),
'context_overflows': trajectory.context_overflows,
'total_tokens': trajectory.total_tokens_used,
'prompt_tokens': trajectory.prompt_tokens_used,
'completion_tokens': trajectory.completion_tokens_used
}
# Calculate compression statistics
total_original = 0
total_compressed = 0
for call in trajectory.tool_calls:
if call.compressed_result:
total_original += call.compressed_result.original_length
total_compressed += call.compressed_result.compressed_length
if total_original > 0:
compression_ratio = total_compressed / total_original
result['metrics']['compression_ratio'] = compression_ratio
result['metrics']['total_original_size'] = total_original
result['metrics']['total_compressed_size'] = total_compressed
result['metrics']['space_saved'] = total_original - total_compressed
# Log metrics
self.logger.info(f"Execution time: {execution_time:.2f}s")
self.logger.info(f"Tool calls: {result['metrics']['tool_calls']}")
self.logger.info(f"Context overflows: {result['metrics']['context_overflows']}")
self.logger.info(f"Total tokens: {result['metrics']['total_tokens']:,}")
if 'compression_ratio' in result['metrics']:
self.logger.info(f"Compression ratio: {result['metrics']['compression_ratio']:.1%}")
self.logger.info(f"Space saved: {result['metrics']['space_saved']:,} chars")
# Log compression details for each tool call
self.logger.debug("\nCompression details by tool call:")
for i, call in enumerate(trajectory.tool_calls, 1):
if call.compressed_result:
self.logger.debug(f" Tool call {i}: {call.tool_name}")
self.logger.debug(f" - Original: {call.compressed_result.original_length:,} chars")
self.logger.debug(f" - Compressed: {call.compressed_result.compressed_length:,} chars")
self.logger.debug(f" - Strategy: {call.compressed_result.strategy.value}")
except Exception as e:
result['error'] = str(e)
self.logger.error(f"❌ Error running strategy: {e}", exc_info=True)
result['end_time'] = datetime.now().isoformat()
return result
def run_all_strategies(self, strategies: Optional[List[CompressionStrategy]] = None):
"""Run the given compression strategies (default: all six)"""
if strategies is None:
strategies = list(ALL_STRATEGIES)
self.log_banner("COMPRESSION STRATEGIES TEST RUN", char="=")
self.logger.info(f"Testing {len(strategies)} strategies")
self.logger.info(f"Log file: {self.log_file}")
self.logger.info(f"JSON results: {self.json_file}")
# Run each strategy
for i, strategy in enumerate(strategies, 1):
self.logger.info(f"\n[{i}/{len(strategies)}] Running {strategy.value}")
result = self.run_strategy(strategy)
self.results.append(result)
# Small delay between strategies
if i < len(strategies):
time.sleep(2)
# Generate summary
self.generate_summary()
# Save results to JSON
self.save_json_results()
self.log_banner("TEST RUN COMPLETE", char="=")
self.logger.info(f"Results saved to:")
self.logger.info(f" - Log: {self.log_file}")
self.logger.info(f" - JSON: {self.json_file}")
def generate_summary(self):
"""Generate and log a summary of all results"""
self.log_banner("RESULTS SUMMARY", char="=")
# Create comparison table
self.logger.info("\nStrategy Comparison:")
self.logger.info("-" * 100)
self.logger.info(f"{'Strategy':<40} {'Success':<10} {'Time(s)':<10} {'Tokens':<12} {'Compression':<12} {'Overflows':<10}")
self.logger.info("-" * 100)
for result in self.results:
strategy = result['strategy'][:38] # Truncate if too long
success = "✅ Yes" if result['success'] else "❌ No"
metrics = result.get('metrics', {})
exec_time = f"{metrics.get('execution_time', 0):.2f}" if metrics else "N/A"
tokens = f"{metrics.get('total_tokens', 0):,}" if metrics else "N/A"
compression = f"{metrics.get('compression_ratio', 0):.1%}" if metrics.get('compression_ratio') else "N/A"
overflows = str(metrics.get('context_overflows', 0)) if metrics else "N/A"
self.logger.info(f"{strategy:<40} {success:<10} {exec_time:<10} {tokens:<12} {compression:<12} {overflows:<10}")
self.logger.info("-" * 100)
# Summary statistics
successful = sum(1 for r in self.results if r['success'])
failed = len(self.results) - successful
self.logger.info(f"\nOverall Results:")
self.logger.info(f" - Successful: {successful}/{len(self.results)}")
self.logger.info(f" - Failed: {failed}/{len(self.results)}")
# Find best performers
if successful > 0:
# Best compression ratio
compressed_results = [r for r in self.results if r.get('metrics', {}).get('compression_ratio')]
if compressed_results:
best_compression = min(compressed_results, key=lambda r: r['metrics']['compression_ratio'])
self.logger.info(f" - Best compression: {best_compression['strategy']} ({best_compression['metrics']['compression_ratio']:.1%})")
# Fastest execution
timed_results = [r for r in self.results if r.get('metrics', {}).get('execution_time')]
if timed_results:
fastest = min(timed_results, key=lambda r: r['metrics']['execution_time'])
self.logger.info(f" - Fastest: {fastest['strategy']} ({fastest['metrics']['execution_time']:.2f}s)")
# Most tokens used
token_results = [r for r in self.results if r.get('metrics', {}).get('total_tokens')]
if token_results:
most_tokens = max(token_results, key=lambda r: r['metrics']['total_tokens'])
least_tokens = min(token_results, key=lambda r: r['metrics']['total_tokens'])
self.logger.info(f" - Most tokens: {most_tokens['strategy']} ({most_tokens['metrics']['total_tokens']:,})")
self.logger.info(f" - Least tokens: {least_tokens['strategy']} ({least_tokens['metrics']['total_tokens']:,})")
def save_json_results(self):
"""Save results to JSON file"""
try:
with open(self.json_file, 'w') as f:
json.dump({
'run_date': datetime.now().isoformat(),
'config': {
'model': Config.MODEL_NAME,
'max_iterations': Config.MAX_ITERATIONS,
'context_window': Config.CONTEXT_WINDOW_SIZE,
'summary_max_tokens': Config.SUMMARY_MAX_TOKENS
},
'results': self.results
}, f, indent=2)
self.logger.info(f"JSON results saved to {self.json_file}")
except Exception as e:
self.logger.error(f"Failed to save JSON results: {e}")
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器"""
parser = argparse.ArgumentParser(
prog="run_all_strategies.py",
description="逐个运行压缩策略并将完整过程(含流式压缩摘要)写入日志。\n"
"与 experiment.py 相比,本脚本侧重“可复盘的详细日志”:每次运行都会生成 "
".log 文本日志和 .json 结果文件,便于逐轮检查压缩效果。",
epilog="示例:\n"
" python run_all_strategies.py # 运行全部 6 种策略\n"
" python run_all_strategies.py -s windowed # 只跑自适应窗口化策略\n"
" python run_all_strategies.py --model kimi-k3 --log-dir logs/k2\n"
" python run_all_strategies.py --list-strategies",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-s", "--strategy", nargs="+", choices=list(STRATEGY_CHOICES.keys()), metavar="NAME",
help="要运行的压缩策略(可指定多个,默认运行全部 6 种)。可选值:"
+ ", ".join(STRATEGY_CHOICES.keys()),
)
parser.add_argument(
"-m", "--model", default=None,
help=f"覆盖使用的模型名称(默认读取环境变量 MODEL_NAME,当前为 {Config.MODEL_NAME}",
)
parser.add_argument(
"--log-dir", default="logs", metavar="DIR",
help="日志与 JSON 结果的输出目录(默认 logs/)",
)
parser.add_argument(
"-n", "--max-iterations", type=int, default=None, metavar="N",
help=f"每个策略允许的最大迭代(工具调用轮数),默认 {Config.MAX_ITERATIONS}",
)
parser.add_argument(
"--list-strategies", action="store_true",
help="列出所有可选的压缩策略名称后退出",
)
return parser
def main():
"""Main entry point"""
parser = build_parser()
args = parser.parse_args()
if args.list_strategies:
print("可选的压缩策略(--strategy 的取值):")
for alias, strat in STRATEGY_CHOICES.items():
print(f" {alias:<16} -> {strat.value}")
return
# Apply CLI overrides onto the shared Config
if args.model:
Config.MODEL_NAME = args.model
if args.max_iterations is not None:
Config.MAX_ITERATIONS = args.max_iterations
strategies = ([STRATEGY_CHOICES[name] for name in args.strategy]
if args.strategy else list(ALL_STRATEGIES))
print(f"\n{Fore.CYAN}{'='*70}")
print(f"{Fore.CYAN}COMPRESSION STRATEGIES AUTOMATED TEST RUNNER")
print(f"{Fore.CYAN}{'='*70}{Style.RESET_ALL}\n")
# Validate configuration
if not Config.validate():
print(f"{Fore.RED}Configuration validation failed!{Style.RESET_ALL}")
print("\nPlease set up your .env file with:")
print(" MOONSHOT_API_KEY=your_api_key_here")
print(" SERPER_API_KEY=your_api_key_here (optional)")
sys.exit(1)
# Create directories
Config.create_directories()
# Run all strategies
runner = StrategyRunner(log_dir=args.log_dir)
try:
print(f"{Fore.YELLOW}Starting test run...{Style.RESET_ALL}")
print(f"Log file: {runner.log_file}\n")
runner.run_all_strategies(strategies)
print(f"\n{Fore.GREEN}✅ Test run complete!{Style.RESET_ALL}")
print(f"\nResults saved to:")
print(f" 📄 Log: {runner.log_file}")
print(f" 📊 JSON: {runner.json_file}")
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Test run interrupted by user{Style.RESET_ALL}")
runner.logger.warning("Test run interrupted by user")
except Exception as e:
print(f"\n{Fore.RED}Fatal error: {e}{Style.RESET_ALL}")
runner.logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,141 @@
"""
Malformed tool-argument JSON must not abort execute_research or cause real dispatch to raise TypeError.
"""
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
# Optional deps used at import time by web_tools.
sys.modules.setdefault("html2text", types.ModuleType("html2text"))
sys.modules.setdefault("dotenv", types.SimpleNamespace(load_dotenv=lambda: None))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from compression_strategies import CompressionStrategy
from agent import ResearchAgent
def test_execute_research_survives_malformed_tool_arguments_json():
with patch("agent.Config.resolve_llm", return_value=("k", "http://x", "m")), \
patch("agent.OpenAI"), \
patch("agent.WebTools") as mock_web_tools_cls, \
patch("agent.ContextCompressor"):
mock_web_tools = MagicMock()
mock_web_tools_cls.return_value = mock_web_tools
agent = ResearchAgent(
api_key="k",
compression_strategy=CompressionStrategy.NO_COMPRESSION,
verbose=False,
enable_streaming=False,
)
bad_call_search = {
"id": "call-bad-search",
"type": "function",
"function": {
"name": "search_web",
"arguments": '{"query": "openai",}', # trailing comma
},
}
bad_call_fetch = {
"id": "call-bad-fetch",
"type": "function",
"function": {
"name": "fetch_webpage",
"arguments": '{"url": "https://example.com",}', # malformed JSON
},
}
tool_msg = {"role": "assistant", "content": "searching", "tool_calls": [bad_call_search, bad_call_fetch]}
final_msg = {"role": "assistant", "content": "FINAL ANSWER: ok", "tool_calls": None}
agent._non_streaming_response = MagicMock(side_effect=[tool_msg, final_msg])
# Do NOT mock _execute_tool, let real dispatch run over missing query/url arguments
result = agent.execute_research(max_iterations=3)
assert result.get("error") is None
assert len(agent.trajectory.tool_calls) == 2
assert agent.trajectory.tool_calls[0].result == {"error": "Missing required argument 'query' for search_web"}
assert agent.trajectory.tool_calls[1].result == {"error": "Missing required argument 'url' for fetch_webpage"}
def test_execute_research_survives_non_dict_and_invalid_bytes_tool_arguments():
"""
Non-dict JSON structures (lists, numbers) and invalid UTF-8 bytes must normalize to {} and not raise.
"""
with patch("agent.Config.resolve_llm", return_value=("k", "http://x", "m")), \
patch("agent.OpenAI"), \
patch("agent.WebTools") as mock_web_tools_cls, \
patch("agent.ContextCompressor"):
mock_web_tools = MagicMock()
mock_web_tools_cls.return_value = mock_web_tools
agent = ResearchAgent(
api_key="k",
compression_strategy=CompressionStrategy.NO_COMPRESSION,
verbose=False,
enable_streaming=False,
)
non_dict_calls = [
{"id": "c1", "type": "function", "function": {"name": "search_web", "arguments": "[]"}},
{"id": "c2", "type": "function", "function": {"name": "fetch_webpage", "arguments": "123"}},
{"id": "c3", "type": "function", "function": {"name": "search_web", "arguments": b"\x80\xff"}},
{"id": "c4", "type": "function", "function": {"name": "fetch_webpage", "arguments": {"invalid": 1}}},
]
tool_msg = {"role": "assistant", "content": "searching", "tool_calls": non_dict_calls}
final_msg = {"role": "assistant", "content": "FINAL ANSWER: ok", "tool_calls": None}
agent._non_streaming_response = MagicMock(side_effect=[tool_msg, final_msg])
result = agent.execute_research(max_iterations=3)
assert result.get("error") is None
assert len(agent.trajectory.tool_calls) == 4
assert agent.trajectory.tool_calls[0].arguments == {}
assert agent.trajectory.tool_calls[1].arguments == {}
assert agent.trajectory.tool_calls[2].arguments == {}
assert agent.trajectory.tool_calls[3].arguments == {"invalid": 1}
assert agent.trajectory.tool_calls[0].result == {"error": "Missing required argument 'query' for search_web"}
assert agent.trajectory.tool_calls[1].result == {"error": "Missing required argument 'url' for fetch_webpage"}
def test_execute_research_survives_tool_execution_exceptions():
"""
Tool execution exceptions in search_web or fetch_webpage must return error dicts with compressed=None and not abort loop.
"""
with patch("agent.Config.resolve_llm", return_value=("k", "http://x", "m")), \
patch("agent.OpenAI"), \
patch("agent.WebTools") as mock_web_tools_cls, \
patch("agent.ContextCompressor"):
mock_web_tools = MagicMock()
mock_web_tools.search_web.side_effect = RuntimeError("network connection failed")
mock_web_tools.fetch_webpage.side_effect = RuntimeError("http 500 error")
mock_web_tools_cls.return_value = mock_web_tools
agent = ResearchAgent(
api_key="k",
compression_strategy=CompressionStrategy.NO_COMPRESSION,
verbose=False,
enable_streaming=False,
)
call_search = {"id": "c1", "type": "function", "function": {"name": "search_web", "arguments": '{"query": "test"}'}}
call_fetch = {"id": "c2", "type": "function", "function": {"name": "fetch_webpage", "arguments": '{"url": "http://test.com"}'}}
tool_msg = {"role": "assistant", "content": "searching", "tool_calls": [call_search, call_fetch]}
final_msg = {"role": "assistant", "content": "FINAL ANSWER: ok", "tool_calls": None}
agent._non_streaming_response = MagicMock(side_effect=[tool_msg, final_msg])
result = agent.execute_research(max_iterations=3)
assert result.get("error") is None
assert len(agent.trajectory.tool_calls) == 2
assert agent.trajectory.tool_calls[0].result == {"error": "Failed to execute search_web: network connection failed"}
assert agent.trajectory.tool_calls[0].compressed_result is None
assert agent.trajectory.tool_calls[1].result == {"error": "Failed to execute fetch_webpage: http 500 error"}
assert agent.trajectory.tool_calls[1].compressed_result is None
@@ -0,0 +1,26 @@
"""
Test suite locking out TypeError in ContextCompressor._no_compression
when a search result dictionary contains 'content': None.
"""
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from compression_strategies import ContextCompressor
def test_no_compression_handles_null_content():
"""
Ensure _no_compression does not raise TypeError when result['content'] is None.
"""
compressor = ContextCompressor.__new__(ContextCompressor)
search_results = {
'results': [
{'title': 'Test', 'url': 'http://example.com', 'snippet': 'snippet', 'content': None}
]
}
compressed = compressor._no_compression(search_results)
assert compressed.original_length == 0
assert "Full Content:" in compressed.content
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,59 @@
"""
Test suite locking out TypeError in WebTools.search_web
when fetch_webpage returns a dictionary containing 'content': None.
"""
import os
import sys
from unittest.mock import MagicMock
sys.modules['html2text'] = MagicMock()
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from web_tools import WebTools
def test_search_web_handles_null_page_content():
"""
Ensure search_web calculates content_length correctly when page_content['content'] is None.
"""
tool = WebTools.__new__(WebTools)
tool.serper_api_key = "dummy"
tool.fetch_webpage = MagicMock(return_value={'content': None, 'success': True})
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
'organic': [
{'title': 'Example', 'link': 'http://example.com', 'snippet': 'Test snippet'}
]
}
import requests
requests.post = MagicMock(return_value=mock_resp)
res = tool.search_web("test query", num_results=1)
assert res['num_results'] == 1
assert res['results'][0]['content_length'] == 0
def test_fetch_webpage_title_with_nested_elements():
"""
Ensure fetch_webpage extracts title text correctly when title tag has nested HTML elements.
"""
html_content = "<html><head><title>Report <span>2026</span></title></head><body><p>Test content</p></body></html>"
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = html_content
mock_resp.raise_for_status = MagicMock()
import requests
requests.get = MagicMock(return_value=mock_resp)
tool = WebTools.__new__(WebTools)
tool.page_cache = {}
tool.html_converter = MagicMock()
tool.html_converter.handle.return_value = "Test content"
res = tool.fetch_webpage("http://example.com/report")
assert res['title'] == "Report 2026"
+306
View File
@@ -0,0 +1,306 @@
"""
Web tools for searching and fetching web pages
"""
import json
import html
import re
import logging
import requests
from typing import List, Dict, Any, Optional
from bs4 import BeautifulSoup
import html2text
from urllib.parse import urlparse, urljoin
import time
from config import Config
# Configure logging
logging.basicConfig(level=logging.INFO, format=Config.LOG_FORMAT)
logger = logging.getLogger(__name__)
class WebTools:
"""Tools for web search and page fetching"""
def __init__(self):
"""Initialize web tools"""
self.serper_api_key = Config.SERPER_API_KEY
self.html_converter = html2text.HTML2Text()
self.html_converter.ignore_links = False
self.html_converter.ignore_images = True
self.html_converter.ignore_emphasis = False
self.html_converter.body_width = 0 # Don't wrap lines
self.html_converter.single_line_break = True
# Cache for fetched pages to avoid redundant fetches
self.page_cache = {}
def search_web(self, query: str, num_results: int = 5) -> Dict[str, Any]:
"""
Search the web using Serper API
Args:
query: Search query
num_results: Number of results to return
Returns:
Dictionary containing search results with crawled content
"""
try:
if not self.serper_api_key:
# Fallback to mock results for demo
logger.warning("No Serper API key, using mock results")
return self._get_mock_search_results(query)
logger.info(f"Searching web for: {query}")
# Call Serper API
headers = {
'X-API-KEY': self.serper_api_key,
'Content-Type': 'application/json'
}
payload = {
'q': query,
'num': num_results
}
response = requests.post(
f"{Config.SERPER_BASE_URL}/search",
headers=headers,
json=payload,
timeout=10
)
if response.status_code != 200:
logger.error(f"Serper API error: {response.status_code}")
return self._get_mock_search_results(query)
data = response.json()
# Process organic results
results = []
organic_results = data.get('organic', [])[:num_results]
for result in organic_results:
# Fetch and convert each page
url = result.get('link', '')
if url:
page_content = self.fetch_webpage(url)
results.append({
'title': result.get('title', ''),
'url': url,
'snippet': result.get('snippet', ''),
'content': page_content.get('content', ''),
'content_length': len(page_content.get('content') or ''),
'fetch_success': page_content.get('success', False)
})
# Small delay to be respectful
time.sleep(0.5)
return {
'query': query,
'num_results': len(results),
'results': results,
'timestamp': time.time()
}
except Exception as e:
logger.error(f"Error searching web: {str(e)}")
return self._get_mock_search_results(query)
def fetch_webpage(self, url: str) -> Dict[str, Any]:
"""
Fetch a webpage and convert HTML to text
Args:
url: URL of the webpage to fetch
Returns:
Dictionary containing the converted text content
"""
try:
# Check cache first
if url in self.page_cache:
logger.info(f"Using cached content for: {url}")
return self.page_cache[url]
logger.info(f"Fetching webpage: {url}")
# Fetch the page
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.text, 'lxml')
# Remove script and style elements
for script in soup(["script", "style", "nav", "footer", "header"]):
script.decompose()
# Convert to text
text_content = self.html_converter.handle(str(soup))
# Clean up the text
lines = text_content.split('\n')
cleaned_lines = []
for line in lines:
line = line.strip()
if line and not line.startswith('#'): # Remove empty lines and navigation markers
cleaned_lines.append(line)
cleaned_text = '\n'.join(cleaned_lines)
# Truncate if too long
if len(cleaned_text) > Config.MAX_WEBPAGE_LENGTH:
cleaned_text = cleaned_text[:Config.MAX_WEBPAGE_LENGTH] + "\n\n[Content truncated...]"
title = 'No title'
if soup.title:
raw_title = soup.title.get_text()
cleaned_title = html.unescape(re.sub(r'<[^>]+>', '', raw_title)).strip()
if cleaned_title:
title = cleaned_title
result = {
'url': url,
'title': title,
'content': cleaned_text,
'content_length': len(cleaned_text),
'success': True,
'timestamp': time.time()
}
# Cache the result
self.page_cache[url] = result
return result
except Exception as e:
logger.error(f"Error fetching webpage {url}: {str(e)}")
error_result = {
'url': url,
'title': 'Error',
'content': f"Failed to fetch webpage: {str(e)}",
'content_length': 0,
'success': False,
'error': str(e),
'timestamp': time.time()
}
# Cache even failed results to avoid retrying
self.page_cache[url] = error_result
return error_result
def _get_mock_search_results(self, query: str) -> Dict[str, Any]:
"""
Get mock search results for testing without API key
Args:
query: Search query
Returns:
Mock search results
"""
# Mock results for OpenAI co-founders
mock_data = {
"openai": [
{
'title': 'OpenAI - Wikipedia',
'url': 'https://en.wikipedia.org/wiki/OpenAI',
'snippet': 'OpenAI was founded in 2015 by Sam Altman, Elon Musk, Ilya Sutskever, Greg Brockman, Wojciech Zaremba, and John Schulman...',
'content': '''OpenAI was founded in December 2015 by Sam Altman, Elon Musk, Ilya Sutskever, Greg Brockman, Wojciech Zaremba, and John Schulman.
The organization was founded with the goal of advancing digital intelligence in a way that benefits humanity.
Current Status of Co-founders (as of 2024):
- Sam Altman: CEO of OpenAI (returned after brief departure in November 2023)
- Elon Musk: Left OpenAI board in 2018, founded xAI in 2023
- Ilya Sutskever: Former Chief Scientist, left OpenAI in May 2024, co-founded Safe Superintelligence Inc.
- Greg Brockman: President and Chairman of OpenAI
- Wojciech Zaremba: Head of Language and Code Generation at OpenAI
- John Schulman: Co-founder, left OpenAI in August 2024 to join Anthropic
Additional early members:
- Andrej Karpathy: Former Director of AI at Tesla, briefly returned to OpenAI, now independent
- Dario Amodei: Left to co-found Anthropic in 2021
- Daniela Amodei: Left to co-found Anthropic in 2021'''
}
],
"sam altman": [
{
'title': 'Sam Altman - CEO of OpenAI',
'url': 'https://example.com/sam-altman',
'snippet': 'Sam Altman is the CEO of OpenAI...',
'content': 'Sam Altman is currently the CEO of OpenAI. He briefly left the company in November 2023 but returned after employee protests. He is also known for his work at Y Combinator and various investments in startups.'
}
],
"elon musk": [
{
'title': 'Elon Musk launches xAI',
'url': 'https://example.com/elon-musk-ai',
'snippet': 'Elon Musk founded xAI in 2023...',
'content': 'Elon Musk, who co-founded OpenAI in 2015, left the board in 2018 citing conflicts of interest with Tesla\'s AI development. In 2023, he founded xAI, a new AI company focused on understanding the universe. He is also CEO of Tesla, SpaceX, and owner of X (formerly Twitter).'
}
],
"ilya sutskever": [
{
'title': 'Ilya Sutskever launches Safe Superintelligence',
'url': 'https://example.com/ilya-sutskever',
'snippet': 'Ilya Sutskever left OpenAI to start SSI...',
'content': 'Ilya Sutskever, former Chief Scientist at OpenAI, left the company in May 2024 after nearly a decade. He co-founded Safe Superintelligence Inc. (SSI) with Daniel Gross and Daniel Levy, focusing on building safe AGI.'
}
]
}
# Find matching mock data
query_lower = query.lower()
for key in mock_data:
if key in query_lower:
results = []
for item in mock_data[key]:
results.append({
'title': item['title'],
'url': item['url'],
'snippet': item['snippet'],
'content': item['content'],
'content_length': len(item['content']),
'fetch_success': True
})
return {
'query': query,
'num_results': len(results),
'results': results,
'timestamp': time.time(),
'mock': True
}
# Default mock result
return {
'query': query,
'num_results': 1,
'results': [{
'title': 'Mock Search Result',
'url': 'https://example.com',
'snippet': 'This is a mock search result for testing',
'content': 'Mock content for testing when no API key is available.',
'content_length': 50,
'fetch_success': True
}],
'timestamp': time.time(),
'mock': True
}
def clear_cache(self):
"""Clear the page cache"""
self.page_cache.clear()
logger.info("Page cache cleared")