feat(session): extract summarization into SessionManager with race-safe async merging

Signed-off-by: Boris Bliznioukov <blib@mail.com>
This commit is contained in:
Boris Bliznioukov 2026-03-06 14:30:37 +01:00
parent 1945436dd4
commit fd382e68e2
No known key found for this signature in database
16 changed files with 1401 additions and 379 deletions

View file

@ -885,7 +885,11 @@ picoclaw agent -m "Bonjour, comment ça va ?"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"providers": { "providers": {

View file

@ -838,7 +838,11 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"providers": { "providers": {

168
README.md
View file

@ -45,12 +45,11 @@
> [!CAUTION] > [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** > **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
> >
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. > - **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
> > - **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** > - **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. > - **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release. > - **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (1020MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (1020MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
## 📢 News ## 📢 News
@ -234,7 +233,11 @@ picoclaw onboard
"model_name": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20,
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"model_list": [ "model_list": [
@ -286,13 +289,13 @@ picoclaw onboard
**3. Get API Keys** **3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) - **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
* **Web Search** (optional): - **Web Search** (optional):
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) - [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface - [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) - [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) - [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
* DuckDuckGo - Built-in fallback (no API key required) - DuckDuckGo - Built-in fallback (no API key required)
> **Note**: See `config.example.json` for a complete configuration template. > **Note**: See `config.example.json` for a complete configuration template.
@ -312,24 +315,24 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. > **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
| Channel | Setup | | Channel | Setup |
| ------------ | ---------------------------------- | | ---------------- | ------------------------------------- |
| **Telegram** | Easy (just a token) | | **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) | | **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **QQ** | Easy (AppID + AppSecret) | | **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) | | **LINE** | Medium (credentials + webhook URL) |
| **WeCom AI Bot** | Medium (Token + AES key) | | **WeCom AI Bot** | Medium (Token + AES key) |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
**1. Create a bot** **1. Create a bot**
* Open Telegram, search `@BotFather` - Open Telegram, search `@BotFather`
* Send `/newbot`, follow prompts - Send `/newbot`, follow prompts
* Copy the token - Copy the token
**2. Configure** **2. Configure**
@ -367,18 +370,19 @@ If command registration fails (network/API transient errors), the channel still
**1. Create a bot** **1. Create a bot**
* Go to <https://discord.com/developers/applications> - Go to <https://discord.com/developers/applications>
* Create an application → Bot → Add Bot - Create an application → Bot → Add Bot
* Copy the bot token - Copy the bot token
**2. Enable intents** **2. Enable intents**
* In the Bot settings, enable **MESSAGE CONTENT INTENT** - In the Bot settings, enable **MESSAGE CONTENT INTENT**
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data - (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID** **3. Get your User ID**
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID** - Discord Settings → Advanced → enable **Developer Mode**
- Right-click your avatar → **Copy User ID**
**4. Configure** **4. Configure**
@ -396,10 +400,10 @@ If command registration fails (network/API transient errors), the channel still
**5. Invite the bot** **5. Invite the bot**
* OAuth2 → URL Generator - OAuth2 → URL Generator
* Scopes: `bot` - Scopes: `bot`
* Bot Permissions: `Send Messages`, `Read Message History` - Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server - Open the generated invite URL and add the bot to your server
**Optional: Group trigger mode** **Optional: Group trigger mode**
@ -500,9 +504,9 @@ picoclaw gateway
**1. Create a bot** **1. Create a bot**
* Go to [Open Platform](https://open.dingtalk.com/) - Go to [Open Platform](https://open.dingtalk.com/)
* Create an internal app - Create an internal app
* Copy Client ID and Client Secret - Copy Client ID and Client Secret
**2. Configure** **2. Configure**
@ -526,6 +530,7 @@ picoclaw gateway
```bash ```bash
picoclaw gateway picoclaw gateway
``` ```
</details> </details>
<details> <details>
@ -591,8 +596,8 @@ See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh
**1. Create a bot** **1. Create a bot**
* Go to WeCom Admin Console → Group Chat → Add Group Bot - Go to WeCom Admin Console → Group Chat → Add Group Bot
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
**2. Configure** **2. Configure**
@ -617,15 +622,15 @@ See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh
**1. Create an app** **1. Create an app**
* Go to WeCom Admin Console → App Management → Create App - Go to WeCom Admin Console → App Management → Create App
* Copy **AgentId** and **Secret** - Copy **AgentId** and **Secret**
* Go to "My Company" page, copy **CorpID** - Go to "My Company" page, copy **CorpID**
**2. Configure receive message** **2. Configure receive message**
* In App details, click "Receive Message" → "Set API" - In App details, click "Receive Message" → "Set API"
* Set URL to `http://your-server:18790/webhook/wecom-app` - Set URL to `http://your-server:18790/webhook/wecom-app`
* Generate **Token** and **EncodingAESKey** - Generate **Token** and **EncodingAESKey**
**3. Configure** **3. Configure**
@ -658,9 +663,9 @@ picoclaw gateway
**1. Create an AI Bot** **1. Create an AI Bot**
* Go to WeCom Admin Console → App Management → AI Bot - Go to WeCom Admin Console → App Management → AI Bot
* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` - In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
* Copy **Token** and click "Random Generate" for **EncodingAESKey** - Copy **Token** and click "Random Generate" for **EncodingAESKey**
**2. Configure** **2. Configure**
@ -703,10 +708,10 @@ Config file: `~/.picoclaw/config.json`
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths. You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
| Variable | Description | Default Path | | Variable | Description | Default Path |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` | | `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | | `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
**Examples:** **Examples:**
@ -802,12 +807,12 @@ When `restrict_to_workspace: true`, the following tools are sandboxed:
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion - `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
* `format`, `mkfs`, `diskpart` — Disk formatting - `format`, `mkfs`, `diskpart` — Disk formatting
* `dd if=` — Disk imaging - `dd if=` — Disk imaging
* Writing to `/dev/sd[a-z]` — Direct disk writes - Writing to `/dev/sd[a-z]` — Direct disk writes
* `shutdown`, `reboot`, `poweroff` — System shutdown - `shutdown`, `reboot`, `poweroff` — System shutdown
* Fork bomb `:(){ :|:& };:` - Fork bomb `:(){ :|:& };:`
#### Error Examples #### Error Examples
@ -933,8 +938,8 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
**Environment variables:** **Environment variables:**
* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable - `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval - `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
### Providers ### Providers
@ -967,7 +972,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors #### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key | | Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
@ -979,7 +984,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
@ -1176,7 +1181,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
**1. Get API key and base URL** **1. Get API key and base URL**
* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. Configure** **2. Configure**
@ -1215,7 +1220,11 @@ picoclaw agent -m "Hello"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"session": { "session": {
@ -1314,9 +1323,9 @@ picoclaw agent -m "Hello"
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool: PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min - **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours - **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression - **Cron expressions**: "Remind me at 9am daily" → uses cron expression
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
@ -1343,6 +1352,7 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro
#### Search Provider Priority #### Search Provider Priority
PicoClaw automatically selects the best available search provider in this order: PicoClaw automatically selects the best available search provider in this order:
1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations 1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries) 2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free) 3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
@ -1351,6 +1361,7 @@ PicoClaw automatically selects the best available search provider in this order:
#### Web Search Configuration Options #### Web Search Configuration Options
**Option 1 (Best Results)**: Perplexity AI Search **Option 1 (Best Results)**: Perplexity AI Search
```json ```json
{ {
"tools": { "tools": {
@ -1366,6 +1377,7 @@ PicoClaw automatically selects the best available search provider in this order:
``` ```
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month) **Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
```json ```json
{ {
"tools": { "tools": {
@ -1381,6 +1393,7 @@ PicoClaw automatically selects the best available search provider in this order:
``` ```
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance **Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
```json ```json
{ {
"tools": { "tools": {
@ -1396,6 +1409,7 @@ PicoClaw automatically selects the best available search provider in this order:
``` ```
Benefits of SearXNG: Benefits of SearXNG:
- **Zero cost**: No API fees or rate limits - **Zero cost**: No API fees or rate limits
- **Privacy-focused**: Self-hosted, no tracking - **Privacy-focused**: Self-hosted, no tracking
- **Aggregate results**: Queries 70+ search engines simultaneously - **Aggregate results**: Queries 70+ search engines simultaneously
@ -1446,11 +1460,11 @@ This happens when another instance of the bot is running. Make sure only one `pi
## 📝 API Key Comparison ## 📝 API Key Comparison
| Service | Free Tier | Use Case | | Service | Free Tier | Use Case |
| ---------------- | ------------------------ | ------------------------------------- | | ---------------- | ----------------------- | ---------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/month | Best for Chinese users | | **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality | | **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |

View file

@ -881,7 +881,11 @@ picoclaw agent -m "Ola, como vai?"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"providers": { "providers": {

View file

@ -853,7 +853,11 @@ picoclaw agent -m "Xin chào"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"providers": { "providers": {

View file

@ -229,7 +229,11 @@ picoclaw onboard
"model_name": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20,
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"model_list": [ "model_list": [
@ -725,7 +729,11 @@ picoclaw agent -m "你好"
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"summarization": {
"message_threshold": 20,
"token_percent": 75
}
} }
}, },
"session": { "session": {

View file

@ -7,8 +7,10 @@
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20, "max_tool_iterations": 20,
"summarize_message_threshold": 20, "summarization": {
"summarize_token_percent": 75 "message_threshold": 20,
"token_percent": 75
}
} }
}, },
"model_list": [ "model_list": [

View file

@ -0,0 +1,220 @@
# DDR: Session Summarization Extraction & Configuration
> Date: 2026-03-06
> Status: In Review
> Packages: `pkg/session`, `pkg/config`, `pkg/agent`
## Problem Frame
Session context grows without bound during long conversations. The original implementation had several issues:
- **Entangled logic**: Summarization was inlined in `pkg/agent/loop.go` — 4 functions (`maybeSummarize`, `forceCompression`, `summarizeSession`, `summarizeBatch`) mixed session I/O, LLM calls, and threshold logic in the agent loop.
- **Race condition**: Async summarization could silently discard messages appended by the user while the LLM was generating a summary.
- **Magic constants**: Token budget (`1024`), temperature (`0.3`), threshold (`20`), percent (`75`) were hardcoded with no user-facing knobs.
- **No injection point**: Tight coupling to `providers.LLMProvider` made unit testing impossible without a live LLM.
- **Flat config**: Two config fields (`summarize_message_threshold`, `summarize_token_percent`) lived as top-level scalars in `AgentDefaults`, not grouped or extensible.
## Decision
### Rule Changes
1. **MUST** extract all summarization orchestration into `pkg/session.SessionManager`.
- `MaybeSummarize(key)` — threshold check + async dispatch.
- `ForceCompression(key)` — emergency drop of older half.
- `ApplySummarization(key, summary, snapshotLen, keepLast)` — atomic merge that compares `snapshotLen` to current message count, preserving messages appended during async work.
- `summarizeSession(ctx, key)` — unexported, handles single/multi-part batching.
2. **MUST** define a `Summarizer` interface in `pkg/session`:
```go
type Summarizer interface {
Summarize(ctx context.Context, messages []providers.Message, existingSummary string) (string, error)
}
```
A `SummarizeFunc` adapter MUST be provided for testing convenience.
3. **MUST** inject the `Summarizer` at construction via functional options:
- `WithSummarizer(s Summarizer, cfg config.SummarizationConfig) Option` — generic.
- `WithLLMSummarizer(provider, model, agentID string, cfg config.SummarizationConfig) Option` — convenience, constructs `LLMSummarizer` internally.
- When `summarizer` is nil, `MaybeSummarize` is a no-op.
4. **MUST** replace all magic constants with named defaults in `pkg/config`:
| Constant | Value | Field |
|---|---|---|
| `DefaultSummarizeMessageThreshold` | `20` | `MessageThreshold` |
| `DefaultSummarizeTokenPercent` | `75` | `TokenPercent` |
| `DefaultKeepLastMessages` | `4` | `KeepLastMessages` |
| `DefaultContextWindow` | `8192` | `ContextWindow` |
| `DefaultSummaryMaxTokens` | `1024` | `SummaryMaxTokens` |
| `DefaultSummaryTemperature` | `0.3` | `SummaryTemperature` |
| `DefaultMultiPartBatchThreshold` | `10` | `MultiPartBatchThreshold` |
| `DefaultTimeout` | `120s` | `Timeout` |
| `DefaultMaxSingleMsgTokenRatio` | `0.5` | `MaxSingleMsgTokenRatio` |
| `DefaultForceCompressionMinMsgs` | `4` | `ForceCompressionMinMessages` |
| `DefaultCharsPerToken` | `2.5` | `CharsPerToken` |
5. **MUST** use a single `SummarizationConfig` struct in `pkg/config` as the sole config type for both user-facing JSON fields and internal tuning parameters:
```go
type SummarizationConfig struct {
// User-facing (documented in config.example.json)
MessageThreshold int `json:"message_threshold,omitempty"`
TokenPercent int `json:"token_percent,omitempty"`
KeepLastMessages int `json:"keep_last_messages,omitempty"`
ContextWindow int `json:"context_window,omitempty"`
SummaryMaxTokens int `json:"summary_max_tokens,omitempty"`
SummaryTemperature float64 `json:"summary_temperature,omitempty"`
// Internal (undocumented, tunable for advanced use)
MultiPartBatchThreshold int `json:"multi_part_batch_threshold,omitempty"`
Timeout time.Duration `json:"timeout,omitempty"`
MaxSingleMsgTokenRatio float64 `json:"max_single_msg_token_ratio,omitempty"`
ForceCompressionMinMessages int `json:"force_compression_min_messages,omitempty"`
CharsPerToken float64 `json:"chars_per_token,omitempty"`
}
```
Nested under `agents.defaults.summarization` in JSON config. The session package imports and uses this directly — no separate config struct.
6. **MUST** preserve backward compatibility: `AgentDefaults.GetSummarization()` merges the new `Summarization *SummarizationConfig` with legacy flat fields `SummarizeMessageThreshold` / `SummarizeTokenPercent`. New struct takes priority; legacy fields apply only when the corresponding new field is zero.
7. **SHOULD** deprecate `summarize_message_threshold` and `summarize_token_percent` in `AgentDefaults`. These fields MUST remain parseable with `omitempty` but SHOULD NOT appear in new documentation or `config.example.json`.
8. **MAY** omit all `SummarizationConfig` fields — zero values are replaced by sensible defaults via `SummarizationConfig.WithDefaults()`.
### Definitions
- **Snapshot length**: The message count captured before dispatching async summarization. Used by `ApplySummarization` to detect concurrent writes.
- **Multi-part batching**: When the message batch exceeds `MultiPartBatchThreshold`, it is split in half; each half is summarized in parallel via goroutines; results are merged into a single summary.
- **Force compression**: Emergency fallback that drops the older half of messages and prepends a `[system]` note. No LLM call required.
### Migration
Old config (deprecated):
```json
{
"agents": {
"defaults": {
"summarize_message_threshold": 30,
"summarize_token_percent": 80
}
}
}
```
New config (preferred):
```json
{
"agents": {
"defaults": {
"summarization": {
"message_threshold": 30,
"token_percent": 80
}
}
}
}
```
Both forms are supported. If both are present, the nested struct wins.
## Rationale
### Core Reasons
- **Separation of concerns**: Agent loop should orchestrate turn logic, not session housekeeping. Summarization is a session-level concern.
- **Testability**: Interface injection enables deterministic unit tests with `SummarizeFunc` stubs — no LLM, no network, no flakes.
- **Race safety**: `ApplySummarization` compares snapshot length to current state, so messages arriving during async summarization are never lost.
- **Configurability**: Users on constrained hardware (RISC-V, 10MB RAM) need different thresholds than users on GPT-4 with 128k context.
- **Extensibility**: New summarization strategies (e.g., embeddings-based, local model) plug in via the `Summarizer` interface.
5. **Unified config** — Single `config.SummarizationConfig` used everywhere. No duplicate struct in session package, no field-by-field mapping.
- **Config hygiene**: Grouping related fields under `summarization` prevents `AgentDefaults` from growing into a flat bag of 30+ fields.
### Alternatives Considered
1. **Do nothing** — Keep inlined in `loop.go`.
- Rejected: Untestable without LLM. Race condition on concurrent writes. Magic constants not user-configurable.
2. **Separate `SessionSummarizer` struct** — Standalone struct with its own state.
- Rejected: Duplicates session access logic. Requires either passing sessions in/out or giving the struct direct storage access — both leak responsibilities.
3. **Mutable closure via `ConfigureSummarizer(fn)`** — Post-construction injection.
- Rejected: Mutable after init violates immutability principle. Anonymous closures are harder to test and document.
4. **Config as flat fields** — Add `keep_last_messages`, `summary_max_tokens`, etc. directly to `AgentDefaults`.
- Rejected: Would add 6 more top-level fields. Not cohesive. Harder to feature-flag or version.
5. **Separate config struct per package**`session.SummarizerConfig` with its own defaults, mapped field-by-field from `config.SummarizationConfig` in `instance.go`.
- Rejected: Duplicate types for the same data. Tedious mapping code. Easy to drift.
### Trade-offs
| Dimension | Cost | Benefit |
| ----------- | ------------------------------------- | ------------------------------------------------ |
| Complexity | New interface + options pattern | Clean test seam, swappable strategies |
| Migration | Must support two config shapes | Zero breaking changes for existing users |
| Performance | One extra goroutine per summarization | Non-blocking — user doesn't wait for LLM summary |
| Binary size | ~0 (no new deps) | N/A |
### Risks & Mitigations
| Risk | Mitigation |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Snapshot stale (messages deleted during summarization) | `ApplySummarization` returns early if `snapshotLen > currentLen` |
| Duplicate in-flight summarizations | `sync.Map` inflight guard — second call is a no-op |
| LLM summarization fails | Logged and skipped; session continues unsummarized. `ForceCompression` as fallback at token overflow. |
| Legacy config silently ignored | `GetSummarization()` merges both; tested in `config_test.go` |
## Consequences
### Immediate Impacts
| What | Change |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `pkg/agent/loop.go` | Calls `agent.Sessions.MaybeSummarize(key)` / `ForceCompression(key)` — no direct summarization logic |
| `pkg/agent/instance.go` | Wires `WithLLMSummarizer(provider, model, agentID, cfg)` at construction |
| `pkg/session/manager.go` | Gains `summarizer`, `summarizerCfg` (`config.SummarizationConfig`), `inflight` fields + 4 new methods |
| `pkg/session/summarizer.go` | New file: interface, `SummarizeFunc` adapter, `LLMSummarizer`, prompt builder (no config types or constants) |
| `pkg/config/config.go` | `SummarizationConfig` struct (all fields + `WithDefaults()`), `GetSummarization()` method, named default constants |
| `config/config.example.json` | Uses nested `summarization` block |
| README config examples | Show `summarization` in quickstart snippets |
### Follow-up Tasks
| Task | Owner (role) | Priority |
| ---------------------------------------------------------------------------------- | ------------ | ------------------------ |
| Remove deprecated `summarize_message_threshold` / `summarize_token_percent` fields | Maintainer | Low — next major version |
| Add env var override for `summarization.*` fields | Maintainer | Medium |
| Evaluate embeddings-based summarization via `Summarizer` interface | Contributor | Low |
### Test / Verification Plan
| Acceptance Criterion | Test |
| -------------------------------------------- | ---------------------------------------------------- |
| Summarization triggers above threshold | `TestMaybeSummarize_AboveThreshold` |
| No-op below threshold | `TestMaybeSummarize_BelowThreshold` |
| Dedup prevents concurrent summarizations | `TestMaybeSummarize_Dedup` |
| Messages appended during async are preserved | `TestApplySummarization_PreservesNewMessages` |
| Stale snapshot rejected | `TestApplySummarization_StaleSnapshot` |
| Force compression drops older half | `TestForceCompression_DropsHalf` |
| Multi-part batching for large sessions | `TestSummarizeSession_MultiPart` |
| Concurrent read/write safety | `TestConcurrent_SummarizeAndWrite` |
| Token estimation (Latin + CJK) | `TestEstimateTokens_Latin`, `TestEstimateTokens_CJK` |
| Legacy config fallback works | `TestGetSummarization_LegacyFallback` |
| New config overrides legacy | `TestGetSummarization_NewOverridesLegacy` |
| Default config has correct thresholds | `TestDefaultConfig_SummarizationThresholds` |
All tests pass as of 2026-03-06 (1455 passed, 0 failed).
### Rollback Plan
Revert the commits that introduce `pkg/session/summarizer.go`, the `Summarizer` interface, and the config changes. The legacy flat fields remain functional — no data migration needed.
### Review Date
2026-06-06 (3 months) — evaluate whether legacy flat config fields can be removed.

View file

@ -18,25 +18,23 @@ import (
// AgentInstance represents a fully configured agent with its own workspace, // AgentInstance represents a fully configured agent with its own workspace,
// session manager, context builder, and tool registry. // session manager, context builder, and tool registry.
type AgentInstance struct { type AgentInstance struct {
ID string ID string
Name string Name string
Model string Model string
Fallbacks []string Fallbacks []string
Workspace string Workspace string
MaxIterations int MaxIterations int
MaxTokens int MaxTokens int
Temperature float64 Temperature float64
ThinkingLevel ThinkingLevel ThinkingLevel ThinkingLevel
ContextWindow int ContextWindow int
SummarizeMessageThreshold int Provider providers.LLMProvider
SummarizeTokenPercent int Sessions *session.SessionManager
Provider providers.LLMProvider ContextBuilder *ContextBuilder
Sessions *session.SessionManager Tools *tools.ToolRegistry
ContextBuilder *ContextBuilder Subagents *config.SubagentsConfig
Tools *tools.ToolRegistry SkillsFilter []string
Subagents *config.SubagentsConfig Candidates []providers.FallbackCandidate
SkillsFilter []string
Candidates []providers.FallbackCandidate
// Router is non-nil when model routing is configured and the light model // Router is non-nil when model routing is configured and the light model
// was successfully resolved. It scores each incoming message and decides // was successfully resolved. It scores each incoming message and decides
@ -94,7 +92,6 @@ func NewAgentInstance(
} }
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace) contextBuilder := NewContextBuilder(workspace)
@ -131,16 +128,6 @@ func NewAgentInstance(
} }
thinkingLevel := parseThinkingLevel(thinkingLevelStr) thinkingLevel := parseThinkingLevel(thinkingLevelStr)
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
if summarizeMessageThreshold == 0 {
summarizeMessageThreshold = 20
}
summarizeTokenPercent := defaults.SummarizeTokenPercent
if summarizeTokenPercent == 0 {
summarizeTokenPercent = 75
}
// Resolve fallback candidates // Resolve fallback candidates
modelCfg := providers.ModelConfig{ modelCfg := providers.ModelConfig{
Primary: model, Primary: model,
@ -207,28 +194,34 @@ func NewAgentInstance(
} }
} }
sc := defaults.GetSummarization()
if sc.ContextWindow == 0 {
sc.ContextWindow = maxTokens
}
sessionsManager := session.NewSessionManager(sessionsDir, session.WithLLMSummarizer(
provider, model, agentID, sc,
))
return &AgentInstance{ return &AgentInstance{
ID: agentID, ID: agentID,
Name: agentName, Name: agentName,
Model: model, Model: model,
Fallbacks: fallbacks, Fallbacks: fallbacks,
Workspace: workspace, Workspace: workspace,
MaxIterations: maxIter, MaxIterations: maxIter,
MaxTokens: maxTokens, MaxTokens: maxTokens,
Temperature: temperature, Temperature: temperature,
ThinkingLevel: thinkingLevel, ThinkingLevel: thinkingLevel,
ContextWindow: maxTokens, ContextWindow: maxTokens,
SummarizeMessageThreshold: summarizeMessageThreshold, Provider: provider,
SummarizeTokenPercent: summarizeTokenPercent, Sessions: sessionsManager,
Provider: provider, ContextBuilder: contextBuilder,
Sessions: sessionsManager, Tools: toolsRegistry,
ContextBuilder: contextBuilder, Subagents: subagents,
Tools: toolsRegistry, SkillsFilter: skillsFilter,
Subagents: subagents, Candidates: candidates,
SkillsFilter: skillsFilter, Router: router,
Candidates: candidates, LightCandidates: lightCandidates,
Router: router,
LightCandidates: lightCandidates,
} }
} }

View file

@ -17,7 +17,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
@ -42,7 +41,6 @@ type AgentLoop struct {
registry *AgentRegistry registry *AgentRegistry
state *state.Manager state *state.Manager
running atomic.Bool running atomic.Bool
summarizing sync.Map
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
mediaStore media.MediaStore mediaStore media.MediaStore
@ -99,7 +97,6 @@ func NewAgentLoop(
cfg: cfg, cfg: cfg,
registry: registry, registry: registry,
state: stateManager, state: stateManager,
summarizing: sync.Map{},
fallback: fallbackChain, fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
} }
@ -784,7 +781,7 @@ func (al *AgentLoop) runAgentLoop(
// 6. Optional: summarization // 6. Optional: summarization
if opts.EnableSummary { if opts.EnableSummary {
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) agent.Sessions.MaybeSummarize(opts.SessionKey)
} }
// 7. Optional: send response via bus // 7. Optional: send response via bus
@ -1017,7 +1014,7 @@ func (al *AgentLoop) runLLMIteration(
}) })
} }
al.forceCompression(agent, opts.SessionKey) agent.Sessions.ForceCompression(opts.SessionKey)
newHistory := agent.Sessions.GetHistory(opts.SessionKey) newHistory := agent.Sessions.GetHistory(opts.SessionKey)
newSummary := agent.Sessions.GetSummary(opts.SessionKey) newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages( messages = agent.ContextBuilder.BuildMessages(
@ -1264,77 +1261,6 @@ func (al *AgentLoop) selectCandidates(
return agent.LightCandidates, agent.Router.LightModel() return agent.LightCandidates, agent.Router.LightModel()
} }
// maybeSummarize triggers summarization if the session history exceeds thresholds.
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer al.summarizing.Delete(summarizeKey)
logger.Debug("Memory threshold reached. Optimizing conversation history...")
al.summarizeSession(agent, sessionKey)
}()
}
}
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest 50% of messages (keeping system prompt and last user message).
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 4 {
return
}
// Keep system prompt (usually [0]) and the very last message (user's trigger)
// We want to drop the oldest half of the *conversation*
// Assuming [0] is system, [1:] is conversation
conversation := history[1 : len(history)-1]
if len(conversation) == 0 {
return
}
// Helper to find the mid-point of the conversation
mid := len(conversation) / 2
// New history structure:
// 1. System Prompt (with compression note appended)
// 2. Second half of conversation
// 3. Last message
droppedCount := mid
keptConversation := conversation[mid:]
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
compressionNote := fmt.Sprintf(
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1]) // Last message
// Update session
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
})
}
// GetStartupInfo returns information about loaded tools and skills for logging. // GetStartupInfo returns information about loaded tools and skills for logging.
func (al *AgentLoop) GetStartupInfo() map[string]any { func (al *AgentLoop) GetStartupInfo() map[string]any {
info := make(map[string]any) info := make(map[string]any)
@ -1422,139 +1348,6 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
return sb.String() return sb.String()
} }
// summarizeSession summarizes the conversation history for a session.
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
return
}
toSummarize := history[:len(history)-4]
// Oversized Message Guard
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
// Multi-Part Summarization
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1,
s2,
)
resp, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4)
agent.Sessions.Save(sessionKey)
}
}
// summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(
ctx context.Context,
agent *AgentInstance,
batch []providers.Message,
existingSummary string,
) (string, error) {
var sb strings.Builder
sb.WriteString(
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
)
if existingSummary != "" {
sb.WriteString("Existing context: ")
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
prompt := sb.String()
response, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err != nil {
return "", err
}
return response.Content, nil
}
// estimateTokens estimates the number of tokens in a message list.
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
// overheads better than the previous 3 chars/token.
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
totalChars := 0
for _, m := range messages {
totalChars += utf8.RuneCountInString(m.Content)
}
// 2.5 chars per token = totalChars * 2 / 5
return totalChars * 2 / 5
}
func (al *AgentLoop) handleCommand( func (al *AgentLoop) handleCommand(
ctx context.Context, ctx context.Context,
msg bus.InboundMessage, msg bus.InboundMessage,

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"sync/atomic" "sync/atomic"
"time"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
@ -180,22 +181,135 @@ type RoutingConfig struct {
} }
type AgentDefaults struct { type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"` ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` Summarization *SummarizationConfig `json:"summarization,omitempty"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"`
Routing *RoutingConfig `json:"routing,omitempty"`
// Deprecated: use summarization.message_threshold instead.
SummarizeMessageThreshold int `json:"summarize_message_threshold,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
// Deprecated: use summarization.token_percent instead.
SummarizeTokenPercent int `json:"summarize_token_percent,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
}
// SummarizationConfig controls session summarization behavior.
// All fields are optional — zero values are replaced by sensible defaults
// via WithDefaults().
type SummarizationConfig struct {
// --- User-facing (documented in config.example.json) ---
// MessageThreshold is the message count above which summarization is triggered.
MessageThreshold int `json:"message_threshold,omitempty"`
// TokenPercent is the percentage of context window above which summarization triggers.
TokenPercent int `json:"token_percent,omitempty"`
// KeepLastMessages is the number of recent messages to retain after summarization.
KeepLastMessages int `json:"keep_last_messages,omitempty"`
// ContextWindow overrides the token budget for threshold calculations (defaults to max_tokens).
ContextWindow int `json:"context_window,omitempty"`
// SummaryMaxTokens is the max_tokens limit for the LLM summarization call.
SummaryMaxTokens int `json:"summary_max_tokens,omitempty"`
// SummaryTemperature is the temperature for the LLM summarization call.
SummaryTemperature float64 `json:"summary_temperature,omitempty"`
// --- Internal (undocumented, tunable for advanced use) ---
// MultiPartBatchThreshold controls when summarization splits the
// message batch into two halves for separate summarization + merge.
MultiPartBatchThreshold int `json:"multi_part_batch_threshold,omitempty"`
// Timeout is the maximum duration for a single summarization operation.
Timeout time.Duration `json:"timeout,omitempty"`
// MaxSingleMsgTokenRatio is the fraction of ContextWindow above which
// a single message is considered oversized and skipped during summarization.
MaxSingleMsgTokenRatio float64 `json:"max_single_msg_token_ratio,omitempty"`
// ForceCompressionMinMessages is the minimum message count below which
// force-compression is a no-op (not enough to drop).
ForceCompressionMinMessages int `json:"force_compression_min_messages,omitempty"`
// CharsPerToken is the estimated character-to-token ratio used by
// EstimateTokens. Lower values are more conservative (estimate more tokens).
CharsPerToken float64 `json:"chars_per_token,omitempty"`
}
// Defaults for SummarizationConfig fields.
const (
DefaultSummarizeMessageThreshold = 20
DefaultSummarizeTokenPercent = 75
DefaultKeepLastMessages = 4
DefaultContextWindow = 8192
DefaultSummaryMaxTokens = 1024
DefaultSummarizationTemperature float64 = 0.3
DefaultMultiPartBatchThreshold = 10
DefaultSummarizationTimeout = 120 * time.Second
DefaultMaxSingleMsgTokenRatio = 0.5
DefaultForceCompressionMinMsgs = 4
DefaultCharsPerToken = 2.5
)
// WithDefaults fills zero-valued fields with sensible defaults.
func (c SummarizationConfig) WithDefaults() SummarizationConfig {
if c.MessageThreshold == 0 {
c.MessageThreshold = DefaultSummarizeMessageThreshold
}
if c.TokenPercent == 0 {
c.TokenPercent = DefaultSummarizeTokenPercent
}
if c.KeepLastMessages == 0 {
c.KeepLastMessages = DefaultKeepLastMessages
}
if c.ContextWindow == 0 {
c.ContextWindow = DefaultContextWindow
}
if c.SummaryMaxTokens == 0 {
c.SummaryMaxTokens = DefaultSummaryMaxTokens
}
if c.SummaryTemperature == 0 {
c.SummaryTemperature = DefaultSummarizationTemperature
}
if c.MultiPartBatchThreshold == 0 {
c.MultiPartBatchThreshold = DefaultMultiPartBatchThreshold
}
if c.Timeout == 0 {
c.Timeout = DefaultSummarizationTimeout
}
if c.MaxSingleMsgTokenRatio == 0 {
c.MaxSingleMsgTokenRatio = DefaultMaxSingleMsgTokenRatio
}
if c.ForceCompressionMinMessages == 0 {
c.ForceCompressionMinMessages = DefaultForceCompressionMinMsgs
}
if c.CharsPerToken == 0 {
c.CharsPerToken = DefaultCharsPerToken
}
return c
}
// GetSummarization returns the effective summarization config, merging the
// new nested field with the deprecated flat fields for backward compatibility.
// The new Summarization struct takes priority; legacy fields are used only
// when the corresponding new field is unset.
func (d *AgentDefaults) GetSummarization() SummarizationConfig {
var sc SummarizationConfig
if d.Summarization != nil {
sc = *d.Summarization
}
// Legacy fallback: only apply if the new field is zero.
if sc.MessageThreshold == 0 && d.SummarizeMessageThreshold != 0 {
sc.MessageThreshold = d.SummarizeMessageThreshold
}
if sc.TokenPercent == 0 && d.SummarizeTokenPercent != 0 {
sc.TokenPercent = d.SummarizeTokenPercent
}
return sc
} }
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB

View file

@ -434,16 +434,48 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
} }
} }
// TestDefaultConfig_DMScope verifies the default dm_scope value
// TestDefaultConfig_SummarizationThresholds verifies summarization defaults // TestDefaultConfig_SummarizationThresholds verifies summarization defaults
func TestDefaultConfig_SummarizationThresholds(t *testing.T) { func TestDefaultConfig_SummarizationThresholds(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 { sc := cfg.Agents.Defaults.GetSummarization()
t.Errorf("SummarizeMessageThreshold = %d, want 20", cfg.Agents.Defaults.SummarizeMessageThreshold) if sc.MessageThreshold != 20 {
t.Errorf("MessageThreshold = %d, want 20", sc.MessageThreshold)
} }
if cfg.Agents.Defaults.SummarizeTokenPercent != 75 { if sc.TokenPercent != 75 {
t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent) t.Errorf("TokenPercent = %d, want 75", sc.TokenPercent)
}
}
func TestGetSummarization_LegacyFallback(t *testing.T) {
d := AgentDefaults{
SummarizeMessageThreshold: 30,
SummarizeTokenPercent: 80,
}
sc := d.GetSummarization()
if sc.MessageThreshold != 30 {
t.Errorf("legacy MessageThreshold = %d, want 30", sc.MessageThreshold)
}
if sc.TokenPercent != 80 {
t.Errorf("legacy TokenPercent = %d, want 80", sc.TokenPercent)
}
}
func TestGetSummarization_NewOverridesLegacy(t *testing.T) {
d := AgentDefaults{
SummarizeMessageThreshold: 30, // legacy
SummarizeTokenPercent: 80, // legacy
Summarization: &SummarizationConfig{
MessageThreshold: 50,
TokenPercent: 90,
},
}
sc := d.GetSummarization()
if sc.MessageThreshold != 50 {
t.Errorf("new MessageThreshold = %d, want 50", sc.MessageThreshold)
}
if sc.TokenPercent != 90 {
t.Errorf("new TokenPercent = %d, want 90", sc.TokenPercent)
} }
} }

View file

@ -26,15 +26,17 @@ func DefaultConfig() *Config {
return &Config{ return &Config{
Agents: AgentsConfig{ Agents: AgentsConfig{
Defaults: AgentDefaults{ Defaults: AgentDefaults{
Workspace: workspacePath, Workspace: workspacePath,
RestrictToWorkspace: true, RestrictToWorkspace: true,
Provider: "", Provider: "",
Model: "", Model: "",
MaxTokens: 32768, MaxTokens: 32768,
Temperature: nil, // nil means use provider default Temperature: nil, // nil means use provider default
MaxToolIterations: 50, MaxToolIterations: 50,
SummarizeMessageThreshold: 20, Summarization: &SummarizationConfig{
SummarizeTokenPercent: 75, MessageThreshold: 20,
TokenPercent: 75,
},
}, },
}, },
Bindings: []AgentBinding{}, Bindings: []AgentBinding{},

View file

@ -1,13 +1,18 @@
package session package session
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
@ -20,17 +25,37 @@ type Session struct {
} }
type SessionManager struct { type SessionManager struct {
sessions map[string]*Session sessions map[string]*Session
mu sync.RWMutex mu sync.RWMutex
storage string storage string
summarizer Summarizer // optional; nil = no summarization
summarizerCfg config.SummarizationConfig // filled via WithDefaults at construction
inflight sync.Map // sessionKey → true (dedup guard)
} }
func NewSessionManager(storage string) *SessionManager { // Option configures a SessionManager at construction time.
type Option func(*SessionManager)
// WithSummarizer enables background summarization and emergency compression.
// The Summarizer is called to produce summaries via the LLM. Zero-valued
// config fields are replaced with defaults.
func WithSummarizer(s Summarizer, cfg config.SummarizationConfig) Option {
return func(sm *SessionManager) {
sm.summarizer = s
sm.summarizerCfg = cfg.WithDefaults()
}
}
func NewSessionManager(storage string, opts ...Option) *SessionManager {
sm := &SessionManager{ sm := &SessionManager{
sessions: make(map[string]*Session), sessions: make(map[string]*Session),
storage: storage, storage: storage,
} }
for _, o := range opts {
o(sm)
}
if storage != "" { if storage != "" {
os.MkdirAll(storage, 0o755) os.MkdirAll(storage, 0o755)
sm.loadSessions() sm.loadSessions()
@ -280,3 +305,250 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
session.Updated = time.Now() session.Updated = time.Now()
} }
} }
// ApplySummarization atomically sets a summary and trims old messages while
// preserving any messages that were appended after the caller's snapshot.
//
// snapshotLen is the len(session.Messages) at the time the caller took its
// snapshot. If the session has grown since then, the new tail is preserved.
// If messages were removed (e.g. another compression), the operation is
// skipped and returns false.
//
// keepLast is the number of messages from the *snapshot* to retain (counted
// from the end of the snapshot window, not the current session).
func (sm *SessionManager) ApplySummarization(key, summary string, snapshotLen, keepLast int) bool {
sm.mu.Lock()
defer sm.mu.Unlock()
session, ok := sm.sessions[key]
if !ok {
return false
}
currentLen := len(session.Messages)
// Stale: session was truncated/replaced since our snapshot.
if currentLen < snapshotLen {
return false
}
// Messages appended by the main loop after the snapshot was taken.
newTail := session.Messages[snapshotLen:]
// Messages to keep from the original snapshot window.
var keptFromSnapshot []providers.Message
if keepLast > 0 && keepLast < snapshotLen {
keptFromSnapshot = session.Messages[snapshotLen-keepLast : snapshotLen]
} else if keepLast >= snapshotLen {
keptFromSnapshot = session.Messages[:snapshotLen]
}
merged := make([]providers.Message, 0, len(keptFromSnapshot)+len(newTail))
merged = append(merged, keptFromSnapshot...)
merged = append(merged, newTail...)
session.Messages = merged
session.Summary = summary
session.Updated = time.Now()
return true
}
// EstimateTokens estimates token count for a list of messages using the
// configured chars-per-token ratio and unicode rune counting (CJK-aware).
func (sm *SessionManager) EstimateTokens(messages []providers.Message) int {
totalRunes := 0
for _, m := range messages {
totalRunes += utf8.RuneCountInString(m.Content)
}
return int(float64(totalRunes) / sm.summarizerCfg.CharsPerToken)
}
// MaybeSummarize checks whether the session's history exceeds the configured
// thresholds and, if so, triggers background summarization. At most one
// summarization runs per session key at a time.
// No-op if no Summarizer was provided via WithSummarizer.
func (sm *SessionManager) MaybeSummarize(sessionKey string) {
if sm.summarizer == nil {
return
}
history := sm.GetHistory(sessionKey)
tokenEstimate := sm.EstimateTokens(history)
cfg := sm.summarizerCfg
threshold := cfg.ContextWindow * cfg.TokenPercent / 100
if len(history) <= cfg.MessageThreshold && tokenEstimate <= threshold {
return
}
if _, loaded := sm.inflight.LoadOrStore(sessionKey, true); loaded {
return // already running for this session
}
go func() {
defer sm.inflight.Delete(sessionKey)
logger.Debug("Memory threshold reached. Optimizing conversation history...")
sm.summarizeSession(sessionKey)
}()
}
// summarizeSession performs the actual summarization: snapshot history,
// generate summary via LLM, and atomically apply the result while
// preserving any messages added by the main loop during summarization.
func (sm *SessionManager) summarizeSession(sessionKey string) {
cfg := sm.summarizerCfg
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
history := sm.GetHistory(sessionKey)
existingSummary := sm.GetSummary(sessionKey)
snapshotLen := len(history)
keepLast := cfg.KeepLastMessages
if snapshotLen <= keepLast {
return
}
toSummarize := history[:snapshotLen-keepLast]
// Oversized message guard: skip individual messages that exceed
// MaxSingleMsgTokenRatio of the context window.
maxMessageTokens := int(float64(cfg.ContextWindow) * cfg.MaxSingleMsgTokenRatio)
var validMessages []providers.Message
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
msgTokens := int(float64(utf8.RuneCountInString(m.Content)) / cfg.CharsPerToken)
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
// Produce summary, splitting into two batches for large conversations.
var finalSummary string
if len(validMessages) > cfg.MultiPartBatchThreshold {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
var s1, s2 string
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
s1, _ = sm.summarizer.Summarize(ctx, part1, "")
}()
go func() {
defer wg.Done()
s2, _ = sm.summarizer.Summarize(ctx, part2, "")
}()
wg.Wait()
// Merge the two partial summaries.
mergeMessages := []providers.Message{
{
Role: "user",
Content: fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1,
s2,
),
},
}
merged, err := sm.summarizer.Summarize(ctx, mergeMessages, "")
if err == nil {
finalSummary = merged
} else {
finalSummary = s1 + " " + s2
}
} else {
var err error
finalSummary, err = sm.summarizer.Summarize(ctx, validMessages, existingSummary)
if err != nil {
logger.WarnCF("session", "Summarization failed", map[string]any{"error": err.Error()})
return
}
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary == "" {
return
}
// Atomically apply: sets summary and trims old messages while preserving
// anything the main loop appended after our snapshot.
applied := sm.ApplySummarization(sessionKey, finalSummary, snapshotLen, keepLast)
if applied {
sm.Save(sessionKey)
logger.InfoCF("session", "Summarization applied", map[string]any{
"session_key": sessionKey,
"snapshot_len": snapshotLen,
"keep_last": keepLast,
})
} else {
logger.WarnCF("session", "Summarization skipped (stale snapshot)", map[string]any{
"session_key": sessionKey,
"snapshot_len": snapshotLen,
})
}
}
// ForceCompression aggressively reduces context when the LLM returns a
// context-window error. It drops the oldest ~50% of conversation messages,
// preserving the system prompt (first message) and the last message.
//
// This is called synchronously from the LLM retry loop — the main loop is
// already blocked, so there is no concurrent-append concern here.
// No-op if no Summarizer was provided via WithSummarizer.
func (sm *SessionManager) ForceCompression(sessionKey string) {
cfg := sm.summarizerCfg
history := sm.GetHistory(sessionKey)
if len(history) <= cfg.ForceCompressionMinMessages {
return
}
// history[0] is the system prompt, history[len-1] is the trigger message.
conversation := history[1 : len(history)-1]
if len(conversation) == 0 {
return
}
mid := len(conversation) / 2
droppedCount := mid
keptConversation := conversation[mid:]
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
// Append compression note to the system prompt to avoid consecutive
// system messages (rejected by some APIs like Zhipu).
compressionNote := fmt.Sprintf(
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content += compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1])
sm.SetHistory(sessionKey, newHistory)
sm.Save(sessionKey)
logger.WarnCF("session", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
})
}

102
pkg/session/summarizer.go Normal file
View file

@ -0,0 +1,102 @@
package session
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
// Summarizer is the LLM-calling strategy injected by the agent layer.
// The session package is agnostic to how the LLM is called — it only
// invokes this interface to produce textual summaries.
type Summarizer interface {
Summarize(ctx context.Context, messages []providers.Message, existingSummary string) (string, error)
}
// Compile-time interface satisfaction checks.
var (
_ Summarizer = (*LLMSummarizer)(nil)
_ Summarizer = SummarizeFunc(nil)
)
// SummarizeFunc is a function adapter for Summarizer.
// It allows passing a plain function where a Summarizer is expected.
type SummarizeFunc func(ctx context.Context, messages []providers.Message, existingSummary string) (string, error)
// Summarize implements the Summarizer interface.
func (f SummarizeFunc) Summarize(
ctx context.Context,
messages []providers.Message,
existingSummary string,
) (string, error) {
return f(ctx, messages, existingSummary)
}
// BuildSummarizationPrompt formats messages into a summarization prompt string.
// This is intended to be used inside the SummarizeFunc implementation provided
// by the agent layer.
func BuildSummarizationPrompt(batch []providers.Message, existingSummary string) string {
var sb strings.Builder
sb.WriteString(
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
)
if existingSummary != "" {
sb.WriteString("Existing context: ")
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
return sb.String()
}
// LLMSummarizer implements Summarizer by calling an LLM provider.
// This is the default implementation used in production.
type LLMSummarizer struct {
provider providers.LLMProvider
model string
agentID string
cfg config.SummarizationConfig
}
// Summarize formats the messages into a summarization prompt and calls the
// LLM to produce a concise summary.
func (s *LLMSummarizer) Summarize(
ctx context.Context,
msgs []providers.Message,
existingSummary string,
) (string, error) {
prompt := BuildSummarizationPrompt(msgs, existingSummary)
resp, err := s.provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
s.model,
map[string]any{
"max_tokens": s.cfg.SummaryMaxTokens,
"temperature": s.cfg.SummaryTemperature,
"prompt_cache_key": s.agentID,
},
)
if err != nil {
return "", err
}
return resp.Content, nil
}
// WithLLMSummarizer is a convenience option that creates an LLMSummarizer
// and configures the SessionManager for summarization in one call.
func WithLLMSummarizer(provider providers.LLMProvider, model, agentID string, cfg config.SummarizationConfig) Option {
cfg = cfg.WithDefaults()
return WithSummarizer(&LLMSummarizer{
provider: provider,
model: model,
agentID: agentID,
cfg: cfg,
}, cfg)
}

View file

@ -0,0 +1,454 @@
package session
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
func newTestManager(t *testing.T, fn SummarizeFunc, cfg config.SummarizationConfig) *SessionManager {
t.Helper()
return NewSessionManager(t.TempDir(), WithSummarizer(fn, cfg))
}
func noopSummarize(_ context.Context, _ []providers.Message, _ string) (string, error) {
return "summary", nil
}
// --- EstimateTokens ---
func TestEstimateTokens_Latin(t *testing.T) {
sm := newTestManager(t, noopSummarize, config.SummarizationConfig{})
msgs := []providers.Message{
{Role: "user", Content: "hello world"}, // 11 runes
{Role: "assistant", Content: "hi there!"}, // 9 runes
}
// 20 runes / 2.5 = 8 tokens
got := sm.EstimateTokens(msgs)
if got != 8 {
t.Errorf("EstimateTokens = %d, want 8", got)
}
}
func TestEstimateTokens_CJK(t *testing.T) {
sm := newTestManager(t, noopSummarize, config.SummarizationConfig{})
msgs := []providers.Message{
{Role: "user", Content: "你好世界"}, //nolint:gosmopolitan // CJK text "你好世界" (4 runes)
}
// 4 / 2.5 = 1.6 → 1
got := sm.EstimateTokens(msgs)
if got != 1 {
t.Errorf("EstimateTokens = %d, want 1", got)
}
}
func TestEstimateTokens_CustomCharsPerToken(t *testing.T) {
cfg := config.SummarizationConfig{CharsPerToken: 5.0}
sm := newTestManager(t, noopSummarize, cfg)
msgs := []providers.Message{
{Role: "user", Content: "12345678901234567890"}, // 20 runes
}
// 20 / 5.0 = 4
got := sm.EstimateTokens(msgs)
if got != 4 {
t.Errorf("EstimateTokens = %d, want 4", got)
}
}
// --- MaybeSummarize ---
func TestMaybeSummarize_BelowThreshold(t *testing.T) {
called := false
fn := func(_ context.Context, _ []providers.Message, _ string) (string, error) {
called = true
return "summary", nil
}
cfg := config.SummarizationConfig{}
cfg.MessageThreshold = 100
cfg.TokenPercent = 99
sm := newTestManager(t, fn, cfg)
// Add a few messages — well below thresholds.
for i := 0; i < 5; i++ {
sm.AddMessage("test", "user", "hello")
}
sm.MaybeSummarize("test")
time.Sleep(50 * time.Millisecond) // give goroutine time to fire (it shouldn't)
if called {
t.Error("SummarizeFunc was called despite being below thresholds")
}
}
func TestMaybeSummarize_AboveMessageThreshold(t *testing.T) {
done := make(chan struct{})
fn := func(_ context.Context, _ []providers.Message, _ string) (string, error) {
defer func() {
select {
case done <- struct{}{}:
default:
}
}()
return "summary of conversation", nil
}
cfg := config.SummarizationConfig{}
cfg.MessageThreshold = 5
cfg.KeepLastMessages = 2
sm := newTestManager(t, fn, cfg)
for i := 0; i < 10; i++ {
sm.AddMessage("test", "user", "msg")
sm.AddMessage("test", "assistant", "reply")
}
sm.MaybeSummarize("test")
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for summarization")
}
// Wait a bit for ApplySummarization to complete.
time.Sleep(100 * time.Millisecond)
summary := sm.GetSummary("test")
if summary == "" {
t.Error("expected non-empty summary after summarization")
}
history := sm.GetHistory("test")
if len(history) > cfg.KeepLastMessages+2 {
// Some slack for messages added during the goroutine window.
t.Errorf("expected at most %d messages after summarization, got %d",
cfg.KeepLastMessages+2, len(history))
}
}
func TestMaybeSummarize_DeduplicatesConcurrent(t *testing.T) {
goroutineCount := 0
var mu sync.Mutex
blocker := make(chan struct{})
fn := func(_ context.Context, _ []providers.Message, _ string) (string, error) {
mu.Lock()
goroutineCount++
mu.Unlock()
<-blocker
return "summary", nil
}
cfg := config.SummarizationConfig{}
cfg.MessageThreshold = 3
cfg.KeepLastMessages = 2
sm := newTestManager(t, fn, cfg)
for i := 0; i < 10; i++ {
sm.AddMessage("test", "user", "msg")
sm.AddMessage("test", "assistant", "reply")
}
// Trigger twice — only one goroutine should enter summarizeSession.
sm.MaybeSummarize("test")
sm.MaybeSummarize("test")
// Let the blocker hold briefly so the second call sees inflight=true.
time.Sleep(50 * time.Millisecond)
close(blocker)
time.Sleep(200 * time.Millisecond)
mu.Lock()
got := goroutineCount
mu.Unlock()
// summarizeSession may call Summarize multiple times internally
// (multi-part batching via parallel goroutines), but only ONE
// goroutine should have entered summarizeSession.
// With 18 valid messages and threshold=10: 3 calls (part1+part2+merge).
// If dedup failed, we'd see 6.
if got > 3 {
t.Errorf("SummarizeFunc called %d times, want at most 3 (single goroutine with multi-part)", got)
}
}
// --- ForceCompression ---
func TestForceCompression_DropsOldestHalf(t *testing.T) {
sm := newTestManager(t, noopSummarize, config.SummarizationConfig{})
// [system, msg1, msg2, msg3, msg4, msg5, trigger]
sm.AddFullMessage("test", providers.Message{Role: "system", Content: "system prompt"})
for i := 1; i <= 5; i++ {
sm.AddMessage("test", "user", "old message")
}
sm.AddMessage("test", "user", "trigger")
sm.ForceCompression("test")
history := sm.GetHistory("test")
if len(history) == 0 {
t.Fatal("history is empty after compression")
}
// System prompt should be first and contain the compression note.
if history[0].Role != "system" {
t.Errorf("first message role = %q, want system", history[0].Role)
}
if !strings.Contains(history[0].Content, "Emergency compression") {
t.Error("system prompt missing compression note")
}
// Trigger message should be last.
last := history[len(history)-1]
if last.Content != "trigger" {
t.Errorf("last message = %q, want trigger", last.Content)
}
}
func TestForceCompression_TooFewMessages(t *testing.T) {
sm := newTestManager(t, noopSummarize, config.SummarizationConfig{})
sm.AddMessage("test", "user", "one")
sm.AddMessage("test", "assistant", "two")
sm.ForceCompression("test")
history := sm.GetHistory("test")
if len(history) != 2 {
t.Errorf("expected 2 messages unchanged, got %d", len(history))
}
}
// --- ApplySummarization ---
func TestApplySummarization_PreservesNewMessages(t *testing.T) {
sm := NewSessionManager(t.TempDir())
// Seed 10 messages.
for i := 0; i < 10; i++ {
sm.AddMessage("test", "user", "original")
}
snapshotLen := 10
// Simulate the main loop appending 3 new messages after the snapshot.
sm.AddMessage("test", "user", "new-1")
sm.AddMessage("test", "assistant", "new-2")
sm.AddMessage("test", "user", "new-3")
applied := sm.ApplySummarization("test", "the summary", snapshotLen, 4)
if !applied {
t.Fatal("ApplySummarization returned false")
}
history := sm.GetHistory("test")
// Should have: 4 kept from snapshot + 3 new = 7
if len(history) != 7 {
t.Errorf("len(history) = %d, want 7", len(history))
}
// Verify the new messages are at the end.
if history[4].Content != "new-1" {
t.Errorf("history[4] = %q, want new-1", history[4].Content)
}
if history[6].Content != "new-3" {
t.Errorf("history[6] = %q, want new-3", history[6].Content)
}
// Summary should be set.
summary := sm.GetSummary("test")
if summary != "the summary" {
t.Errorf("summary = %q, want 'the summary'", summary)
}
}
func TestApplySummarization_StaleSnapshot(t *testing.T) {
sm := NewSessionManager(t.TempDir())
for i := 0; i < 10; i++ {
sm.AddMessage("test", "user", "original")
}
snapshotLen := 10
// Simulate another compression reducing messages below snapshot.
sm.SetHistory("test", []providers.Message{
{Role: "user", Content: "compressed-1"},
{Role: "assistant", Content: "compressed-2"},
})
applied := sm.ApplySummarization("test", "stale summary", snapshotLen, 4)
if applied {
t.Error("ApplySummarization should return false for stale snapshot")
}
// Summary should NOT have changed.
summary := sm.GetSummary("test")
if summary == "stale summary" {
t.Error("stale summary was applied despite stale snapshot")
}
}
func TestApplySummarization_NoNewMessages(t *testing.T) {
sm := NewSessionManager(t.TempDir())
for i := 0; i < 10; i++ {
sm.AddMessage("test", "user", "original")
}
applied := sm.ApplySummarization("test", "the summary", 10, 4)
if !applied {
t.Fatal("ApplySummarization returned false")
}
history := sm.GetHistory("test")
// Should have exactly 4 kept.
if len(history) != 4 {
t.Errorf("len(history) = %d, want 4", len(history))
}
}
// --- SummarizeSession Multi-Part ---
func TestSummarizeSession_MultiPart(t *testing.T) {
callCount := 0
var mu sync.Mutex
fn := func(_ context.Context, msgs []providers.Message, existing string) (string, error) {
mu.Lock()
callCount++
mu.Unlock()
return "partial summary", nil
}
cfg := config.SummarizationConfig{}
cfg.MultiPartBatchThreshold = 5
cfg.KeepLastMessages = 2
sm := newTestManager(t, fn, cfg)
// Add 12 user+assistant messages (well above threshold of 5).
for i := 0; i < 12; i++ {
sm.AddMessage("test", "user", "hello")
sm.AddMessage("test", "assistant", "world")
}
sm.summarizeSession("test")
mu.Lock()
got := callCount
mu.Unlock()
// Should be 3 calls: part1 + part2 + merge.
if got != 3 {
t.Errorf("SummarizeFunc called %d times, want 3 (part1 + part2 + merge)", got)
}
summary := sm.GetSummary("test")
if summary == "" {
t.Error("expected non-empty summary after multi-part summarization")
}
}
// --- Concurrent safety: summarize + main loop writes ---
func TestConcurrent_SummarizeAndWrite(t *testing.T) {
// Simulates the core race scenario: background summarization runs
// while the main loop adds new messages.
snapshotTaken := make(chan struct{})
blocker := make(chan struct{})
firstCall := true
var mu sync.Mutex
fn := func(_ context.Context, _ []providers.Message, _ string) (string, error) {
mu.Lock()
if firstCall {
firstCall = false
mu.Unlock()
// Signal that summarizeSession has taken its snapshot and entered summarize.
close(snapshotTaken)
<-blocker // block until new messages are added
} else {
mu.Unlock()
}
return "concurrent summary", nil
}
cfg := config.SummarizationConfig{}
cfg.KeepLastMessages = 2
cfg.MultiPartBatchThreshold = 100 // force single-batch path
sm := newTestManager(t, fn, cfg)
// Seed 20 messages.
for i := 0; i < 20; i++ {
sm.AddMessage("test", "user", "seed")
}
// Start summarization in background.
done := make(chan struct{})
go func() {
sm.summarizeSession("test")
close(done)
}()
// Wait for the summarizer to take its snapshot and enter the LLM call.
<-snapshotTaken
// While summarization is blocked, add 5 new messages.
for i := 0; i < 5; i++ {
sm.AddMessage("test", "user", "new-during-summarize")
}
// Unblock the summarizer.
close(blocker)
<-done
// All 5 new messages must survive.
history := sm.GetHistory("test")
newCount := 0
for _, m := range history {
if m.Content == "new-during-summarize" {
newCount++
}
}
if newCount != 5 {
t.Errorf("expected 5 new messages preserved, got %d (total history: %d)",
newCount, len(history))
}
summary := sm.GetSummary("test")
if summary == "" {
t.Error("expected non-empty summary")
}
}
// --- BuildSummarizationPrompt ---
func TestBuildSummarizationPrompt(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
}
prompt := BuildSummarizationPrompt(msgs, "")
if !strings.Contains(prompt, "concise summary") {
t.Error("prompt missing summary instruction")
}
if !strings.Contains(prompt, "user: hello") {
t.Error("prompt missing user message")
}
// With existing summary.
prompt2 := BuildSummarizationPrompt(msgs, "previous context")
if !strings.Contains(prompt2, "Existing context: previous context") {
t.Error("prompt missing existing summary")
}
}