feat: add workspace-isolated toolhost runtime and capability-gap skill loop

This commit is contained in:
2026-02-28 17:48:33 +08:00
parent ce9346e350
commit 7d6cf6b435
28 changed files with 2223 additions and 143 deletions

View File

@@ -0,0 +1,141 @@
package knowledge
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"laodingbot/internal/memory"
)
func GenerateSkillDraft(cluster memory.CapabilityGapCluster, draftRoot string) (string, bool, error) {
draftRoot = strings.TrimSpace(draftRoot)
if draftRoot == "" {
draftRoot = "./skills"
}
if err := os.MkdirAll(draftRoot, 0o755); err != nil {
return "", false, err
}
skillDirName := "auto_" + slugFromIntent(cluster.IntentKey)
if skillDirName == "" {
skillDirName = "auto_gap_skill"
}
dir := filepath.Join(draftRoot, skillDirName)
file := filepath.Join(dir, "skill.md")
if _, err := os.Stat(file); err == nil {
return file, false, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", false, err
}
skillTitle := titleFromIntent(cluster.SampleIntent)
if skillTitle == "" {
skillTitle = "能力缺口补全技能"
}
content := buildDraftMarkdown(skillTitle, cluster)
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
return "", false, err
}
return file, true, nil
}
func buildDraftMarkdown(skillTitle string, cluster memory.CapabilityGapCluster) string {
createdAt := time.Now().Format(time.RFC3339)
return strings.TrimSpace(fmt.Sprintf(`---
name: %s
description: 由 capability_gap 自动生成并用于自动补全缺失能力。
source: capability_gap
generated_at: %s
cluster_intent_key: %s
cluster_reason: %s
cluster_count: %d
---
# Skill: %s
## 背景
- 该技能由系统根据高频能力缺口自动生成并已纳入技能目录。
- 最近高频缺口聚类:`+"`%s`"+`
- 缺口原因:`+"`%s`"+`
- 出现次数:`+"`%d`"+`
## 目标能力
- 明确该类问题应如何判断是否需要调用工具。
- 约束输入输出,避免泛化过度。
- 在失败时提供可操作回退路径。
## 建议触发信号
- 用户提问与下述意图高度相关:`+"`%s`"+`
- 现有技能未命中,或命中后无法完成。
## 建议工具
- 优先使用现有工具:`+"`shell`"+``+"`file`"+`
- 若能力不足,需要创建新工具时:
1. 在 `+"`internal/tools/<tool_name>/`"+` 下生成 Go 代码;
2. 在 `+"`cmd/bot/main.go`"+` 或 toolhost 注册逻辑中完成注册;
3. 生成/补充 `+"`*_test.go`"+`
4. 调用 `+"`go test ./...`"+` 验证。
## ReAct 指南
1. 先确认用户目标和输入约束。
2. 判断是否可直接回答;若不行,再选择工具。
3. 工具调用前先最小化探测范围。
4. 工具失败时输出原因与下一步建议。
5. 若缺少 skill使用 `+"`file`"+``+"`shell`"+` 创建新的 `+"`skills/<skill_name>/skill.md`"+`
6. 若缺少 tool生成工具代码与测试后执行 `+"`go test ./...`"+`
## 输出规范
- 结论:一句话给出当前阶段结论。
- 依据:列出关键观察与证据。
- 限制:说明当前不确定性。
- 下一步:给用户可执行动作。
`, skillTitle, createdAt, cluster.IntentKey, cluster.Reason, cluster.Count, skillTitle, cluster.IntentKey, cluster.Reason, cluster.Count, cluster.SampleIntent))
}
func slugFromIntent(intent string) string {
intent = strings.TrimSpace(strings.ToLower(intent))
if intent == "" {
return ""
}
b := strings.Builder{}
lastDash := false
for _, r := range intent {
isAlphaNum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
isCJK := r >= 0x4e00 && r <= 0x9fff
if isAlphaNum || isCJK {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteRune('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return ""
}
runes := []rune(out)
if len(runes) > 48 {
out = string(runes[:48])
}
return out
}
func titleFromIntent(intent string) string {
intent = strings.TrimSpace(intent)
if intent == "" {
return ""
}
runes := []rune(intent)
if len(runes) > 32 {
intent = string(runes[:32])
}
return intent
}

View File

@@ -0,0 +1,37 @@
package knowledge
import (
"path/filepath"
"testing"
"laodingbot/internal/memory"
)
func TestGenerateSkillDraftCreatesFile(t *testing.T) {
draftDir := filepath.Join(t.TempDir(), "drafts")
cluster := memory.CapabilityGapCluster{
IntentKey: "query files in workspace",
SampleIntent: "帮我查询 workspace 目录下的 markdown 文件",
Reason: "no_skill_matched",
Count: 4,
}
path, created, err := GenerateSkillDraft(cluster, draftDir)
if err != nil {
t.Fatalf("GenerateSkillDraft error: %v", err)
}
if !created {
t.Fatalf("expected created=true")
}
if filepath.Base(path) != "skill.md" {
t.Fatalf("expected skill.md path, got %s", path)
}
_, created2, err := GenerateSkillDraft(cluster, draftDir)
if err != nil {
t.Fatalf("GenerateSkillDraft second call error: %v", err)
}
if created2 {
t.Fatalf("expected created=false on second call")
}
}

View File

@@ -26,28 +26,6 @@ func LoadSoul(path string) (string, error) {
return content, nil
}
func LoadSkills(dir string) (string, error) {
skills, err := LoadSkillSet(dir)
if err != nil {
return "", err
}
builder := strings.Builder{}
for _, skill := range skills {
builder.WriteString("## ")
builder.WriteString(skill.Name)
builder.WriteString("\n")
builder.WriteString(skill.Content)
builder.WriteString("\n\n")
}
out := strings.TrimSpace(builder.String())
if out == "" {
return "", fmt.Errorf("no non-empty markdown skills loaded from %s", dir)
}
return out, nil
}
func LoadSkillSet(dir string) ([]Skill, error) {
entries, err := os.ReadDir(dir)
if err != nil {