feat: 实现基于上下文的动态技能工具选择机制
## 新特性 ### 1. 上下文构建策略优化 (context.go) - 新增三种上下文构建策略:Full/Lite/Custom - Full 策略:完整上下文,向后兼容 - Lite 策略:最小上下文,减少 99.7% 耗时 (~2,500 ns/op) - Custom 策略:自定义内容,精确控制技能和工具 - 实现系统提示词缓存机制,避免重复构建 ### 2. 工具可见性动态过滤 (registry.go) - 新增 ToolVisibilityContext 和 ToolVisibilityFilter - 支持基于角色、通道、用户 ID 的细粒度权限控制 - 新增 RegisterWithFilter() 注册带过滤器的工具 - 新增 GetDefinitionsForContext() 根据上下文获取工具定义 - 使用场景: * 多租户权限控制 (admin vs user) * 通道特定功能 (Telegram vs Slack vs WeCom) * 安全敏感操作限制 ### 3. 技能按需加载 (loader.go) - 新增 BuildSkillsSummaryFiltered() 按名称列表过滤 - 新增 BuildSkillsSummaryForNames() 显式指定技能 - 空列表时返回全部技能,保持向后兼容 ### 4. 智能技能推荐器 (recommender.go) - 混合推荐算法:规则预筛选 + LLM 智能选择 - 多维度评分:channel(40%) + keyword(30%) + history(20%) + recency(10%) - 支持自定义权重配置 - 自动触发技能推荐并应用到上下文构建 ### 5. Agent 实例动态技能过滤 (instance.go) - 新增 SetSkillsFilter() 动态设置过滤器 - 新增 EnableSkillRecommender() 启用智能推荐 - 线程安全设计,支持运行时动态调整 - 自动触发 ContextBuilder 缓存失效 ## 实现机制 ### 工作流程 1. 用户消息到达 → ContextBuilder.BuildMessagesWithOptions 2. 根据 Strategy 选择构建方式 (Full/Lite/Custom) 3. 如启用推荐器且无显式过滤 → RecommendSkillsForContext 4. 规则预筛选与评分 → LLM 智能选择 (多候选时) 5. SkillsLoader.BuildSkillsSummaryFiltered 构建技能摘要 6. ToolRegistry.GetDefinitionsForContext 过滤工具定义 7. 组合完整系统消息返回给 LLM ### 性能优化 - Lite 策略:~2,500 ns/op,减少 99.7% 处理时间 - 缓存机制:系统提示词缓存避免重复构建 - 动态/静态上下文分离处理 ### 向后兼容性 - BuildMessages() 自动调用新方法使用 Full 策略 - Register() 继续工作,工具对所有上下文可见 - 默认不启用推荐器,需显式设置 - 所有现有代码无需修改 ## 测试覆盖 ### 新增测试文件 - pkg/agent/recommender_test.go (技能推荐器测试) - pkg/agent/context_benchmark_test.go (性能基准测试) - pkg/agent/instance_skills_filter_test.go (实例过滤测试) ### 新增测试用例 - TestContextBuilder_BuildMessagesWithOptions_* (8 个策略测试) - TestToolRegistry_RegisterWithFilter_* (5 个过滤测试) - TestSkillsLoaderBuildSkillsSummaryFiltered* (7 个过滤测试) - TestSkillRecommender_* (推荐器集成测试) ### 测试结果 ✅ pkg/agent: PASS (coverage: 51.0%) ✅ pkg/tools: PASS (coverage: 59.2%) ✅ pkg/skills: PASS (coverage: 79.6%) ✅ 所有现有测试保持通过 ## 文档更新 - ARCHITECTURE.md: 新增'上下文动态选择增强'章节 - pkg/agent/RECOMMENDER_EXAMPLES.md: 推荐器使用示例 - docs/openspec-global-rules-guide.md: OpenSpec 全局规则配置 ## 配置示例 // 启用技能推荐器 agent.EnableSkillRecommender() // 自定义推荐权重 agent.EnableSkillRecommenderWithWeights(0.5, 0.3, 0.15, 0.05) // 动态设置技能过滤 agent.SetSkillsFilter([]string{"customer-service", "faq"}) // 注册管理员专用工具 registry.RegisterWithFilter(adminTool, func(ctx tools.ToolVisibilityContext) bool { for _, role := range ctx.UserRoles { if role == "admin" { return true } } return false })
This commit is contained in:
parent
f814711621
commit
c0fa771dc9
44 changed files with 6630 additions and 61 deletions
379
.qoder/commands/README-OFFICIAL.md
Normal file
379
.qoder/commands/README-OFFICIAL.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# OpenSpec 官方配置指南 - PicoClaw 项目
|
||||
|
||||
根据 OpenSpec 官方文档整理的正确配置方法。
|
||||
|
||||
## 📋 官方文档参考
|
||||
|
||||
- **Commands 文档**: `/Users/pengweiye/Documents/codes/OpenSpec/docs/commands.md`
|
||||
- **Getting Started**: `/Users/pengweiye/Documents/codes/OpenSpec/docs/getting-started.md`
|
||||
- **OPSX Workflow**: `/Users/pengweiye/Documents/codes/OpenSpec/docs/opsx.md`
|
||||
- **Supported Tools**: `/Users/pengweiye/Documents/codes/OpenSpec/docs/supported-tools.md`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 正确的目录结构
|
||||
|
||||
### Qoder Agent 配置位置
|
||||
|
||||
根据官方文档,Qoder 的命令应该放在:
|
||||
|
||||
```
|
||||
.qoder/commands/opsx/ # ← 注意 opsx/ 子目录
|
||||
├── new.md # /opsx:new
|
||||
├── ff.md # /opsx:ff
|
||||
├── apply.md # /opsx:apply
|
||||
├── list.md # /opsx:list
|
||||
├── validate.md # /opsx:validate
|
||||
├── archive.md # /opsx:archive
|
||||
└── show.md # /opsx:show
|
||||
```
|
||||
|
||||
**而不是:**
|
||||
```
|
||||
.qoder/commands/ # ❌ 错误位置
|
||||
├── opsx-new.md
|
||||
├── opsx-ff.md
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 完整的交互流程
|
||||
|
||||
### 1. 初始化(如果还没做)
|
||||
|
||||
```bash
|
||||
cd /Users/pengweiye/Documents/codes/picoclaw
|
||||
openspec init
|
||||
```
|
||||
|
||||
这会:
|
||||
- ✅ 自动检测 Qoder
|
||||
- ✅ 在 `.qoder/skills/` 生成技能文件
|
||||
- ✅ 在 `.qoder/commands/opsx/` 生成命令文件
|
||||
- ✅ 创建 `openspec/config.yaml`(可选但推荐)
|
||||
|
||||
### 2. 开始工作
|
||||
|
||||
#### **方式 A: 使用 slash commands(推荐)**
|
||||
|
||||
在你的 AI 助手(Qoder)对话中直接使用:
|
||||
|
||||
```text
|
||||
# 1. 探索需求(可选)
|
||||
/opsx:explore context-dynamic-selection
|
||||
|
||||
# 2. 创建变更
|
||||
/opsx:new context-dynamic-selection-enhancement
|
||||
|
||||
# 3. 生成所有规划文档
|
||||
/opsx:ff
|
||||
|
||||
# 4. 实现功能
|
||||
/opsx:apply
|
||||
|
||||
# 5. 验证质量
|
||||
/opsx:verify
|
||||
|
||||
# 6. 归档
|
||||
/opsx:archive
|
||||
```
|
||||
|
||||
#### **方式 B: 手动指示 AI**
|
||||
|
||||
如果 slash commands 不工作,可以直接告诉 AI:
|
||||
|
||||
```text
|
||||
我们将使用 OpenSpec spec-driven 工作流。
|
||||
|
||||
请遵循以下步骤:
|
||||
|
||||
1. 创建变更目录
|
||||
- 运行:openspec new change context-dynamic-selection-enhancement
|
||||
|
||||
2. 生成规划文档
|
||||
- 读取 openspec/changes/context-dynamic-selection-enhancement/proposal.md
|
||||
- 按照 proposal 中的 Capabilities 创建 specs
|
||||
- 创建设计文档 design.md
|
||||
- 创建任务清单 tasks.md
|
||||
|
||||
3. 实现功能
|
||||
- 从 tasks.md 的第一个任务开始
|
||||
- 每完成一个任务就标记为 [x]
|
||||
- 参考 specs 确保符合要求
|
||||
|
||||
4. 验证和归档
|
||||
- 检查所有任务完成
|
||||
- 运行测试确保通过
|
||||
- 归档到 openspec/changes/archive/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 项目配置(强烈推荐)
|
||||
|
||||
创建 `openspec/config.yaml` 注入项目上下文:
|
||||
|
||||
```yaml
|
||||
# openspec/config.yaml
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
## PicoClaw 项目上下文
|
||||
Tech stack: Go 1.25.7
|
||||
Project type: Ultra-lightweight AI assistant gateway
|
||||
Architecture: Event-driven, message bus pattern
|
||||
Testing: go test with testify/assert and testify/require
|
||||
Code style: Go standard formatting, Godoc comments required
|
||||
Concurrency: Use sync.RWMutex for shared state protection
|
||||
|
||||
## 关键组件
|
||||
- AgentInstance: Agent 实例管理
|
||||
- ContextBuilder: System prompt 构建(带缓存机制)
|
||||
- ToolRegistry: 工具注册和执行(支持可见性过滤)
|
||||
- SkillsLoader: 技能加载(workspace > global > builtin)
|
||||
- SessionManager: 会话管理(按 channel + chatID 隔离)
|
||||
|
||||
## API 约定
|
||||
- 公开方法:首字母大写,线程安全
|
||||
- 错误处理:返回 error,使用 errors.Wrap
|
||||
- 日志:使用 logger.DebugCF/InfoCF/ErrorCF
|
||||
- 配置:从 config.json 读取,支持热重载
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- Must include backward compatibility analysis
|
||||
- Identify affected packages and APIs
|
||||
- Include performance impact assessment
|
||||
|
||||
specs:
|
||||
- Use WHEN/THEN format for scenarios
|
||||
- Each requirement must have at least one scenario
|
||||
- Include concurrency requirements if applicable
|
||||
- Specify thread-safety guarantees
|
||||
|
||||
design:
|
||||
- Explain mutex usage and lock granularity
|
||||
- Document cache invalidation strategies
|
||||
- Include rollback plan
|
||||
- Address backward compatibility
|
||||
|
||||
tasks:
|
||||
- Tasks must be small enough to complete in one session
|
||||
- Order by dependency (what must be done first)
|
||||
- Include test writing tasks
|
||||
- Mark breaking changes clearly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 让 AI 遵循 Spec 驱动的技巧
|
||||
|
||||
### 技巧 1: 在对话开始时设定上下文
|
||||
|
||||
```text
|
||||
在这个对话中,我们将严格遵循 OpenSpec spec-driven 工作流。
|
||||
|
||||
当前变更:context-dynamic-selection-enhancement
|
||||
相关文档:
|
||||
- proposal.md: Why, What, Capabilities
|
||||
- specs/: 详细需求和场景
|
||||
- design.md: 技术决策和权衡
|
||||
- tasks.md: 实现任务清单
|
||||
|
||||
规则:
|
||||
1. 始终先阅读相关文档再开始编码
|
||||
2. 每个任务完成后更新 tasks.md
|
||||
3. 实现必须符合 specs 中的场景
|
||||
4. 设计决策必须与 design.md 一致
|
||||
5. 发现文档问题时先更新文档再改代码
|
||||
|
||||
现在开始实现 Task 2.1...
|
||||
```
|
||||
|
||||
### 技巧 2: 使用明确的检查点
|
||||
|
||||
```text
|
||||
在继续之前,让我们确认:
|
||||
|
||||
✅ 已读取 proposal.md,理解为什么要做这个改动
|
||||
✅ 已读取 specs/tool-visibility-filters/spec.md,了解需求
|
||||
✅ 已读取 design.md,理解技术决策
|
||||
✅ 已读取 tasks.md,知道当前任务是 2.1
|
||||
|
||||
现在开始实现...
|
||||
```
|
||||
|
||||
### 技巧 3: 要求 AI 自我验证
|
||||
|
||||
```text
|
||||
完成每个任务后,请:
|
||||
|
||||
1. 列出你修改的文件
|
||||
2. 说明如何验证功能正确
|
||||
3. 指出是否影响向后兼容性
|
||||
4. 确认是否符合 specs 中的场景
|
||||
5. 标记 tasks.md 为完成状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 问题 1: Slash commands 不工作
|
||||
|
||||
**症状**: 输入 `/opsx:new` 没有反应
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 1. 检查命令文件位置
|
||||
ls -la ~/.qoder/commands/opsx/
|
||||
|
||||
# 2. 重新生成命令
|
||||
openspec update
|
||||
|
||||
# 3. 重启 Qoder
|
||||
# 关闭并重新打开 Qoder 窗口
|
||||
```
|
||||
|
||||
### 问题 2: AI 不遵循 Spec
|
||||
|
||||
**症状**: AI 直接开始写代码,不看文档
|
||||
|
||||
**解决方案**:
|
||||
|
||||
在对话中明确指示:
|
||||
|
||||
```text
|
||||
暂停!我们使用的是 OpenSpec spec-driven 工作流。
|
||||
|
||||
在写代码之前,请先:
|
||||
1. 读取 proposal.md 理解为什么做这个改动
|
||||
2. 读取 specs/ 了解具体需求
|
||||
3. 读取 design.md 理解技术决策
|
||||
4. 读取 tasks.md 知道当前任务
|
||||
|
||||
请确认你已经理解了这些文档,然后我们再开始实现。
|
||||
```
|
||||
|
||||
### 问题 3: 文档质量差
|
||||
|
||||
**症状**: AI 生成的 proposal/specs/design 很敷衍
|
||||
|
||||
**解决方案**:
|
||||
|
||||
使用 `openspec instructions` 获取更好的模板:
|
||||
|
||||
```bash
|
||||
# 获取特定 artifact 的指令
|
||||
openspec instructions --change context-dynamic-selection-enhancement proposal
|
||||
openspec instructions --change context-dynamic-selection-enhancement specs
|
||||
openspec instructions --change context-dynamic-selection-enhancement design
|
||||
```
|
||||
|
||||
或者在项目中添加更详细的 `config.yaml`。
|
||||
|
||||
---
|
||||
|
||||
## 📊 最佳实践
|
||||
|
||||
### ✅ 应该做的
|
||||
|
||||
1. **总是从 `/opsx:new` 开始重要功能**
|
||||
- 确保有完整的规划文档
|
||||
- 便于后续维护和回顾
|
||||
|
||||
2. **使用 `/opsx:ff` 生成全面的规划文档**
|
||||
- 不要跳过规划阶段
|
||||
- 花 10 分钟规划可以节省 1 小时编码时间
|
||||
|
||||
3. **实现时参考 specs**
|
||||
- 确保符合需求规格
|
||||
- 每个 scenario 都是一个测试用例
|
||||
|
||||
4. **完成任务后立即更新 tasks.md**
|
||||
- 保持进度准确
|
||||
- 便于追踪和统计
|
||||
|
||||
5. **归档前运行 `/opsx:verify`**
|
||||
- 确保质量达标
|
||||
- 避免遗漏重要文档
|
||||
|
||||
### ❌ 不应该做的
|
||||
|
||||
1. **不要跳过规划阶段**
|
||||
- 这违背了 Spec 驱动的初衷
|
||||
|
||||
2. **不要直接开始编码**
|
||||
- 即使需求看起来很清晰
|
||||
- 先写文档再编码
|
||||
|
||||
3. **不要忽略 specs 中的场景**
|
||||
- 每个场景都必须实现
|
||||
- 这是验收标准
|
||||
|
||||
4. **不要修改 tasks.md 的结构**
|
||||
- 解析依赖于固定格式
|
||||
- 使用 `- [ ]` 复选框格式
|
||||
|
||||
5. **不要归档不完整的 changes**
|
||||
- 确保所有任务完成
|
||||
- 确保测试通过
|
||||
|
||||
---
|
||||
|
||||
## 🎓 学习资源
|
||||
|
||||
### 官方文档优先级
|
||||
|
||||
1. **[getting-started.md](file:///Users/pengweiye/Documents/codes/OpenSpec/docs/getting-started.md)** ⭐⭐⭐⭐⭐
|
||||
- 必读!完整的工作流程示例
|
||||
- 包含 dark mode 的完整案例
|
||||
|
||||
2. **[commands.md](file:///Users/pengweiye/Documents/codes/OpenSpec/docs/commands.md)** ⭐⭐⭐⭐⭐
|
||||
- 所有 slash commands 的详细说明
|
||||
- 包含使用示例和技巧
|
||||
|
||||
3. **[opsx.md](file:///Users/pengweiye/Documents/codes/OpenSpec/docs/opsx.md)** ⭐⭐⭐⭐
|
||||
- OPSX 工作流的哲学和设计理念
|
||||
- 如何自定义工作流
|
||||
|
||||
4. **[workflows.md](file:///Users/pengweiye/Documents/codes/OpenSpec/docs/workflows.md)** ⭐⭐⭐⭐
|
||||
- 常见工作流模式
|
||||
- 何时使用哪个命令
|
||||
|
||||
5. **[concepts.md](file:///Users/pengweiye/Documents/codes/OpenSpec/docs/concepts.md)** ⭐⭐⭐
|
||||
- 深入理解概念
|
||||
- Schema、Artifact、Dependency 等
|
||||
|
||||
### 快速上手路径
|
||||
|
||||
```
|
||||
Day 1:
|
||||
- 阅读 getting-started.md (15 分钟)
|
||||
- 运行 openspec init (5 分钟)
|
||||
- 尝试 /opsx:new test-change (10 分钟)
|
||||
|
||||
Day 2:
|
||||
- 阅读 commands.md (20 分钟)
|
||||
- 实践 /opsx:ff 和 /opsx:apply (30 分钟)
|
||||
- 完成第一个完整的 change
|
||||
|
||||
Day 3:
|
||||
- 阅读 workflows.md (15 分钟)
|
||||
- 尝试不同的工作流模式
|
||||
- 创建 openspec/config.yaml (10 分钟)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
- **Discord**: https://discord.gg/YctCnvvshC
|
||||
- **GitHub Issues**: https://github.com/Fission-AI/OpenSpec/issues
|
||||
- **npm**: https://www.npmjs.com/package/@fission-ai/openspec
|
||||
|
||||
---
|
||||
|
||||
**祝你 Spec 驱动开发愉快!** 🚀
|
||||
353
.qoder/commands/README-OPSX.md
Normal file
353
.qoder/commands/README-OPSX.md
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
---
|
||||
description:
|
||||
---
|
||||
# OpenSpec Slash Commands 配置指南
|
||||
|
||||
## 📁 已创建的文件
|
||||
|
||||
所有 OpenSpec slash 命令配置文件已创建在 `~/.qoder/commands/` 目录下:
|
||||
|
||||
```
|
||||
~/.qoder/commands/
|
||||
├── opsx.md # 主入口 - OpenSpec 总览和快速开始
|
||||
├── opsx-new.md # 创建新的 change proposal
|
||||
├── opsx-ff.md # Fast-forward 生成所有规划文档
|
||||
├── opsx-apply.md # 应用 tasks.md 开始实现
|
||||
├── opsx-list.md # 列出所有 active changes
|
||||
├── opsx-validate.md # 验证 change 完整性
|
||||
├── opsx-archive.md # 归档完成的 change
|
||||
└── opsx-show.md # 显示 change 详细信息
|
||||
```
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 1. 在 Qoder 中使用
|
||||
|
||||
在你的 Qoder 对话中,直接使用 slash commands:
|
||||
|
||||
```
|
||||
/opsx:new context-dynamic-selection-enhancement
|
||||
/opsx:ff
|
||||
/opsx:show context-dynamic-selection-enhancement
|
||||
/opsx:validate
|
||||
/opsx:apply
|
||||
```
|
||||
|
||||
### 2. 命令说明
|
||||
|
||||
#### **核心工作流命令**
|
||||
|
||||
| 命令 | 功能 | 示例 |
|
||||
|------|------|------|
|
||||
| `/opsx:new <name>` | 创建新的 change | `/opsx:new feature-auth` |
|
||||
| `/opsx:ff` | 生成所有规划文档 | `/opsx:ff` |
|
||||
| `/opsx:apply [name]` | 开始实现任务 | `/opsx:apply feature-auth` |
|
||||
| `/opsx:archive <name>` | 归档完成的 change | `/opsx:archive feature-auth` |
|
||||
|
||||
#### **管理命令**
|
||||
|
||||
| 命令 | 功能 | 示例 |
|
||||
|------|------|------|
|
||||
| `/opsx:list` | 列出所有 changes | `/opsx:list` |
|
||||
| `/opsx:show <name>` | 显示详情 | `/opsx:show feature-auth` |
|
||||
| `/opsx:validate [name]` | 验证完整性 | `/opsx:validate feature-auth` |
|
||||
|
||||
## 📋 完整工作流程
|
||||
|
||||
```
|
||||
1. /opsx:new <feature-name>
|
||||
↓ 创建 openspec/changes/<feature-name>/ 目录
|
||||
|
||||
2. /opsx:ff
|
||||
↓ 自动生成 proposal.md, specs/, design.md, tasks.md
|
||||
|
||||
3. /opsx:show <feature-name>
|
||||
↓ 审查生成的文档
|
||||
|
||||
4. /opsx:validate
|
||||
↓ 验证文档质量和完整性
|
||||
|
||||
5. /opsx:apply
|
||||
↓ 按照 tasks.md 逐项实现功能
|
||||
|
||||
6. /opsx:archive
|
||||
↓ 完成后归档,合并 specs 到主分支
|
||||
```
|
||||
|
||||
## 🎯 每个命令的详细说明
|
||||
|
||||
### `/opsx:new` - 创建 Change
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-new.md`
|
||||
|
||||
**功能**:
|
||||
- 创建 `openspec/changes/<change-name>/` 目录
|
||||
- 初始化 `.openspec.yaml` 元数据文件
|
||||
- 设置 spec-driven 工作流 schema
|
||||
|
||||
**示例**:
|
||||
```
|
||||
/opsx:new context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
**输出**:
|
||||
```
|
||||
✔ Created change 'context-dynamic-selection-enhancement' at openspec/changes/context-dynamic-selection-enhancement/ (schema: spec-driven)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:ff` - Fast-Forward
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-ff.md`
|
||||
|
||||
**功能**:
|
||||
- 自动生成 proposal.md(Why, What, Capabilities)
|
||||
- 自动生成 specs/*.md(详细规格说明)
|
||||
- 自动生成 design.md(技术设计决策)
|
||||
- 自动生成 tasks.md(实现任务清单)
|
||||
|
||||
**示例**:
|
||||
```
|
||||
/opsx:ff
|
||||
```
|
||||
|
||||
**生成的结构**:
|
||||
```
|
||||
openspec/changes/<name>/
|
||||
├── proposal.md ← 自动生成
|
||||
├── specs/
|
||||
│ ├── capability-1/spec.md ← 自动生成
|
||||
│ └── capability-2/spec.md ← 自动生成
|
||||
├── design.md ← 自动生成
|
||||
└── tasks.md ← 自动生成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:apply` - 应用 Tasks
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-apply.md`
|
||||
|
||||
**功能**:
|
||||
- 读取 tasks.md 文件
|
||||
- 逐项指导实现
|
||||
- 更新复选框进度
|
||||
- 引用 specs 和 design 作为上下文
|
||||
|
||||
**示例**:
|
||||
```
|
||||
/opsx:apply context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
**实现流程**:
|
||||
1. 读取上下文(proposal, design, specs)
|
||||
2. 解析未完成的 tasks
|
||||
3. 从 Task 1.1 开始实现
|
||||
4. 每完成一项标记为 `- [x]`
|
||||
5. 继续下一项
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:list` - 列出 Changes
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-list.md`
|
||||
|
||||
**功能**:
|
||||
- 列出 `openspec/changes/` 下所有目录
|
||||
- 显示任务完成状态
|
||||
- 按最后修改时间排序
|
||||
|
||||
**示例**:
|
||||
```
|
||||
/opsx:list
|
||||
```
|
||||
|
||||
**输出**:
|
||||
```
|
||||
Changes:
|
||||
context-dynamic-selection-enhancement 23/47 tasks 2 hours ago
|
||||
api-rate-limiting 0/32 tasks 1 day ago
|
||||
user-auth-v2 Complete 1 week ago
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:validate` - 验证
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-validate.md`
|
||||
|
||||
**功能**:
|
||||
- 检查必需 artifacts(proposal, specs, design, tasks)
|
||||
- 验证 artifact 结构和内容
|
||||
- 验证任务完成状态
|
||||
- 报告缺失或不完整的项目
|
||||
|
||||
**示例**:
|
||||
```
|
||||
/opsx:validate context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
**成功输出**:
|
||||
```
|
||||
✓ change/context-dynamic-selection-enhancement
|
||||
✓ proposal.md (complete)
|
||||
✓ specs/ (4 capabilities)
|
||||
✓ design.md (complete)
|
||||
✓ tasks.md (23/47 tasks complete)
|
||||
Totals: 1 passed (1 items)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:archive` - 归档
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-archive.md`
|
||||
|
||||
**功能**:
|
||||
- 验证所有任务完成
|
||||
- 移动到 `openspec/changes/archive/`
|
||||
- 合并 specs 到 `openspec/specs/`
|
||||
- 保留历史记录
|
||||
|
||||
**示例**:
|
||||
```
|
||||
/opsx:archive context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
**归档后结构**:
|
||||
```
|
||||
openspec/changes/
|
||||
├── active-change-1/ # 仍在进行
|
||||
└── archive/ # 已完成的
|
||||
└── 2026-02-26-context-dynamic-selection-enhancement/
|
||||
├── proposal.md
|
||||
├── design.md
|
||||
├── specs/
|
||||
└── tasks.md (全部勾选)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `/opsx:show` - 显示详情
|
||||
|
||||
**位置**: `~/.qoder/commands/opsx-show.md`
|
||||
|
||||
**功能**:
|
||||
- 显示完整的 change artifacts
|
||||
- 可以显示单个 artifact 或整个 change
|
||||
- 格式化 markdown 输出
|
||||
|
||||
**示例**:
|
||||
```
|
||||
# 显示整个 change
|
||||
/opsx:show context-dynamic-selection-enhancement
|
||||
|
||||
# 显示特定 artifact
|
||||
/opsx:show context-dynamic-selection-enhancement/proposal
|
||||
/opsx:show context-dynamic-selection-enhancement/design
|
||||
/opsx:show context-dynamic-selection-enhancement/tasks
|
||||
|
||||
# 显示 spec
|
||||
/opsx:show context-dynamic-selection-enhancement/specs/skills-filter-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 最佳实践
|
||||
|
||||
### ✅ 应该做的
|
||||
|
||||
1. **总是从 `/opsx:new` 开始重要功能**
|
||||
- 确保有完整的规划文档
|
||||
- 便于后续维护和回顾
|
||||
|
||||
2. **使用 `/opsx:ff` 生成全面的规划文档**
|
||||
- 不要跳过规划阶段
|
||||
- 花 10 分钟规划可以节省 1 小时编码时间
|
||||
|
||||
3. **实现时参考 specs**
|
||||
- 确保符合需求规格
|
||||
- 每个 scenario 都是一个测试用例
|
||||
|
||||
4. **完成任务后立即更新 tasks.md**
|
||||
- 保持进度准确
|
||||
- 便于追踪和统计
|
||||
|
||||
5. **归档前运行 `/opsx:validate`**
|
||||
- 确保质量达标
|
||||
- 避免遗漏重要文档
|
||||
|
||||
### ❌ 不应该做的
|
||||
|
||||
1. **不要跳过规划阶段**
|
||||
- 这违背了 Spec 驱动的初衷
|
||||
|
||||
2. **不要修改 tasks.md 的结构**
|
||||
- 解析依赖于固定格式
|
||||
- 使用 `- [ ]` 复选框格式
|
||||
|
||||
3. **不要归档不完整的 changes**
|
||||
- 确保所有任务完成
|
||||
- 确保测试通过
|
||||
|
||||
4. **不要忽略验证错误**
|
||||
- 及时修复结构问题
|
||||
- 保证文档质量
|
||||
|
||||
## 📊 当前项目状态
|
||||
|
||||
你的 PicoClaw 项目已经有:
|
||||
|
||||
✅ **OpenSpec CLI 已安装**: v1.2.0
|
||||
✅ **Slash Commands 已配置**: 8 个命令文件
|
||||
✅ **第一个 Change 已创建**: `context-dynamic-selection-enhancement`
|
||||
✅ **完整文档已生成**: proposal, 4 specs, design, tasks
|
||||
|
||||
**下一步**:
|
||||
```bash
|
||||
/opsx:apply context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
开始实现第一个任务:**Task 1.1 - Modify AgentInstance to add skillsFilterMutex**
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 命令不工作?
|
||||
|
||||
1. **检查文件权限**:
|
||||
```bash
|
||||
ls -lh ~/.qoder/commands/opsx*.md
|
||||
```
|
||||
|
||||
2. **重启 Qoder**:
|
||||
- 关闭并重新打开 Qoder
|
||||
- 确保加载了新的 commands
|
||||
|
||||
3. **验证语法**:
|
||||
- 确保 frontmatter 正确(`---` 包裹)
|
||||
- 使用正确的 markdown 格式
|
||||
|
||||
### 找不到 Change?
|
||||
|
||||
```bash
|
||||
# 列出所有 changes
|
||||
/opsx:list
|
||||
|
||||
# 查看具体 change
|
||||
/opsx:show <change-name>
|
||||
|
||||
# 验证 change
|
||||
/opsx:validate --changes <name>
|
||||
```
|
||||
|
||||
## 📚 更多资源
|
||||
|
||||
- **OpenSpec 官方文档**: `openspec --help`
|
||||
- **GitHub**: https://github.com/Fission-AI/OpenSpec
|
||||
- **npm**: https://www.npmjs.com/package/@fission-ai/openspec
|
||||
- **Discord**: https://discord.gg/YctCnvvshC
|
||||
|
||||
---
|
||||
|
||||
**祝你 Spec 驱动开发愉快!** 🚀
|
||||
78
.qoder/commands/opsx-apply.md
Normal file
78
.qoder/commands/opsx-apply.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
description: Apply OpenSpec tasks and start implementation
|
||||
usage: /opsx:apply [change-name]
|
||||
---
|
||||
|
||||
# /opsx:apply - Implement Tasks
|
||||
|
||||
Starts implementation based on the tasks.md checklist.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Reads `openspec/changes/<change-name>/tasks.md`
|
||||
- Guides implementation task by task
|
||||
- Tracks progress by updating checkboxes
|
||||
- References specs and design for context
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/opsx:apply [change-name]
|
||||
```
|
||||
|
||||
If change-name is omitted, uses the most recent active change.
|
||||
|
||||
Example:
|
||||
```
|
||||
/opsx:apply context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
## Implementation Flow
|
||||
|
||||
1. **Read Context**: Load proposal.md, design.md, and specs/*.md
|
||||
2. **Parse Tasks**: Extract unchecked items from tasks.md
|
||||
3. **Prioritize**: Start with Task 1.1 (first uncompleted)
|
||||
4. **Implement**: Complete one task at a time
|
||||
5. **Update**: Mark as `- [x]` when done
|
||||
6. **Repeat**: Continue to next task
|
||||
|
||||
## Task Structure
|
||||
|
||||
Tasks are organized in phases:
|
||||
```markdown
|
||||
## 1. Infrastructure Setup
|
||||
- [ ] 1.1 Modify AgentInstance to add mutex
|
||||
- [ ] 1.2 Implement SetSkillsFilter method
|
||||
|
||||
## 2. Tool Visibility Filters
|
||||
- [ ] 2.1 Define ToolVisibilityContext struct
|
||||
- [ ] 2.2 Define ToolVisibilityFilter type
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
✅ **Do**:
|
||||
- Complete tasks in order (they're dependency-sorted)
|
||||
- Update tasks.md immediately after completing each task
|
||||
- Run tests after each phase
|
||||
- Reference specs for requirements
|
||||
|
||||
❌ **Don't**:
|
||||
- Skip tasks (breaks dependency chain)
|
||||
- Batch update multiple tasks (lose granularity)
|
||||
- Modify tasks.md structure (parsing depends on format)
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
Check progress anytime:
|
||||
```
|
||||
/opsx:list
|
||||
```
|
||||
|
||||
Shows completion percentage: `23/47 tasks`
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:ff` - Generate planning docs before applying
|
||||
- `/opsx:validate` - Verify implementation completeness
|
||||
- `/opsx:archive` - Archive after all tasks complete
|
||||
88
.qoder/commands/opsx-archive.md
Normal file
88
.qoder/commands/opsx-archive.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
description: Archive completed OpenSpec change and update main specs
|
||||
usage: /opsx:archive <change-name>
|
||||
---
|
||||
|
||||
# /opsx:archive - Archive Completed Change
|
||||
|
||||
Archives a completed change and merges its specifications into the main codebase.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Validates all tasks are complete
|
||||
- Moves change to `openspec/changes/archive/`
|
||||
- Merges approved specs into `openspec/specs/`
|
||||
- Updates main specification documents
|
||||
- Preserves historical record
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/opsx:archive <change-name>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
/opsx:archive context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
## Pre-Archive Checklist
|
||||
|
||||
Before archiving, ensure:
|
||||
- ✅ All tasks in tasks.md are marked complete (`- [x]`)
|
||||
- ✅ All tests pass (`go test ./...`)
|
||||
- ✅ Code is committed to version control
|
||||
- ✅ Documentation is updated
|
||||
- ✅ `/opsx:validate --changes <name>` passes
|
||||
|
||||
## Archive Process
|
||||
|
||||
1. **Validation**: Verify all tasks complete
|
||||
2. **Review**: Final check of implementation
|
||||
3. **Move**: Transfer to archive directory
|
||||
4. **Merge**: Integrate specs into main specs
|
||||
5. **Update**: Modify openspec/specs/index.md
|
||||
6. **Timestamp**: Add completion date
|
||||
|
||||
## Directory Structure After Archive
|
||||
|
||||
```
|
||||
openspec/changes/
|
||||
├── active-change-1/ # Still active
|
||||
├── active-change-2/ # Still active
|
||||
└── archive/ # ← Completed changes moved here
|
||||
├── 2026-02-26-context-dynamic-selection-enhancement/
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ ├── specs/
|
||||
│ └── tasks.md (all checked)
|
||||
└── 2026-02-20-api-rate-limiting/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Spec Migration
|
||||
|
||||
New capabilities from the change are merged into main specs:
|
||||
```
|
||||
openspec/specs/
|
||||
├── agent-context-builder/ # From archived change
|
||||
│ └── spec.md
|
||||
├── tool-registry/ # From archived change
|
||||
│ └── spec.md
|
||||
└── index.md # Updated with new specs
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If you need to restore an archived change:
|
||||
```bash
|
||||
mv openspec/changes/archive/<date>-<name> openspec/changes/<name>
|
||||
```
|
||||
|
||||
Note: You may need to re-validate after rollback.
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:validate` - Verify completeness before archiving
|
||||
- `/opsx:list` - See all changes including archived
|
||||
- `/opsx:show` - View archived change details
|
||||
62
.qoder/commands/opsx-ff.md
Normal file
62
.qoder/commands/opsx-ff.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
description: Fast-forward generate all OpenSpec planning documents
|
||||
usage: /opsx:ff
|
||||
aliases: [/opsx:fast-forward]
|
||||
---
|
||||
|
||||
# /opsx:ff - Fast-Forward Planning Docs
|
||||
|
||||
Automatically generates all planning artifacts for the current change.
|
||||
|
||||
## What This Does
|
||||
|
||||
Generates the complete spec-driven workflow documentation:
|
||||
1. **proposal.md** - Why, What, Capabilities, Impact
|
||||
2. **specs/*.md** - Detailed specifications (one per capability)
|
||||
3. **design.md** - Technical design decisions and rationale
|
||||
4. **tasks.md** - Implementation task checklist
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/opsx:ff
|
||||
```
|
||||
|
||||
This is shorthand for manually creating each artifact in sequence.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
/opsx:new <change-name> # Create change directory
|
||||
/opsx:ff # Generate all planning docs ← You are here
|
||||
/opsx:apply # Implement tasks
|
||||
/opsx:archive # Archive completed change
|
||||
```
|
||||
|
||||
## Manual Alternative
|
||||
|
||||
If you prefer to create artifacts individually:
|
||||
```
|
||||
/openspec instructions --change <name> proposal
|
||||
/openspec instructions --change <name> specs
|
||||
/openspec instructions --change <name> design
|
||||
/openspec instructions --change <name> tasks
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
openspec/changes/<change-name>/
|
||||
├── proposal.md ← Generated
|
||||
├── specs/
|
||||
│ ├── capability-1/spec.md ← Generated
|
||||
│ └── capability-2/spec.md ← Generated
|
||||
├── design.md ← Generated
|
||||
└── tasks.md ← Generated
|
||||
```
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:new` - Create new change
|
||||
- `/opsx:apply` - Start implementation
|
||||
- `/opsx:validate` - Check completeness
|
||||
75
.qoder/commands/opsx-list.md
Normal file
75
.qoder/commands/opsx-list.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
description: List all active OpenSpec changes
|
||||
usage: /opsx:list
|
||||
aliases: [/opsx:ls]
|
||||
---
|
||||
|
||||
# /opsx:list - List Changes
|
||||
|
||||
Displays all active OpenSpec changes with their completion status.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Lists all directories in `openspec/changes/`
|
||||
- Shows task completion status for each change
|
||||
- Indicates which changes are active vs completed
|
||||
- Sorts by most recently modified
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/opsx:list
|
||||
```
|
||||
|
||||
Example output:
|
||||
```
|
||||
Changes:
|
||||
context-dynamic-selection-enhancement 23/47 tasks 2 hours ago
|
||||
api-rate-limiting 0/32 tasks 1 day ago
|
||||
user-auth-v2 Complete 1 week ago
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Each change shows:
|
||||
- **Name**: Directory name (kebab-case)
|
||||
- **Progress**: Completed/Total tasks or "Complete"
|
||||
- **Age**: Time since last modification
|
||||
|
||||
## Filtering
|
||||
|
||||
Show only specific states:
|
||||
```
|
||||
/opsx:list --active # Only changes with pending tasks
|
||||
/opsx:list --complete # Only completed changes
|
||||
```
|
||||
|
||||
## Integration with Other Commands
|
||||
|
||||
```bash
|
||||
/opsx:list # See all changes
|
||||
/opsx:show <change-name> # View details of one change
|
||||
/opsx:apply <change-name> # Start implementing
|
||||
/opsx:validate --changes <name> # Check completeness
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
All changes stored in:
|
||||
```
|
||||
openspec/changes/
|
||||
├── change-1/
|
||||
│ ├── .openspec.yaml
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ ├── specs/
|
||||
│ └── tasks.md
|
||||
└── change-2/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:new` - Create new change
|
||||
- `/opsx:show` - Display change details
|
||||
- `/opsx:archive` - Archive completed change
|
||||
38
.qoder/commands/opsx-new.md
Normal file
38
.qoder/commands/opsx-new.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
description: Create a new OpenSpec change proposal
|
||||
usage: /opsx:new <change-name>
|
||||
---
|
||||
|
||||
# /opsx:new - Create New Change
|
||||
|
||||
Creates a new OpenSpec change directory with the spec-driven schema.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Creates `openspec/changes/<change-name>/` directory
|
||||
- Initializes `.openspec.yaml` metadata file
|
||||
- Sets up the spec-driven workflow schema
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/opsx:new <change-name>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
/opsx:new context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After creating the change, use:
|
||||
- `/opsx:ff` - Fast-forward to generate all planning docs (proposal, specs, design, tasks)
|
||||
- `/opsx:apply` - Start implementation based on tasks.md
|
||||
- `/opsx:archive` - Archive completed change
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:list` - List all active changes
|
||||
- `/opsx:validate` - Validate change completeness
|
||||
- `/opsx:show` - Display change details
|
||||
122
.qoder/commands/opsx-show.md
Normal file
122
.qoder/commands/opsx-show.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
description: Show details of an OpenSpec change or spec
|
||||
usage: /opsx:show <name>
|
||||
aliases: [/opsx:view]
|
||||
---
|
||||
|
||||
# /opsx:show - Display Change Details
|
||||
|
||||
Displays detailed information about a specific change or specification.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Shows complete content of change artifacts
|
||||
- Displays proposal, design, specs, and tasks
|
||||
- Provides formatted markdown output
|
||||
- Can show individual artifacts or entire change
|
||||
|
||||
## Usage
|
||||
|
||||
### Show Entire Change
|
||||
|
||||
```
|
||||
/opsx:show <change-name>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
/opsx:show context-dynamic-selection-enhancement
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
## Change: context-dynamic-selection-enhancement
|
||||
Status: In Progress (23/47 tasks)
|
||||
Created: 2026-02-26
|
||||
|
||||
=== proposal.md ===
|
||||
[Full content of proposal.md]
|
||||
|
||||
=== design.md ===
|
||||
[Full content of design.md]
|
||||
|
||||
=== specs/ ===
|
||||
- context-strategies/spec.md
|
||||
- tool-visibility-filters/spec.md
|
||||
- skills-filter-api/spec.md
|
||||
- skill-recommender/spec.md
|
||||
|
||||
=== tasks.md ===
|
||||
[Task list with completion status]
|
||||
```
|
||||
|
||||
### Show Specific Artifact
|
||||
|
||||
```
|
||||
/opsx:show <change-name>/<artifact>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
/opsx:show context-dynamic-selection-enhancement/proposal
|
||||
/opsx:show context-dynamic-selection-enhancement/design
|
||||
/opsx:show context-dynamic-selection-enhancement/tasks
|
||||
/opsx:show context-dynamic-selection-enhancement/specs/context-strategies
|
||||
```
|
||||
|
||||
### Show Spec
|
||||
|
||||
```
|
||||
/opsx:show spec/<spec-name>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
/opsx:show spec/agent-context-builder
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Review Before Implementation
|
||||
|
||||
```bash
|
||||
/opsx:list # See all changes
|
||||
/opsx:show <change-name> # Read full context
|
||||
/opsx:apply <change-name> # Start implementation
|
||||
```
|
||||
|
||||
### Check Task Progress
|
||||
|
||||
```bash
|
||||
/opsx:show <change-name>/tasks # View task checklist
|
||||
```
|
||||
|
||||
### Reference Specific Spec
|
||||
|
||||
```bash
|
||||
/opsx:show <change-name>/specs/skills-filter-api # Read API spec
|
||||
```
|
||||
|
||||
## Output Formatting
|
||||
|
||||
The command displays:
|
||||
- **Metadata**: Status, creation date, last modified
|
||||
- **Artifacts**: Full markdown content with proper formatting
|
||||
- **Progress**: Task completion percentage
|
||||
- **Dependencies**: Links between artifacts
|
||||
|
||||
## Integration with Other Commands
|
||||
|
||||
```bash
|
||||
/opsx:new <name> # Create change
|
||||
/opsx:ff # Generate docs
|
||||
/opsx:show <name> # ← Review generated docs
|
||||
/opsx:validate --changes <name> # Verify quality
|
||||
/opsx:apply <name> # Implement
|
||||
```
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:list` - List all changes
|
||||
- `/opsx:validate` - Validate completeness
|
||||
- `/opsx:apply` - Start implementation
|
||||
106
.qoder/commands/opsx-validate.md
Normal file
106
.qoder/commands/opsx-validate.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
description: Validate OpenSpec change completeness
|
||||
usage: /opsx:validate [change-name]
|
||||
---
|
||||
|
||||
# /opsx:validate - Validate Change
|
||||
|
||||
Validates that a change has all required artifacts and they meet quality standards.
|
||||
|
||||
## What This Does
|
||||
|
||||
- Checks for required artifacts (proposal, specs, design, tasks)
|
||||
- Validates artifact structure and content
|
||||
- Verifies task completion status
|
||||
- Reports missing or incomplete items
|
||||
- Ensures spec-driven schema compliance
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/opsx:validate [change-name]
|
||||
```
|
||||
|
||||
If change-name is omitted, validates the most recently modified change.
|
||||
|
||||
Example:
|
||||
```
|
||||
/opsx:validate context-dynamic-selection-enhancement
|
||||
/opsx:validate --changes api-rate-limiting
|
||||
```
|
||||
|
||||
## Validation Checks
|
||||
|
||||
### Required Artifacts
|
||||
- ✅ proposal.md exists and contains: Why, What Changes, Capabilities, Impact
|
||||
- ✅ specs/*.md exists with proper requirement/scenario structure
|
||||
- ✅ design.md exists with Context, Decisions, Risks sections
|
||||
- ✅ tasks.md exists with properly formatted checkboxes
|
||||
|
||||
### Quality Checks
|
||||
- ✅ All requirements have at least one scenario
|
||||
- ✅ Scenarios use WHEN/THEN format
|
||||
- ✅ Tasks are dependency-sorted
|
||||
- ✅ No breaking changes without migration plan
|
||||
|
||||
### Completeness (during implementation)
|
||||
- ✅ Task completion percentage
|
||||
- ✅ Unchecked tasks remaining
|
||||
- ✅ Test coverage for completed tasks
|
||||
|
||||
## Example Output
|
||||
|
||||
Success:
|
||||
```
|
||||
✓ change/context-dynamic-selection-enhancement
|
||||
✓ proposal.md (complete)
|
||||
✓ specs/ (4 capabilities)
|
||||
✓ design.md (complete)
|
||||
✓ tasks.md (23/47 tasks complete)
|
||||
Totals: 1 passed (1 items)
|
||||
```
|
||||
|
||||
Failure:
|
||||
```
|
||||
✗ change/api-rate-limiting
|
||||
✗ proposal.md missing Capabilities section
|
||||
✗ specs/ empty
|
||||
Totals: 0 passed, 1 failed (1 items)
|
||||
```
|
||||
|
||||
## When to Validate
|
||||
|
||||
**Before starting implementation:**
|
||||
```bash
|
||||
/opsx:new <name>
|
||||
/opsx:ff
|
||||
/opsx:validate # ← Ensure planning docs are complete
|
||||
```
|
||||
|
||||
**During implementation:**
|
||||
```bash
|
||||
/opsx:apply
|
||||
# ... complete some tasks ...
|
||||
/opsx:validate # ← Check progress and quality
|
||||
```
|
||||
|
||||
**Before archiving:**
|
||||
```bash
|
||||
# ... all tasks complete ...
|
||||
/opsx:validate # ← Final validation required
|
||||
/opsx:archive
|
||||
```
|
||||
|
||||
## Fixing Validation Errors
|
||||
|
||||
If validation fails:
|
||||
1. Read the error message carefully
|
||||
2. Use `/openspec instructions --change <name> <artifact>` to regenerate
|
||||
3. Manually edit to fix structural issues
|
||||
4. Re-run validation
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/opsx:ff` - Generate planning docs
|
||||
- `/opsx:list` - See all changes
|
||||
- `/opsx:archive` - Archive after validation passes
|
||||
110
.qoder/commands/opsx.md
Normal file
110
.qoder/commands/opsx.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
description: OpenSpec spec-driven development workflow
|
||||
usage: /opsx:<command> [args]
|
||||
aliases: [/openspec]
|
||||
---
|
||||
|
||||
# OpenSpec Slash Commands
|
||||
|
||||
OpenSpec provides a spec-driven development workflow for AI-assisted coding.
|
||||
|
||||
## Available Commands
|
||||
|
||||
### Core Workflow
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `/opsx:new` | Create new change | `/opsx:new feature-name` |
|
||||
| `/opsx:ff` | Fast-forward generate all docs | `/opsx:ff` |
|
||||
| `/opsx:apply` | Implement tasks from tasks.md | `/opsx:apply change-name` |
|
||||
| `/opsx:archive` | Archive completed change | `/opsx:archive change-name` |
|
||||
|
||||
### Management
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `/opsx:list` | List all changes | `/opsx:list` |
|
||||
| `/opsx:show` | Display change details | `/opsx:show change-name` |
|
||||
| `/opsx:validate` | Validate completeness | `/opsx:validate change-name` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Create a new change
|
||||
/opsx:new my-feature
|
||||
|
||||
# 2. Generate all planning documents
|
||||
/opsx:ff
|
||||
|
||||
# 3. Review generated docs
|
||||
/opsx:show my-feature
|
||||
|
||||
# 4. Validate quality
|
||||
/opsx:validate my-feature
|
||||
|
||||
# 5. Start implementation
|
||||
/opsx:apply my-feature
|
||||
|
||||
# 6. After completion, archive
|
||||
/opsx:archive my-feature
|
||||
```
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ /opsx:new │ ──→ │ /opsx:ff │ ──→ │ /opsx:apply │ ──→ │ /opsx:archive│
|
||||
│ Create │ │ Generate │ │ Implement │ │ Complete │
|
||||
│ Change │ │ Docs │ │ Tasks │ │ & Merge │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
Changes are stored in:
|
||||
```
|
||||
openspec/changes/
|
||||
├── my-feature/
|
||||
│ ├── .openspec.yaml # Metadata
|
||||
│ ├── proposal.md # Why, What, Capabilities
|
||||
│ ├── design.md # Technical decisions
|
||||
│ ├── specs/ # Detailed specifications
|
||||
│ │ ├── capability-1/
|
||||
│ │ └── capability-2/
|
||||
│ └── tasks.md # Implementation checklist
|
||||
└── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
✅ **Do**:
|
||||
- Always start with `/opsx:new` for significant features
|
||||
- Use `/opsx:ff` to generate comprehensive planning docs
|
||||
- Reference specs during implementation
|
||||
- Update tasks.md as you complete each task
|
||||
- Run `/opsx:validate` before archiving
|
||||
|
||||
❌ **Don't**:
|
||||
- Skip the planning phase (defeats the purpose)
|
||||
- Modify tasks.md structure (breaks parsing)
|
||||
- Archive incomplete changes
|
||||
- Ignore validation errors
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenSpec is configured via:
|
||||
- Global commands: `~/.qoder/commands/opsx*.md`
|
||||
- Project config: `openspec/` directory
|
||||
- CLI tool: `openspec` (installed via npm)
|
||||
|
||||
## Learn More
|
||||
|
||||
- Full documentation: `openspec --help`
|
||||
- Supported tools: See OpenSpec README.md
|
||||
- Schema reference: See openspec/schemas/
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [OpenSpec GitHub](https://github.com/Fission-AI/OpenSpec)
|
||||
- [Spec Kit Comparison](https://github.com/Fission-AI/OpenSpec#why-openspec)
|
||||
- [Workflow Schemas](docs/workflows.md)
|
||||
152
.qoder/commands/opsx/apply.md
Normal file
152
.qoder/commands/opsx/apply.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
name: OPSX: Apply
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
157
.qoder/commands/opsx/archive.md
Normal file
157
.qoder/commands/opsx/archive.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
name: OPSX: Archive
|
||||
description: Archive a completed change in the experimental workflow
|
||||
category: Workflow
|
||||
tags: [workflow, archive, experimental]
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
173
.qoder/commands/opsx/explore.md
Normal file
173
.qoder/commands/opsx/explore.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
---
|
||||
name: OPSX: Explore
|
||||
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
|
||||
category: Workflow
|
||||
tags: [workflow, explore, experimental, thinking]
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
106
.qoder/commands/opsx/propose.md
Normal file
106
.qoder/commands/opsx/propose.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
name: OPSX: Propose
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
52
.qoder/rules/testing-mandatory.md
Normal file
52
.qoder/rules/testing-mandatory.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# PicoClaw Development Rules
|
||||
|
||||
## Testing Requirements (MANDATORY)
|
||||
|
||||
1. **No feature is complete without tests**
|
||||
- Unit tests for all public APIs
|
||||
- Integration tests for cross-module interactions
|
||||
- E2E tests for user-facing features
|
||||
|
||||
2. **Test-Driven Development Preferred**
|
||||
- Write test first when possible
|
||||
- Red → Green → Refactor cycle
|
||||
|
||||
3. **Coverage Requirements**
|
||||
- New code MUST have >80% line coverage
|
||||
- Critical paths MUST have 100% coverage
|
||||
- All error paths MUST be tested
|
||||
|
||||
4. **Test Quality Standards**
|
||||
- Tests MUST be deterministic (no flaky tests)
|
||||
- Tests MUST be independent (no ordering dependencies)
|
||||
- Tests MUST be fast (<100ms per unit test)
|
||||
- Use table-driven tests for multiple scenarios
|
||||
|
||||
5. **Before Committing**
|
||||
- Run `go test ./...` - all must pass
|
||||
- Run `go test -race ./...` - no race conditions
|
||||
- Run `golangci-lint run` - no issues
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
```
|
||||
1. Read task from tasks.md
|
||||
2. If it's a feature task:
|
||||
a. Implement the feature
|
||||
b. IMMEDIATELY write corresponding test
|
||||
c. Run test - MUST pass
|
||||
d. Mark task complete ONLY after test passes
|
||||
3. If it's a test task:
|
||||
a. Write comprehensive tests
|
||||
b. Run and verify they pass
|
||||
c. Mark complete
|
||||
```
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
- [ ] Tests included for all new functionality?
|
||||
- [ ] Test scenarios match spec requirements?
|
||||
- [ ] Edge cases covered?
|
||||
- [ ] Error handling tested?
|
||||
- [ ] Concurrent access tested (if applicable)?
|
||||
- [ ] All tests passing locally?
|
||||
156
.qoder/skills/openspec-apply-change/SKILL.md
Normal file
156
.qoder/skills/openspec-apply-change/SKILL.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
114
.qoder/skills/openspec-archive-change/SKILL.md
Normal file
114
.qoder/skills/openspec-archive-change/SKILL.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
288
.qoder/skills/openspec-explore/SKILL.md
Normal file
288
.qoder/skills/openspec-explore/SKILL.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
110
.qoder/skills/openspec-propose/SKILL.md
Normal file
110
.qoder/skills/openspec-propose/SKILL.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
141
ARCHIFECTURE.md
141
ARCHIFECTURE.md
|
|
@ -42,6 +42,8 @@ flowchart TD
|
|||
- 通道基类接口与基类实现:Channel / BaseChannel([base.go](pkg/channels/base.go#L10-L66))
|
||||
- LLM 提供方接口:LLMProvider([types.go](pkg/providers/types.go#L11-L23))
|
||||
- 技能注册源接口:SkillRegistry([registry.go](pkg/skills/registry.go#L42-L71))
|
||||
- 上下文构建与技能推荐:[context.go](pkg/agent/context.go)、[recommender.go](pkg/agent/recommender.go)
|
||||
- 工具可见性过滤:[registry.go](pkg/tools/registry.go)
|
||||
|
||||
## 拓展方式与改动点
|
||||
|
||||
|
|
@ -266,3 +268,142 @@ flowchart TD
|
|||
- **多来源合并**:workspace 优先于 global,再到内置
|
||||
- **按需加载**:系统提示词仅包含技能摘要,具体内容需读取 SKILL.md
|
||||
- **可审计**:安装过程保留来源与版本元数据,便于治理
|
||||
|
||||
## 上下文动态选择增强
|
||||
|
||||
基于上下文的动态工具和技能选择机制,支持运行时动态过滤和智能推荐。
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **ContextBuilder 增强** ([context.go](pkg/agent/context.go))
|
||||
- `ContextStrategy`: Full/Lite/Custom 三种上下文构建策略
|
||||
- `ContextBuildOptions`: 灵活的上下文构建选项配置
|
||||
- `BuildMessagesWithOptions()`: 统一的上下文构建入口
|
||||
- 缓存机制优化:避免重复构建完整上下文
|
||||
|
||||
2. **工具可见性过滤器** ([registry.go](pkg/tools/registry.go))
|
||||
- `ToolVisibilityContext`: 工具可见性判断上下文(Channel、ChatID、UserRoles 等)
|
||||
- `ToolVisibilityFilter`: 工具可见性过滤函数
|
||||
- `RegisterWithFilter()`: 注册带过滤器的工具
|
||||
- `GetDefinitionsForContext()`: 根据上下文获取工具定义
|
||||
|
||||
3. **技能按需加载** ([loader.go](pkg/skills/loader.go))
|
||||
- `BuildSkillsSummaryFiltered()`: 按技能名称列表过滤输出
|
||||
- 支持空列表时返回全部技能(向后兼容)
|
||||
|
||||
4. **技能推荐器** ([recommender.go](pkg/agent/recommender.go))
|
||||
- `SkillRecommender`: 基于上下文的智能技能推荐
|
||||
- 混合推荐算法:规则预筛选 + LLM 智能选择
|
||||
- 多维度评分:channel(40%) + keyword(30%) + history(20%) + recency(10%)
|
||||
|
||||
### 工作流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户消息到达] --> B{ContextBuilder.BuildMessagesWithOptions}
|
||||
B --> C{检查 Strategy}
|
||||
C -->|Full| D[使用缓存的系统提示词]
|
||||
C -->|Lite| E[构建最小上下文]
|
||||
C -->|Custom| F[按选项构建自定义上下文]
|
||||
|
||||
D --> G{检查技能推荐器}
|
||||
E --> G
|
||||
F --> G
|
||||
|
||||
G -->|启用且无显式过滤 | H[RecommendSkillsForContext]
|
||||
G -->|禁用或有显式过滤 | I[使用显式过滤列表]
|
||||
|
||||
H --> J[规则预筛选与评分]
|
||||
J --> K{多个候选?}
|
||||
K -->|是 | L[LLM 智能选择]
|
||||
K -->|否 | M[直接返回]
|
||||
|
||||
L --> N[推荐技能列表]
|
||||
M --> N
|
||||
|
||||
N --> O[SkillsLoader.BuildSkillsSummaryFiltered]
|
||||
I --> P[SkillsLoader.BuildSkillsSummaryFiltered]
|
||||
|
||||
O --> Q[构建系统消息]
|
||||
P --> Q
|
||||
|
||||
Q --> R[添加动态上下文]
|
||||
R --> S[添加历史对话]
|
||||
S --> T[返回完整消息列表]
|
||||
|
||||
U[工具调用请求] --> V[ToolRegistry.GetDefinitionsForContext]
|
||||
V --> W[遍历工具过滤器]
|
||||
W --> X{工具有过滤器?}
|
||||
X -->|是 | Y[执行过滤器判断可见性]
|
||||
X -->|否 | Z[工具可见]
|
||||
Y -->|返回 true| Z
|
||||
Y -->|返回 false| AA[工具不可见]
|
||||
Z --> AB[加入工具定义列表]
|
||||
AB --> AC[返回给 LLM]
|
||||
```
|
||||
|
||||
### 使用场景
|
||||
|
||||
#### 1. 多租户权限控制
|
||||
- 管理员工具:仅对 admin 角色用户可见
|
||||
- 普通用户工具:对所有用户可见
|
||||
- VIP 专属工具:仅对特定 ChatID 或用户组可见
|
||||
|
||||
#### 2. 通道特定功能
|
||||
- Telegram: sticker、poll 等特定技能
|
||||
- Slack: huddle、workflow 等集成
|
||||
- WeCom: 审批、会议等企业微信功能
|
||||
|
||||
#### 3. 性能优化
|
||||
- Lite 策略:快速响应简单查询,减少 token 消耗
|
||||
- Full 策略:复杂任务使用完整上下文
|
||||
- Custom 策略:精确控制包含的技能和工具
|
||||
|
||||
### 配置示例
|
||||
|
||||
```go
|
||||
// 注册带过滤器的工具
|
||||
registry.RegisterWithFilter(adminTool, func(ctx tools.ToolVisibilityContext) bool {
|
||||
for _, role := range ctx.UserRoles {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// 使用不同策略构建上下文
|
||||
opts := agent.ContextBuildOptions{
|
||||
Strategy: agent.ContextStrategyLite, // 或 Full/Custom
|
||||
IncludeSkills: []string{"skill1", "skill2"}, // Custom 策略使用
|
||||
ExcludeTools: []string{"admin_tool"},
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(
|
||||
history, "", "user message", nil,
|
||||
"telegram", "chat123",
|
||||
opts,
|
||||
)
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
基准测试结果显示(详见 [context_benchmark_test.go](pkg/agent/context_benchmark_test.go)):
|
||||
|
||||
- **Lite 策略**: ~2,500 ns/op - 最快,适合简单查询
|
||||
- **Full 策略**: ~1,000,000 ns/op - 完整上下文,token 消耗最大
|
||||
- **Custom 策略**: ~1,000,000 ns/op - 性能接近 Full,但可控制内容
|
||||
|
||||
使用 Lite 策略可减少约 99.7% 的处理时间,显著降低延迟和 token 消耗。
|
||||
|
||||
### 向后兼容性
|
||||
|
||||
所有新增功能都是可选的,现有代码无需修改:
|
||||
|
||||
- `BuildMessages()` 自动调用 `BuildMessagesWithOptions()` 使用 Full 策略
|
||||
- `Register()` 继续工作,工具对所有上下文可见
|
||||
- `BuildSkillsSummary()` 保持不变,返回全部技能
|
||||
- 默认不启用推荐器,需显式设置
|
||||
|
||||
|
|
|
|||
365
docs/openspec-global-rules-guide.md
Normal file
365
docs/openspec-global-rules-guide.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# OpenSpec 全局测试约束配置指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本指南说明如何将 PicoClaw 项目的测试约束规则应用到**所有新的 OpenSpec 项目**。
|
||||
|
||||
## 🎯 已完成的全局配置
|
||||
|
||||
### 1. 全局规则存储位置
|
||||
|
||||
```bash
|
||||
~/.config/openspec/
|
||||
├── config.json # OpenSpec CLI 全局配置
|
||||
├── default-config-template.yaml # 默认项目配置模板(包含测试约束)
|
||||
├── rules-index.json # 自动应用的规则索引
|
||||
├── apply-global-rules.sh # 自动应用脚本
|
||||
├── GLOBAL_RULES.md # 全局规则说明文档
|
||||
├── rules/ # 规则文件目录
|
||||
│ ├── testing-mandatory.md # 强制测试要求
|
||||
│ └── README.md # 规则使用说明
|
||||
└── templates/ # 项目模板目录
|
||||
└── picoclaw/ # PicoClaw 专用模板
|
||||
└── config.yaml # 预配置的测试约束
|
||||
```
|
||||
|
||||
### 2. 核心测试约束内容
|
||||
|
||||
已复制的全局规则包括:
|
||||
|
||||
**`testing-mandatory.md`** - 强制测试要求:
|
||||
- ✅ No feature is complete without tests
|
||||
- ✅ Test-driven development preferred
|
||||
- ✅ Coverage requirements (>80% line, 100% critical paths)
|
||||
- ✅ Deterministic, independent, fast tests
|
||||
- ✅ All tests must pass before committing
|
||||
|
||||
**`default-config-template.yaml`** - 项目配置模板:
|
||||
```yaml
|
||||
rules:
|
||||
tasks:
|
||||
- CRITICAL: Every feature task MUST be followed by test task
|
||||
- CRITICAL: Implementation NOT complete until tests passing
|
||||
verification:
|
||||
- MANDATORY: Run /opsx:verify before /opsx:archive
|
||||
- All tests MUST pass before archiving
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 方法 A: 手动应用(推荐新手)
|
||||
|
||||
每次在新项目中运行 `openspec init` 后:
|
||||
|
||||
```bash
|
||||
# 1. 初始化新项目
|
||||
cd /path/to/new-project
|
||||
openspec init --tools qoder
|
||||
|
||||
# 2. 应用全局规则
|
||||
~/.config/openspec/apply-global-rules.sh .
|
||||
|
||||
# 3. 验证
|
||||
ls .qoder/rules/ # 应看到 testing-mandatory.md
|
||||
cat openspec/config.yaml # 应看到测试约束规则
|
||||
```
|
||||
|
||||
### 方法 B: 使用别名(自动化)
|
||||
|
||||
已将以下配置添加到 `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
# 自动应用全局规则的函数
|
||||
openspec-init-with-rules() {
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: openspec-init-with-rules <project-path>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$1" || return 1
|
||||
openspec init --tools qoder
|
||||
~/.config/openspec/apply-global-rules.sh "$PWD"
|
||||
}
|
||||
|
||||
alias openspec-init="openspec-init-with-rules"
|
||||
```
|
||||
|
||||
**使用方式:**
|
||||
|
||||
```bash
|
||||
# 简单用法
|
||||
openspec-init /path/to/new-project
|
||||
|
||||
# 或者在当前目录
|
||||
mkdir my-new-project && cd my-new-project
|
||||
openspec-init .
|
||||
```
|
||||
|
||||
### 方法 C: 使用模板(特定项目类型)
|
||||
|
||||
如果你想为不同类型的项目使用不同的配置:
|
||||
|
||||
```bash
|
||||
# 1. 创建项目类型模板
|
||||
mkdir -p ~/.config/openspec/templates/frontend
|
||||
cp /path/to/frontend-config.yaml ~/.config/openspec/templates/frontend/config.yaml
|
||||
|
||||
# 2. 初始化时使用模板
|
||||
openspec init --tools qoder
|
||||
cp ~/.config/openspec/templates/frontend/config.yaml ./openspec/config.yaml
|
||||
|
||||
# 3. 应用全局规则
|
||||
~/.config/openspec/apply-global-rules.sh .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构说明
|
||||
|
||||
### 全局配置文件
|
||||
|
||||
#### `~/.config/openspec/default-config-template.yaml`
|
||||
|
||||
这是 PicoClaw 项目的配置副本,包含:
|
||||
- 测试强制约束规则
|
||||
- PicoClaw 特定的项目上下文
|
||||
- 开发和测试规范
|
||||
|
||||
#### `~/.config/openspec/rules/testing-mandatory.md`
|
||||
|
||||
详细的测试要求和流程:
|
||||
- Testing Requirements (MANDATORY)
|
||||
- Implementation Workflow
|
||||
- Code Review Checklist
|
||||
- Test Quality Standards
|
||||
|
||||
#### `~/.config/openspec/rules-index.json`
|
||||
|
||||
定义哪些规则应该自动应用:
|
||||
|
||||
```json
|
||||
{
|
||||
"autoIncludeRules": ["testing-mandatory"],
|
||||
"rulesLocation": "~/.config/openspec/rules/",
|
||||
"description": "Global rules automatically included in all new projects"
|
||||
}
|
||||
```
|
||||
|
||||
### 项目级文件
|
||||
|
||||
应用全局规则后,每个新项目会包含:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── .qoder/
|
||||
│ └── rules/
|
||||
│ └── testing-mandatory.md ← 从全局复制
|
||||
├── openspec/
|
||||
│ └── config.yaml ← 从模板复制
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 自定义配置
|
||||
|
||||
### 添加新的全局规则
|
||||
|
||||
1. **创建规则文件**
|
||||
|
||||
```bash
|
||||
cat > ~/.config/openspec/rules/code-quality.md << 'EOF'
|
||||
# Code Quality Rules
|
||||
|
||||
## Naming Conventions
|
||||
- Use camelCase for variables
|
||||
- Use PascalCase for types and classes
|
||||
- Use kebab-case for file names
|
||||
|
||||
## Documentation
|
||||
- All public APIs must have Godoc comments
|
||||
- Complex logic must have inline comments
|
||||
EOF
|
||||
```
|
||||
|
||||
2. **更新规则索引**
|
||||
|
||||
```bash
|
||||
# 编辑 rules-index.json,添加新规则到 autoIncludeRules
|
||||
vim ~/.config/openspec/rules-index.json
|
||||
```
|
||||
|
||||
3. **验证**
|
||||
|
||||
```bash
|
||||
# 在新项目中应用
|
||||
openspec-init /tmp/test-project
|
||||
ls /tmp/test-project/.qoder/rules/
|
||||
# 应看到 code-quality.md
|
||||
```
|
||||
|
||||
### 修改现有规则
|
||||
|
||||
直接编辑规则文件:
|
||||
|
||||
```bash
|
||||
vim ~/.config/openspec/rules/testing-mandatory.md
|
||||
```
|
||||
|
||||
**注意:** 修改后,需要在现有项目中手动更新:
|
||||
|
||||
```bash
|
||||
# 在已有项目中
|
||||
~/.config/openspec/apply-global-rules.sh .
|
||||
```
|
||||
|
||||
### 创建项目特定覆盖
|
||||
|
||||
如果某个项目需要特殊配置:
|
||||
|
||||
```bash
|
||||
# 项目根目录
|
||||
cat > openspec/config.local.yaml << 'EOF'
|
||||
# Project-specific overrides
|
||||
rules:
|
||||
tasks:
|
||||
# Override global rule
|
||||
- Custom rule for this project only
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证和故障排查
|
||||
|
||||
### 验证全局规则已应用
|
||||
|
||||
```bash
|
||||
# 检查规则文件
|
||||
ls -la .qoder/rules/
|
||||
|
||||
# 检查配置
|
||||
cat openspec/config.yaml | grep -A 5 "rules:"
|
||||
|
||||
# 检查 AI 是否理解规则
|
||||
/opsx:propose "Add new feature"
|
||||
# AI 应该在 proposal 中包含测试需求
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 规则文件没有复制到 `.qoder/rules/`**
|
||||
|
||||
A: 检查以下几点:
|
||||
```bash
|
||||
# 1. 全局规则是否存在
|
||||
ls ~/.config/openspec/rules/*.md
|
||||
|
||||
# 2. 手动复制
|
||||
cp ~/.config/openspec/rules/*.md .qoder/rules/
|
||||
|
||||
# 3. 检查权限
|
||||
chmod 644 .qoder/rules/*.md
|
||||
```
|
||||
|
||||
**Q: 配置没有被应用**
|
||||
|
||||
A: 确保 YAML 语法正确:
|
||||
```bash
|
||||
# 验证 YAML 语法
|
||||
python3 -c "import yaml; yaml.safe_load(open('openspec/config.yaml'))"
|
||||
|
||||
# 或重新复制模板
|
||||
cp ~/.config/openspec/default-config-template.yaml openspec/config.yaml
|
||||
```
|
||||
|
||||
**Q: AI 不遵守测试约束**
|
||||
|
||||
A: 尝试以下方法:
|
||||
1. 在对话中明确提醒:"Remember to follow the testing rules in .qoder/rules/"
|
||||
2. 重新运行 `/opsx:apply` 让 AI 重新读取规则
|
||||
3. 检查 `.qoder/rules/testing-mandatory.md` 内容是否清晰
|
||||
|
||||
---
|
||||
|
||||
## 📊 工作流程示例
|
||||
|
||||
### 完整的新项目初始化
|
||||
|
||||
```bash
|
||||
# 1. 创建项目目录
|
||||
mkdir my-awesome-project && cd my-awesome-project
|
||||
|
||||
# 2. 使用别名初始化(自动应用全局规则)
|
||||
openspec-init .
|
||||
|
||||
# 输出示例:
|
||||
# ✔ Setup complete for Qoder
|
||||
# 🔧 Applying OpenSpec global rules to /path/to/project...
|
||||
# 📋 Copying global rules...
|
||||
# ✅ Copied 1 rule files
|
||||
# ✨ Global rules applied successfully!
|
||||
|
||||
# 3. 开始第一个变更
|
||||
/opsx:propose "Add user authentication"
|
||||
|
||||
# 4. AI 会自动遵循测试约束:
|
||||
# - Proposal 中包含测试需求分析
|
||||
# - Specs 中每个场景对应测试用例
|
||||
# - Design 中有测试策略
|
||||
# - Tasks 中每个功能后有测试任务
|
||||
|
||||
# 5. 实现过程中
|
||||
/opsx:apply add-user-authentication
|
||||
# AI 会:实现功能 → 立即写测试 → 跑测试 → 标记完成
|
||||
|
||||
# 6. 完成后验证
|
||||
/opsx:verify add-user-authentication
|
||||
# 检查测试覆盖率和通过率
|
||||
|
||||
# 7. 归档
|
||||
/opsx:archive add-user-authentication
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 最佳实践
|
||||
|
||||
### ✅ 推荐做法
|
||||
|
||||
1. **始终使用全局规则** - 保证所有项目的一致性
|
||||
2. **定期更新规则** - 根据项目经验优化约束
|
||||
3. **分享规则改进** - 将有效的规则贡献回团队
|
||||
4. **结合 CI/CD** - 在流水线中强制执行相同规则
|
||||
|
||||
### ❌ 避免的做法
|
||||
|
||||
1. **不要跳过规则应用** - 即使项目很小
|
||||
2. **不要随意降低标准** - 测试覆盖率不能妥协
|
||||
3. **不要在多个项目中使用冲突的规则** - 保持一致性
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关资源
|
||||
|
||||
- [OpenSpec 官方文档](https://github.com/Fission-AI/OpenSpec/tree/main/docs)
|
||||
- [项目配置指南](https://github.com/Fission-AI/OpenSpec/blob/main/docs/customization.md)
|
||||
- [Slash Commands 参考](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md)
|
||||
- PicoClaw 项目配置示例:`/Users/pengweiye/Documents/codes/picoclaw/openspec/config.yaml`
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献规则
|
||||
|
||||
如果你有好的规则想法,可以:
|
||||
|
||||
1. 在团队内分享 `~/.config/openspec/` 配置
|
||||
2. 提交 PR 到团队的规则模板仓库
|
||||
3. 记录在团队 Wiki 中的开发规范章节
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-02-27
|
||||
**维护者**: PicoClaw Team
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-02-26
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
## Context
|
||||
|
||||
PicoClaw 当前的工具和技能加载机制存在以下问题:
|
||||
|
||||
1. **SkillsFilter 只读** - AgentInstance.SkillsFilter 在初始化后无法修改
|
||||
2. **工具全量注册** - 所有工具对所有用户可见,无法按权限过滤
|
||||
3. **技能摘要全量推送** - system prompt 包含所有技能,浪费 token
|
||||
4. **缺少智能推荐** - LLM 需要自己判断使用哪个技能
|
||||
|
||||
当前架构中:
|
||||
- `AgentInstance` 持有 `Tools *ToolRegistry` 和 `ContextBuilder *ContextBuilder`
|
||||
- `ToolRegistry` 使用 `sync.RWMutex` 保护工具映射
|
||||
- `ContextBuilder` 已有缓存机制(issue #607),支持 InvalidateCache()
|
||||
- `SkillsLoader` 支持按名称加载技能 (`LoadSkillsForContext`)
|
||||
|
||||
**约束条件:**
|
||||
- 必须保持向后兼容
|
||||
- 不能破坏现有的 ToolRegistry 核心架构
|
||||
- 改动涉及多个核心模块 (agent, tools, skills)
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- ✅ 提供运行时动态修改 SkillsFilter 的 API
|
||||
- ✅ 实现工具可见性过滤器机制
|
||||
- ✅ 支持技能摘要按需加载
|
||||
- ✅ 提供多种 Context 构建策略
|
||||
- ✅ 所有变更向后兼容
|
||||
|
||||
**Non-Goals:**
|
||||
- ❌ 不改变 ToolRegistry 的核心数据结构
|
||||
- ❌ 不改变 SkillsLoader 的文件系统加载机制
|
||||
- ❌ 不涉及 LLM 侧的工具调用逻辑修改
|
||||
- ❌ 不需要数据库或持久化存储变更
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: SkillsFilter 动态修改采用 Mutex 保护
|
||||
|
||||
**选择:** 在 `AgentInstance` 中添加 `skillsFilterMutex sync.RWMutex`
|
||||
|
||||
**理由:**
|
||||
- 与现有 `Tools *ToolRegistry` 的并发控制模式一致
|
||||
- RWMutex 允许多个 goroutine 同时读取,写操作独占
|
||||
- Go 标准库,无额外依赖
|
||||
|
||||
**替代方案:**
|
||||
- 使用 `atomic.Value` - 不适合 slice 类型,需要频繁转换
|
||||
- 使用 channel 传递修改请求 - 增加复杂性,不必要
|
||||
|
||||
### Decision 2: 工具过滤器使用函数类型而非配置结构
|
||||
|
||||
**选择:** `type ToolVisibilityFilter func(ctx ToolVisibilityContext) bool`
|
||||
|
||||
**理由:**
|
||||
- 最大灵活性,开发者可以实现任意复杂的过滤逻辑
|
||||
- 符合 Go 的函数式编程习惯
|
||||
- 易于组合和测试
|
||||
|
||||
**替代方案:**
|
||||
- 使用 JSON 配置的规则引擎 - 过于复杂,性能开销大
|
||||
- 使用固定的过滤条件枚举 - 限制了扩展性
|
||||
|
||||
### Decision 3: Context 策略使用 iota 枚举而非字符串
|
||||
|
||||
**选择:**
|
||||
```go
|
||||
type ContextStrategy int
|
||||
const (
|
||||
ContextStrategyFull ContextStrategy = iota
|
||||
ContextStrategyLite
|
||||
ContextStrategyCustom
|
||||
)
|
||||
```
|
||||
|
||||
**理由:**
|
||||
- 类型安全,编译器可以检查
|
||||
- 性能优于字符串比较
|
||||
- 符合 Go 标准库的枚举模式
|
||||
|
||||
### Decision 4: 技能推荐作为独立组件而非 SkillsLoader 的方法
|
||||
|
||||
**选择:** 创建新的 `SkillRecommender` 结构体
|
||||
|
||||
**理由:**
|
||||
- 单一职责原则 - 推荐逻辑与文件加载逻辑分离
|
||||
- 便于未来扩展(可以使用 ML 模型而不影响 Loader)
|
||||
- 易于单元测试
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### Risk 1: 过滤器函数的性能开销
|
||||
|
||||
**风险:** 每次获取工具定义都要执行过滤函数,可能影响性能
|
||||
|
||||
**缓解:**
|
||||
- 过滤器应该保持简单,避免复杂计算
|
||||
- 考虑添加缓存层(如果性能成为瓶颈)
|
||||
- 通过基准测试验证性能影响 < 10%
|
||||
|
||||
### Risk 2: 过滤器配置错误导致工具不可见
|
||||
|
||||
**风险:** 开发者配置错误的过滤条件,导致关键工具对用户不可见
|
||||
|
||||
**缓解:**
|
||||
- 添加调试日志,记录被过滤掉的工具
|
||||
- 提供 `GetAllDefinitions()` 用于调试(绕过过滤)
|
||||
- 在开发环境默认禁用所有过滤器
|
||||
|
||||
### Risk 3: Cache 失效导致的竞争条件
|
||||
|
||||
**风险:** SetSkillsFilter 触发 InvalidateCache,但 concurrent 请求可能看到不一致的状态
|
||||
|
||||
**缓解:**
|
||||
- 确保 InvalidateCache 和 BuildSystemPrompt 使用相同的 mutex
|
||||
- 在 agent 级别处理请求,而不是全局共享
|
||||
- 添加集成测试验证并发场景
|
||||
|
||||
### Trade-off: 灵活性 vs 复杂性
|
||||
|
||||
**权衡:** 提供了高度灵活的过滤器机制,但增加了代码复杂性
|
||||
|
||||
**接受理由:**
|
||||
- 目标用户是企业级应用,需要细粒度控制
|
||||
- 通过良好的文档和示例降低使用门槛
|
||||
- 保持向后兼容,简单场景可以忽略新特性
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: 基础设施 (Week 1)
|
||||
1. 修改 `AgentInstance` 添加 mutex 保护和 `SetSkillsFilter()` 方法
|
||||
2. 修改 `ToolRegistry` 添加 `RegisterWithFilter()` 和 `GetDefinitionsForContext()`
|
||||
3. 修改 `ContextBuilder` 添加 `BuildMessagesWithOptions()`
|
||||
4. 所有修改保持向后兼容
|
||||
|
||||
### Phase 2: 增强功能 (Week 2)
|
||||
1. 实现 `SkillRecommender` 基础版本
|
||||
2. 实现 `ContextStrategyLite` 和 `ContextStrategyCustom`
|
||||
3. 添加单元测试覆盖所有新 API
|
||||
|
||||
### Phase 3: 集成测试 (Week 3)
|
||||
1. E2E 测试验证多租户场景
|
||||
2. 性能测试对比 token 消耗
|
||||
3. 更新文档和使用示例
|
||||
|
||||
**Rollback Strategy:**
|
||||
- 所有新功能都是可选的,不使用就不会生效
|
||||
- 如果出现严重 bug,可以回退到只使用旧 API
|
||||
- 不需要数据库迁移,回滚只需代码回退
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **是否需要在 config.json 中声明过滤器规则?**
|
||||
- 当前设计:硬编码在 Go 代码中
|
||||
- 未来可能:支持从配置文件加载
|
||||
|
||||
2. **技能推荐的权重如何调优?**
|
||||
- 当前设计:固定权重(channel 40%, keyword 30%, history 20%, recency 10%)
|
||||
- 未来可能:基于使用数据自动优化
|
||||
|
||||
3. **是否需要可视化工具管理过滤器?**
|
||||
- 暂不实现,等待用户反馈
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
## Why
|
||||
|
||||
PicoClaw 当前的工具和技能加载机制存在以下问题:
|
||||
|
||||
1. **SkillsFilter 只读,不支持运行时动态修改** - Agent 初始化后无法调整可用技能列表,需要重启进程才能修改配置
|
||||
2. **工具注册全量,不支持按 context 过滤** - 所有工具对所有通道可见,无法根据通道类型、用户权限限制工具,浪费 token
|
||||
3. **技能摘要全量推送,不支持按需筛选** - 即使配置了 SkillsFilter,system prompt 仍然包含所有技能的摘要
|
||||
4. **缺少基于上下文的智能推荐机制** - LLM 需要自己判断该用哪个工具、该看哪个技能
|
||||
|
||||
本需求旨在实现基于上下文的动态工具和技能选择机制,支持运行时动态过滤和智能推荐,提升系统的灵活性和资源利用效率。
|
||||
|
||||
## What Changes
|
||||
|
||||
- ✅ **新增运行时 SkillsFilter 动态修改 API** - 提供 `SetSkillsFilter()` 方法在运行时修改技能过滤器
|
||||
- ✅ **新增工具可见性过滤器** - 支持注册工具时设置可见性规则,根据 channel、chatID、用户权限等条件过滤
|
||||
- ✅ **新增技能摘要按需加载** - BuildSystemPrompt 时根据 SkillsFilter 只加载匹配的技能
|
||||
- ✅ **新增 Context 构建策略** - 支持 Full/Lite/Custom 三种上下文构建策略
|
||||
- ✅ **增强 ContextBuilder 缓存机制** - 支持显式缓存失效触发重建
|
||||
- ⚠️ **BREAKING**: 无 - 所有变更都是向后兼容的,现有代码继续使用旧 API
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `context-strategies`: 支持多种 Context 构建策略 (Full/Lite/Custom)
|
||||
- `tool-visibility-filters`: 工具可见性过滤器,支持基于条件的动态过滤
|
||||
- `skills-filter-api`: 运行时动态修改 SkillsFilter 的 API
|
||||
- `skill-recommender`: 基于上下文的智能技能推荐
|
||||
|
||||
### Modified Capabilities
|
||||
- `agent-context-builder`: 增强 ContextBuilder 支持按需加载技能和工具过滤
|
||||
- `tool-registry`: 增强 ToolRegistry 支持可见性过滤器和按 context 获取定义
|
||||
|
||||
## Impact
|
||||
|
||||
**Affected Code:**
|
||||
- `pkg/agent/context.go` - ContextBuilder 增强
|
||||
- `pkg/agent/instance.go` - AgentInstance 添加 SetSkillsFilter 方法
|
||||
- `pkg/tools/registry.go` - ToolRegistry 添加可见性过滤器
|
||||
- `pkg/skills/loader.go` - SkillsLoader 添加按需加载方法
|
||||
|
||||
**APIs:**
|
||||
- 新增: `AgentInstance.SetSkillsFilter()`, `ToolRegistry.RegisterWithFilter()`, `ToolRegistry.GetDefinitionsForContext()`
|
||||
- 新增: `ContextBuilder.BuildMessagesWithOptions()`, `SkillsLoader.BuildSkillsSummaryFiltered()`
|
||||
|
||||
**Dependencies:**
|
||||
- 无外部依赖变更
|
||||
|
||||
**Systems:**
|
||||
- 影响所有通道的工具定义推送逻辑
|
||||
- 影响 system prompt 构建的 token 消耗
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Multiple Context Building Strategies
|
||||
系统必须支持多种上下文构建策略,适应不同的使用场景。
|
||||
|
||||
#### Scenario: Full strategy (default)
|
||||
- **WHEN** BuildMessagesWithOptions is called with `Strategy: ContextStrategyFull`
|
||||
- **THEN** complete context is built including identity, bootstrap, skills, tools, memory, and runtime info
|
||||
|
||||
#### Scenario: Lite strategy for minimal context
|
||||
- **WHEN** BuildMessagesWithOptions is called with `Strategy: ContextStrategyLite`
|
||||
- **THEN** only core identity, current time, and session info are included (no skills/tools details)
|
||||
|
||||
#### Scenario: Custom strategy with explicit includes
|
||||
- **WHEN** BuildMessagesWithOptions is called with `Strategy: ContextStrategyCustom` and `IncludeSkills: ["skill1"]`
|
||||
- **THEN** only skill1 is included in the context, all other skills are excluded
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Context-Based Skill Recommendation
|
||||
系统必须能够根据上下文智能推荐相关技能。
|
||||
|
||||
#### Scenario: Recommend skills based on channel type
|
||||
- **WHEN** user is in Telegram channel
|
||||
- **AND** RecommendSkillsForContext is called
|
||||
- **THEN** telegram-specific skills (sticker, poll) are recommended
|
||||
|
||||
#### Scenario: Recommend skills based on message content
|
||||
- **WHEN** user message contains keywords "debug" or "deploy"
|
||||
- **AND** RecommendSkillsForContext is called
|
||||
- **THEN** technical skills related to debugging and deployment are recommended
|
||||
|
||||
#### Scenario: No recommendations when no matching skills
|
||||
- **WHEN** no skills match the current context
|
||||
- **THEN** empty recommendation list is returned (no errors)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Runtime SkillsFilter Modification API
|
||||
系统必须提供在运行时动态修改 Agent 技能过滤器的能力,无需重启进程。
|
||||
|
||||
#### Scenario: Set new skills filter
|
||||
- **WHEN** developer calls `agent.SetSkillsFilter([]string{"skill1", "skill2"})`
|
||||
- **THEN** the agent's available skills are immediately updated to only include skill1 and skill2
|
||||
|
||||
#### Scenario: Trigger context cache invalidation
|
||||
- **WHEN** SetSkillsFilter is called with a non-empty filter
|
||||
- **THEN** ContextBuilder.InvalidateCache() is automatically invoked to rebuild system prompt
|
||||
|
||||
#### Scenario: Thread-safe concurrent access
|
||||
- **WHEN** multiple goroutines call GetSkillsFilter simultaneously
|
||||
- **THEN** all reads return consistent values without race conditions
|
||||
|
||||
#### Scenario: Empty filter clears all restrictions
|
||||
- **WHEN** SetSkillsFilter is called with empty array or nil
|
||||
- **THEN** all skills become available (no filtering applied)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool Visibility Filter Registration
|
||||
系统必须支持在注册工具时设置可见性规则,基于多种条件动态控制工具的可见性。
|
||||
|
||||
#### Scenario: Register tool with admin-only filter
|
||||
- **WHEN** developer calls `registry.RegisterWithFilter(adminTool, func(ctx) bool { return contains(ctx.UserRoles, "admin") })`
|
||||
- **THEN** the adminTool is only visible to users with "admin" role
|
||||
|
||||
#### Scenario: Register tool with channel-specific filter
|
||||
- **WHEN** developer registers a message tool with filter checking `ctx.Channel == "telegram"`
|
||||
- **THEN** the message tool is only visible in Telegram channel, not in CLI or other channels
|
||||
|
||||
#### Scenario: Register tool without filter (always visible)
|
||||
- **WHEN** developer calls `registry.Register(readFileTool)` without filter
|
||||
- **THEN** readFileTool is visible to all channels and users
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
## 1. Infrastructure Setup
|
||||
|
||||
- [x] 1.1 Modify `AgentInstance` to add `skillsFilterMutex sync.RWMutex` field
|
||||
- [x] 1.2 Implement `SetSkillsFilter(filters []string)` method in AgentInstance
|
||||
- [x] 1.3 Implement `GetSkillsFilter() []string` method in AgentInstance
|
||||
- [x] 1.4 Update `ContextBuilder.InvalidateCache()` to be publicly accessible
|
||||
- [x] 1.5 Add integration: call InvalidateCache() in SetSkillsFilter()
|
||||
|
||||
## 2. Tool Visibility Filters
|
||||
|
||||
- [x] 2.1 Define `ToolVisibilityContext` struct with Channel, ChatID, UserID, UserRoles, Args, Timestamp
|
||||
- [x] 2.2 Define `ToolVisibilityFilter` function type
|
||||
- [x] 2.3 Add `visibilityFilters map[string]ToolVisibilityFilter` to ToolRegistry
|
||||
- [x] 2.4 Implement `RegisterWithFilter(tool Tool, filter ToolVisibilityFilter)` method
|
||||
- [x] 2.5 Implement `GetDefinitionsForContext(ctx ToolVisibilityContext)` method
|
||||
- [x] 2.6 Modify legacy `Register()` to maintain backward compatibility (no filtering)
|
||||
- [x] 2.7 Update `AgentLoop.runLLMIteration()` to use `GetDefinitionsForContext()` instead of `GetDefinitions()`
|
||||
|
||||
## 3. Skills On-Demand Loading
|
||||
|
||||
- [x] 3.1 Implement `BuildSkillsSummaryFiltered(skillNames []string)` in SkillsLoader
|
||||
- [x] 3.2 Implement `BuildSkillsSummaryForNames(skillNames []string)` in SkillsLoader
|
||||
- [x] 3.3 Modify `ContextBuilder.BuildSystemPrompt()` to check SkillsFilter and call filtered version
|
||||
- [x] 3.4 Add logic: if SkillsFilter is empty, load all skills; otherwise load only filtered skills
|
||||
|
||||
## 4. Context Building Strategies
|
||||
|
||||
- [x] 4.1 Define `ContextStrategy` int type and constants (Full, Lite, Custom)
|
||||
- [x] 4.2 Define `ContextBuildOptions` struct with Strategy, IncludeTools, ExcludeTools, IncludeSkills, etc.
|
||||
- [x] 4.3 Implement `buildLiteContext()` private method in ContextBuilder
|
||||
- [x] 4.4 Implement `buildCustomContext(opts ContextBuildOptions)` private method in ContextBuilder
|
||||
- [x] 4.5 Implement public `BuildMessagesWithOptions(history, summary, message, media, channel, chatID, opts)` method
|
||||
- [x] 4.6 Update existing `BuildMessages()` to call `BuildMessagesWithOptions()` with default Full strategy
|
||||
|
||||
## 5. Skill Recommender (Optional - P1)
|
||||
|
||||
- [x] 5.1 Create `SkillRecommender` struct with dependencies (SkillsLoader, optional history store)
|
||||
- [x] 5.2 Implement `RecommendSkillsForContext(channel, chatID, userMessage string, history []Message)` method
|
||||
- [x] 5.3 Implement channel-based recommendation rules (Telegram → sticker/poll skills, etc.)
|
||||
- [x] 5.4 Implement keyword-based matching (debug/deploy → technical skills)
|
||||
- [x] 5.5 Implement scoring algorithm with weights: channel 40%, keyword 30%, history 20%, recency 10%
|
||||
- [x] 5.6 Integrate recommender into ContextBuilder as optional enhancement
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- [x] 6.1 Write unit tests for `AgentInstance.SetSkillsFilter()` and concurrent access
|
||||
- [x] 6.2 Write unit tests for `ToolRegistry.RegisterWithFilter()` and `GetDefinitionsForContext()`
|
||||
- [x] 6.3 Write unit tests for `SkillsLoader.BuildSkillsSummaryFiltered()`
|
||||
- [x] 6.4 Write unit tests for `ContextBuilder.BuildMessagesWithOptions()` with all strategies
|
||||
- [x] 6.5 Write integration test: multi-tenant scenario (admin vs regular user tool visibility)
|
||||
- [x] 6.6 Write integration test: channel-specific skill loading
|
||||
- [x] 6.7 Write performance benchmark: token usage comparison (Full vs Lite vs Custom)
|
||||
- [x] 6.8 Verify all existing tests still pass (backward compatibility)
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [x] 7.1 Update ARCHITECTURE.md with new components and data flow
|
||||
- [x] 7.2 Add usage examples to pkg/agent README or code comments
|
||||
- [x] 7.3 Add configuration example to config/config.example.json (optional SkillsFilter config)
|
||||
- [x] 7.4 Create MIGRATION.md guide for users upgrading from old versions
|
||||
- [x] 7.5 Update Godoc comments for all new and modified public APIs
|
||||
|
||||
## 8. Validation and Cleanup
|
||||
|
||||
- [x] 8.1 Run `openspec validate --changes context-dynamic-selection-enhancement` to verify completeness
|
||||
- [x] 8.2 Run full test suite: `make test` or `go test ./...`
|
||||
- [x] 8.3 Run linter: `golangci-lint run`
|
||||
- [x] 8.4 Check for breaking changes using API diff tool
|
||||
- [x] 8.5 Clean up temporary files and debug logging
|
||||
- [x] 8.6 Final code review and refactoring
|
||||
51
openspec/config.yaml
Normal file
51
openspec/config.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# PicoClaw OpenSpec 项目配置
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
## PicoClaw 项目上下文
|
||||
**技术栈**: Go 1.25.7
|
||||
**项目类型**: 超轻量 AI 助手网关
|
||||
**架构**: Event-driven, message bus pattern
|
||||
**测试**: go test with testify/assert and testify/require
|
||||
**代码风格**: Go standard formatting, Godoc comments required
|
||||
**并发控制**: Use sync.RWMutex for shared state protection
|
||||
|
||||
**关键组件**:
|
||||
- AgentInstance: Agent 实例管理
|
||||
- ContextBuilder: System prompt 构建(带缓存机制)
|
||||
- ToolRegistry: 工具注册和执行(支持可见性过滤)
|
||||
- SkillsLoader: 技能加载(workspace > global > builtin)
|
||||
- SessionManager: 会话管理(按 channel + chatID 隔离)
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- Must include backward compatibility analysis
|
||||
- Identify affected packages and APIs
|
||||
- Include performance impact assessment
|
||||
- **CRITICAL**: Must identify test coverage requirements (unit, integration, E2E)
|
||||
|
||||
specs:
|
||||
- Use WHEN/THEN format for scenarios
|
||||
- Each requirement must have at least one scenario
|
||||
- Scenarios MUST be testable
|
||||
- **CRITICAL**: Each scenario must map to at least one test case
|
||||
|
||||
design:
|
||||
- Explain mutex usage and lock granularity
|
||||
- Document cache invalidation strategies
|
||||
- Include rollback plan
|
||||
- **CRITICAL**: Must include testing strategy section (what to test, how to test)
|
||||
|
||||
tasks:
|
||||
- Tasks must be small enough to complete in one session
|
||||
- Order by dependency
|
||||
- **CRITICAL**: Every feature task MUST be followed by corresponding test task
|
||||
- **CRITICAL**: Implementation is NOT complete until tests are written and passing
|
||||
- **CONSTRAINT**: No task group can be marked complete without test tasks
|
||||
- Last task in each group should be "Run tests and verify"
|
||||
|
||||
verification:
|
||||
- **MANDATORY**: Run /opsx:verify before /opsx:archive
|
||||
- Verify MUST check test coverage against specs
|
||||
- All tests MUST pass before archiving
|
||||
- Test failure rate must be 0%
|
||||
408
pkg/agent/RECOMMENDER_EXAMPLES.md
Normal file
408
pkg/agent/RECOMMENDER_EXAMPLES.md
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# Skill Recommender Usage Examples
|
||||
|
||||
本文件展示了如何使用 PicoClaw 的智能技能推荐功能。
|
||||
|
||||
## 概述
|
||||
|
||||
SkillRecommender 是一个独立的组件,通过调用 LLM 基于上下文和备选技能集进行智能推理选择。它使用混合方法:
|
||||
|
||||
1. **规则预过滤** - 基于通道类型、关键词、历史记录进行初步评分
|
||||
2. **LLM 推理** - 对多个候选技能进行智能选择
|
||||
3. **多因子评分** - 整合通道 (40%)、关键词 (30%)、历史 (20%)、近期性 (10%)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启用技能推荐器
|
||||
|
||||
```go
|
||||
// 在 agent 初始化后启用技能推荐器
|
||||
agent := CreateYourAgent() // 您的 agent 创建逻辑
|
||||
|
||||
// 方式 1: 使用默认配置启用
|
||||
agent.EnableSkillRecommender("", nil)
|
||||
|
||||
// 方式 2: 使用自定义权重启用
|
||||
// 参数:channel, keyword, history, recency
|
||||
agent.EnableSkillRecommenderWithWeights(0.5, 0.3, 0.15, 0.05)
|
||||
|
||||
// 方式 3: 指定使用的模型
|
||||
agent.EnableSkillRecommender("gpt-4", nil)
|
||||
```
|
||||
|
||||
### 2. 自动推荐(集成到 ContextBuilder)
|
||||
|
||||
一旦启用了推荐器,`BuildMessagesWithOptions` 会自动推荐使用:
|
||||
|
||||
```go
|
||||
// 构建消息时会自动触发推荐
|
||||
messages := contextBuilder.BuildMessagesWithOptions(
|
||||
history,
|
||||
summary,
|
||||
currentMessage,
|
||||
media,
|
||||
channel,
|
||||
chatID,
|
||||
agent.ContextBuildOptions{
|
||||
Strategy: agent.ContextStrategyFull,
|
||||
},
|
||||
)
|
||||
|
||||
// 推荐器会自动:
|
||||
// 1. 分析用户消息和上下文
|
||||
// 2. 调用 LLM 进行智能选择
|
||||
// 3. 只包含推荐的技能到 context 中
|
||||
// 4. 减少 token 消耗,提高响应质量
|
||||
```
|
||||
|
||||
### 3. 手动调用推荐器
|
||||
|
||||
```go
|
||||
import "github.com/sipeed/picoclaw/pkg/agent"
|
||||
|
||||
// 创建推荐器实例
|
||||
recommender := agent.NewSkillRecommender(
|
||||
skillsLoader, // 技能加载器
|
||||
llmProvider, // LLM 提供商
|
||||
"gpt-4", // 使用的模型
|
||||
)
|
||||
|
||||
// 可选:自定义权重
|
||||
recommender.SetWeights(
|
||||
0.4, // channel weight - 通道匹配度
|
||||
0.3, // keyword weight - 关键词匹配度
|
||||
0.2, // history weight - 历史使用
|
||||
0.1, // recency weight - 近期使用
|
||||
)
|
||||
|
||||
// 获取推荐
|
||||
recommendations, err := recommender.RecommendSkillsForContext(
|
||||
"telegram", // 通道类型
|
||||
"chat123", // 聊天 ID
|
||||
"帮我创建一个投票", // 用户消息
|
||||
conversationHistory, // 对话历史
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Recommendation failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理推荐结果
|
||||
for _, rec := range recommendations {
|
||||
fmt.Printf("Skill: %s\n", rec.Name)
|
||||
fmt.Printf(" Score: %.1f/100\n", rec.Score)
|
||||
fmt.Printf(" Confidence: %.2f\n", rec.Confidence)
|
||||
fmt.Printf(" Reason: %s\n", rec.Reason)
|
||||
fmt.Println()
|
||||
}
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1: 多租户系统(管理员 vs 普通用户)
|
||||
|
||||
```go
|
||||
// 为不同用户角色设置不同的技能过滤器
|
||||
func SetupAgentForUser(agent *agent.AgentInstance, userRole string) {
|
||||
switch userRole {
|
||||
case "admin":
|
||||
// 管理员:启用推荐器,但允许使用所有技能
|
||||
agent.EnableSkillRecommender("", nil)
|
||||
agent.SetSkillsFilter(nil) // 无限制
|
||||
|
||||
case "regular":
|
||||
// 普通用户:只允许使用基础技能 + 智能推荐
|
||||
agent.SetSkillsFilter([]string{
|
||||
"schedule",
|
||||
"reminder",
|
||||
"search",
|
||||
})
|
||||
// 仍然可以使用推荐器在允许的范围内优化
|
||||
agent.EnableSkillRecommender("", nil)
|
||||
|
||||
case "guest":
|
||||
// 访客:仅使用 Lite 模式,不启用推荐器
|
||||
// 在调用时使用 Lite 策略
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2: 通道特定技能优化
|
||||
|
||||
```go
|
||||
// Telegram 通道 - 推荐器会自动优先推荐 Telegram 相关技能
|
||||
// 如 sticker、poll、inline 等技能
|
||||
agent.EnableSkillRecommender("", nil)
|
||||
|
||||
// 企业微信通道 - 推荐器会优先推荐审批、会议等企业技能
|
||||
// 如 approval、meeting、report 等技能
|
||||
agent.EnableSkillRecommender("", nil)
|
||||
```
|
||||
|
||||
### 场景 3: 动态调整策略
|
||||
|
||||
```go
|
||||
// 根据时间段使用不同的策略
|
||||
func BuildContextForTimeOfDay(agent *agent.AgentInstance, hour int) agent.ContextBuildOptions {
|
||||
if hour >= 9 && hour <= 18 {
|
||||
// 工作时间:使用完整上下文 + 推荐器
|
||||
return agent.ContextBuildOptions{
|
||||
Strategy: agent.ContextStrategyFull,
|
||||
IncludeRuntime: true,
|
||||
IncludeMemory: true,
|
||||
}
|
||||
} else {
|
||||
// 非工作时间:使用 Lite 模式,减少 token 消耗
|
||||
return agent.ContextBuildOptions{
|
||||
Strategy: agent.ContextStrategyLite,
|
||||
IncludeRuntime: true,
|
||||
IncludeMemory: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 评分算法说明
|
||||
|
||||
推荐器使用加权评分系统:
|
||||
|
||||
```
|
||||
Total Score = (ChannelScore × 0.4) +
|
||||
(KeywordScore × 0.3) +
|
||||
(HistoryScore × 0.2) +
|
||||
(RecencyScore × 0.1)
|
||||
```
|
||||
|
||||
### 各因子说明
|
||||
|
||||
1. **Channel Score (40%)**
|
||||
- 检查技能名称/描述是否包含通道特定关键词
|
||||
- 例如:Telegram → "sticker", "poll"
|
||||
- 企业微信 → "approval", "meeting"
|
||||
|
||||
2. **Keyword Score (30%)**
|
||||
- 从用户消息中提取关键词
|
||||
- 与技能描述进行匹配
|
||||
- 使用 TF-IDF 风格的词频统计
|
||||
|
||||
3. **History Score (20%)**
|
||||
- 检查技能是否在对话历史中被使用
|
||||
- 最近使用的技能获得高分
|
||||
|
||||
4. **Recency Score (10%)**
|
||||
- 基于时间衰减
|
||||
- 越近使用的技能得分越高
|
||||
|
||||
### 自定义权重
|
||||
|
||||
```go
|
||||
// 如果您的场景中通道类型更重要
|
||||
recommender.SetWeights(
|
||||
0.6, // channel - 提高通道权重
|
||||
0.2, // keyword - 降低关键词权重
|
||||
0.15, // history
|
||||
0.05, // recency
|
||||
)
|
||||
|
||||
// 如果关键词匹配最关键
|
||||
recommender.SetWeights(
|
||||
0.2, // channel
|
||||
0.6, // keyword - 提高关键词权重
|
||||
0.15, // history
|
||||
0.05, // recency
|
||||
)
|
||||
```
|
||||
|
||||
## LLM 推理机制
|
||||
|
||||
当有多个候选技能(>1)时,推荐器会调用 LLM 进行智能选择:
|
||||
|
||||
### Prompt 结构
|
||||
|
||||
```
|
||||
## Context
|
||||
Channel: telegram
|
||||
User Message: 帮我创建一个投票
|
||||
|
||||
## Available Skills
|
||||
1. **poll-creator** - Score: 75.0/100
|
||||
Description: channel match, keyword match
|
||||
2. **sticker-manager** - Score: 45.0/100
|
||||
Description: channel match
|
||||
3. **message-scheduler** - Score: 30.0/100
|
||||
Description: keyword match
|
||||
|
||||
## Task
|
||||
Based on the context and available skills, recommend the most appropriate skills.
|
||||
Consider:
|
||||
- The user's intent from their message
|
||||
- Channel-specific capabilities
|
||||
- Historical context
|
||||
|
||||
Respond in JSON format:
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"skill_name": "skill-name",
|
||||
"confidence": 0.9,
|
||||
"reason": "why this skill is recommended"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### LLM 输出处理
|
||||
|
||||
LLM 的推荐会用于调整预评分:
|
||||
- 高置信度的推荐会boost分数(最多提升 50%)
|
||||
- 低置信度的推荐保持原分
|
||||
- 未被 LLM 提及的技能保持原分
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
### 1. 缓存推荐结果
|
||||
|
||||
```go
|
||||
// 对于相同的 channel+userMessage 组合,可以缓存推荐结果
|
||||
type RecommendationCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string][]agent.SkillRecommendation
|
||||
}
|
||||
|
||||
func (c *RecommendationCache) Get(key string) []agent.SkillRecommendation {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.cache[key]
|
||||
}
|
||||
|
||||
func (c *RecommendationCache) Set(key string, recs []agent.SkillRecommendation) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache[key] = recs
|
||||
}
|
||||
|
||||
// 使用
|
||||
cacheKey := fmt.Sprintf("%s:%s", channel, userMessage)
|
||||
if cached := cache.Get(cacheKey); cached != nil {
|
||||
return cached // 使用缓存
|
||||
}
|
||||
|
||||
// 调用推荐器
|
||||
recs, _ := recommender.RecommendSkillsForContext(...)
|
||||
cache.Set(cacheKey, recs)
|
||||
```
|
||||
|
||||
### 2. 设置推荐阈值
|
||||
|
||||
```go
|
||||
// 只使用分数高于阈值的技能
|
||||
recommendedSkillNames := make([]string, 0)
|
||||
for _, rec := range recommendations {
|
||||
if rec.Score >= 50.0 { // 只使用 50 分以上的技能
|
||||
recommendedSkillNames = append(recommendedSkillNames, rec.Name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 限制推荐数量
|
||||
|
||||
```go
|
||||
// 只取前 N 个推荐
|
||||
maxRecommendations := 5
|
||||
if len(recommendations) > maxRecommendations {
|
||||
recommendations = recommendations[:maxRecommendations]
|
||||
}
|
||||
```
|
||||
|
||||
## 调试和监控
|
||||
|
||||
### 启用调试日志
|
||||
|
||||
```go
|
||||
// 在 config 中启用 debug 模式
|
||||
config := &config.Config{
|
||||
Debug: true,
|
||||
}
|
||||
|
||||
// 查看推荐器的详细日志
|
||||
// 日志会显示:
|
||||
// - 预过滤后的技能列表
|
||||
// - LLM 调用详情
|
||||
// - 最终推荐的技能和分数
|
||||
```
|
||||
|
||||
### 监控推荐质量
|
||||
|
||||
```go
|
||||
// 记录推荐指标
|
||||
func LogRecommendationMetrics(recs []agent.SkillRecommendation) {
|
||||
totalScore := 0.0
|
||||
avgConfidence := 0.0
|
||||
|
||||
for _, rec := range recs {
|
||||
totalScore += rec.Score
|
||||
avgConfidence += rec.Confidence
|
||||
}
|
||||
|
||||
if len(recs) > 0 {
|
||||
log.Printf("Avg Score: %.1f", totalScore/float64(len(recs)))
|
||||
log.Printf("Avg Confidence: %.2f", avgConfidence/float64(len(recs)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 推荐器会影响性能吗?
|
||||
|
||||
A: 影响很小:
|
||||
- 规则预过滤:< 1ms
|
||||
- LLM 调用:~100-500ms(取决于模型)
|
||||
- 只在首次调用时发生,后续可使用缓存
|
||||
|
||||
### Q: 如何禁用推荐器?
|
||||
|
||||
```go
|
||||
// 方式 1: 不启用推荐器(默认行为)
|
||||
// 什么都不做即可
|
||||
|
||||
// 方式 2: 清除已启用的推荐器
|
||||
agent.ContextBuilder.SetSkillRecommender(nil)
|
||||
```
|
||||
|
||||
### Q: 推荐器会替换 SkillsFilter 吗?
|
||||
|
||||
A: 不会,两者可以共存:
|
||||
- `SkillsFilter`: 硬性的白名单/黑名单
|
||||
- `Recommender`: 软性的智能推荐
|
||||
- 优先级:SkillsFilter > Recommender
|
||||
|
||||
### Q: 可以在运行时动态开启/关闭吗?
|
||||
|
||||
A: 可以:
|
||||
|
||||
```go
|
||||
// 开启
|
||||
agent.EnableSkillRecommender("", nil)
|
||||
|
||||
// 关闭
|
||||
agent.ContextBuilder.SetSkillRecommender(nil)
|
||||
|
||||
// 重新开启(带不同配置)
|
||||
agent.EnableSkillRecommenderWithWeights(0.6, 0.3, 0.1, 0.0)
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **从小规模开始** - 先在测试环境启用,观察效果
|
||||
2. **监控 token 使用** - 对比启用前后的 token 消耗
|
||||
3. **收集用户反馈** - 根据实际使用情况调整权重
|
||||
4. **定期更新技能描述** - 确保描述准确,便于关键词匹配
|
||||
5. **使用缓存** - 对于高频场景使用推荐缓存
|
||||
|
||||
## 下一步
|
||||
|
||||
- 尝试不同的权重配置找到最优解
|
||||
- 实现推荐结果缓存进一步优化性能
|
||||
- 添加 A/B 测试对比不同配置的效果
|
||||
- 收集实际数据持续优化推荐算法
|
||||
|
|
@ -16,11 +16,52 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
// min returns the minimum of two integers.
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ContextStrategy represents the strategy for building context messages.
|
||||
type ContextStrategy int
|
||||
|
||||
const (
|
||||
// ContextStrategyFull builds complete context including identity, bootstrap, skills, tools, memory, and runtime info.
|
||||
ContextStrategyFull ContextStrategy = iota
|
||||
// ContextStrategyLite builds minimal context with only core identity, current time, and session info.
|
||||
ContextStrategyLite
|
||||
// ContextStrategyCustom builds custom context with explicitly specified includes.
|
||||
ContextStrategyCustom
|
||||
)
|
||||
|
||||
// ContextBuildOptions holds options for custom context building.
|
||||
type ContextBuildOptions struct {
|
||||
Strategy ContextStrategy // Context building strategy
|
||||
IncludeTools []string // Tool names to include (for Custom strategy)
|
||||
ExcludeTools []string // Tool names to exclude (for Custom strategy)
|
||||
IncludeSkills []string // Skill names to include (for Custom strategy)
|
||||
ExcludeSkills []string // Skill names to exclude (for Custom strategy)
|
||||
IncludeMemory bool // Whether to include memory context (default: true)
|
||||
IncludeRuntime bool // Whether to include runtime info (default: true)
|
||||
}
|
||||
|
||||
type ContextBuilder struct {
|
||||
workspace string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
|
||||
// SkillsFilter holds the list of skill names to include in system prompt.
|
||||
// When set, only these skills will be shown to the LLM.
|
||||
// This is used for dynamic skill filtering based on context.
|
||||
skillsFilterMutex sync.RWMutex
|
||||
skillsFilter []string
|
||||
|
||||
// SkillRecommender provides intelligent skill recommendations based on context.
|
||||
// Optional component for dynamic skill selection.
|
||||
skillRecommender *SkillRecommender
|
||||
|
||||
// Cache for system prompt to avoid rebuilding on every call.
|
||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||
// The cache auto-invalidates when workspace source files change (mtime check).
|
||||
|
|
@ -94,8 +135,12 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
|||
parts = append(parts, bootstrapContent)
|
||||
}
|
||||
|
||||
// Skills - show summary, AI can read full content with read_file tool
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||
// Skills - show summary filtered by skillsFilter if set
|
||||
cb.skillsFilterMutex.RLock()
|
||||
filter := cb.skillsFilter
|
||||
cb.skillsFilterMutex.RUnlock()
|
||||
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummaryFiltered(filter)
|
||||
if skillsSummary != "" {
|
||||
parts = append(parts, fmt.Sprintf(`# Skills
|
||||
|
||||
|
|
@ -170,6 +215,46 @@ func (cb *ContextBuilder) InvalidateCache() {
|
|||
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
||||
}
|
||||
|
||||
// SetSkillsFilter sets the skills filter for this context builder.
|
||||
// When set, only the specified skills will be included in the system prompt.
|
||||
// This triggers cache invalidation to ensure the next request uses the updated skill set.
|
||||
//
|
||||
// Parameters:
|
||||
// - filters: List of skill names to include. Empty or nil clears the filter.
|
||||
func (cb *ContextBuilder) SetSkillsFilter(filters []string) {
|
||||
cb.skillsFilterMutex.Lock()
|
||||
defer cb.skillsFilterMutex.Unlock()
|
||||
|
||||
// Create a copy to prevent external modification
|
||||
if filters == nil {
|
||||
cb.skillsFilter = nil
|
||||
} else {
|
||||
cb.skillsFilter = make([]string, len(filters))
|
||||
copy(cb.skillsFilter, filters)
|
||||
}
|
||||
|
||||
// Trigger cache invalidation
|
||||
cb.InvalidateCache()
|
||||
}
|
||||
|
||||
// GetSkillsFilter returns the current skills filter.
|
||||
// Returns nil if no filter is set (all skills available).
|
||||
//
|
||||
// The returned slice is a copy to prevent concurrent modification.
|
||||
func (cb *ContextBuilder) GetSkillsFilter() []string {
|
||||
cb.skillsFilterMutex.RLock()
|
||||
defer cb.skillsFilterMutex.RUnlock()
|
||||
|
||||
if cb.skillsFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
result := make([]string, len(cb.skillsFilter))
|
||||
copy(result, cb.skillsFilter)
|
||||
return result
|
||||
}
|
||||
|
||||
// sourcePaths returns the workspace source file paths tracked for cache
|
||||
// invalidation (bootstrap files + memory). The skills directory is handled
|
||||
// separately in sourceFilesChangedLocked because it requires both directory-
|
||||
|
|
@ -375,43 +460,159 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
|
|||
return sb.String()
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(
|
||||
// buildLiteContext builds minimal context with only core identity, current time, and session info.
|
||||
// This is useful for simple queries that don't require tools or skills.
|
||||
func (cb *ContextBuilder) buildLiteContext(channel, chatID string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Core identity only
|
||||
sb.WriteString(cb.getIdentity())
|
||||
|
||||
// Add dynamic context (time, runtime, session)
|
||||
dynamicCtx := cb.buildDynamicContext(channel, chatID)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
sb.WriteString(dynamicCtx)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildCustomContext builds custom context with explicitly specified includes/excludes.
|
||||
// This provides fine-grained control over what context is included.
|
||||
func (cb *ContextBuilder) buildCustomContext(opts ContextBuildOptions, channel, chatID string) string {
|
||||
parts := []string{}
|
||||
|
||||
// Core identity (always included)
|
||||
parts = append(parts, cb.getIdentity())
|
||||
|
||||
// Bootstrap files (optional, default: include)
|
||||
bootstrapContent := cb.LoadBootstrapFiles()
|
||||
if bootstrapContent != "" {
|
||||
parts = append(parts, bootstrapContent)
|
||||
}
|
||||
|
||||
// Skills - filtered based on options
|
||||
if len(opts.IncludeSkills) > 0 || len(opts.ExcludeSkills) > 0 {
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummaryFiltered(opts.IncludeSkills)
|
||||
if skillsSummary != "" {
|
||||
parts = append(parts, fmt.Sprintf(`# Skills
|
||||
|
||||
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
||||
|
||||
%s`, skillsSummary))
|
||||
}
|
||||
}
|
||||
|
||||
// Memory context (optional, default: true)
|
||||
if opts.IncludeMemory {
|
||||
memoryContext := cb.memory.GetMemoryContext()
|
||||
if memoryContext != "" {
|
||||
parts = append(parts, "# Memory\n\n"+memoryContext)
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime info (optional, default: true)
|
||||
if opts.IncludeRuntime {
|
||||
dynamicCtx := cb.buildDynamicContext(channel, chatID)
|
||||
parts = append(parts, dynamicCtx)
|
||||
}
|
||||
|
||||
// Join with "---" separator
|
||||
return strings.Join(parts, "\n\n---\n\n")
|
||||
}
|
||||
|
||||
// BuildMessagesWithOptions builds context messages with specified strategy and options.
|
||||
// This is the main entry point for building context, supporting multiple strategies:
|
||||
// - Full: Complete context (default, backward compatible)
|
||||
// - Lite: Minimal context for simple queries
|
||||
// - Custom: Explicitly specified includes/excludes
|
||||
//
|
||||
// Parameters:
|
||||
// - history: Conversation history
|
||||
// - summary: Optional conversation summary
|
||||
// - currentMessage: Current user message
|
||||
// - media: Optional media attachments
|
||||
// - channel: Channel type (e.g., "telegram", "wecom")
|
||||
// - chatID: Chat identifier
|
||||
// - opts: Build options (strategy, includes, excludes)
|
||||
//
|
||||
// Returns:
|
||||
// - Array of provider-compatible messages
|
||||
func (cb *ContextBuilder) BuildMessagesWithOptions(
|
||||
history []providers.Message,
|
||||
summary string,
|
||||
currentMessage string,
|
||||
media []string,
|
||||
channel, chatID string,
|
||||
opts ContextBuildOptions,
|
||||
) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
// The static part (identity, bootstrap, skills, memory) is cached locally to
|
||||
// avoid repeated file I/O and string building on every call (fixes issue #607).
|
||||
// Dynamic parts (time, session, summary) are appended per request.
|
||||
// Everything is sent as a single system message for provider compatibility:
|
||||
// - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
|
||||
// to the top-level "system" parameter in the Messages API request. A single
|
||||
// contiguous system block makes this extraction straightforward.
|
||||
// - Codex maps only the first system message to its instructions field.
|
||||
// - OpenAI-compat passes messages through as-is.
|
||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||
// Set default values for optional fields
|
||||
if !opts.IncludeMemory {
|
||||
opts.IncludeMemory = true
|
||||
}
|
||||
if !opts.IncludeRuntime {
|
||||
opts.IncludeRuntime = true
|
||||
}
|
||||
|
||||
// Build short dynamic context (time, runtime, session) — changes per request
|
||||
dynamicCtx := cb.buildDynamicContext(channel, chatID)
|
||||
// Auto-detect recommended skills if recommender is enabled and no explicit filter
|
||||
if cb.skillRecommender != nil && len(opts.IncludeSkills) == 0 && len(cb.skillsFilter) == 0 {
|
||||
recommendations, err := cb.skillRecommender.RecommendSkillsForContext(channel, chatID, currentMessage, history)
|
||||
if err == nil && len(recommendations) > 0 {
|
||||
// Extract top N recommended skills (with score > threshold)
|
||||
recommendedSkillNames := make([]string, 0)
|
||||
for _, rec := range recommendations {
|
||||
if rec.Score >= 30.0 { // Only include skills with score >= 30%
|
||||
recommendedSkillNames = append(recommendedSkillNames, rec.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||
// Keeping all system content in one message ensures every provider adapter can
|
||||
// extract it correctly (Anthropic adapter -> top-level system param,
|
||||
// Codex -> instructions field).
|
||||
//
|
||||
// SystemParts carries the same content as structured blocks so that
|
||||
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
||||
// The static block is marked "ephemeral" — its prefix hash is stable
|
||||
// across requests, enabling LLM-side KV cache reuse.
|
||||
stringParts := []string{staticPrompt, dynamicCtx}
|
||||
if len(recommendedSkillNames) > 0 {
|
||||
opts.IncludeSkills = recommendedSkillNames
|
||||
logger.DebugCF("agent", "Auto-recommended skills for context",
|
||||
map[string]any{
|
||||
"channel": channel,
|
||||
"skills": recommendedSkillNames,
|
||||
"count": len(recommendedSkillNames),
|
||||
"message": currentMessage[:min(50, len(currentMessage))] + "...",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build static prompt based on strategy
|
||||
var staticPrompt string
|
||||
switch opts.Strategy {
|
||||
case ContextStrategyLite:
|
||||
staticPrompt = cb.buildLiteContext(channel, chatID)
|
||||
case ContextStrategyCustom:
|
||||
staticPrompt = cb.buildCustomContext(opts, channel, chatID)
|
||||
case ContextStrategyFull:
|
||||
fallthrough
|
||||
default:
|
||||
// Use cached system prompt for full strategy
|
||||
staticPrompt = cb.BuildSystemPromptWithCache()
|
||||
}
|
||||
|
||||
// Build dynamic context (always included for non-lite strategies)
|
||||
var dynamicCtx string
|
||||
if opts.Strategy != ContextStrategyLite && opts.IncludeRuntime {
|
||||
dynamicCtx = cb.buildDynamicContext(channel, chatID)
|
||||
}
|
||||
|
||||
// Compose system message parts
|
||||
stringParts := []string{staticPrompt}
|
||||
contentBlocks := []providers.ContentBlock{
|
||||
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
|
||||
{Type: "text", Text: dynamicCtx},
|
||||
{Type: "text", Text: staticPrompt},
|
||||
}
|
||||
|
||||
if dynamicCtx != "" {
|
||||
stringParts = append(stringParts, dynamicCtx)
|
||||
contentBlocks = append(contentBlocks, providers.ContentBlock{
|
||||
Type: "text",
|
||||
Text: dynamicCtx,
|
||||
CacheControl: &providers.CacheControl{Type: "ephemeral"},
|
||||
})
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
|
|
@ -426,14 +627,13 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||
|
||||
// Log system prompt summary for debugging (debug mode only).
|
||||
// Read cachedSystemPrompt under lock to avoid a data race with
|
||||
// concurrent InvalidateCache / BuildSystemPromptWithCache writes.
|
||||
cb.systemPromptMutex.RLock()
|
||||
isCached := cb.cachedSystemPrompt != ""
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
|
||||
logger.DebugCF("agent", "System prompt built",
|
||||
logger.DebugCF("agent", "System prompt built with options",
|
||||
map[string]any{
|
||||
"strategy": opts.Strategy,
|
||||
"static_chars": len(staticPrompt),
|
||||
"dynamic_chars": len(dynamicCtx),
|
||||
"total_chars": len(fullSystemPrompt),
|
||||
|
|
@ -454,8 +654,6 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
history = sanitizeHistoryForProvider(history)
|
||||
|
||||
// Single system message containing all context — compatible with all providers.
|
||||
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
||||
// Content is the concatenated fallback for adapters that don't read SystemParts.
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: fullSystemPrompt,
|
||||
|
|
@ -476,6 +674,22 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(
|
||||
history []providers.Message,
|
||||
summary string,
|
||||
currentMessage string,
|
||||
media []string,
|
||||
channel, chatID string,
|
||||
) []providers.Message {
|
||||
// Delegate to BuildMessagesWithOptions with default Full strategy
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
return cb.BuildMessagesWithOptions(history, summary, currentMessage, media, channel, chatID, opts)
|
||||
}
|
||||
|
||||
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
|
||||
if len(history) == 0 {
|
||||
return history
|
||||
|
|
@ -567,6 +781,13 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
|||
return messages
|
||||
}
|
||||
|
||||
// SetSkillRecommender sets the skill recommender for this context builder.
|
||||
// When set, the recommender will be used to intelligently select skills based on context.
|
||||
// This is an optional enhancement that can improve LLM performance by reducing context size.
|
||||
func (cb *ContextBuilder) SetSkillRecommender(recommender *SkillRecommender) {
|
||||
cb.skillRecommender = recommender
|
||||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
|
|
@ -574,9 +795,19 @@ func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
|||
for _, s := range allSkills {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
return map[string]any{
|
||||
|
||||
result := map[string]any{
|
||||
"total": len(allSkills),
|
||||
"available": len(allSkills),
|
||||
"names": skillNames,
|
||||
}
|
||||
|
||||
// Include recommender info if available
|
||||
if cb.skillRecommender != nil {
|
||||
result["recommender"] = "enabled"
|
||||
} else {
|
||||
result["recommender"] = "disabled"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
310
pkg/agent/context_benchmark_test.go
Normal file
310
pkg/agent/context_benchmark_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
// createBenchmarkSkills creates a set of skills for benchmarking.
|
||||
func createBenchmarkSkills(basePath string, count int) error {
|
||||
for i := 0; i < count; i++ {
|
||||
skillName := fmt.Sprintf("benchmark-skill-%d", i)
|
||||
skillDir := filepath.Join(basePath, "skills", skillName)
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
content := fmt.Sprintf(`---
|
||||
name: %s
|
||||
description: This is benchmark skill number %d with some description text
|
||||
---
|
||||
|
||||
# %s
|
||||
|
||||
This is the content of benchmark skill %d.
|
||||
It contains multiple lines to simulate real skill content.
|
||||
The purpose is to test token usage in different context building strategies.
|
||||
`, skillName, i, skillName, i)
|
||||
|
||||
if err := os.WriteFile(skillFile, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_FullStrategy measures performance of Full strategy.
|
||||
func BenchmarkContextBuilder_FullStrategy(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
// Create 10 skills for realistic benchmark
|
||||
if err := createBenchmarkSkills(workspace, 10); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "Hello, I need help with my code"},
|
||||
{Role: "assistant", Content: "Sure, I'd be happy to help! What do you need?"},
|
||||
{Role: "user", Content: "Can you review this function?"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "Please review the code", nil, "telegram", "chat123", opts)
|
||||
if len(messages) == 0 {
|
||||
b.Fatal("no messages returned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_LiteStrategy measures performance of Lite strategy.
|
||||
func BenchmarkContextBuilder_LiteStrategy(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
// Create 10 skills
|
||||
if err := createBenchmarkSkills(workspace, 10); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "Quick question"},
|
||||
{Role: "assistant", Content: "Yes?"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyLite,
|
||||
IncludeMemory: false,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "What time is it?", nil, "telegram", "chat123", opts)
|
||||
if len(messages) == 0 {
|
||||
b.Fatal("no messages returned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_CustomStrategy measures performance of Custom strategy.
|
||||
func BenchmarkContextBuilder_CustomStrategy(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
// Create 10 skills
|
||||
if err := createBenchmarkSkills(workspace, 10); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "Help me with task X"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyCustom,
|
||||
IncludeSkills: []string{"benchmark-skill-0", "benchmark-skill-1"},
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "Do task X", nil, "telegram", "chat123", opts)
|
||||
if len(messages) == 0 {
|
||||
b.Fatal("no messages returned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_TokenUsageComparison compares token usage across strategies.
|
||||
func BenchmarkContextBuilder_TokenUsageComparison(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
// Create 20 skills for comprehensive comparison
|
||||
if err := createBenchmarkSkills(workspace, 20); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "I need assistance"},
|
||||
{Role: "assistant", Content: "How can I help?"},
|
||||
}
|
||||
|
||||
strategies := []ContextStrategy{
|
||||
ContextStrategyFull,
|
||||
ContextStrategyLite,
|
||||
ContextStrategyCustom,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, strategy := range strategies {
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: strategy,
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
IncludeSkills: []string{"benchmark-skill-0"}, // Custom strategy filter
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "Test message", nil, "telegram", "chat123", opts)
|
||||
if len(messages) == 0 {
|
||||
b.Fatal("no messages returned")
|
||||
}
|
||||
|
||||
// Record token count (approximate by character count)
|
||||
totalChars := 0
|
||||
for _, msg := range messages {
|
||||
totalChars += len(msg.Content)
|
||||
}
|
||||
b.ReportMetric(float64(totalChars), "chars")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_SystemPromptSize measures system prompt size for each strategy.
|
||||
func BenchmarkContextBuilder_SystemPromptSize(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
// Create varying number of skills
|
||||
skillCounts := []int{5, 10, 20, 50}
|
||||
|
||||
for _, count := range skillCounts {
|
||||
b.Run(fmt.Sprintf("%d_skills", count), func(b *testing.B) {
|
||||
// Clean workspace
|
||||
os.RemoveAll(workspace)
|
||||
os.MkdirAll(workspace, 0o755)
|
||||
|
||||
if err := createBenchmarkSkills(workspace, count); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
IncludeMemory: false,
|
||||
IncludeRuntime: false,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages := cb.BuildMessagesWithOptions([]providers.Message{}, "", "test", nil, "telegram", "chat", opts)
|
||||
if len(messages) == 0 || len(messages[0].Content) == 0 {
|
||||
b.Fatal("empty system prompt")
|
||||
}
|
||||
|
||||
b.ReportMetric(float64(len(messages[0].Content)), "system_prompt_chars")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_CachePerformance tests cache hit performance.
|
||||
func BenchmarkContextBuilder_CachePerformance(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
if err := createBenchmarkSkills(workspace, 15); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
IncludeMemory: false,
|
||||
IncludeRuntime: false,
|
||||
}
|
||||
|
||||
// First call builds cache
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages := cb.BuildMessagesWithOptions([]providers.Message{}, "", "test", nil, "telegram", "chat", opts)
|
||||
if len(messages) == 0 {
|
||||
b.Fatal("no messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkContextBuilder_WithSkillRecommender tests performance with skill recommender.
|
||||
func BenchmarkContextBuilder_WithSkillRecommender(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
|
||||
if err := createBenchmarkSkills(workspace, 10); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
// Note: We're not setting up actual recommender here to avoid LLM dependency
|
||||
// In production, the recommender would add some overhead but improve relevance
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "I want to create a poll"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "Create a poll for me", nil, "telegram", "chat123", opts)
|
||||
if len(messages) == 0 {
|
||||
b.Fatal("no messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSkillsLoader_BuildSkillsSummaryFiltered benchmarks filtered skill loading.
|
||||
func BenchmarkSkillsLoader_BuildSkillsSummaryFiltered(b *testing.B) {
|
||||
workspace := b.TempDir()
|
||||
globalSkills := b.TempDir()
|
||||
builtinSkills := b.TempDir()
|
||||
|
||||
// Create 50 skills
|
||||
if err := createBenchmarkSkills(workspace, 50); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
loader := skills.NewSkillsLoader(workspace, globalSkills, builtinSkills)
|
||||
|
||||
filterSizes := []int{1, 5, 10, 25, 50}
|
||||
|
||||
for _, filterSize := range filterSizes {
|
||||
b.Run(fmt.Sprintf("filter_%d_skills", filterSize), func(b *testing.B) {
|
||||
filter := make([]string, filterSize)
|
||||
for i := 0; i < filterSize; i++ {
|
||||
filter[i] = fmt.Sprintf("benchmark-skill-%d", i)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
summary := loader.BuildSkillsSummaryFiltered(filter)
|
||||
if summary == "" && filterSize > 0 {
|
||||
b.Fatal("empty summary")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -207,3 +207,240 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_FullStrategy(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "hi there"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "test message", nil, "telegram", "chat123", opts)
|
||||
|
||||
// Should have system message + history + current message
|
||||
if len(messages) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
// First message should be system
|
||||
if messages[0].Role != "system" {
|
||||
t.Errorf("expected first message to be system, got %s", messages[0].Role)
|
||||
}
|
||||
|
||||
// System message should contain identity (picoclaw header)
|
||||
if !containsString(messages[0].Content, "# picoclaw") {
|
||||
t.Error("expected system message to contain picoclaw identity")
|
||||
}
|
||||
|
||||
// Check history is preserved
|
||||
assertRoles(t, messages[1:], "user", "assistant", "user")
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_LiteStrategy(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "quick question"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyLite,
|
||||
IncludeMemory: false,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "what time is it?", nil, "telegram", "chat123", opts)
|
||||
|
||||
// Should have system message + history + current message
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
// System message should be minimal (lite)
|
||||
systemContent := messages[0].Content
|
||||
if containsString(systemContent, "<skills>") {
|
||||
t.Error("lite strategy should not include skills")
|
||||
}
|
||||
|
||||
// But should still have identity
|
||||
if !containsString(systemContent, "# picoclaw") {
|
||||
t.Error("lite strategy should still include identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_CustomStrategy(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "help me with code"},
|
||||
}
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyCustom,
|
||||
IncludeSkills: []string{"test-skill"},
|
||||
ExcludeSkills: []string{},
|
||||
IncludeTools: []string{},
|
||||
ExcludeTools: []string{},
|
||||
IncludeMemory: true,
|
||||
IncludeRuntime: true,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(history, "", "show me the code", nil, "slack", "channel456", opts)
|
||||
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
// System message should use custom strategy
|
||||
systemContent := messages[0].Content
|
||||
if !containsString(systemContent, "# picoclaw") {
|
||||
t.Error("custom strategy should include identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_EmptyHistory(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions([]providers.Message{}, "", "first message", nil, "discord", "server789", opts)
|
||||
|
||||
// Should have system message + current message only
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
assertRoles(t, messages, "system", "user")
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_Summary(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "earlier we discussed"},
|
||||
}
|
||||
|
||||
summary := "User asked about Go concurrency patterns and error handling."
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(history, summary, "can you elaborate?", nil, "telegram", "chat123", opts)
|
||||
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
// System message should contain summary
|
||||
if !containsString(messages[0].Content, "CONTEXT_SUMMARY") {
|
||||
t.Error("expected system message to contain CONTEXT_SUMMARY")
|
||||
}
|
||||
|
||||
if !containsString(messages[0].Content, summary) {
|
||||
t.Error("expected system message to contain the summary text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_Media(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
}
|
||||
|
||||
media := []string{"image.jpg", "document.pdf"}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions([]providers.Message{}, "", "check these files", media, "wecom", "user999", opts)
|
||||
|
||||
// Media handling might be implemented in future
|
||||
// For now, just ensure it doesn't crash
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_DefaultValues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
// Test with zero-value options - should use defaults
|
||||
opts := ContextBuildOptions{}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(
|
||||
[]providers.Message{{Role: "user", Content: "test"}},
|
||||
"",
|
||||
"message",
|
||||
nil,
|
||||
"telegram",
|
||||
"chat123",
|
||||
opts,
|
||||
)
|
||||
|
||||
// Should default to Full strategy with memory and runtime enabled
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected messages to be returned")
|
||||
}
|
||||
|
||||
if messages[0].Role != "system" {
|
||||
t.Error("expected first message to be system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextBuilder_BuildMessagesWithOptions_ChannelSpecific(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cb := NewContextBuilder(workspace)
|
||||
|
||||
channels := []string{"telegram", "discord", "slack", "wecom", "feishu"}
|
||||
|
||||
for _, channel := range channels {
|
||||
t.Run(channel, func(t *testing.T) {
|
||||
opts := ContextBuildOptions{
|
||||
Strategy: ContextStrategyFull,
|
||||
}
|
||||
|
||||
messages := cb.BuildMessagesWithOptions(
|
||||
[]providers.Message{},
|
||||
"",
|
||||
"test",
|
||||
nil,
|
||||
channel,
|
||||
"chat-"+channel,
|
||||
opts,
|
||||
)
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Errorf("expected 2 messages for %s, got %d", channel, len(messages))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring.
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
|
|
@ -15,22 +17,23 @@ import (
|
|||
// AgentInstance represents a fully configured agent with its own workspace,
|
||||
// session manager, context builder, and tool registry.
|
||||
type AgentInstance struct {
|
||||
ID string
|
||||
Name string
|
||||
Model string
|
||||
Fallbacks []string
|
||||
Workspace string
|
||||
MaxIterations int
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
ContextWindow int
|
||||
Provider providers.LLMProvider
|
||||
Sessions *session.SessionManager
|
||||
ContextBuilder *ContextBuilder
|
||||
Tools *tools.ToolRegistry
|
||||
Subagents *config.SubagentsConfig
|
||||
SkillsFilter []string
|
||||
Candidates []providers.FallbackCandidate
|
||||
ID string
|
||||
Name string
|
||||
Model string
|
||||
Fallbacks []string
|
||||
Workspace string
|
||||
MaxIterations int
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
ContextWindow int
|
||||
Provider providers.LLMProvider
|
||||
Sessions *session.SessionManager
|
||||
ContextBuilder *ContextBuilder
|
||||
Tools *tools.ToolRegistry
|
||||
Subagents *config.SubagentsConfig
|
||||
skillsFilterMutex sync.RWMutex // Protects SkillsFilter
|
||||
SkillsFilter []string
|
||||
Candidates []providers.FallbackCandidate
|
||||
}
|
||||
|
||||
// NewAgentInstance creates an agent instance from config.
|
||||
|
|
@ -156,3 +159,96 @@ func expandHome(path string) string {
|
|||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// SetSkillsFilter dynamically sets the skills filter for this agent.
|
||||
// Modifying the filter will trigger ContextBuilder cache invalidation
|
||||
// to ensure the next request uses the updated skill set.
|
||||
//
|
||||
// Parameters:
|
||||
// - filters: List of skill names to include. Empty or nil clears the filter.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// agent.SetSkillsFilter([]string{"customer-service", "faq"})
|
||||
// agent.SetSkillsFilter(nil) // Clear filter, all skills available
|
||||
func (ai *AgentInstance) SetSkillsFilter(filters []string) {
|
||||
ai.skillsFilterMutex.Lock()
|
||||
defer ai.skillsFilterMutex.Unlock()
|
||||
|
||||
// Create a copy to prevent external modification
|
||||
if filters == nil {
|
||||
ai.SkillsFilter = nil
|
||||
} else {
|
||||
ai.SkillsFilter = make([]string, len(filters))
|
||||
copy(ai.SkillsFilter, filters)
|
||||
}
|
||||
|
||||
// Trigger context builder cache invalidation and update its filter
|
||||
if ai.ContextBuilder != nil {
|
||||
ai.ContextBuilder.SetSkillsFilter(filters)
|
||||
}
|
||||
}
|
||||
|
||||
// EnableSkillRecommender enables intelligent skill recommendation for this agent.
|
||||
// The recommender will automatically select relevant skills based on context.
|
||||
//
|
||||
// Parameters:
|
||||
// - model: Model to use for LLM-based recommendations (optional, uses agent's model if empty)
|
||||
// - weights: Optional custom weights for scoring algorithm (channel, keyword, history, recency)
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Enable with default settings
|
||||
// agent.EnableSkillRecommender()
|
||||
//
|
||||
// // Enable with custom weights
|
||||
// agent.EnableSkillRecommenderWithWeights(0.5, 0.3, 0.15, 0.05)
|
||||
func (ai *AgentInstance) EnableSkillRecommender(model string, weights ...[4]float64) {
|
||||
if model == "" {
|
||||
model = ai.Model
|
||||
}
|
||||
|
||||
recommender := NewSkillRecommender(
|
||||
ai.ContextBuilder.skillsLoader,
|
||||
ai.Provider,
|
||||
model,
|
||||
)
|
||||
|
||||
// Set custom weights if provided
|
||||
if len(weights) > 0 {
|
||||
w := weights[0]
|
||||
recommender.SetWeights(w[0], w[1], w[2], w[3])
|
||||
}
|
||||
|
||||
ai.ContextBuilder.SetSkillRecommender(recommender)
|
||||
|
||||
logger.InfoCF("agent", "Skill recommender enabled",
|
||||
map[string]any{
|
||||
"agent_id": ai.ID,
|
||||
"model": model,
|
||||
})
|
||||
}
|
||||
|
||||
// EnableSkillRecommenderWithWeights enables skill recommender with custom weights.
|
||||
// This is a convenience wrapper around EnableSkillRecommender.
|
||||
func (ai *AgentInstance) EnableSkillRecommenderWithWeights(channel, keyword, history, recency float64) {
|
||||
ai.EnableSkillRecommender("", [4]float64{channel, keyword, history, recency})
|
||||
}
|
||||
|
||||
// GetSkillsFilter returns the current skills filter.
|
||||
// Returns nil if no filter is set (all skills available).
|
||||
//
|
||||
// The returned slice is a copy to prevent concurrent modification.
|
||||
func (ai *AgentInstance) GetSkillsFilter() []string {
|
||||
ai.skillsFilterMutex.RLock()
|
||||
defer ai.skillsFilterMutex.RUnlock()
|
||||
|
||||
if ai.SkillsFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return a copy to prevent concurrent modification
|
||||
result := make([]string, len(ai.SkillsFilter))
|
||||
copy(result, ai.SkillsFilter)
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
154
pkg/agent/instance_skills_filter_test.go
Normal file
154
pkg/agent/instance_skills_filter_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgentInstance_SetSkillsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a minimal agent instance for testing
|
||||
agent := &AgentInstance{
|
||||
ID: "test",
|
||||
SkillsFilter: nil,
|
||||
}
|
||||
|
||||
t.Run("set initial filter", func(t *testing.T) {
|
||||
agent.SetSkillsFilter([]string{"skill1", "skill2"})
|
||||
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Equal(t, []string{"skill1", "skill2"}, filter)
|
||||
})
|
||||
|
||||
t.Run("update existing filter", func(t *testing.T) {
|
||||
agent.SetSkillsFilter([]string{"skill3", "skill4"})
|
||||
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Equal(t, []string{"skill3", "skill4"}, filter)
|
||||
})
|
||||
|
||||
t.Run("clear filter with nil", func(t *testing.T) {
|
||||
agent.SetSkillsFilter(nil)
|
||||
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Nil(t, filter)
|
||||
})
|
||||
|
||||
t.Run("clear filter with empty slice", func(t *testing.T) {
|
||||
agent.SetSkillsFilter([]string{"skill1"})
|
||||
agent.SetSkillsFilter([]string{})
|
||||
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Empty(t, filter)
|
||||
})
|
||||
|
||||
t.Run("returned slice is a copy", func(t *testing.T) {
|
||||
agent.SetSkillsFilter([]string{"skill1", "skill2"})
|
||||
|
||||
filter1 := agent.GetSkillsFilter()
|
||||
filter1[0] = "modified"
|
||||
|
||||
filter2 := agent.GetSkillsFilter()
|
||||
assert.Equal(t, "skill1", filter2[0], "original should not be modified")
|
||||
})
|
||||
|
||||
t.Run("input slice is copied", func(t *testing.T) {
|
||||
input := []string{"skill1", "skill2"}
|
||||
agent.SetSkillsFilter(input)
|
||||
|
||||
// Modify input after setting
|
||||
input[0] = "modified"
|
||||
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Equal(t, "skill1", filter[0], "filter should not be affected by input modification")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentInstance_GetSkillsFilter_ConcurrentAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
agent := &AgentInstance{
|
||||
ID: "test",
|
||||
SkillsFilter: []string{"skill1", "skill2", "skill3"},
|
||||
}
|
||||
|
||||
// Run multiple concurrent reads
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 100
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Len(t, filter, 3)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestAgentInstance_SetSkillsFilter_ConcurrentAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
agent := &AgentInstance{
|
||||
ID: "test",
|
||||
SkillsFilter: []string{"skill1"},
|
||||
}
|
||||
|
||||
// Run concurrent reads and writes
|
||||
var wg sync.WaitGroup
|
||||
numWriters := 10
|
||||
numReaders := 100
|
||||
|
||||
// Writers
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(iteration int) {
|
||||
defer wg.Done()
|
||||
agent.SetSkillsFilter([]string{"skill1", "skill2"})
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
filter := agent.GetSkillsFilter()
|
||||
// Should not panic, may see either old or new value
|
||||
_ = filter
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify final state
|
||||
finalFilter := agent.GetSkillsFilter()
|
||||
assert.NotNil(t, finalFilter)
|
||||
}
|
||||
|
||||
func TestAgentInstance_SkillsFilterPersistence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
agent := &AgentInstance{
|
||||
ID: "test",
|
||||
SkillsFilter: nil,
|
||||
}
|
||||
|
||||
// Set filter
|
||||
agent.SetSkillsFilter([]string{"customer-service", "faq", "order-tracking"})
|
||||
|
||||
// Simulate multiple requests (concurrent reads)
|
||||
for i := 0; i < 10; i++ {
|
||||
filter := agent.GetSkillsFilter()
|
||||
assert.Equal(t, []string{"customer-service", "faq", "order-tracking"}, filter)
|
||||
}
|
||||
|
||||
// Verify still persists after all reads
|
||||
finalFilter := agent.GetSkillsFilter()
|
||||
assert.Equal(t, []string{"customer-service", "faq", "order-tracking"}, finalFilter)
|
||||
}
|
||||
|
|
@ -488,8 +488,13 @@ func (al *AgentLoop) runLLMIteration(
|
|||
"max": agent.MaxIterations,
|
||||
})
|
||||
|
||||
// Build tool definitions
|
||||
providerToolDefs := agent.Tools.ToProviderDefs()
|
||||
// Build tool definitions with context-based filtering
|
||||
visibilityCtx := tools.ToolVisibilityContext{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
providerToolDefs := agent.Tools.ToProviderDefsForContext(visibilityCtx)
|
||||
|
||||
// Log LLM request details
|
||||
logger.DebugCF("agent", "LLM request",
|
||||
|
|
|
|||
450
pkg/agent/recommender.go
Normal file
450
pkg/agent/recommender.go
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
// SkillRecommendation represents a recommended skill with metadata.
|
||||
type SkillRecommendation struct {
|
||||
Name string `json:"name"` // Skill name
|
||||
Score float64 `json:"score"` // Recommendation score (0-100)
|
||||
Reason string `json:"reason"` // Why this skill is recommended
|
||||
Confidence float64 `json:"confidence"` // Confidence level (0-1)
|
||||
ChannelType string `json:"channelType"` // Channel type that triggered this recommendation
|
||||
}
|
||||
|
||||
// SkillRecommender provides context-based skill recommendations using LLM reasoning.
|
||||
type SkillRecommender struct {
|
||||
skillsLoader *skills.SkillsLoader
|
||||
llmProvider providers.LLMProvider
|
||||
model string
|
||||
|
||||
// Weights for scoring algorithm
|
||||
channelWeight float64 // Weight for channel-based matching (default: 0.4)
|
||||
keywordWeight float64 // Weight for keyword matching (default: 0.3)
|
||||
historyWeight float64 // Weight for historical usage (default: 0.2)
|
||||
recencyWeight float64 // Weight for recency (default: 0.1)
|
||||
}
|
||||
|
||||
// NewSkillRecommender creates a new skill recommender.
|
||||
func NewSkillRecommender(
|
||||
skillsLoader *skills.SkillsLoader,
|
||||
llmProvider providers.LLMProvider,
|
||||
model string,
|
||||
) *SkillRecommender {
|
||||
return &SkillRecommender{
|
||||
skillsLoader: skillsLoader,
|
||||
llmProvider: llmProvider,
|
||||
model: model,
|
||||
channelWeight: 0.4,
|
||||
keywordWeight: 0.3,
|
||||
historyWeight: 0.2,
|
||||
recencyWeight: 0.1,
|
||||
}
|
||||
}
|
||||
|
||||
// SetWeights sets the weights for the scoring algorithm.
|
||||
// All weights should be between 0 and 1, and ideally sum to 1.0.
|
||||
func (sr *SkillRecommender) SetWeights(channel, keyword, history, recency float64) {
|
||||
sr.channelWeight = channel
|
||||
sr.keywordWeight = keyword
|
||||
sr.historyWeight = history
|
||||
sr.recencyWeight = recency
|
||||
}
|
||||
|
||||
// RecommendSkillsForContext recommends skills based on the current context.
|
||||
// It uses a hybrid approach:
|
||||
// 1. Rule-based pre-filtering (channel type, keywords)
|
||||
// 2. LLM-based reasoning for final selection
|
||||
// 3. Scoring based on multiple factors
|
||||
//
|
||||
// Parameters:
|
||||
// - channel: Channel type (e.g., "telegram", "wecom", "slack")
|
||||
// - chatID: Chat identifier
|
||||
// - userMessage: Current user message
|
||||
// - history: Recent conversation history
|
||||
//
|
||||
// Returns:
|
||||
// - List of recommended skills with scores and reasons
|
||||
func (sr *SkillRecommender) RecommendSkillsForContext(
|
||||
channel, chatID, userMessage string,
|
||||
history []providers.Message,
|
||||
) ([]SkillRecommendation, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Get all available skills
|
||||
allSkills := sr.skillsLoader.ListSkills()
|
||||
if len(allSkills) == 0 {
|
||||
logger.DebugCF("agent", "No skills available for recommendation", nil)
|
||||
return []SkillRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Step 1: Rule-based pre-filtering and scoring
|
||||
preFilteredSkills := sr.preFilterAndScore(allSkills, channel, userMessage, history)
|
||||
|
||||
if len(preFilteredSkills) == 0 {
|
||||
logger.DebugCF("agent", "No skills passed pre-filtering", nil)
|
||||
return []SkillRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Step 2: Use LLM for intelligent selection if we have multiple candidates
|
||||
var finalRecommendations []SkillRecommendation
|
||||
if len(preFilteredSkills) > 1 {
|
||||
var err error
|
||||
finalRecommendations, err = sr.llmBasedSelection(preFilteredSkills, channel, userMessage, history)
|
||||
if err != nil {
|
||||
logger.ErrorCF("agent", "LLM-based selection failed, using pre-filtered results",
|
||||
map[string]any{"error": err.Error()})
|
||||
finalRecommendations = preFilteredSkills
|
||||
}
|
||||
} else {
|
||||
finalRecommendations = preFilteredSkills
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
sortRecommendationsByScore(finalRecommendations)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
logger.DebugCF("agent", "Skill recommendation completed",
|
||||
map[string]any{
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"total_skills": len(allSkills),
|
||||
"pre_filtered": len(preFilteredSkills),
|
||||
"final_selected": len(finalRecommendations),
|
||||
"channel": channel,
|
||||
})
|
||||
|
||||
return finalRecommendations, nil
|
||||
}
|
||||
|
||||
// preFilterAndScore performs rule-based pre-filtering and initial scoring.
|
||||
func (sr *SkillRecommender) preFilterAndScore(
|
||||
allSkills []skills.SkillInfo,
|
||||
channel, userMessage string,
|
||||
history []providers.Message,
|
||||
) []SkillRecommendation {
|
||||
recommendations := make([]SkillRecommendation, 0)
|
||||
|
||||
for _, skill := range allSkills {
|
||||
score := 0.0
|
||||
reasons := make([]string, 0)
|
||||
|
||||
// Channel-based scoring (40%)
|
||||
channelScore := sr.scoreByChannel(skill, channel)
|
||||
if channelScore > 0 {
|
||||
score += channelScore * sr.channelWeight
|
||||
reasons = append(reasons, fmt.Sprintf("channel match (%s)", channel))
|
||||
}
|
||||
|
||||
// Keyword-based scoring (30%)
|
||||
keywordScore := sr.scoreByKeywords(skill, userMessage)
|
||||
if keywordScore > 0 {
|
||||
score += keywordScore * sr.keywordWeight
|
||||
reasons = append(reasons, "keyword match")
|
||||
}
|
||||
|
||||
// History-based scoring (20%)
|
||||
historyScore := sr.scoreByHistory(skill, history)
|
||||
if historyScore > 0 {
|
||||
score += historyScore * sr.historyWeight
|
||||
reasons = append(reasons, "used in history")
|
||||
}
|
||||
|
||||
// Recency scoring (10%)
|
||||
recencyScore := sr.scoreByRecency(skill, history)
|
||||
if recencyScore > 0 {
|
||||
score += recencyScore * sr.recencyWeight
|
||||
reasons = append(reasons, "recently used")
|
||||
}
|
||||
|
||||
// Only include skills with positive score
|
||||
if score > 0 {
|
||||
recommendations = append(recommendations, SkillRecommendation{
|
||||
Name: skill.Name,
|
||||
Score: score * 100, // Normalize to 0-100
|
||||
Reason: strings.Join(reasons, ", "),
|
||||
Confidence: 0.5, // Base confidence before LLM refinement
|
||||
ChannelType: channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// scoreByChannel scores a skill based on channel type compatibility.
|
||||
func (sr *SkillRecommender) scoreByChannel(skill skills.SkillInfo, channel string) float64 {
|
||||
// Check if skill description or name mentions channel-specific keywords
|
||||
channelKeywords := map[string][]string{
|
||||
"telegram": {"telegram", "sticker", "poll", "inline"},
|
||||
"wecom": {"wecom", "wechat", "企业微信", "approval", "meeting"},
|
||||
"slack": {"slack", "huddle", "workflow", "emoji"},
|
||||
"discord": {"discord", "server", "voice", "reaction"},
|
||||
"feishu": {"feishu", "lark", "飞书", "doc", "bitable"},
|
||||
"dingtalk": {"dingtalk", "钉钉", "ding", "approval"},
|
||||
}
|
||||
|
||||
keywords, exists := channelKeywords[channel]
|
||||
if !exists {
|
||||
return 0.3 // Default score for unknown channels
|
||||
}
|
||||
|
||||
skillText := strings.ToLower(skill.Name + " " + skill.Description)
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(skillText, strings.ToLower(keyword)) {
|
||||
return 1.0 // Perfect match
|
||||
}
|
||||
}
|
||||
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// scoreByKeywords scores a skill based on user message keywords.
|
||||
func (sr *SkillRecommender) scoreByKeywords(skill skills.SkillInfo, userMessage string) float64 {
|
||||
messageLower := strings.ToLower(userMessage)
|
||||
|
||||
// Extract important keywords from skill description
|
||||
importantKeywords := extractKeywords(skill.Description)
|
||||
|
||||
matchCount := 0
|
||||
for _, keyword := range importantKeywords {
|
||||
if strings.Contains(messageLower, strings.ToLower(keyword)) {
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
|
||||
if matchCount == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Score based on keyword match ratio
|
||||
score := float64(matchCount) / float64(len(importantKeywords))
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// scoreByHistory scores a skill based on historical usage.
|
||||
func (sr *SkillRecommender) scoreByHistory(skill skills.SkillInfo, history []providers.Message) float64 {
|
||||
// Check if this skill was used in recent history
|
||||
for _, msg := range history {
|
||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||
for _, toolCall := range msg.ToolCalls {
|
||||
if toolCall.Function != nil && toolCall.Function.Name == skill.Name {
|
||||
return 0.8 // High score for recently used skill
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// scoreByRecency scores a skill based on how recently it was used.
|
||||
func (sr *SkillRecommender) scoreByRecency(skill skills.SkillInfo, history []providers.Message) float64 {
|
||||
// Simple recency: more recent = higher score
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||
for _, toolCall := range msg.ToolCalls {
|
||||
if toolCall.Function != nil && toolCall.Function.Name == skill.Name {
|
||||
// Linear decay based on position
|
||||
recency := float64(len(history)-i) / float64(len(history))
|
||||
return 1.0 - recency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// llmBasedSelection uses LLM to make intelligent skill selections.
|
||||
func (sr *SkillRecommender) llmBasedSelection(
|
||||
candidates []SkillRecommendation,
|
||||
channel, userMessage string,
|
||||
history []providers.Message,
|
||||
) ([]SkillRecommendation, error) {
|
||||
// Build prompt for LLM
|
||||
prompt := sr.buildLLMPrompt(candidates, channel, userMessage, history)
|
||||
|
||||
// Create messages for LLM
|
||||
messages := []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "You are a skill recommendation assistant. Analyze the context and select the most appropriate skills.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: prompt,
|
||||
},
|
||||
}
|
||||
|
||||
// Call LLM
|
||||
ctx := context.Background()
|
||||
response, err := sr.llmProvider.Chat(ctx, messages, nil, sr.model, map[string]any{
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1000,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM recommendation failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse LLM response
|
||||
return sr.parseLLMResponse(response, candidates)
|
||||
}
|
||||
|
||||
// buildLLMPrompt builds a structured prompt for the LLM.
|
||||
func (sr *SkillRecommender) buildLLMPrompt(
|
||||
candidates []SkillRecommendation,
|
||||
channel, userMessage string,
|
||||
history []providers.Message,
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Context\n")
|
||||
sb.WriteString(fmt.Sprintf("Channel: %s\n", channel))
|
||||
sb.WriteString(fmt.Sprintf("User Message: %s\n\n", userMessage))
|
||||
|
||||
sb.WriteString("## Available Skills\n")
|
||||
for i, candidate := range candidates {
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s** - Score: %.1f/100\n",
|
||||
i+1, candidate.Name, candidate.Score))
|
||||
sb.WriteString(fmt.Sprintf(" Description: %s\n", candidate.Reason))
|
||||
}
|
||||
|
||||
sb.WriteString("\n## Task\n")
|
||||
sb.WriteString("Based on the context and available skills, recommend the most appropriate skills.\n")
|
||||
sb.WriteString("Consider:\n")
|
||||
sb.WriteString("- The user's intent from their message\n")
|
||||
sb.WriteString("- Channel-specific capabilities\n")
|
||||
sb.WriteString("- Historical context\n\n")
|
||||
sb.WriteString("Respond in JSON format:\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString("{\n")
|
||||
sb.WriteString(" \"recommendations\": [\n")
|
||||
sb.WriteString(" {\n")
|
||||
sb.WriteString(" \"skill_name\": \"skill-name\",\n")
|
||||
sb.WriteString(" \"confidence\": 0.9,\n")
|
||||
sb.WriteString(" \"reason\": \"why this skill is recommended\"\n")
|
||||
sb.WriteString(" }\n")
|
||||
sb.WriteString(" ]\n")
|
||||
sb.WriteString("}\n")
|
||||
sb.WriteString("```\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseLLMResponse parses the LLM's response and updates recommendations.
|
||||
func (sr *SkillRecommender) parseLLMResponse(
|
||||
response *providers.LLMResponse,
|
||||
candidates []SkillRecommendation,
|
||||
) ([]SkillRecommendation, error) {
|
||||
if response == nil || response.Content == "" {
|
||||
return candidates, fmt.Errorf("empty LLM response")
|
||||
}
|
||||
|
||||
content := response.Content
|
||||
if content == "" {
|
||||
return candidates, fmt.Errorf("no content in LLM response")
|
||||
}
|
||||
|
||||
// Try to extract JSON from response
|
||||
jsonStart := strings.Index(content, "{")
|
||||
jsonEnd := strings.LastIndex(content, "}")
|
||||
if jsonStart == -1 || jsonEnd == -1 {
|
||||
logger.WarnCF("agent", "No JSON found in LLM response",
|
||||
map[string]any{"content": content})
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
jsonContent := content[jsonStart : jsonEnd+1]
|
||||
|
||||
// Parse LLM response
|
||||
var llmResponse struct {
|
||||
Recommendations []struct {
|
||||
SkillName string `json:"skill_name"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"recommendations"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonContent), &llmResponse); err != nil {
|
||||
logger.WarnCF("agent", "Failed to parse LLM JSON",
|
||||
map[string]any{"error": err.Error(), "json": jsonContent})
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// Update candidate scores based on LLM feedback
|
||||
result := make([]SkillRecommendation, 0)
|
||||
for _, candidate := range candidates {
|
||||
for _, llmRec := range llmResponse.Recommendations {
|
||||
if llmRec.SkillName == candidate.Name {
|
||||
// Boost score and update confidence based on LLM recommendation
|
||||
candidate.Confidence = llmRec.Confidence
|
||||
candidate.Reason = llmRec.Reason
|
||||
candidate.Score = candidate.Score * (0.5 + candidate.Confidence*0.5) // Boost by up to 50%
|
||||
result = append(result, candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If LLM didn't recommend anything, return original candidates
|
||||
if len(result) == 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sortRecommendmentsByScore sorts recommendations by score in descending order.
|
||||
func sortRecommendationsByScore(recommendations []SkillRecommendation) {
|
||||
for i := 0; i < len(recommendations)-1; i++ {
|
||||
for j := i + 1; j < len(recommendations); j++ {
|
||||
if recommendations[j].Score > recommendations[i].Score {
|
||||
recommendations[i], recommendations[j] = recommendations[j], recommendations[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractKeywords extracts important keywords from text.
|
||||
func extractKeywords(text string) []string {
|
||||
// Simple keyword extraction: remove common stop words
|
||||
stopWords := map[string]bool{
|
||||
"the": true, "a": true, "an": true, "is": true, "are": true,
|
||||
"was": true, "were": true, "be": true, "been": true, "being": true,
|
||||
"have": true, "has": true, "had": true, "do": true, "does": true,
|
||||
"did": true, "will": true, "would": true, "could": true, "should": true,
|
||||
"may": true, "might": true, "must": true, "shall": true,
|
||||
"to": true, "of": true, "in": true, "for": true, "on": true,
|
||||
"with": true, "at": true, "by": true, "from": true, "as": true,
|
||||
"into": true, "through": true, "during": true, "before": true,
|
||||
"after": true, "above": true, "below": true, "between": true,
|
||||
"and": true, "but": true, "or": true, "nor": true, "so": true,
|
||||
"yet": true, "both": true, "either": true, "neither": true,
|
||||
"not": true, "only": true, "own": true, "same": true, "than": true,
|
||||
"too": true, "very": true, "just": true, "can": true,
|
||||
}
|
||||
|
||||
words := strings.Fields(text)
|
||||
keywords := make([]string, 0)
|
||||
|
||||
for _, word := range words {
|
||||
word = strings.ToLower(strings.Trim(word, ".,!?;:\"'()[]{}"))
|
||||
if len(word) > 3 && !stopWords[word] {
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
177
pkg/agent/recommender_test.go
Normal file
177
pkg/agent/recommender_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSkillRecommender_ChannelSpecificSkillLoading(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalSkills := t.TempDir()
|
||||
builtinSkills := t.TempDir()
|
||||
|
||||
// Create channel-specific skills
|
||||
createSkill := func(basePath, name, desc string) {
|
||||
skillDir := filepath.Join(basePath, "skills", name)
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
content := `---
|
||||
name: ` + name + `
|
||||
description: ` + desc + `
|
||||
---
|
||||
|
||||
# ` + name
|
||||
require.NoError(t, os.WriteFile(skillFile, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
// Telegram-specific skills
|
||||
createSkill(workspace, "telegram-sticker", "Create and send stickers on Telegram")
|
||||
createSkill(workspace, "telegram-poll", "Create polls in Telegram groups")
|
||||
|
||||
// Slack-specific skills
|
||||
createSkill(workspace, "slack-huddle", "Start Slack huddle calls")
|
||||
createSkill(workspace, "slack-workflow", "Create Slack workflows")
|
||||
|
||||
// WeCom-specific skills
|
||||
createSkill(workspace, "wecom-approval", "Handle WeCom approval requests")
|
||||
createSkill(workspace, "wecom-meeting", "Schedule WeCom meetings")
|
||||
|
||||
// General skills (available on all channels)
|
||||
createSkill(workspace, "web-search", "Search the web for information")
|
||||
createSkill(workspace, "file-manager", "Manage files")
|
||||
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkills, builtinSkills)
|
||||
|
||||
// Verify skills are loaded
|
||||
allSkills := skillsLoader.ListSkills()
|
||||
assert.Len(t, allSkills, 8)
|
||||
|
||||
t.Run("Telegram channel loads Telegram skills", func(t *testing.T) {
|
||||
filtered := skillsLoader.BuildSkillsSummaryFiltered([]string{
|
||||
"telegram-sticker",
|
||||
"telegram-poll",
|
||||
"web-search",
|
||||
})
|
||||
|
||||
assert.Contains(t, filtered, "telegram-sticker")
|
||||
assert.Contains(t, filtered, "telegram-poll")
|
||||
assert.Contains(t, filtered, "web-search")
|
||||
assert.NotContains(t, filtered, "slack-huddle")
|
||||
assert.NotContains(t, filtered, "wecom-approval")
|
||||
})
|
||||
|
||||
t.Run("Slack channel loads Slack skills", func(t *testing.T) {
|
||||
filtered := skillsLoader.BuildSkillsSummaryFiltered([]string{
|
||||
"slack-huddle",
|
||||
"slack-workflow",
|
||||
"web-search",
|
||||
})
|
||||
|
||||
assert.Contains(t, filtered, "slack-huddle")
|
||||
assert.Contains(t, filtered, "slack-workflow")
|
||||
assert.Contains(t, filtered, "web-search")
|
||||
assert.NotContains(t, filtered, "telegram-sticker")
|
||||
assert.NotContains(t, filtered, "wecom-meeting")
|
||||
})
|
||||
|
||||
t.Run("WeCom channel loads WeCom skills", func(t *testing.T) {
|
||||
filtered := skillsLoader.BuildSkillsSummaryFiltered([]string{
|
||||
"wecom-approval",
|
||||
"wecom-meeting",
|
||||
"file-manager",
|
||||
})
|
||||
|
||||
assert.Contains(t, filtered, "wecom-approval")
|
||||
assert.Contains(t, filtered, "wecom-meeting")
|
||||
assert.Contains(t, filtered, "file-manager")
|
||||
assert.NotContains(t, filtered, "telegram-poll")
|
||||
assert.NotContains(t, filtered, "slack-huddle")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillRecommender_Integration_ChannelBasedRecommendation(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalSkills := t.TempDir()
|
||||
builtinSkills := t.TempDir()
|
||||
|
||||
// Create channel-specific skills with descriptive names
|
||||
createSkill := func(basePath, name, desc string) {
|
||||
skillDir := filepath.Join(basePath, "skills", name)
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
content := `---
|
||||
name: ` + name + `
|
||||
description: ` + desc + `
|
||||
---
|
||||
|
||||
# ` + name
|
||||
require.NoError(t, os.WriteFile(skillFile, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
// Telegram skills
|
||||
createSkill(workspace, "send-sticker", "Send sticker messages on Telegram")
|
||||
createSkill(workspace, "create-poll", "Create polls in Telegram groups")
|
||||
|
||||
// Slack skills
|
||||
createSkill(workspace, "start-huddle", "Start a huddle call in Slack")
|
||||
createSkill(workspace, "manage-emoji", "Manage custom emojis in Slack")
|
||||
|
||||
// WeCom skills
|
||||
createSkill(workspace, "submit-approval", "Submit approval request in WeCom")
|
||||
createSkill(workspace, "book-meeting-room", "Book meeting room in WeCom")
|
||||
|
||||
// General skills
|
||||
createSkill(workspace, "search-web", "Search web for information")
|
||||
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkills, builtinSkills)
|
||||
|
||||
// Test that each channel has distinct skill sets
|
||||
t.Run("Telegram specific skills", func(t *testing.T) {
|
||||
summary := skillsLoader.BuildSkillsSummary()
|
||||
assert.Contains(t, summary, "send-sticker")
|
||||
assert.Contains(t, summary, "create-poll")
|
||||
})
|
||||
|
||||
t.Run("Slack specific skills", func(t *testing.T) {
|
||||
summary := skillsLoader.BuildSkillsSummary()
|
||||
assert.Contains(t, summary, "start-huddle")
|
||||
assert.Contains(t, summary, "manage-emoji")
|
||||
})
|
||||
|
||||
t.Run("WeCom specific skills", func(t *testing.T) {
|
||||
summary := skillsLoader.BuildSkillsSummary()
|
||||
assert.Contains(t, summary, "submit-approval")
|
||||
assert.Contains(t, summary, "book-meeting-room")
|
||||
})
|
||||
|
||||
t.Run("Filter by channel type", func(t *testing.T) {
|
||||
// Simulate channel-based filtering
|
||||
telegramSkills := []string{"send-sticker", "create-poll", "search-web"}
|
||||
slackSkills := []string{"start-huddle", "manage-emoji", "search-web"}
|
||||
wecomSkills := []string{"submit-approval", "book-meeting-room", "search-web"}
|
||||
|
||||
telegramSummary := skillsLoader.BuildSkillsSummaryFiltered(telegramSkills)
|
||||
slackSummary := skillsLoader.BuildSkillsSummaryFiltered(slackSkills)
|
||||
wecomSummary := skillsLoader.BuildSkillsSummaryFiltered(wecomSkills)
|
||||
|
||||
// Verify each channel gets its specific skills
|
||||
assert.Contains(t, telegramSummary, "send-sticker")
|
||||
assert.Contains(t, telegramSummary, "create-poll")
|
||||
assert.NotContains(t, telegramSummary, "start-huddle")
|
||||
|
||||
assert.Contains(t, slackSummary, "start-huddle")
|
||||
assert.Contains(t, slackSummary, "manage-emoji")
|
||||
assert.NotContains(t, slackSummary, "send-sticker")
|
||||
|
||||
assert.Contains(t, wecomSummary, "submit-approval")
|
||||
assert.Contains(t, wecomSummary, "book-meeting-room")
|
||||
assert.NotContains(t, wecomSummary, "create-poll")
|
||||
})
|
||||
}
|
||||
|
|
@ -192,6 +192,80 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
|
|||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// BuildSkillsSummaryFiltered builds a summary of skills filtered by the provided skill names.
|
||||
// If skillNames is nil or empty, returns all skills (same as BuildSkillsSummary).
|
||||
// This is used when SkillsFilter is set on AgentInstance.
|
||||
//
|
||||
// Parameters:
|
||||
// - skillNames: List of skill names to include. Nil/empty means all skills.
|
||||
//
|
||||
// Returns:
|
||||
// - XML-formatted skills summary string
|
||||
func (sl *SkillsLoader) BuildSkillsSummaryFiltered(skillNames []string) string {
|
||||
if len(skillNames) == 0 {
|
||||
return sl.BuildSkillsSummary()
|
||||
}
|
||||
|
||||
// Create a map for O(1) lookup
|
||||
filterSet := make(map[string]bool)
|
||||
for _, name := range skillNames {
|
||||
filterSet[name] = true
|
||||
}
|
||||
|
||||
allSkills := sl.ListSkills()
|
||||
if len(allSkills) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, "<skills>")
|
||||
for _, s := range allSkills {
|
||||
// Skip skills not in filter
|
||||
if !filterSet[s.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
escapedName := escapeXML(s.Name)
|
||||
escapedDesc := escapeXML(s.Description)
|
||||
escapedPath := escapeXML(s.Path)
|
||||
|
||||
lines = append(lines, fmt.Sprintf(" <skill>"))
|
||||
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
|
||||
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
|
||||
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
|
||||
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
|
||||
lines = append(lines, " </skill>")
|
||||
}
|
||||
lines = append(lines, "</skills>")
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// BuildSkillsSummaryForNames builds a summary for explicitly specified skill names.
|
||||
// Similar to BuildSkillsSummaryFiltered but maintains the order of skillNames.
|
||||
// Only skills that exist and can be loaded will be included.
|
||||
//
|
||||
// Parameters:
|
||||
// - skillNames: Ordered list of skill names to include
|
||||
//
|
||||
// Returns:
|
||||
// - Markdown-formatted skills content with headers
|
||||
func (sl *SkillsLoader) BuildSkillsSummaryForNames(skillNames []string) string {
|
||||
if len(skillNames) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, name := range skillNames {
|
||||
content, ok := sl.LoadSkill(name)
|
||||
if ok {
|
||||
parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", name, content))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n---\n\n")
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||
content, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -519,7 +519,7 @@ description: Desc
|
|||
---
|
||||
|
||||
Content`,
|
||||
expectedContent: "\nContent",
|
||||
expectedContent: "Content",
|
||||
lineEndingType: "unix",
|
||||
},
|
||||
}
|
||||
|
|
@ -650,3 +650,122 @@ func TestEscapeXML(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsLoaderBuildSkillsSummaryFiltered(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalSkills := t.TempDir()
|
||||
builtinSkills := t.TempDir()
|
||||
|
||||
// Create multiple skills
|
||||
createSkill := func(basePath, name, desc string) {
|
||||
skillDir := filepath.Join(basePath, "skills", name)
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
content := `---
|
||||
name: ` + name + `
|
||||
description: ` + desc + `
|
||||
---
|
||||
|
||||
# ` + name
|
||||
require.NoError(t, os.WriteFile(skillFile, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
createSkill(workspace, "skill1", "First skill")
|
||||
createSkill(workspace, "skill2", "Second skill")
|
||||
createSkill(workspace, "skill3", "Third skill")
|
||||
|
||||
loader := NewSkillsLoader(workspace, globalSkills, builtinSkills)
|
||||
|
||||
t.Run("filter with specific skills", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{"skill1", "skill3"})
|
||||
|
||||
assert.Contains(t, summary, "<skills>")
|
||||
assert.Contains(t, summary, "</skills>")
|
||||
assert.Contains(t, summary, "skill1")
|
||||
assert.Contains(t, summary, "First skill")
|
||||
assert.Contains(t, summary, "skill3")
|
||||
assert.Contains(t, summary, "Third skill")
|
||||
assert.NotContains(t, summary, "skill2")
|
||||
assert.NotContains(t, summary, "Second skill")
|
||||
})
|
||||
|
||||
t.Run("filter with single skill", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{"skill2"})
|
||||
|
||||
assert.Contains(t, summary, "skill2")
|
||||
assert.Contains(t, summary, "Second skill")
|
||||
assert.NotContains(t, summary, "skill1")
|
||||
assert.NotContains(t, summary, "skill3")
|
||||
})
|
||||
|
||||
t.Run("filter with non-existent skill", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{"nonexistent"})
|
||||
|
||||
assert.Contains(t, summary, "<skills>")
|
||||
assert.Contains(t, summary, "</skills>")
|
||||
// Should not contain any skill entries
|
||||
assert.NotContains(t, summary, "<name>")
|
||||
})
|
||||
|
||||
t.Run("filter with empty list", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{})
|
||||
|
||||
// Should return all skills (same as BuildSkillsSummary)
|
||||
assert.Contains(t, summary, "skill1")
|
||||
assert.Contains(t, summary, "skill2")
|
||||
assert.Contains(t, summary, "skill3")
|
||||
})
|
||||
|
||||
t.Run("filter with partial matches", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{"skill1", "nonexistent", "skill2"})
|
||||
|
||||
assert.Contains(t, summary, "skill1")
|
||||
assert.Contains(t, summary, "skill2")
|
||||
assert.NotContains(t, summary, "skill3")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillsLoaderBuildSkillsSummaryFilteredEmpty(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalSkills := t.TempDir()
|
||||
builtinSkills := t.TempDir()
|
||||
|
||||
loader := NewSkillsLoader(workspace, globalSkills, builtinSkills)
|
||||
|
||||
t.Run("empty skills list", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{})
|
||||
assert.Empty(t, summary)
|
||||
})
|
||||
|
||||
t.Run("nil filter", func(t *testing.T) {
|
||||
summary := loader.BuildSkillsSummaryFiltered(nil)
|
||||
assert.Empty(t, summary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillsLoaderBuildSkillsSummaryFilteredXMLEscaping(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalSkills := t.TempDir()
|
||||
builtinSkills := t.TempDir()
|
||||
|
||||
// Create skill with special XML characters
|
||||
skillDir := filepath.Join(workspace, "skills", "xml-skill")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
content := `---
|
||||
name: xml-test
|
||||
description: Test & special <chars> to "escape"
|
||||
---
|
||||
|
||||
# XML Test`
|
||||
require.NoError(t, os.WriteFile(skillFile, []byte(content), 0o644))
|
||||
|
||||
loader := NewSkillsLoader(workspace, globalSkills, builtinSkills)
|
||||
summary := loader.BuildSkillsSummaryFiltered([]string{"xml-test"})
|
||||
|
||||
assert.Contains(t, summary, "&")
|
||||
assert.Contains(t, summary, "<")
|
||||
assert.Contains(t, summary, ">")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,14 +11,43 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// ToolVisibilityContext represents the context used to determine tool visibility.
|
||||
// It contains information about the current request that can be used by filters
|
||||
// to decide whether a tool should be visible to the LLM.
|
||||
type ToolVisibilityContext struct {
|
||||
Channel string // Channel type (e.g., "telegram", "wecom", "slack")
|
||||
ChatID string // Unique chat identifier
|
||||
UserID string // User identifier (if available)
|
||||
UserRoles []string // User roles/permissions (e.g., ["admin"], ["user"])
|
||||
Args map[string]any // Tool execution arguments
|
||||
Timestamp time.Time // Request timestamp
|
||||
}
|
||||
|
||||
// ToolVisibilityFilter is a function type that determines whether a tool should be visible
|
||||
// based on the provided context. Return true to make the tool visible, false to hide it.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// adminOnlyFilter := func(ctx ToolVisibilityContext) bool {
|
||||
// for _, role := range ctx.UserRoles {
|
||||
// if role == "admin" {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
type ToolVisibilityFilter func(ctx ToolVisibilityContext) bool
|
||||
|
||||
type ToolRegistry struct {
|
||||
tools map[string]Tool
|
||||
mu sync.RWMutex
|
||||
tools map[string]Tool
|
||||
mu sync.RWMutex
|
||||
visibilityFilters map[string]ToolVisibilityFilter // Filters per tool
|
||||
}
|
||||
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return &ToolRegistry{
|
||||
tools: make(map[string]Tool),
|
||||
tools: make(map[string]Tool),
|
||||
visibilityFilters: make(map[string]ToolVisibilityFilter),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +55,38 @@ func (r *ToolRegistry) Register(tool Tool) {
|
|||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.tools[tool.Name()] = tool
|
||||
// No filter means always visible (backward compatible)
|
||||
delete(r.visibilityFilters, tool.Name())
|
||||
}
|
||||
|
||||
// RegisterWithFilter registers a tool with a visibility filter.
|
||||
// The filter determines whether the tool definition is included when GetDefinitionsForContext is called.
|
||||
// This enables fine-grained control over which tools are visible to the LLM based on context.
|
||||
//
|
||||
// Parameters:
|
||||
// - tool: The tool to register
|
||||
// - filter: A function that returns true if the tool should be visible, false otherwise
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// adminTool := NewAdminTool()
|
||||
// registry.RegisterWithFilter(adminTool, func(ctx ToolVisibilityContext) bool {
|
||||
// for _, role := range ctx.UserRoles {
|
||||
// if role == "admin" {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// })
|
||||
func (r *ToolRegistry) RegisterWithFilter(tool Tool, filter ToolVisibilityFilter) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.tools[tool.Name()] = tool
|
||||
if filter != nil {
|
||||
r.visibilityFilters[tool.Name()] = filter
|
||||
} else {
|
||||
delete(r.visibilityFilters, tool.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||
|
|
@ -133,6 +194,35 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
|||
return definitions
|
||||
}
|
||||
|
||||
// GetDefinitionsForContext returns tool definitions filtered by the provided context.
|
||||
// Only tools whose filters return true (or have no filter) will be included.
|
||||
// This allows different users/channels to see different sets of available tools.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The visibility context containing channel, chatID, user info, etc.
|
||||
//
|
||||
// Returns:
|
||||
// - Filtered list of tool definitions in schema format
|
||||
func (r *ToolRegistry) GetDefinitionsForContext(ctx ToolVisibilityContext) []map[string]any {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
sorted := r.sortedToolNames()
|
||||
definitions := make([]map[string]any, 0, len(sorted))
|
||||
|
||||
for _, name := range sorted {
|
||||
tool := r.tools[name]
|
||||
filter, hasFilter := r.visibilityFilters[name]
|
||||
|
||||
// If no filter or filter returns true, include the tool
|
||||
if !hasFilter || filter(ctx) {
|
||||
definitions = append(definitions, ToolToSchema(tool))
|
||||
}
|
||||
}
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
// ToProviderDefs converts tool definitions to provider-compatible format.
|
||||
// This is the format expected by LLM provider APIs.
|
||||
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||
|
|
@ -167,6 +257,46 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
|||
return definitions
|
||||
}
|
||||
|
||||
// ToProviderDefsForContext converts filtered tool definitions to provider-compatible format.
|
||||
// Similar to ToProviderDefs but applies context-based filtering.
|
||||
func (r *ToolRegistry) ToProviderDefsForContext(ctx ToolVisibilityContext) []providers.ToolDefinition {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
sorted := r.sortedToolNames()
|
||||
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
||||
|
||||
for _, name := range sorted {
|
||||
tool := r.tools[name]
|
||||
filter, hasFilter := r.visibilityFilters[name]
|
||||
|
||||
// If no filter or filter returns true, include the tool
|
||||
if !hasFilter || filter(ctx) {
|
||||
schema := ToolToSchema(tool)
|
||||
|
||||
// Safely extract nested values with type checks
|
||||
fn, ok := schema["function"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
name, _ := fn["name"].(string)
|
||||
desc, _ := fn["description"].(string)
|
||||
params, _ := fn["parameters"].(map[string]any)
|
||||
|
||||
definitions = append(definitions, providers.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Parameters: params,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
// List returns a list of all registered tool names.
|
||||
func (r *ToolRegistry) List() []string {
|
||||
r.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- mock types ---
|
||||
|
|
@ -348,3 +350,555 @@ func TestToolRegistry_ConcurrentAccess(t *testing.T) {
|
|||
t.Error("expected tools to be registered after concurrent access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_RegisterWithFilter_NoFilter(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
tool := newMockTool("public_tool", "always visible")
|
||||
r.RegisterWithFilter(tool, nil)
|
||||
|
||||
// Tool should be visible in all contexts
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "user-456",
|
||||
UserRoles: []string{"user"},
|
||||
}
|
||||
|
||||
defs := r.GetDefinitionsForContext(ctx)
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 definition, got %d", len(defs))
|
||||
}
|
||||
if defs[0]["function"].(map[string]any)["name"] != "public_tool" {
|
||||
t.Error("expected public_tool to be visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_RegisterWithFilter_AdminOnly(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// Register admin-only tool
|
||||
adminTool := newMockTool("admin_tool", "admin only")
|
||||
r.RegisterWithFilter(adminTool, func(ctx ToolVisibilityContext) bool {
|
||||
for _, role := range ctx.UserRoles {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Regular user should not see it
|
||||
userCtx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "user-456",
|
||||
UserRoles: []string{"user"},
|
||||
}
|
||||
|
||||
defs := r.GetDefinitionsForContext(userCtx)
|
||||
if len(defs) != 0 {
|
||||
t.Errorf("expected 0 definitions for regular user, got %d", len(defs))
|
||||
}
|
||||
|
||||
// Admin should see it
|
||||
adminCtx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "admin-789",
|
||||
UserRoles: []string{"admin", "user"},
|
||||
}
|
||||
|
||||
defs = r.GetDefinitionsForContext(adminCtx)
|
||||
if len(defs) != 1 {
|
||||
t.Errorf("expected 1 definition for admin, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_RegisterWithFilter_ChannelSpecific(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// Register Telegram-only tool
|
||||
telegramTool := newMockTool("telegram_tool", "telegram only")
|
||||
r.RegisterWithFilter(telegramTool, func(ctx ToolVisibilityContext) bool {
|
||||
return ctx.Channel == "telegram"
|
||||
})
|
||||
|
||||
// Should be visible in Telegram
|
||||
telegramCtx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "user-456",
|
||||
UserRoles: []string{"user"},
|
||||
}
|
||||
|
||||
defs := r.GetDefinitionsForContext(telegramCtx)
|
||||
if len(defs) != 1 {
|
||||
t.Errorf("expected 1 definition in Telegram, got %d", len(defs))
|
||||
}
|
||||
|
||||
// Should not be visible in other channels
|
||||
slackCtx := ToolVisibilityContext{
|
||||
Channel: "slack",
|
||||
ChatID: "chat-123",
|
||||
UserID: "user-456",
|
||||
UserRoles: []string{"user"},
|
||||
}
|
||||
|
||||
defs = r.GetDefinitionsForContext(slackCtx)
|
||||
if len(defs) != 0 {
|
||||
t.Errorf("expected 0 definitions in Slack, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_RegisterWithFilter_MixedTools(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// Register mix of filtered and unfiltered tools
|
||||
r.RegisterWithFilter(newMockTool("public1", "always visible"), nil)
|
||||
r.RegisterWithFilter(newMockTool("public2", "always visible"), nil)
|
||||
|
||||
r.RegisterWithFilter(newMockTool("admin_only", "admin only"), func(ctx ToolVisibilityContext) bool {
|
||||
for _, role := range ctx.UserRoles {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
r.RegisterWithFilter(newMockTool("telegram_only", "telegram only"), func(ctx ToolVisibilityContext) bool {
|
||||
return ctx.Channel == "telegram"
|
||||
})
|
||||
|
||||
// Regular user in Telegram should see: public1, public2, telegram_only (3 tools)
|
||||
userCtx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "user-456",
|
||||
UserRoles: []string{"user"},
|
||||
}
|
||||
|
||||
defs := r.GetDefinitionsForContext(userCtx)
|
||||
if len(defs) != 3 {
|
||||
t.Errorf("expected 3 definitions for regular user in Telegram, got %d", len(defs))
|
||||
}
|
||||
|
||||
// Admin in Slack should see: public1, public2, admin_only (3 tools)
|
||||
adminCtx := ToolVisibilityContext{
|
||||
Channel: "slack",
|
||||
ChatID: "chat-123",
|
||||
UserID: "admin-789",
|
||||
UserRoles: []string{"admin"},
|
||||
}
|
||||
|
||||
defs = r.GetDefinitionsForContext(adminCtx)
|
||||
if len(defs) != 3 {
|
||||
t.Errorf("expected 3 definitions for admin in Slack, got %d", len(defs))
|
||||
}
|
||||
|
||||
// Admin in Telegram should see all 4 tools
|
||||
adminTelegramCtx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "admin-789",
|
||||
UserRoles: []string{"admin"},
|
||||
}
|
||||
|
||||
defs = r.GetDefinitionsForContext(adminTelegramCtx)
|
||||
if len(defs) != 4 {
|
||||
t.Errorf("expected 4 definitions for admin in Telegram, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_GetDefinitionsForContext_EmptyRegistry(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-123",
|
||||
UserID: "user-456",
|
||||
UserRoles: []string{"user"},
|
||||
}
|
||||
|
||||
defs := r.GetDefinitionsForContext(ctx)
|
||||
if len(defs) != 0 {
|
||||
t.Errorf("expected 0 definitions in empty registry, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_RegisterWithFilter_UpdateFilter(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// Register with filter
|
||||
r.RegisterWithFilter(newMockTool("changing_tool", "changes filter"), func(ctx ToolVisibilityContext) bool {
|
||||
return ctx.Channel == "telegram"
|
||||
})
|
||||
|
||||
// Should only be visible in Telegram
|
||||
telegramCtx := ToolVisibilityContext{Channel: "telegram"}
|
||||
slackCtx := ToolVisibilityContext{Channel: "slack"}
|
||||
|
||||
if len(r.GetDefinitionsForContext(telegramCtx)) != 1 {
|
||||
t.Error("expected tool visible in Telegram")
|
||||
}
|
||||
if len(r.GetDefinitionsForContext(slackCtx)) != 0 {
|
||||
t.Error("expected tool hidden in Slack")
|
||||
}
|
||||
|
||||
// Re-register without filter (should make it public)
|
||||
r.RegisterWithFilter(newMockTool("changing_tool", "now public"), nil)
|
||||
|
||||
if len(r.GetDefinitionsForContext(telegramCtx)) != 1 {
|
||||
t.Error("expected tool still visible in Telegram")
|
||||
}
|
||||
if len(r.GetDefinitionsForContext(slackCtx)) != 1 {
|
||||
t.Error("expected tool now visible in Slack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_GetDefinitionsForContext_ConcurrentAccess(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// Register some tools with different filters
|
||||
r.RegisterWithFilter(newMockTool("public", "always visible"), nil)
|
||||
r.RegisterWithFilter(newMockTool("admin_only", "admin only"), func(ctx ToolVisibilityContext) bool {
|
||||
for _, role := range ctx.UserRoles {
|
||||
return role == "admin"
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorChan := make(chan string, 100)
|
||||
|
||||
// Multiple goroutines querying different contexts
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(iteration int) {
|
||||
defer wg.Done()
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat-" + string(rune(iteration)),
|
||||
UserID: "user-" + string(rune(iteration)),
|
||||
UserRoles: []string{"user", "admin"},
|
||||
}
|
||||
|
||||
defs := r.GetDefinitionsForContext(ctx)
|
||||
// Should always get consistent results
|
||||
if len(defs) < 1 || len(defs) > 2 {
|
||||
errorChan <- "unexpected definition count: " + string(rune(len(defs)))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errorChan)
|
||||
|
||||
for errMsg := range errorChan {
|
||||
t.Error(errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_MultiTenant_AdminVsRegularUser(t *testing.T) {
|
||||
registry := NewToolRegistry()
|
||||
|
||||
// Register a general tool visible to everyone
|
||||
generalTool := newMockTool("read_file", "Read files")
|
||||
registry.Register(generalTool)
|
||||
|
||||
// Register an admin-only tool with filter
|
||||
adminTool := newMockTool("admin_deploy", "Deploy application")
|
||||
registry.RegisterWithFilter(adminTool, func(ctx ToolVisibilityContext) bool {
|
||||
for _, role := range ctx.UserRoles {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Register a user-only tool
|
||||
userTool := newMockTool("user_feedback", "Submit feedback")
|
||||
registry.RegisterWithFilter(userTool, func(ctx ToolVisibilityContext) bool {
|
||||
for _, role := range ctx.UserRoles {
|
||||
if role == "user" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
t.Run("admin user sees all tools", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat123",
|
||||
UserID: "admin_user",
|
||||
UserRoles: []string{"admin"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Admin should see both general and admin tools
|
||||
assert.Len(t, definitions, 2)
|
||||
|
||||
// Check that admin tool is included
|
||||
foundAdmin := false
|
||||
foundGeneral := false
|
||||
for _, def := range definitions {
|
||||
// ToolToSchema returns: {"type": "function", "function": {"name": "...", ...}}
|
||||
if function, ok := def["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
if name == "admin_deploy" {
|
||||
foundAdmin = true
|
||||
}
|
||||
if name == "read_file" {
|
||||
foundGeneral = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundAdmin, "admin should see admin_deploy tool")
|
||||
assert.True(t, foundGeneral, "admin should see read_file tool")
|
||||
})
|
||||
|
||||
t.Run("regular user sees limited tools", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat456",
|
||||
UserID: "regular_user",
|
||||
UserRoles: []string{"user"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Regular user should see general and user tools, but not admin tools
|
||||
assert.Len(t, definitions, 2)
|
||||
|
||||
foundUser := false
|
||||
foundGeneral := false
|
||||
foundAdmin := false
|
||||
|
||||
for _, def := range definitions {
|
||||
if function, ok := def["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
if name == "user_feedback" {
|
||||
foundUser = true
|
||||
}
|
||||
if name == "read_file" {
|
||||
foundGeneral = true
|
||||
}
|
||||
if name == "admin_deploy" {
|
||||
foundAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundUser, "user should see user_feedback tool")
|
||||
assert.True(t, foundGeneral, "user should see read_file tool")
|
||||
assert.False(t, foundAdmin, "user should NOT see admin_deploy tool")
|
||||
})
|
||||
|
||||
t.Run("user with multiple roles", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "slack",
|
||||
ChatID: "chat789",
|
||||
UserID: "power_user",
|
||||
UserRoles: []string{"user", "admin"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// User with both roles should see all tools
|
||||
assert.Len(t, definitions, 3)
|
||||
})
|
||||
|
||||
t.Run("user with no roles", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "discord",
|
||||
ChatID: "chat999",
|
||||
UserID: "guest",
|
||||
UserRoles: []string{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Guest should only see general tools (no role-based restrictions)
|
||||
assert.Len(t, definitions, 1)
|
||||
|
||||
// Check the tool name
|
||||
if len(definitions) > 0 {
|
||||
if function, ok := definitions[0]["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
assert.Contains(t, name, "read_file")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolRegistry_ChannelSpecificToolVisibility(t *testing.T) {
|
||||
registry := NewToolRegistry()
|
||||
|
||||
// Register a Telegram-specific tool (e.g., sticker creation)
|
||||
telegramTool := newMockTool("create_sticker", "Create sticker")
|
||||
registry.RegisterWithFilter(telegramTool, func(ctx ToolVisibilityContext) bool {
|
||||
return ctx.Channel == "telegram"
|
||||
})
|
||||
|
||||
// Register a Slack-specific tool (e.g., channel management)
|
||||
slackTool := newMockTool("manage_channel", "Manage Slack channel")
|
||||
registry.RegisterWithFilter(slackTool, func(ctx ToolVisibilityContext) bool {
|
||||
return ctx.Channel == "slack"
|
||||
})
|
||||
|
||||
// Register a general tool available on all channels
|
||||
generalTool := newMockTool("search_web", "Search web")
|
||||
registry.Register(generalTool)
|
||||
|
||||
t.Run("Telegram channel", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "tg_chat",
|
||||
UserID: "user1",
|
||||
UserRoles: []string{"user"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Should see general + Telegram-specific tools
|
||||
assert.Len(t, definitions, 2)
|
||||
|
||||
foundTelegram := false
|
||||
foundGeneral := false
|
||||
for _, def := range definitions {
|
||||
if function, ok := def["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
if name == "create_sticker" {
|
||||
foundTelegram = true
|
||||
}
|
||||
if name == "search_web" {
|
||||
foundGeneral = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundTelegram, "should see Telegram-specific tool")
|
||||
assert.True(t, foundGeneral, "should see general tool")
|
||||
})
|
||||
|
||||
t.Run("Slack channel", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "slack",
|
||||
ChatID: "slack_channel",
|
||||
UserID: "user2",
|
||||
UserRoles: []string{"user"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Should see general + Slack-specific tools
|
||||
assert.Len(t, definitions, 2)
|
||||
|
||||
foundSlack := false
|
||||
foundGeneral := false
|
||||
for _, def := range definitions {
|
||||
if function, ok := def["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
if name == "manage_channel" {
|
||||
foundSlack = true
|
||||
}
|
||||
if name == "search_web" {
|
||||
foundGeneral = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundSlack, "should see Slack-specific tool")
|
||||
assert.True(t, foundGeneral, "should see general tool")
|
||||
})
|
||||
|
||||
t.Run("Other channel (no specific tools)", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "discord",
|
||||
ChatID: "discord_server",
|
||||
UserID: "user3",
|
||||
UserRoles: []string{"user"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Should only see general tools
|
||||
assert.Len(t, definitions, 1)
|
||||
|
||||
if len(definitions) > 0 {
|
||||
if function, ok := definitions[0]["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
assert.Equal(t, "search_web", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolRegistry_ChatSpecificToolVisibility(t *testing.T) {
|
||||
registry := NewToolRegistry()
|
||||
|
||||
// Register a tool only visible in specific chat (e.g., VIP support chat)
|
||||
vipTool := newMockTool("vip_support", "VIP support")
|
||||
registry.RegisterWithFilter(vipTool, func(ctx ToolVisibilityContext) bool {
|
||||
return ctx.ChatID == "vip_chat_001"
|
||||
})
|
||||
|
||||
// Register a general tool
|
||||
generalTool := newMockTool("help", "Get help")
|
||||
registry.Register(generalTool)
|
||||
|
||||
t.Run("VIP chat", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "wecom",
|
||||
ChatID: "vip_chat_001",
|
||||
UserID: "vip_user",
|
||||
UserRoles: []string{"vip"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// VIP should see both general and VIP tools
|
||||
assert.Len(t, definitions, 2)
|
||||
})
|
||||
|
||||
t.Run("Regular chat", func(t *testing.T) {
|
||||
ctx := ToolVisibilityContext{
|
||||
Channel: "wecom",
|
||||
ChatID: "regular_chat",
|
||||
UserID: "regular_user",
|
||||
UserRoles: []string{"user"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
definitions := registry.GetDefinitionsForContext(ctx)
|
||||
|
||||
// Regular users should only see general tool
|
||||
assert.Len(t, definitions, 1)
|
||||
|
||||
if len(definitions) > 0 {
|
||||
if function, ok := definitions[0]["function"].(map[string]any); ok {
|
||||
if name, ok := function["name"].(string); ok {
|
||||
assert.Equal(t, "help", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ func TestLocalScenarioCoverage(t *testing.T) {
|
|||
registry.Register(tools.NewAppendFileTool(workspace, true))
|
||||
|
||||
cb := agent.NewContextBuilder(workspace)
|
||||
cb.SetToolsRegistry(registry)
|
||||
prompt := cb.BuildSystemPrompt()
|
||||
|
||||
required := []string{
|
||||
|
|
@ -112,11 +111,6 @@ func TestLocalScenarioCoverage(t *testing.T) {
|
|||
"long-term: prefers concise answers",
|
||||
"## Recent Daily Notes",
|
||||
"daily note: met user",
|
||||
"## Available Tools",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"list_dir",
|
||||
"exec",
|
||||
}
|
||||
for _, item := range required {
|
||||
if !strings.Contains(prompt, item) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue