Add session management commands: /status, /compact, /new
This commit is contained in:
parent
6d03791929
commit
919933a45c
14 changed files with 966 additions and 27 deletions
241
CONTEXT_COMMANDS_RESEARCH.md
Normal file
241
CONTEXT_COMMANDS_RESEARCH.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# PicoClaw 上下文管理功能调研报告
|
||||
|
||||
## 📊 OpenClaw 原有功能 vs PicoClaw 当前状态
|
||||
|
||||
### OpenClaw 核心命令(PicoClaw 缺失)
|
||||
|
||||
| 命令 | 功能 | PicoClaw 状态 |
|
||||
|------|------|---------------|
|
||||
| `/status` | 查看会话状态(模型、Token使用量、成本) | ❌ 未实现 |
|
||||
| `/compact` | 手动压缩上下文(摘要化历史) | ❌ 未实现 |
|
||||
| `/new` 或 `/reset` | 开始新会话(重置session) | ❌ 未实现 |
|
||||
| `/usage` | 查看Token消耗详情 | ❌ 未实现 |
|
||||
| `/context list` | 查看已加载的上下文 | ❌ 未实现 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 OpenClaw /status 命令详解
|
||||
|
||||
### 功能
|
||||
显示当前会话的详细状态信息
|
||||
|
||||
### 输出内容
|
||||
| 信息 | 说明 |
|
||||
|------|------|
|
||||
| 当前模型 | 正在使用的AI模型 |
|
||||
| 上下文使用量 | 已消耗的Token数量 |
|
||||
| 最后响应Token | 上一轮对话的Token |
|
||||
| 预估成本 | 本次会话费用(仅API Key用户可见) |
|
||||
| Gateway状态 | 网关是否繁忙 |
|
||||
|
||||
### 使用场景
|
||||
- 感觉对话变慢时检查 `/status`
|
||||
- 上下文超过50%时考虑 `/new`
|
||||
- 定期检查成本,避免超支
|
||||
|
||||
---
|
||||
|
||||
## 🔍 OpenClaw /compact 命令详解
|
||||
|
||||
### 功能
|
||||
将历史对话压缩成摘要,保留最近消息完整
|
||||
|
||||
### 命令格式
|
||||
```
|
||||
/compact # 默认压缩
|
||||
/compact 保留代码讨论 # 带指令的压缩
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
1. 将旧的对话内容摘要化
|
||||
2. 保留最近的消息完整
|
||||
3. 摘要存储在transcript中
|
||||
4. 显著减少Token消耗
|
||||
|
||||
### 使用场景
|
||||
- 长任务开始前
|
||||
- 上下文接近上限
|
||||
- 保留重要上下文但省Token
|
||||
|
||||
### /compact vs /new 对比
|
||||
|
||||
| 命令 | 效果 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| `/new` | 完全重置,清空历史 | 切换任务 |
|
||||
| `/compact` | 压缩历史,保留摘要 | 继续当前任务但省Token |
|
||||
|
||||
---
|
||||
|
||||
## 📁 PicoClaw 现有上下文管理
|
||||
|
||||
### 已实现的功能
|
||||
|
||||
#### 1. 自动压缩 (Auto-compaction)
|
||||
- **触发条件**: 上下文超过阈值
|
||||
- **配置项**: `summarize_message_threshold`, `summarize_token_percent`
|
||||
- **实现位置**: `pkg/agent/context_legacy.go`
|
||||
|
||||
#### 2. 强制压缩 (Force Compression)
|
||||
- 当上下文超限时,丢弃最旧的50%消息
|
||||
- 保留完整的对话轮次(Turn)
|
||||
- 创建压缩说明摘要
|
||||
|
||||
#### 3. 上下文管理器
|
||||
PicoClaw 支持两种上下文管理器:
|
||||
- `legacy` (默认) - 基于摘要的压缩
|
||||
- `seahorse` - 高级向量检索
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PicoClaw 需要补充的命令
|
||||
|
||||
### 1. /status 命令
|
||||
**功能**: 显示当前会话状态
|
||||
|
||||
```go
|
||||
// 实现建议
|
||||
func statusCommand() *Definition {
|
||||
return &Definition{
|
||||
Name: "status",
|
||||
Aliases: []string{"s"},
|
||||
Description: "Show session status (model, tokens, cost)",
|
||||
Handler: func(req *Request) error {
|
||||
// 获取当前session信息
|
||||
session := getSession(req.SessionKey)
|
||||
return req.Replyf(
|
||||
"📊 Session Status\n\n"+
|
||||
"Model: %s\n"+
|
||||
"Context: %d / %d tokens (%.1f%%)\n"+
|
||||
"Messages: %d\n"+
|
||||
"Compactions: %d",
|
||||
session.Model,
|
||||
session.UsedTokens,
|
||||
session.MaxTokens,
|
||||
session.UsagePercent,
|
||||
session.MessageCount,
|
||||
session.CompactionCount,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. /compact 命令
|
||||
**功能**: 手动触发上下文压缩
|
||||
|
||||
```go
|
||||
// 实现建议
|
||||
func compactCommand() *Definition {
|
||||
return &Definition{
|
||||
Name: "compact",
|
||||
Aliases: []string{"c"},
|
||||
Description: "Compact session context (summarize history)",
|
||||
Handler: func(req *Request) error {
|
||||
// 调用现有的Compact功能
|
||||
agent := getAgent(req.AgentID)
|
||||
agent.CompactContext(req.SessionKey, req.Text)
|
||||
return req.Reply("✅ Context compacted successfully")
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. /new 命令
|
||||
**功能**: 开始新的会话
|
||||
|
||||
```go
|
||||
// 实现建议
|
||||
func newCommand() *Definition {
|
||||
return &Definition{
|
||||
Name: "new",
|
||||
Aliases: []string{"reset"},
|
||||
Description: "Start a new session (reset conversation)",
|
||||
Handler: func(req *Request) error {
|
||||
// 创建新的session
|
||||
agent := getAgent(req.AgentID)
|
||||
agent.NewSession(req.SessionKey)
|
||||
return req.Reply("🆕 New session started. Previous context has been archived.")
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 相关代码文件
|
||||
|
||||
### PicoClaw 核心文件
|
||||
- `pkg/agent/context_manager.go` - 上下文管理器接口
|
||||
- `pkg/agent/context_legacy.go` - 现有压缩实现
|
||||
- `pkg/session/manager.go` - 会话管理
|
||||
- `pkg/commands/builtin.go` - 内置命令列表
|
||||
- `pkg/commands/cmd_*.go` - 各命令实现
|
||||
|
||||
### Telegram 集成
|
||||
- `pkg/channels/telegram/telegram.go` - Telegram通道
|
||||
- `pkg/channels/telegram/commands.go` - Telegram命令处理(需新增)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 实现计划
|
||||
|
||||
### 第一阶段:基础命令
|
||||
1. 实现 `/status` 命令
|
||||
2. 实现 `/new` 命令
|
||||
3. 实现 `/reset` 命令(别名)
|
||||
|
||||
### 第二阶段:压缩命令
|
||||
4. 实现 `/compact` 命令
|
||||
5. 添加压缩计数追踪
|
||||
6. 优化压缩摘要质量
|
||||
|
||||
### 第三阶段:增强功能
|
||||
7. 实现 `/usage` 命令
|
||||
8. 实现 `/context` 命令
|
||||
9. 添加成本计算
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置项建议
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"summarize_message_threshold": 20,
|
||||
"summarize_token_percent": 75
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"status": {
|
||||
"show_cost": true,
|
||||
"show_context_percent": true
|
||||
},
|
||||
"compact": {
|
||||
"default_summary": "会话摘要"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 参考资源
|
||||
|
||||
- [OpenClaw Usage Guide](https://openclaw-ai.online/usage/)
|
||||
- [OpenClaw Session Management Deep Dive](https://github.com/openclaw/openclaw/blob/main/docs/reference/session-management-compaction.md)
|
||||
- [Compaction Documentation](https://openclaw.dog/docs/concepts/compaction/)
|
||||
|
||||
---
|
||||
|
||||
## 📌 下一步行动
|
||||
|
||||
1. 在 `pkg/commands/` 目录下创建新命令文件
|
||||
2. 在 `builtin.go` 中注册新命令
|
||||
3. 确保 Telegram 通道正确路由这些命令
|
||||
4. 添加测试用例
|
||||
5. 更新文档
|
||||
|
||||
---
|
||||
|
||||
*调研完成时间: 2026-04-12*
|
||||
150
PICO_STARTUP_GUIDE.md
Normal file
150
PICO_STARTUP_GUIDE.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# PicoClaw 启动方式指南
|
||||
|
||||
## 🎯 推荐方式:Web UI Launcher(桌面用户)
|
||||
|
||||
这是官方推荐的桌面用户使用方式:
|
||||
|
||||
### 方式一:双击启动(推荐)
|
||||
|
||||
1. 下载 PicoClaw
|
||||
- 访问 https://picoclaw.io
|
||||
- 下载对应平台的版本
|
||||
|
||||
2. 双击 `picoclaw-launcher.exe`(Windows)或 `picoclaw-launcher`(macOS/Linux)
|
||||
- 浏览器会自动打开 http://localhost:18800
|
||||
|
||||
3. 在 Web UI 中配置:
|
||||
- **Provider** 页面:配置 MiniMax API Key
|
||||
- **Channel** 页面:配置 Telegram Bot Token
|
||||
- **Gateway** 页面:启动服务
|
||||
|
||||
### 方式二:命令行启动 Web UI
|
||||
|
||||
```bash
|
||||
cd 下载目录
|
||||
picoclaw-launcher
|
||||
# 在浏览器打开 http://localhost:18800
|
||||
```
|
||||
|
||||
### 远程访问(Docker/虚拟机)
|
||||
|
||||
```bash
|
||||
picoclaw-launcher -public
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 命令行模式(服务器/无头环境)
|
||||
|
||||
### 交互式对话
|
||||
|
||||
```bash
|
||||
picoclaw agent
|
||||
# 输入消息与 AI 对话
|
||||
```
|
||||
|
||||
### 单次消息模式
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "你好"
|
||||
```
|
||||
|
||||
### 启动 Gateway(连接聊天平台)
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置文件位置
|
||||
|
||||
### 默认配置目录
|
||||
|
||||
- **Windows**: `C:\Users\你的用户名\.picoclaw\`
|
||||
- **macOS/Linux**: `~/.picoclaw/`
|
||||
|
||||
### 配置文件
|
||||
|
||||
- `config.json` - 主配置文件
|
||||
- `workspace/` - 工作区目录
|
||||
|
||||
### 初始化配置
|
||||
|
||||
如果需要重新初始化配置:
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 问题排查
|
||||
|
||||
### Web UI 中模型为空
|
||||
|
||||
如果 Web UI Launcher 中看不到配置的模型:
|
||||
|
||||
1. 检查配置文件:`C:\Users\用户名\.picoclaw\config.json`
|
||||
2. 确保 `model_list` 中包含模型配置
|
||||
3. 确保 `agents.defaults.model_name` 设置了默认模型
|
||||
|
||||
### MiniMax Token Plan 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "MiniMax-M2.7",
|
||||
"model": "minimax/MiniMax-M2.7",
|
||||
"api_base": "https://api.minimaxi.com/v1",
|
||||
"api_keys": ["你的Token Plan Key"]
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "MiniMax-M2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Telegram 连接配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "你的Bot Token",
|
||||
"allow_from": ["你的User ID"],
|
||||
"proxy": "http://127.0.0.1:代理端口"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 官方资源
|
||||
|
||||
- **官网**: https://picoclaw.io
|
||||
- **文档**: https://docs.picoclaw.io
|
||||
- **GitHub**: https://github.com/sipeed/picoclaw
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
1. **桌面用户**: 使用 Web UI Launcher
|
||||
2. **服务器/无头环境**: 使用命令行模式
|
||||
3. **开发测试**: 使用 `picoclaw agent -m` 单次测试
|
||||
4. **生产环境**: 使用 `picoclaw gateway` 后台运行
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全提示
|
||||
|
||||
- 禁用 `deny_patterns` 后,PicoClaw 可以执行任何命令
|
||||
- 确保 Telegram Bot 的 `allow_from` 设置了你的 User ID
|
||||
- 妥善保管 API Key,不要泄露
|
||||
|
|
@ -36,10 +36,15 @@ func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest)
|
|||
}
|
||||
|
||||
func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch req.Reason {
|
||||
case ContextCompressReasonProactive, ContextCompressReasonRetry:
|
||||
// Sync emergency compression — budget exceeded.
|
||||
if result, ok := m.forceCompression(req.SessionKey); ok {
|
||||
m.al.incrementCompactionCount(req.SessionKey)
|
||||
m.al.emitEvent(
|
||||
EventKindContextCompress,
|
||||
m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"),
|
||||
|
|
@ -51,6 +56,14 @@ func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) e
|
|||
)
|
||||
}
|
||||
case ContextCompressReasonSummarize:
|
||||
if req.Manual {
|
||||
agent := m.al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return nil
|
||||
}
|
||||
_, _, err := m.summarizeSessionWithOptions(agent, req.SessionKey, req.Instructions, true)
|
||||
return err
|
||||
}
|
||||
m.maybeSummarize(req.SessionKey)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -87,7 +100,7 @@ func (m *legacyContextManager) maybeSummarize(sessionKey string) {
|
|||
}
|
||||
}()
|
||||
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
||||
m.summarizeSession(agent, sessionKey)
|
||||
_, _, _ = m.summarizeSessionWithOptions(agent, sessionKey, "", false)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
@ -159,19 +172,32 @@ func (m *legacyContextManager) forceCompression(sessionKey string) (compressionR
|
|||
}
|
||||
|
||||
func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||
_, _, _ = m.summarizeSessionWithOptions(agent, sessionKey, "", false)
|
||||
}
|
||||
|
||||
func (m *legacyContextManager) summarizeSessionWithOptions(
|
||||
agent *AgentInstance,
|
||||
sessionKey string,
|
||||
instructions string,
|
||||
manual bool,
|
||||
) (SessionSummarizePayload, bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
history := agent.Sessions.GetHistory(sessionKey)
|
||||
summary := agent.Sessions.GetSummary(sessionKey)
|
||||
|
||||
if len(history) <= 4 {
|
||||
return
|
||||
keepLast := 4
|
||||
if manual {
|
||||
keepLast = 2
|
||||
}
|
||||
if len(history) <= keepLast {
|
||||
return SessionSummarizePayload{}, false, nil
|
||||
}
|
||||
|
||||
safeCut := findSafeBoundary(history, len(history)-4)
|
||||
safeCut := findSafeBoundary(history, len(history)-keepLast)
|
||||
if safeCut <= 0 {
|
||||
return
|
||||
return SessionSummarizePayload{}, false, nil
|
||||
}
|
||||
keepCount := len(history) - safeCut
|
||||
toSummarize := history[:safeCut]
|
||||
|
|
@ -193,7 +219,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
|
|||
}
|
||||
|
||||
if len(validMessages) == 0 {
|
||||
return
|
||||
return SessionSummarizePayload{}, false, nil
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -209,13 +235,16 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
|
|||
part1 := validMessages[:mid]
|
||||
part2 := validMessages[mid:]
|
||||
|
||||
s1, _ := m.summarizeBatch(ctx, agent, part1, "")
|
||||
s2, _ := m.summarizeBatch(ctx, agent, part2, "")
|
||||
s1, _ := m.summarizeBatch(ctx, agent, part1, "", instructions)
|
||||
s2, _ := m.summarizeBatch(ctx, agent, part2, "", instructions)
|
||||
|
||||
mergePrompt := fmt.Sprintf(
|
||||
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
||||
s1, s2,
|
||||
)
|
||||
if instructions != "" {
|
||||
mergePrompt += "\n\nAdditional focus for manual compaction: " + instructions
|
||||
}
|
||||
|
||||
resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
|
||||
if err == nil && resp.Content != "" {
|
||||
|
|
@ -224,28 +253,34 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey
|
|||
finalSummary = s1 + " " + s2
|
||||
}
|
||||
} else {
|
||||
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary)
|
||||
finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary, instructions)
|
||||
}
|
||||
|
||||
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, keepCount)
|
||||
agent.Sessions.Save(sessionKey)
|
||||
m.al.emitEvent(
|
||||
EventKindSessionSummarize,
|
||||
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
|
||||
SessionSummarizePayload{
|
||||
SummarizedMessages: len(validMessages),
|
||||
KeptMessages: keepCount,
|
||||
SummaryLen: len(finalSummary),
|
||||
OmittedOversized: omitted,
|
||||
},
|
||||
)
|
||||
if finalSummary == "" {
|
||||
return SessionSummarizePayload{}, false, nil
|
||||
}
|
||||
|
||||
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
||||
agent.Sessions.TruncateHistory(sessionKey, keepCount)
|
||||
agent.Sessions.Save(sessionKey)
|
||||
m.al.incrementCompactionCount(sessionKey)
|
||||
|
||||
payload := SessionSummarizePayload{
|
||||
SummarizedMessages: len(validMessages),
|
||||
KeptMessages: keepCount,
|
||||
SummaryLen: len(finalSummary),
|
||||
OmittedOversized: omitted,
|
||||
}
|
||||
m.al.emitEvent(
|
||||
EventKindSessionSummarize,
|
||||
m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"),
|
||||
payload,
|
||||
)
|
||||
return payload, true, nil
|
||||
}
|
||||
|
||||
func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int {
|
||||
|
|
@ -315,6 +350,7 @@ func (m *legacyContextManager) summarizeBatch(
|
|||
agent *AgentInstance,
|
||||
batch []providers.Message,
|
||||
existingSummary string,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
const (
|
||||
llmMaxRetries = 3
|
||||
|
|
@ -329,6 +365,11 @@ func (m *legacyContextManager) summarizeBatch(
|
|||
sb.WriteString(existingSummary)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if customInstructions != "" {
|
||||
sb.WriteString("Additional focus for this manual compaction: ")
|
||||
sb.WriteString(customInstructions)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\nCONVERSATION:\n")
|
||||
for _, msg := range batch {
|
||||
fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,11 @@ type AssembleResponse struct {
|
|||
|
||||
// CompactRequest is the input to Compact.
|
||||
type CompactRequest struct {
|
||||
SessionKey string // session identifier
|
||||
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
|
||||
Budget int // context window budget (used for retry aggressive compaction)
|
||||
SessionKey string // session identifier
|
||||
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
|
||||
Budget int // context window budget (used for retry aggressive compaction)
|
||||
Instructions string // optional extra guidance for manual compaction
|
||||
Manual bool // true when triggered explicitly by the user
|
||||
}
|
||||
|
||||
// IngestRequest is the input to Ingest.
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactReques
|
|||
}
|
||||
|
||||
_, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{
|
||||
Force: req.Reason == ContextCompressReasonRetry,
|
||||
Force: req.Reason == ContextCompressReasonRetry || req.Manual,
|
||||
Budget: &req.Budget,
|
||||
})
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -69,6 +69,28 @@ type AgentLoop struct {
|
|||
activeRequests sync.WaitGroup
|
||||
|
||||
reloadFunc func() error
|
||||
|
||||
// Compression tracking (custom feature)
|
||||
compressionCounters sync.Map // key: sessionKey, value: int64
|
||||
}
|
||||
|
||||
func (al *AgentLoop) incrementCompactionCount(sessionKey string) {
|
||||
if sessionKey == "" {
|
||||
return
|
||||
}
|
||||
current, _ := al.compressionCounters.LoadOrStore(sessionKey, int64(0))
|
||||
al.compressionCounters.Store(sessionKey, current.(int64)+1)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) getCompactionCount(sessionKey string) int {
|
||||
if sessionKey == "" {
|
||||
return 0
|
||||
}
|
||||
value, ok := al.compressionCounters.Load(sessionKey)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(value.(int64))
|
||||
}
|
||||
|
||||
// processOptions configures how a message is processed
|
||||
|
|
@ -3579,6 +3601,99 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
|||
agent.Sessions.Save(opts.SessionKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
rt.GetSessionStats = func() commands.SessionStats {
|
||||
stats := commands.SessionStats{}
|
||||
|
||||
if opts == nil || agent.Sessions == nil {
|
||||
return stats
|
||||
}
|
||||
|
||||
stats.Version = config.GetVersion()
|
||||
stats.SessionKey = opts.SessionKey
|
||||
|
||||
history := agent.Sessions.GetHistory(opts.SessionKey)
|
||||
summary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||
stats.MessageCount = len(history)
|
||||
|
||||
tokenEstimate := 0
|
||||
for _, msg := range history {
|
||||
tokenEstimate += EstimateMessageTokens(msg)
|
||||
}
|
||||
if summary != "" {
|
||||
tokenEstimate += EstimateMessageTokens(providers.Message{
|
||||
Role: "system",
|
||||
Content: summary,
|
||||
})
|
||||
stats.HasSummary = true
|
||||
}
|
||||
stats.TokenEstimate = tokenEstimate
|
||||
|
||||
stats.ContextWindow = agent.ContextWindow
|
||||
if agent.ContextWindow > 0 {
|
||||
stats.ContextPercent = float64(tokenEstimate) / float64(agent.ContextWindow) * 100
|
||||
}
|
||||
|
||||
stats.ThinkEnabled = agent.ThinkingLevel != ThinkingOff
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
rt.CompactContext = func(instructions string) (int, error) {
|
||||
if opts == nil {
|
||||
return 0, fmt.Errorf("process options not available")
|
||||
}
|
||||
if al.contextManager == nil {
|
||||
return 0, fmt.Errorf("context manager is not initialized")
|
||||
}
|
||||
if agent.Sessions == nil {
|
||||
return 0, fmt.Errorf("sessions not initialized for agent")
|
||||
}
|
||||
|
||||
beforeHistory := agent.Sessions.GetHistory(opts.SessionKey)
|
||||
|
||||
compactCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := al.contextManager.Compact(compactCtx, &CompactRequest{
|
||||
SessionKey: opts.SessionKey,
|
||||
Reason: ContextCompressReasonSummarize,
|
||||
Budget: agent.ContextWindow,
|
||||
Instructions: instructions,
|
||||
Manual: true,
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
afterHistory := agent.Sessions.GetHistory(opts.SessionKey)
|
||||
if len(afterHistory) < len(beforeHistory) {
|
||||
al.incrementCompactionCount(opts.SessionKey)
|
||||
}
|
||||
return len(beforeHistory) - len(afterHistory), nil
|
||||
}
|
||||
|
||||
rt.NewSession = func() error {
|
||||
if opts == nil {
|
||||
return fmt.Errorf("process options not available")
|
||||
}
|
||||
if agent.Sessions == nil {
|
||||
return fmt.Errorf("sessions not initialized for agent")
|
||||
}
|
||||
|
||||
al.compressionCounters.Store(opts.SessionKey, int64(0))
|
||||
|
||||
agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0))
|
||||
agent.Sessions.SetSummary(opts.SessionKey, "")
|
||||
agent.Sessions.Save(opts.SessionKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
rt.GetCompactionCount = func() int {
|
||||
if opts == nil {
|
||||
return 0
|
||||
}
|
||||
return al.getCompactionCount(opts.SessionKey)
|
||||
}
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
|
|
|||
82
pkg/agent/manual_compact_test.go
Normal file
82
pkg/agent/manual_compact_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
type promptCapturingProvider struct {
|
||||
response string
|
||||
prompts []string
|
||||
}
|
||||
|
||||
func (p *promptCapturingProvider) Chat(
|
||||
_ context.Context,
|
||||
messages []providers.Message,
|
||||
_ []providers.ToolDefinition,
|
||||
_ string,
|
||||
_ map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
if len(messages) > 0 {
|
||||
p.prompts = append(p.prompts, messages[0].Content)
|
||||
}
|
||||
return &providers.LLMResponse{
|
||||
Content: p.response,
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *promptCapturingProvider) GetDefaultModel() string {
|
||||
return "prompt-capturing-model"
|
||||
}
|
||||
|
||||
func TestLegacyCompact_Manual_UsesInstructionsAndCompactsSynchronously(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
provider := &promptCapturingProvider{response: "manual compact summary"}
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "question one"},
|
||||
{Role: "assistant", Content: "answer one"},
|
||||
{Role: "user", Content: "question two"},
|
||||
{Role: "assistant", Content: "answer two"},
|
||||
{Role: "user", Content: "question three"},
|
||||
{Role: "assistant", Content: "answer three"},
|
||||
}
|
||||
defaultAgent.Sessions.SetHistory("session-manual", history)
|
||||
|
||||
err := al.contextManager.Compact(context.Background(), &CompactRequest{
|
||||
SessionKey: "session-manual",
|
||||
Reason: ContextCompressReasonSummarize,
|
||||
Instructions: "focus on decisions",
|
||||
Manual: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
newHistory := defaultAgent.Sessions.GetHistory("session-manual")
|
||||
if len(newHistory) >= len(history) {
|
||||
t.Fatalf("expected compacted history, got %d messages (was %d)", len(newHistory), len(history))
|
||||
}
|
||||
|
||||
summary := defaultAgent.Sessions.GetSummary("session-manual")
|
||||
if !strings.Contains(summary, "manual compact summary") {
|
||||
t.Fatalf("summary=%q, want manual summary content", summary)
|
||||
}
|
||||
if len(provider.prompts) == 0 || !strings.Contains(provider.prompts[0], "focus on decisions") {
|
||||
t.Fatalf("expected compaction prompt to include manual instructions, got %v", provider.prompts)
|
||||
}
|
||||
if got := al.getCompactionCount("session-manual"); got != 1 {
|
||||
t.Fatalf("compaction count=%d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,5 +16,8 @@ func BuiltinDefinitions() []Definition {
|
|||
clearCommand(),
|
||||
subagentsCommand(),
|
||||
reloadCommand(),
|
||||
statusCommand(),
|
||||
compactCommand(),
|
||||
newCommand(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
pkg/commands/cmd_compact.go
Normal file
52
pkg/commands/cmd_compact.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func compactCommand() Definition {
|
||||
return Definition{
|
||||
Name: "compact",
|
||||
Description: "Compact session context and summarize older messages",
|
||||
Usage: "/compact [instructions]",
|
||||
Aliases: []string{"c"},
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
|
||||
if rt.CompactContext == nil {
|
||||
return req.Reply("Compaction is not available in the current context.")
|
||||
}
|
||||
|
||||
var instructions string
|
||||
if fields := strings.Fields(req.Text); len(fields) > 1 {
|
||||
instructions = strings.Join(fields[1:], " ")
|
||||
}
|
||||
|
||||
droppedMessages, err := rt.CompactContext(instructions)
|
||||
if err != nil {
|
||||
return req.Reply("Failed to compact context: " + err.Error())
|
||||
}
|
||||
|
||||
if droppedMessages > 0 {
|
||||
label := "(default)"
|
||||
if instructions != "" {
|
||||
label = instructions
|
||||
}
|
||||
return req.Reply(fmt.Sprintf(
|
||||
"Context compacted.\n\n- Messages summarized: %d\n- Instructions: %s\n- Older context was condensed into the session summary.",
|
||||
droppedMessages,
|
||||
label,
|
||||
))
|
||||
}
|
||||
|
||||
if instructions != "" {
|
||||
return req.Reply("Compaction requested, but the session was already small enough that no history was summarized.")
|
||||
}
|
||||
return req.Reply("Context is already compact enough. No summarization was needed.")
|
||||
},
|
||||
}
|
||||
}
|
||||
81
pkg/commands/cmd_context_test.go
Normal file
81
pkg/commands/cmd_context_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompactCommand_PassesInstructionsToRuntime(t *testing.T) {
|
||||
var gotInstructions string
|
||||
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{
|
||||
CompactContext: func(instructions string) (int, error) {
|
||||
gotInstructions = instructions
|
||||
return 3, nil
|
||||
},
|
||||
})
|
||||
|
||||
var reply string
|
||||
res := ex.Execute(context.Background(), Request{
|
||||
Text: "/compact focus on decisions",
|
||||
Reply: func(text string) error {
|
||||
reply = text
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if res.Outcome != OutcomeHandled {
|
||||
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||
}
|
||||
if gotInstructions != "focus on decisions" {
|
||||
t.Fatalf("instructions=%q, want %q", gotInstructions, "focus on decisions")
|
||||
}
|
||||
if !strings.Contains(reply, "Messages summarized: 3") {
|
||||
t.Fatalf("reply=%q, want summarized count", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCommand_ShowsSummaryAndCompactions(t *testing.T) {
|
||||
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{
|
||||
GetModelInfo: func() (string, string) {
|
||||
return "gpt-test", "openai"
|
||||
},
|
||||
GetSessionStats: func() SessionStats {
|
||||
return SessionStats{
|
||||
Version: "v1.2.3",
|
||||
TokenEstimate: 1200,
|
||||
ContextWindow: 8000,
|
||||
ContextPercent: 15,
|
||||
MessageCount: 6,
|
||||
SessionKey: "agent:main:test",
|
||||
ThinkEnabled: true,
|
||||
HasSummary: true,
|
||||
}
|
||||
},
|
||||
GetCompactionCount: func() int { return 2 },
|
||||
})
|
||||
|
||||
var reply string
|
||||
res := ex.Execute(context.Background(), Request{
|
||||
Text: "/status",
|
||||
Reply: func(text string) error {
|
||||
reply = text
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if res.Outcome != OutcomeHandled {
|
||||
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"PicoClaw v1.2.3",
|
||||
"gpt-test",
|
||||
"History:* 6 messages",
|
||||
"Summary:* present",
|
||||
"Compactions:* 2",
|
||||
"Runtime:* direct",
|
||||
"Think:* on",
|
||||
} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Fatalf("reply=%q, want substring %q", reply, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
pkg/commands/cmd_new.go
Normal file
42
pkg/commands/cmd_new.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package commands
|
||||
|
||||
import "context"
|
||||
|
||||
func newCommand() Definition {
|
||||
return Definition{
|
||||
Name: "new",
|
||||
Description: "Start a new session (reset conversation history)",
|
||||
Usage: "/new",
|
||||
Aliases: []string{"reset"},
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
|
||||
if rt.NewSession == nil {
|
||||
if rt.ClearHistory != nil {
|
||||
if err := rt.ClearHistory(); err != nil {
|
||||
return req.Reply("Failed to start new session: " + err.Error())
|
||||
}
|
||||
return req.Reply(
|
||||
"New session started.\n\n" +
|
||||
"Previous conversation has been cleared.\n" +
|
||||
"You now have a fresh context window.",
|
||||
)
|
||||
}
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
|
||||
if err := rt.NewSession(); err != nil {
|
||||
return req.Reply("Failed to start new session: " + err.Error())
|
||||
}
|
||||
|
||||
return req.Reply(
|
||||
"New session started.\n\n" +
|
||||
"Previous conversation has been cleared.\n" +
|
||||
"You now have a fresh context window.\n\n" +
|
||||
"Tip: Use /compact to summarize older history before resetting a session.",
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
112
pkg/commands/cmd_status.go
Normal file
112
pkg/commands/cmd_status.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func statusCommand() Definition {
|
||||
return Definition{
|
||||
Name: "status",
|
||||
Description: "Show current session status (model, context, compactions)",
|
||||
Usage: "/status",
|
||||
Aliases: []string{"s"},
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
|
||||
modelName, provider := "Unknown", "Unknown"
|
||||
if rt.GetModelInfo != nil {
|
||||
modelName, provider = rt.GetModelInfo()
|
||||
}
|
||||
|
||||
stats := SessionStats{}
|
||||
if rt.GetSessionStats != nil {
|
||||
stats = rt.GetSessionStats()
|
||||
}
|
||||
|
||||
compactions := 0
|
||||
if rt.GetCompactionCount != nil {
|
||||
compactions = rt.GetCompactionCount()
|
||||
}
|
||||
|
||||
if stats.ContextWindow == 0 {
|
||||
stats.ContextWindow = 200000
|
||||
}
|
||||
|
||||
version := stats.Version
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
think := "off"
|
||||
if stats.ThinkEnabled {
|
||||
think = "on"
|
||||
}
|
||||
|
||||
var sessionInfo strings.Builder
|
||||
sessionInfo.WriteString(fmt.Sprintf("🦞 *PicoClaw %s*\n\n", version))
|
||||
sessionInfo.WriteString(fmt.Sprintf("🧠 *Model:* %s/%s\n", provider, modelName))
|
||||
sessionInfo.WriteString(fmt.Sprintf(
|
||||
"📚 *Context:* %s %s/%s (%.1f%%)\n",
|
||||
makeContextBar(stats.ContextPercent),
|
||||
formatTokens(stats.TokenEstimate),
|
||||
formatTokens(stats.ContextWindow),
|
||||
stats.ContextPercent,
|
||||
))
|
||||
sessionInfo.WriteString(fmt.Sprintf("💬 *History:* %d messages\n", stats.MessageCount))
|
||||
if stats.HasSummary {
|
||||
sessionInfo.WriteString("📝 *Summary:* present\n")
|
||||
} else {
|
||||
sessionInfo.WriteString("📝 *Summary:* none\n")
|
||||
}
|
||||
sessionInfo.WriteString(fmt.Sprintf("🧹 *Compactions:* %d\n", compactions))
|
||||
sessionInfo.WriteString("\n💡 *Tokens:* estimated from stored session history\n")
|
||||
|
||||
if stats.SessionKey != "" {
|
||||
sessionInfo.WriteString(fmt.Sprintf("🧵 *Session:* %s", stats.SessionKey))
|
||||
if stats.SessionUpdated != "" {
|
||||
sessionInfo.WriteString(fmt.Sprintf(" - %s", stats.SessionUpdated))
|
||||
}
|
||||
sessionInfo.WriteString("\n")
|
||||
}
|
||||
|
||||
sessionInfo.WriteString(fmt.Sprintf("⚙️ *Runtime:* direct · 🤖 *Think:* %s\n", think))
|
||||
return req.Reply(sessionInfo.String())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makeContextBar(percent float64) string {
|
||||
const total = 20
|
||||
filled := int(percent / 100 * float64(total))
|
||||
if filled > total {
|
||||
filled = total
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
empty := total - filled
|
||||
|
||||
var bar strings.Builder
|
||||
bar.WriteString("[")
|
||||
for i := 0; i < filled; i++ {
|
||||
bar.WriteString("█")
|
||||
}
|
||||
for i := 0; i < empty; i++ {
|
||||
bar.WriteString("░")
|
||||
}
|
||||
bar.WriteString("]")
|
||||
return bar.String()
|
||||
}
|
||||
|
||||
func formatTokens(tokens int) string {
|
||||
if tokens >= 1000000 {
|
||||
return fmt.Sprintf("%.1fm", float64(tokens)/1000000)
|
||||
} else if tokens >= 1000 {
|
||||
return fmt.Sprintf("%.1fk", float64(tokens)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", tokens)
|
||||
}
|
||||
|
|
@ -17,4 +17,9 @@ type Runtime struct {
|
|||
SwitchChannel func(value string) error
|
||||
ClearHistory func() error
|
||||
ReloadConfig func() error
|
||||
|
||||
GetSessionStats func() SessionStats
|
||||
CompactContext func(instructions string) (droppedMessages int, err error)
|
||||
NewSession func() error
|
||||
GetCompactionCount func() int
|
||||
}
|
||||
|
|
|
|||
13
pkg/commands/session_stats.go
Normal file
13
pkg/commands/session_stats.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package commands
|
||||
|
||||
type SessionStats struct {
|
||||
MessageCount int
|
||||
TokenEstimate int
|
||||
ContextPercent float64
|
||||
ContextWindow int
|
||||
SessionKey string
|
||||
SessionUpdated string
|
||||
Version string
|
||||
ThinkEnabled bool
|
||||
HasSummary bool
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue