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

38
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "LaodingBot: Debug (from configs/env)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/bot",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/configs/env"
},
{
"name": "LaodingBot: Debug Telegram",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/bot",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/configs/env",
"env": {
"MESSAGE_CHANNEL": "telegram"
}
},
{
"name": "LaodingBot: Debug Feishu",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/bot",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/configs/env",
"env": {
"MESSAGE_CHANNEL": "feishu"
}
}
]
}

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
@@ -13,9 +14,9 @@ import (
"laodingbot/internal/llm" "laodingbot/internal/llm"
"laodingbot/internal/logger" "laodingbot/internal/logger"
"laodingbot/internal/memory" "laodingbot/internal/memory"
"laodingbot/internal/runtimews"
"laodingbot/internal/toolhost"
"laodingbot/internal/tools" "laodingbot/internal/tools"
"laodingbot/internal/tools/filetool"
"laodingbot/internal/tools/shelltool"
"laodingbot/internal/transport/feishu" "laodingbot/internal/transport/feishu"
"laodingbot/internal/transport/telegram" "laodingbot/internal/transport/telegram"
) )
@@ -23,18 +24,29 @@ import (
func main() { func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
isToolhostChild := len(os.Args) > 1 && os.Args[1] == "--toolhost"
workspaceRoot, err := runtimews.PrepareFromEnv()
if err != nil {
panic(fmt.Sprintf("prepare runtime workspace failed: %v", err))
}
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
panic(fmt.Sprintf("load config failed: %v", err)) panic(fmt.Sprintf("load config failed: %v", err))
} }
if isToolhostChild {
if err := toolhost.RunChild(ctx, cfg, nil); err != nil && ctx.Err() == nil {
panic(fmt.Sprintf("toolhost child failed: %v", err))
}
return
}
appLogger, err := logger.New(cfg.LogLevel) appLogger, err := logger.New(cfg.LogLevel)
if err != nil { if err != nil {
panic(fmt.Sprintf("init logger failed: %v", err)) panic(fmt.Sprintf("init logger failed: %v", err))
} }
appLogger = appLogger.WithComponent("main") appLogger = appLogger.WithComponent("main")
appLogger.Infof("config loaded; channel=%s, log_level=%s", cfg.MessageChannel, cfg.LogLevel) appLogger.Infof("config loaded; channel=%s, log_level=%s workspace=%s", cfg.MessageChannel, cfg.LogLevel, workspaceRoot)
store, err := memory.NewSQLiteStore(cfg.SQLitePath, appLogger.WithComponent("memory")) store, err := memory.NewSQLiteStore(cfg.SQLitePath, appLogger.WithComponent("memory"))
if err != nil { if err != nil {
@@ -44,19 +56,44 @@ func main() {
defer store.Close() defer store.Close()
toolRegistry := tools.NewRegistry(appLogger.WithComponent("tools.registry")) toolRegistry := tools.NewRegistry(appLogger.WithComponent("tools.registry"))
toolRegistry.Register(filetool.New(cfg.Security.AllowedDirs, appLogger.WithComponent("tools.file"))) exePath, err := os.Executable()
toolRegistry.Register(shelltool.New(cfg.Security.AllowedCommands, cfg.Security.WorkDir, 15*time.Second, appLogger.WithComponent("tools.shell"))) if err != nil {
appLogger.Errorf("resolve executable path failed: %v", err)
panic(err)
}
tc, err := toolhost.NewClient(toolhost.ClientConfig{
ExecutablePath: exePath,
Args: []string{"--toolhost"},
WorkDir: ".",
CallTimeout: time.Duration(cfg.ToolCallTimeoutSec) * time.Second,
HeartbeatInterval: 5 * time.Second,
MaxConcurrency: 4,
}, appLogger.WithComponent("toolhost.client"))
if err != nil {
appLogger.Errorf("init toolhost client failed: %v", err)
panic(err)
}
defer tc.Close()
listCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
toolInfos, err := tc.ToolList(listCtx)
cancel()
if err != nil {
appLogger.Errorf("toolhost list failed: %v", err)
panic(err)
}
if len(toolInfos) == 0 {
panic("toolhost returned empty tool list")
}
for _, info := range toolInfos {
toolRegistry.Register(toolhost.NewRemoteTool(info.Name, info.Description, time.Duration(cfg.ToolCallTimeoutSec)*time.Second, tc))
}
soul, err := knowledge.LoadSoul(cfg.SoulPath) soul, err := knowledge.LoadSoul(cfg.SoulPath)
if err != nil { if err != nil {
appLogger.Errorf("load soul failed path=%s err=%v", cfg.SoulPath, err) appLogger.Errorf("load soul failed path=%s err=%v", cfg.SoulPath, err)
panic(err) panic(err)
} }
skillsDoc, err := knowledge.LoadSkills(cfg.SkillsDir)
if err != nil {
appLogger.Errorf("load skills failed dir=%s err=%v", cfg.SkillsDir, err)
panic(err)
}
skillSet, err := knowledge.LoadSkillSet(cfg.SkillsDir) skillSet, err := knowledge.LoadSkillSet(cfg.SkillsDir)
if err != nil { if err != nil {
appLogger.Errorf("load skill set failed dir=%s err=%v", cfg.SkillsDir, err) appLogger.Errorf("load skill set failed dir=%s err=%v", cfg.SkillsDir, err)
@@ -71,8 +108,12 @@ func main() {
toolRegistry, toolRegistry,
soul, soul,
skillSet, skillSet,
skillsDoc, cfg.SkillsDir,
cfg.ReactMaxSteps, cfg.ReactMaxSteps,
cfg.EnableCapabilityGap,
cfg.AutoSkillDir,
cfg.GapDraftTriggerCount,
time.Duration(cfg.GapClusterLookbackHours)*time.Hour,
appLogger.WithComponent("agent"), appLogger.WithComponent("agent"),
) )

View File

@@ -1,8 +1,15 @@
MESSAGE_CHANNEL=telegram MESSAGE_CHANNEL=telegram
LOG_LEVEL=info LOG_LEVEL=info
AGENT_WORKSPACE_DIR=./workspace/agent_runtime
SOUL_PATH=./bot_context/soul.md SOUL_PATH=./bot_context/soul.md
SKILLS_DIR=./skills SKILLS_DIR=./skills
REACT_MAX_STEPS=4 REACT_MAX_STEPS=4
TOOL_CALL_TIMEOUT_SEC=15
TOOL_OUTPUT_MAX_CHARS=4000
ENABLE_CAPABILITY_GAP=true
AUTO_SKILL_DIR=./skills
GAP_DRAFT_TRIGGER_COUNT=3
GAP_CLUSTER_LOOKBACK_HOURS=168
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_POLL_TIMEOUT_SECONDS=30 TELEGRAM_POLL_TIMEOUT_SECONDS=30
@@ -16,6 +23,6 @@ LLM_API_KEY=
LLM_MODEL=gpt-4o-mini LLM_MODEL=gpt-4o-mini
SQLITE_PATH=./data/laodingbot.db SQLITE_PATH=./data/laodingbot.db
ALLOWED_DIRS=./workspace,./data ALLOWED_DIRS=./workspace,./data,./skills
ALLOWED_COMMANDS=pwd,ls,cat,echo,grep,find,head,tail ALLOWED_COMMANDS=pwd,ls,cat,echo,grep,find,head,tail,go,curl,curl.exe
WORK_DIR=./workspace WORK_DIR=./workspace

196
doc/技术说明文档.md Normal file
View File

@@ -0,0 +1,196 @@
# LaodingBot 技术说明文档2026-02-28 最新实现)
> 本文档基于当前代码状态,描述真实可运行架构与能力边界。
---
## 1. 项目定位
LaodingBot 当前已从“单进程工具调用 MVP”演进为
- **父进程 Agent 编排**(技能路由 + ReAct + 记忆)
- **子进程 ToolHost 执行**JSON-RPC
- **workspace 隔离运行空间**(配置与工具权限收敛)
- **能力缺口闭环**(落库、聚类、自动生成技能并热加载)
核心目标:让 Agent 在安全边界内持续补全能力,而不是仅做静态问答。
---
## 2. 目录与模块
- `cmd/bot/main.go`应用入口、workspace 引导、toolhost 启动、通道分发
- `internal/config/config.go`配置加载、workspace 路径解析、安全策略归一化
- `internal/runtimews/bootstrap.go`:运行时 workspace 准备与种子目录复制
- `internal/agent/orchestrator.go`主编排器技能匹配、ReAct、能力缺口闭环
- `internal/toolhost/*`:工具子进程协议、服务端、客户端、远程工具适配
- `internal/tools/filetool/filetool.go`:文件工具(`read/list/write`
- `internal/tools/shelltool/shelltool.go`:命令工具(白名单 + 超时 + 输出限制)
- `internal/memory/store_sqlite.go`:消息与能力缺口存储、聚类查询
- `internal/knowledge/loader.go`skill/soul 加载
- `internal/knowledge/drafts.go`:能力缺口驱动的 skill 自动生成
---
## 3. 启动链路(当前)
`main()` 执行顺序:
1. 建立可取消上下文SIGINT/SIGTERM
2. 调用 `runtimews.PrepareFromEnv()`
- 解析 `AGENT_WORKSPACE_DIR`(默认 `./workspace/agent_runtime`
-`configs/data/skills/bot_context` 种子复制到 runtime workspace缺失才复制
- 设定 `CONFIG_ENV_FILE=<workspace>/configs/env`
3. 调用 `config.Load()`,优先读取 workspace env。
4.`--toolhost` 模式,进入子进程服务。
5. 正常父进程初始化日志、SQLite、ToolHost Client、知识、Orchestrator。
6. 根据 `MESSAGE_CHANNEL` 启动 Telegram 或 Feishu transport。
---
## 4. 配置加载与优先级(关键变更)
`config.Load()` 的 env 读取优先级:
1. `CONFIG_ENV_FILE`(强覆盖)
2. `<workspace>/configs/env``<workspace>/.env`(强覆盖)
3. 根目录 `configs/env``.env`(仅兜底,不覆盖已有值)
这保证 VS Code Debug 场景下,**workspace 配置优先于根目录配置**。
### 关键配置
- `REACT_MAX_STEPS`:必须来自 env无代码默认值
- `AGENT_WORKSPACE_DIR`agent 运行空间根目录
- `ALLOWED_DIRS` / `ALLOWED_COMMANDS` / `WORK_DIR`:工具安全边界
- `AUTO_SKILL_DIR`:自动生成 skill 的目标目录(默认 workspace/skills
- `GAP_DRAFT_TRIGGER_COUNT` / `GAP_CLUSTER_LOOKBACK_HOURS`:缺口聚类触发参数
---
## 5. workspace 隔离策略
当前实现中Agent 与工具默认都在 workspace 内高权限运行:
- 相对路径统一按 `AGENT_WORKSPACE_DIR` 解析
- `ALLOWED_DIRS` 强制补齐:
- workspace 根
- `workspace/skills`
- `workspace/data`
- `workspace/workspace`
- `ALLOWED_COMMANDS` 自动补齐:`go``curl``curl.exe`
`filetool` 对相对路径优先按 workspace 根解析,避免写到代码仓库根目录。
---
## 6. ToolHost 子进程架构
当前工具调用已迁移到 JSON-RPC 子进程:
- 协议方法:`ping``tool.list``tool.call`
- 父进程 `Client` 能力:
- 调用超时
- 心跳检测
- 失败重启与重试
- 并发限制(信号量)
- 子进程 stdout 仅承载协议数据(避免日志污染 RPC
效果:工具崩溃不会直接拖垮 Agent 主编排逻辑。
---
## 7. ReAct 与技能路由
`Orchestrator` 流程:
1. 保存用户消息到 SQLite
2. 读取最近对话并压缩
3. LLM 进行技能路由(最多命中 2 个)
4. 若无技能命中:尝试回退到 `创建skill` 技能
5. 进入 ReAct 多轮决策(`action/final`
6. 工具调用观察写入 scratchpad
7. 保存 assistant 回复
工具错误会结构化为:
- `ERROR_CODE=...; TOOL=...; REASON=...`
---
## 8. 能力缺口闭环(已落地)
当出现“不会做”信号(如无 skill、解析失败、工具失败
1. 写入 `capability_gaps`
2. 进行意图归一化聚类(按 `intent_key + reason`
3. 高频达到阈值后自动生成 skill 文件
4. 自动调用 `ReloadSkills()` 热加载
可通过消息命令查看与控制:
- `/capability_gaps`:输出当前高频缺口清单
- `/reload_skills`:手动热加载 skills
---
## 9. 自动生成 skill 的当前行为
自动生成由 `internal/knowledge/drafts.go` 执行:
- 目标目录:`AUTO_SKILL_DIR`(默认 workspace/skills
- 命名:`auto_<intent_key>/skill.md`
- 仅在文件不存在时创建,避免重复覆盖
- 模板内包含:触发背景、执行流程、工具建议、测试建议
并额外提供基础引导技能:
- `skills/skill_builder/skill.md`
---
## 10. file/shell 工具现状
### file tool
支持:
- `read <path>`
- `list <path>`
- `write <path>\n<content>`
特性:
- 白名单路径检查
- 目录误读防护:`read` 目录返回 `PATH_IS_DIRECTORY`
- 输出长度限制
### shell tool
特性:
- 命令白名单(首 token
- 超时中断
- 固定工作目录
- 输出截断
- Windows 不可执行命令友好报错
---
## 11. 数据存储
SQLite 表:
1. `messages`:对话消息
2. `capability_gaps`:能力缺口事件
提供查询:
- 最近消息
- 最近缺口事件
- 高频缺口聚类(含计数与最近出现时间)
---
## 12. 与最初文档相比的变化
当前代码已经完成并替代旧文档中的以下“待实现项”:
- ToolHost 子进程隔离(已实现)
- 能力缺口闭环(已实现)
- 自动 skill 生成与热加载(已实现)
- workspace 配置优先与运行空间隔离(已实现)
仍属于持续演进项:
- 新工具代码自动注册与生效的全自动化流水线
- 更细粒度权限域(按 skill/tool 分级)
- 更强的自动化验收e2e + 故障注入)
---
## 13. 下一步建议
1.`toolhost client/server` 增加专项故障单测(心跳失败、子进程崩溃、并发压力)。
2. 增加“自动生成 tool 后自动接线注册”的流水线模块。
3. 为 skill 自动生成增加结构门禁frontmatter/章节完整性校验)。
4. 引入操作审计视图,串联 trace_id 与 capability_gap。

View File

@@ -5,7 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"time"
"laodingbot/internal/knowledge" "laodingbot/internal/knowledge"
"laodingbot/internal/llm" "laodingbot/internal/llm"
@@ -20,9 +23,14 @@ type Orchestrator struct {
tools *tools.Registry tools *tools.Registry
soul string soul string
skills []knowledge.Skill skills []knowledge.Skill
skillsDoc string skillsDir string
autoSkillDir string
gapDraftTriggerCount int
gapLookbackDuration time.Duration
reactMaxStep int reactMaxStep int
enableCapabilityGap bool
log *logger.Logger log *logger.Logger
skillsMu sync.RWMutex
} }
func NewOrchestrator( func NewOrchestrator(
@@ -31,33 +39,66 @@ func NewOrchestrator(
registry *tools.Registry, registry *tools.Registry,
soul string, soul string,
skills []knowledge.Skill, skills []knowledge.Skill,
skillsDoc string, skillsDir string,
reactMaxStep int, reactMaxStep int,
enableCapabilityGap bool,
autoSkillDir string,
gapDraftTriggerCount int,
gapLookbackDuration time.Duration,
log *logger.Logger, log *logger.Logger,
) *Orchestrator { ) *Orchestrator {
if reactMaxStep <= 0 { if reactMaxStep <= 0 {
reactMaxStep = 4 reactMaxStep = 4
} }
if gapDraftTriggerCount <= 0 {
gapDraftTriggerCount = 3
}
if gapLookbackDuration <= 0 {
gapLookbackDuration = 7 * 24 * time.Hour
}
if strings.TrimSpace(autoSkillDir) == "" {
autoSkillDir = skillsDir
}
return &Orchestrator{ return &Orchestrator{
llm: llmClient, llm: llmClient,
store: store, store: store,
tools: registry, tools: registry,
soul: soul, soul: soul,
skills: skills, skills: skills,
skillsDoc: skillsDoc, skillsDir: skillsDir,
autoSkillDir: autoSkillDir,
gapDraftTriggerCount: gapDraftTriggerCount,
gapLookbackDuration: gapLookbackDuration,
reactMaxStep: reactMaxStep, reactMaxStep: reactMaxStep,
enableCapabilityGap: enableCapabilityGap,
log: log, log: log,
} }
} }
func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) { func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) {
traceID := logger.NewTraceID()
ctx = logger.WithTraceID(ctx, traceID)
traceLogPrefix := "trace_id=" + traceID
if o.log != nil { if o.log != nil {
o.log.Infof("handle message chat_id=%s user_id=%s text_len=%d", chatID, userID, len(text)) o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d", traceLogPrefix, chatID, userID, len(text))
o.log.Debugf("handle message text=%q", text) o.log.Debugf("%s handle message text=%q", traceLogPrefix, text)
}
if strings.EqualFold(strings.TrimSpace(text), "/reload_skills") {
if err := o.ReloadSkills(); err != nil {
return "技能热加载失败: " + err.Error(), nil
}
return "技能已热加载完成。", nil
}
if strings.EqualFold(strings.TrimSpace(text), "/capability_gaps") {
report, err := o.BuildCapabilityGapReport(10)
if err != nil {
return "缺口报告生成失败: " + err.Error(), nil
}
return report, nil
} }
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil { if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
if o.log != nil { if o.log != nil {
o.log.Errorf("save user message failed chat_id=%s err=%v", chatID, err) o.log.Errorf("%s save user message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
} }
return "", err return "", err
} }
@@ -65,50 +106,59 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
recent, err := o.store.LoadRecent(chatID, 16) recent, err := o.store.LoadRecent(chatID, 16)
if err != nil { if err != nil {
if o.log != nil { if o.log != nil {
o.log.Errorf("load recent failed chat_id=%s err=%v", chatID, err) o.log.Errorf("%s load recent failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
} }
return "", err return "", err
} }
compressed := memory.CompressForPrompt(recent, 6000) compressed := memory.CompressForPrompt(recent, 6000)
if o.log != nil { if o.log != nil {
o.log.Debugf("prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", chatID, len(recent), len(compressed)) o.log.Debugf("%s prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", traceLogPrefix, chatID, len(recent), len(compressed))
} }
matchedSkills := o.matchSkills(ctx, compressed, text) matchedSkills := o.matchSkills(ctx, compressed, text)
if len(matchedSkills) == 0 {
if bootstrap, ok := o.findSkillByKeyword("创建skill", "skill builder", "skill 创建", "构建技能"); ok {
matchedSkills = []knowledge.Skill{bootstrap}
if o.log != nil {
o.log.Infof("%s fallback bootstrap skill selected name=%s", traceLogPrefix, bootstrap.Name)
}
}
}
var response string var response string
if len(matchedSkills) == 0 { if len(matchedSkills) == 0 {
if o.log != nil { if o.log != nil {
o.log.Infof("no skill matched; use direct llm chat_id=%s", chatID) o.log.Infof("%s no skill matched; use direct llm chat_id=%s", traceLogPrefix, chatID)
} }
o.emitCapabilityGap(chatID, userID, text, "no_skill_matched")
response, err = o.runDirectLLM(ctx, compressed, text) response, err = o.runDirectLLM(ctx, compressed, text)
} else { } else {
if o.log != nil { if o.log != nil {
names := make([]string, 0, len(matchedSkills)) names := make([]string, 0, len(matchedSkills))
for _, s := range matchedSkills { for _, s := range matchedSkills {
names = append(names, s.Name) names = append(names, s.Name)
o.log.Infof("skill selected name=%s source=%s", s.Name, s.Source) o.log.Infof("%s skill selected name=%s source=%s", traceLogPrefix, s.Name, s.Source)
o.log.Debugf("skill selected content name=%s content=%q", s.Name, s.Content) o.log.Debugf("%s skill selected content name=%s content=%q", traceLogPrefix, s.Name, s.Content)
} }
o.log.Infof("skills matched chat_id=%s skills=%s", chatID, strings.Join(names, ",")) o.log.Infof("%s skills matched chat_id=%s skills=%s", traceLogPrefix, chatID, strings.Join(names, ","))
} }
response, err = o.runReAct(ctx, compressed, text, matchedSkills) response, err = o.runReAct(ctx, chatID, userID, compressed, text, matchedSkills)
} }
if err != nil { if err != nil {
if o.log != nil { if o.log != nil {
o.log.Errorf("message generation failed chat_id=%s err=%v", chatID, err) o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
} }
return "", err return "", err
} }
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil { if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
if o.log != nil { if o.log != nil {
o.log.Errorf("save assistant response failed chat_id=%s err=%v", chatID, err) o.log.Errorf("%s save assistant response failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
} }
return "", err return "", err
} }
if o.log != nil { if o.log != nil {
o.log.Infof("message handled chat_id=%s response_len=%d", chatID, len(response)) o.log.Infof("%s message handled chat_id=%s response_len=%d", traceLogPrefix, chatID, len(response))
} }
return response, nil return response, nil
} }
@@ -140,7 +190,9 @@ type reactDecision struct {
Final string `json:"final"` Final string `json:"final"`
} }
func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInput string, selectedSkills []knowledge.Skill) (string, error) { func (o *Orchestrator) runReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, selectedSkills []knowledge.Skill) (string, error) {
traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID
selectedSkillsDoc := formatSkills(selectedSkills) selectedSkillsDoc := formatSkills(selectedSkills)
toolDoc := o.formatToolDoc() toolDoc := o.formatToolDoc()
if o.log != nil { if o.log != nil {
@@ -148,9 +200,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
for _, s := range selectedSkills { for _, s := range selectedSkills {
names = append(names, s.Name) names = append(names, s.Name)
} }
o.log.Infof("react start steps=%d skills=%s", o.reactMaxStep, strings.Join(names, ",")) o.log.Infof("%s react start steps=%d skills=%s", traceLogPrefix, o.reactMaxStep, strings.Join(names, ","))
o.log.Debugf("react selected_skills_doc=%q", selectedSkillsDoc) o.log.Debugf("%s react selected_skills_doc=%q", traceLogPrefix, selectedSkillsDoc)
o.log.Debugf("react tools_doc=%q", toolDoc) o.log.Debugf("%s react tools_doc=%q", traceLogPrefix, toolDoc)
} }
systemPrompt := strings.Join([]string{ systemPrompt := strings.Join([]string{
@@ -176,8 +228,8 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
scratchpad := "" scratchpad := ""
for step := 1; step <= o.reactMaxStep; step++ { for step := 1; step <= o.reactMaxStep; step++ {
if o.log != nil { if o.log != nil {
o.log.Infof("react step start step=%d/%d", step, o.reactMaxStep) o.log.Infof("%s react step start step=%d/%d", traceLogPrefix, step, o.reactMaxStep)
o.log.Debugf("react scratchpad_before step=%d content=%q", step, scratchpad) o.log.Debugf("%s react scratchpad_before step=%d content=%q", traceLogPrefix, step, scratchpad)
} }
prompt := strings.Join([]string{ prompt := strings.Join([]string{
"历史上下文:", "历史上下文:",
@@ -197,17 +249,18 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
return "", err return "", err
} }
if o.log != nil { if o.log != nil {
o.log.Infof("react step llm output step=%d raw=%q", step, raw) o.log.Infof("%s react step llm output step=%d raw=%q", traceLogPrefix, step, raw)
} }
decision, err := parseDecision(raw) decision, err := parseDecision(raw)
if err != nil { if err != nil {
if o.log != nil { if o.log != nil {
o.log.Warnf("react parse failed, use raw as final err=%v", err) o.log.Warnf("%s react parse failed, fallback to direct llm err=%v", traceLogPrefix, err)
} }
return strings.TrimSpace(raw), nil o.emitCapabilityGap(chatID, userID, userInput, "react_parse_failed")
return o.runDirectLLM(ctx, compressedContext, userInput)
} }
if o.log != nil { if o.log != nil {
o.log.Infof("react step decision step=%d thought=%q action=%q action_input=%q final=%q", step, decision.Thought, decision.Action, decision.ActionInput, decision.Final) o.log.Infof("%s react step decision step=%d thought=%q action=%q action_input=%q final=%q", traceLogPrefix, step, decision.Thought, decision.Action, decision.ActionInput, decision.Final)
} }
action := strings.ToLower(strings.TrimSpace(decision.Action)) action := strings.ToLower(strings.TrimSpace(decision.Action))
@@ -221,7 +274,7 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
finalText = "我已完成思考,但当前没有足够信息给出稳定结论。" finalText = "我已完成思考,但当前没有足够信息给出稳定结论。"
} }
if o.log != nil { if o.log != nil {
o.log.Infof("react final step=%d final=%q", step, finalText) o.log.Infof("%s react final step=%d final=%q", traceLogPrefix, step, finalText)
} }
return finalText, nil return finalText, nil
} }
@@ -229,37 +282,45 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
tool, ok := o.tools.Get(action) tool, ok := o.tools.Get(action)
if !ok { if !ok {
if o.log != nil { if o.log != nil {
o.log.Warnf("react step tool missing step=%d tool=%s", step, action) o.log.Warnf("%s react step tool missing step=%d tool=%s", traceLogPrefix, step, action)
} }
scratchpad += fmt.Sprintf("Step %d Thought: %s\nStep %d Observation: tool %s 不存在\n", step, decision.Thought, step, action) scratchpad += "Step " + strconv.Itoa(step) + " Thought: " + decision.Thought + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Observation: " + formatToolErrorObservation("TOOL_NOT_FOUND", action, "tool not found") + "\n"
o.emitCapabilityGap(chatID, userID, userInput, "tool_not_found:"+action)
continue continue
} }
toolOut, toolErr := tool.Call(ctx, decision.ActionInput) toolOut, toolErr := tool.Call(ctx, decision.ActionInput)
if o.log != nil { if o.log != nil {
o.log.Infof("react step tool call step=%d tool=%s input=%q", step, action, decision.ActionInput) o.log.Infof("%s react step tool call step=%d tool=%s input=%q", traceLogPrefix, step, action, decision.ActionInput)
} }
obs := strings.TrimSpace(toolOut) obs := strings.TrimSpace(toolOut)
if obs == "" { if obs == "" {
obs = "(empty output)" obs = "(empty output)"
} }
if toolErr != nil { if toolErr != nil {
obs = obs + "\nERROR: " + toolErr.Error() obs = formatToolErrorObservation("TOOL_EXEC_ERROR", action, toolErr.Error()) + "\nOUTPUT:\n" + obs
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+action)
} }
if o.log != nil { if o.log != nil {
o.log.Infof("react step observation step=%d tool=%s observation=%q", step, action, obs) o.log.Infof("%s react step observation step=%d tool=%s observation=%q", traceLogPrefix, step, action, obs)
} }
if len(obs) > 2000 { if len(obs) > 2000 {
obs = obs[:2000] obs = obs[:2000]
} }
scratchpad += fmt.Sprintf("Step %d Thought: %s\nStep %d Action: %s\nStep %d ActionInput: %s\nStep %d Observation: %s\n", step, decision.Thought, step, action, step, decision.ActionInput, step, obs) scratchpad += "Step " + strconv.Itoa(step) + " Thought: " + decision.Thought + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Action: " + action + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " ActionInput: " + decision.ActionInput + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Observation: " + obs + "\n"
} }
o.emitCapabilityGap(chatID, userID, userInput, "react_step_exhausted")
return "我尝试了多轮思考与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil return "我尝试了多轮思考与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
} }
func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userInput string) []knowledge.Skill { func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userInput string) []knowledge.Skill {
if len(o.skills) == 0 { skills := o.getSkillsSnapshot()
if len(skills) == 0 {
return nil return nil
} }
@@ -277,7 +338,7 @@ func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userI
userPrompt := strings.Join([]string{ userPrompt := strings.Join([]string{
"候选技能:", "候选技能:",
formatSkillCatalog(o.skills), formatSkillCatalog(skills),
"", "",
"历史上下文:", "历史上下文:",
compressedContext, compressedContext,
@@ -316,7 +377,7 @@ func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userI
if _, ok := seen[name]; ok { if _, ok := seen[name]; ok {
continue continue
} }
for _, skill := range o.skills { for _, skill := range skills {
if strings.ToLower(strings.TrimSpace(skill.Name)) == name { if strings.ToLower(strings.TrimSpace(skill.Name)) == name {
picked = append(picked, skill) picked = append(picked, skill)
seen[name] = struct{}{} seen[name] = struct{}{}
@@ -338,28 +399,132 @@ func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userI
return picked return picked
} }
func parseDecision(raw string) (reactDecision, error) { func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string) {
raw = normalizeJSON(raw) if !o.enableCapabilityGap {
start := strings.Index(raw, "{") return
end := strings.LastIndex(raw, "}")
if start < 0 || end < start {
return reactDecision{}, fmt.Errorf("no json object found")
} }
raw = raw[start : end+1] intent = strings.TrimSpace(intent)
reason = strings.TrimSpace(reason)
var out reactDecision if intent == "" || reason == "" {
if err := json.Unmarshal([]byte(raw), &out); err != nil { return
return reactDecision{}, err
} }
return out, nil if len(intent) > 1000 {
intent = intent[:1000]
}
if len(reason) > 240 {
reason = reason[:240]
}
if err := o.store.SaveCapabilityGap(chatID, userID, intent, reason); err != nil && o.log != nil {
o.log.Warnf("save capability gap failed chat_id=%s user_id=%s err=%v", chatID, userID, err)
return
} }
func normalizeJSON(raw string) string { clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration))
raw = strings.TrimSpace(raw) if err != nil {
raw = strings.TrimPrefix(raw, "```json") if o.log != nil {
raw = strings.TrimPrefix(raw, "```") o.log.Warnf("query capability gap clusters failed err=%v", err)
raw = strings.TrimSuffix(raw, "```") }
return strings.TrimSpace(raw) return
}
for _, c := range clusters {
if c.Count < o.gapDraftTriggerCount {
continue
}
path, created, draftErr := knowledge.GenerateSkillDraft(c, o.autoSkillDir)
if draftErr != nil {
if o.log != nil {
o.log.Warnf("generate skill draft failed intent_key=%s reason=%s err=%v", c.IntentKey, c.Reason, draftErr)
}
continue
}
if created && o.log != nil {
o.log.Infof("capability gap draft generated path=%s intent_key=%s reason=%s count=%d", path, c.IntentKey, c.Reason, c.Count)
}
if created {
if reloadErr := o.ReloadSkills(); reloadErr != nil && o.log != nil {
o.log.Warnf("auto reload skills failed after generation path=%s err=%v", path, reloadErr)
}
}
}
}
func (o *Orchestrator) ReloadSkills() error {
skills, err := knowledge.LoadSkillSet(o.skillsDir)
if err != nil {
return err
}
o.skillsMu.Lock()
o.skills = skills
o.skillsMu.Unlock()
if o.log != nil {
o.log.Infof("skills hot reloaded count=%d dir=%s", len(skills), o.skillsDir)
}
return nil
}
func (o *Orchestrator) getSkillsSnapshot() []knowledge.Skill {
o.skillsMu.RLock()
defer o.skillsMu.RUnlock()
out := make([]knowledge.Skill, len(o.skills))
copy(out, o.skills)
return out
}
func (o *Orchestrator) BuildCapabilityGapReport(limit int) (string, error) {
clusters, err := o.store.TopCapabilityGapClusters(limit, time.Now().UTC().Add(-o.gapLookbackDuration))
if err != nil {
return "", err
}
if len(clusters) == 0 {
return "最近没有采集到能力缺口记录。", nil
}
b := strings.Builder{}
b.WriteString("高频能力缺口清单:\n")
for i, c := range clusters {
line := fmt.Sprintf("%d) intent=%s | reason=%s | count=%d | last_seen=%s\n", i+1, c.IntentKey, c.Reason, c.Count, c.LastSeenAt.Format("2006-01-02 15:04:05"))
b.WriteString(line)
}
b.WriteString("\n草稿目录")
b.WriteString(o.autoSkillDir)
b.WriteString("\n系统会在达到阈值后自动生成并热加载技能你也可以手动发送 /reload_skills。")
return b.String(), nil
}
func (o *Orchestrator) findSkillByKeyword(keywords ...string) (knowledge.Skill, bool) {
if len(keywords) == 0 {
return knowledge.Skill{}, false
}
skills := o.getSkillsSnapshot()
for _, s := range skills {
name := strings.ToLower(strings.TrimSpace(s.Name))
content := strings.ToLower(strings.TrimSpace(s.Content))
for _, kw := range keywords {
kw = strings.ToLower(strings.TrimSpace(kw))
if kw == "" {
continue
}
if strings.Contains(name, kw) || strings.Contains(content, kw) {
return s, true
}
}
}
return knowledge.Skill{}, false
}
func formatToolErrorObservation(code, action, reason string) string {
code = strings.TrimSpace(code)
action = strings.TrimSpace(action)
reason = strings.TrimSpace(reason)
if code == "" {
code = "TOOL_EXEC_ERROR"
}
if action == "" {
action = "unknown"
}
if reason == "" {
reason = "unknown error"
}
return "ERROR_CODE=" + code + "; TOOL=" + action + "; REASON=" + reason
} }
func formatSkills(skills []knowledge.Skill) string { func formatSkills(skills []knowledge.Skill) string {

View File

@@ -0,0 +1,31 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
)
func parseDecision(raw string) (reactDecision, error) {
raw = normalizeJSON(raw)
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end < start {
return reactDecision{}, fmt.Errorf("no json object found")
}
raw = raw[start : end+1]
var out reactDecision
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return reactDecision{}, err
}
return out, nil
}
func normalizeJSON(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
return strings.TrimSpace(raw)
}

View File

@@ -0,0 +1,32 @@
package agent
import "testing"
func TestParseDecisionPlainJSON(t *testing.T) {
raw := `{"thought":"t","action":"none","action_input":"","final":"ok"}`
got, err := parseDecision(raw)
if err != nil {
t.Fatalf("parseDecision error: %v", err)
}
if got.Action != "none" || got.Final != "ok" {
t.Fatalf("unexpected decision: %+v", got)
}
}
func TestParseDecisionCodeFence(t *testing.T) {
raw := "```json\n{\"thought\":\"t\",\"action\":\"shell\",\"action_input\":\"ls\",\"final\":\"\"}\n```"
got, err := parseDecision(raw)
if err != nil {
t.Fatalf("parseDecision error: %v", err)
}
if got.Action != "shell" || got.ActionInput != "ls" {
t.Fatalf("unexpected decision: %+v", got)
}
}
func TestParseDecisionInvalid(t *testing.T) {
_, err := parseDecision("not json")
if err == nil {
t.Fatal("expected parse error")
}
}

View File

@@ -16,6 +16,12 @@ type Config struct {
SoulPath string SoulPath string
SkillsDir string SkillsDir string
ReactMaxSteps int ReactMaxSteps int
ToolCallTimeoutSec int
ToolOutputMaxChars int
EnableCapabilityGap bool
AutoSkillDir string
GapDraftTriggerCount int
GapClusterLookbackHours int
Telegram TelegramConfig Telegram TelegramConfig
Feishu FeishuConfig Feishu FeishuConfig
@@ -51,16 +57,25 @@ type SecurityConfig struct {
} }
func Load() (Config, error) { func Load() (Config, error) {
agentWorkspaceDir := resolveAgentWorkspaceDir()
if err := preloadEnvFiles(); err != nil { if err := preloadEnvFiles(); err != nil {
return Config{}, err return Config{}, err
} }
defaultWorkSubdir := filepath.Join(agentWorkspaceDir, "workspace")
defaultDataDir := filepath.Join(agentWorkspaceDir, "data")
cfg := Config{ cfg := Config{
MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"), MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"),
LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"), LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"),
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), "./bot_context/soul.md"), SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), filepath.Join(agentWorkspaceDir, "bot_context", "soul.md")),
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), "./skills"), SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 4), ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0),
ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15),
ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000),
EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true),
AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3),
GapClusterLookbackHours: intFromEnv("GAP_CLUSTER_LOOKBACK_HOURS", 168),
Telegram: TelegramConfig{ Telegram: TelegramConfig{
Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30), PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30),
@@ -77,11 +92,11 @@ func Load() (Config, error) {
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")), APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"), Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
}, },
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), "./data/laodingbot.db"), SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
Security: SecurityConfig{ Security: SecurityConfig{
AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), "./workspace,./data")), AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), strings.Join([]string{agentWorkspaceDir, defaultDataDir, defaultWorkSubdir}, ","))),
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail")), AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), "./workspace"), WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
}, },
} }
@@ -96,6 +111,18 @@ func Load() (Config, error) {
if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 8 { if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 8 {
return Config{}, fmt.Errorf("REACT_MAX_STEPS must be between 1 and 8") return Config{}, fmt.Errorf("REACT_MAX_STEPS must be between 1 and 8")
} }
if cfg.ToolCallTimeoutSec < 1 || cfg.ToolCallTimeoutSec > 300 {
return Config{}, fmt.Errorf("TOOL_CALL_TIMEOUT_SEC must be between 1 and 300")
}
if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 {
return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000")
}
if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 {
return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100")
}
if cfg.GapClusterLookbackHours < 1 || cfg.GapClusterLookbackHours > 24*365 {
return Config{}, fmt.Errorf("GAP_CLUSTER_LOOKBACK_HOURS must be between 1 and 8760")
}
if cfg.MessageChannel == "telegram" { if cfg.MessageChannel == "telegram" {
if cfg.Telegram.Token == "" { if cfg.Telegram.Token == "" {
@@ -119,28 +146,152 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("LLM_API_KEY is required") return Config{}, fmt.Errorf("LLM_API_KEY is required")
} }
cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir)
cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir)
cfg.AutoSkillDir = resolvePathInWorkspace(cfg.AutoSkillDir, agentWorkspaceDir)
cfg.SQLitePath = resolvePathInWorkspace(cfg.SQLitePath, agentWorkspaceDir)
cfg.Security.WorkDir = resolvePathInWorkspace(cfg.Security.WorkDir, agentWorkspaceDir)
cfg.Security.AllowedDirs = resolveDirsInWorkspace(cfg.Security.AllowedDirs, agentWorkspaceDir)
cfg.Security.AllowedDirs = ensureAllowedDirs(cfg.Security.AllowedDirs,
filepath.Clean(agentWorkspaceDir),
filepath.Join(agentWorkspaceDir, "skills"),
filepath.Join(agentWorkspaceDir, "data"),
filepath.Join(agentWorkspaceDir, "workspace"),
)
cfg.Security.AllowedCommands = ensureAllowedCommands(cfg.Security.AllowedCommands, "go", "curl", "curl.exe")
return cfg, nil return cfg, nil
} }
func preloadEnvFiles() error { func preloadEnvFiles() error {
paths := []string{} explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE"))
if explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE")); explicit != "" { if explicit != "" {
paths = append(paths, explicit) if err := tryLoadEnvFile(explicit, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", explicit, err)
}
} }
paths = append(paths, "configs/env", ".env")
for _, p := range paths { workspaceDir := resolveAgentWorkspaceDir()
if err := loadEnvFile(p); err != nil { workspaceEnv := filepath.Join(workspaceDir, "configs", "env")
if errors.Is(err, os.ErrNotExist) { workspaceDotEnv := filepath.Join(workspaceDir, ".env")
continue if err := tryLoadEnvFile(workspaceEnv, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", workspaceEnv, err)
} }
return fmt.Errorf("load env file %s failed: %w", p, err) if err := tryLoadEnvFile(workspaceDotEnv, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", workspaceDotEnv, err)
} }
if err := tryLoadEnvFile("configs/env", false); err != nil {
return fmt.Errorf("load env file %s failed: %w", "configs/env", err)
}
if err := tryLoadEnvFile(".env", false); err != nil {
return fmt.Errorf("load env file %s failed: %w", ".env", err)
} }
return nil return nil
} }
func loadEnvFile(path string) error { func tryLoadEnvFile(path string, override bool) error {
err := loadEnvFile(path, override)
if err != nil && errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func resolveAgentWorkspaceDir() string {
raw := strings.TrimSpace(os.Getenv("AGENT_WORKSPACE_DIR"))
if raw == "" {
raw = filepath.Join(".", "workspace", "agent_runtime")
}
abs, err := filepath.Abs(raw)
if err != nil {
return raw
}
return abs
}
func resolvePathInWorkspace(path, workspaceDir string) string {
path = strings.TrimSpace(path)
if path == "" {
return path
}
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Clean(filepath.Join(workspaceDir, path))
}
func resolveDirsInWorkspace(dirs []string, workspaceDir string) []string {
out := make([]string, 0, len(dirs))
for _, d := range dirs {
resolved := resolvePathInWorkspace(d, workspaceDir)
if strings.TrimSpace(resolved) != "" {
out = append(out, resolved)
}
}
if len(out) == 0 {
out = append(out, filepath.Clean(workspaceDir))
}
return out
}
func ensureAllowedDirs(existing []string, dirs ...string) []string {
set := map[string]struct{}{}
out := make([]string, 0, len(existing)+len(dirs))
for _, d := range existing {
clean := filepath.Clean(strings.TrimSpace(d))
if clean == "" {
continue
}
if _, ok := set[clean]; ok {
continue
}
set[clean] = struct{}{}
out = append(out, clean)
}
for _, d := range dirs {
clean := filepath.Clean(strings.TrimSpace(d))
if clean == "" {
continue
}
if _, ok := set[clean]; ok {
continue
}
set[clean] = struct{}{}
out = append(out, clean)
}
return out
}
func ensureAllowedCommands(existing []string, commands ...string) []string {
set := map[string]struct{}{}
out := make([]string, 0, len(existing)+len(commands))
for _, c := range existing {
cmd := strings.ToLower(strings.TrimSpace(c))
if cmd == "" {
continue
}
if _, ok := set[cmd]; ok {
continue
}
set[cmd] = struct{}{}
out = append(out, cmd)
}
for _, c := range commands {
cmd := strings.ToLower(strings.TrimSpace(c))
if cmd == "" {
continue
}
if _, ok := set[cmd]; ok {
continue
}
set[cmd] = struct{}{}
out = append(out, cmd)
}
return out
}
func loadEnvFile(path string, override bool) error {
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
absPath = path absPath = path
@@ -173,12 +324,15 @@ func loadEnvFile(path string) error {
val = val[1 : len(val)-1] val = val[1 : len(val)-1]
} }
} }
if _, exists := os.LookupEnv(key); !exists { if !override {
if _, exists := os.LookupEnv(key); exists {
continue
}
}
if err := os.Setenv(key, val); err != nil { if err := os.Setenv(key, val); err != nil {
return err return err
} }
} }
}
return scanner.Err() return scanner.Err()
} }
@@ -202,6 +356,20 @@ func intFromEnv(name string, d int) int {
return v return v
} }
func boolFromEnv(name string, d bool) bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
if raw == "" {
return d
}
if raw == "1" || raw == "true" || raw == "yes" || raw == "on" {
return true
}
if raw == "0" || raw == "false" || raw == "no" || raw == "off" {
return false
}
return d
}
func splitCSV(raw string) []string { func splitCSV(raw string) []string {
parts := strings.Split(raw, ",") parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))

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 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) { func LoadSkillSet(dir string) ([]Skill, error) {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {

32
internal/logger/trace.go Normal file
View File

@@ -0,0 +1,32 @@
package logger
import (
"context"
"fmt"
"math/rand"
"time"
)
type traceIDKey struct{}
func NewTraceID() string {
now := time.Now().UTC().UnixNano()
randPart := rand.Int63()
return fmt.Sprintf("tr-%x-%x", now, randPart)
}
func WithTraceID(ctx context.Context, traceID string) context.Context {
if traceID == "" {
return ctx
}
return context.WithValue(ctx, traceIDKey{}, traceID)
}
func TraceIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
v := ctx.Value(traceIDKey{})
s, _ := v.(string)
return s
}

View File

@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"time" "time"
"laodingbot/internal/logger" "laodingbot/internal/logger"
@@ -110,6 +112,154 @@ func (s *SQLiteStore) LoadRecent(chatID string, limit int) ([]Message, error) {
return messages, nil return messages, nil
} }
func (s *SQLiteStore) SaveCapabilityGap(chatID, userID, intent, reason string) error {
_, err := s.db.Exec(`
INSERT INTO capability_gaps(chat_id, user_id, intent, reason, created_at)
VALUES (?, ?, ?, ?, ?)
`, chatID, userID, intent, reason, time.Now().UTC())
if err != nil && s.log != nil {
s.log.Errorf("save capability gap failed chat_id=%s user_id=%s err=%v", chatID, userID, err)
}
return err
}
func (s *SQLiteStore) TopCapabilityGaps(limit int) ([]CapabilityGap, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, chat_id, user_id, intent, reason, created_at
FROM capability_gaps
ORDER BY id DESC
LIMIT ?
`, limit)
if err != nil {
if s.log != nil {
s.log.Errorf("top capability gaps query failed err=%v", err)
}
return nil, err
}
defer rows.Close()
out := make([]CapabilityGap, 0, limit)
for rows.Next() {
var item CapabilityGap
if err := rows.Scan(&item.ID, &item.ChatID, &item.UserID, &item.Intent, &item.Reason, &item.CreatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *SQLiteStore) TopCapabilityGapClusters(limit int, since time.Time) ([]CapabilityGapCluster, error) {
if limit <= 0 {
limit = 20
}
if since.IsZero() {
since = time.Now().UTC().Add(-7 * 24 * time.Hour)
}
rows, err := s.db.Query(`
SELECT intent, reason, created_at
FROM capability_gaps
WHERE created_at >= ?
`, since)
if err != nil {
if s.log != nil {
s.log.Errorf("top capability gap clusters query failed err=%v", err)
}
return nil, err
}
defer rows.Close()
type groupKey struct {
IntentKey string
Reason string
}
type agg struct {
cluster CapabilityGapCluster
}
groups := map[groupKey]agg{}
for rows.Next() {
var intent string
var reason string
var createdAt time.Time
if err := rows.Scan(&intent, &reason, &createdAt); err != nil {
return nil, err
}
intentKey := normalizeIntentKey(intent)
reason = strings.TrimSpace(reason)
k := groupKey{IntentKey: intentKey, Reason: reason}
current, ok := groups[k]
if !ok {
current = agg{cluster: CapabilityGapCluster{
IntentKey: intentKey,
SampleIntent: strings.TrimSpace(intent),
Reason: reason,
Count: 0,
LastSeenAt: createdAt,
}}
}
current.cluster.Count++
if createdAt.After(current.cluster.LastSeenAt) {
current.cluster.LastSeenAt = createdAt
}
if current.cluster.SampleIntent == "" {
current.cluster.SampleIntent = strings.TrimSpace(intent)
}
groups[k] = current
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]CapabilityGapCluster, 0, len(groups))
for _, v := range groups {
out = append(out, v.cluster)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return out[i].LastSeenAt.After(out[j].LastSeenAt)
}
return out[i].Count > out[j].Count
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func normalizeIntentKey(intent string) string {
intent = strings.ToLower(strings.TrimSpace(intent))
if intent == "" {
return "empty"
}
intent = strings.ReplaceAll(intent, " ", "")
intent = strings.ReplaceAll(intent, "\t", "")
intent = strings.ReplaceAll(intent, "\n", "")
intent = strings.ReplaceAll(intent, "\r", "")
b := strings.Builder{}
for _, r := range intent {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || (r >= 0x4e00 && r <= 0x9fff) {
b.WriteRune(r)
}
}
normalized := b.String()
if normalized == "" {
return "empty"
}
runes := []rune(normalized)
if len(runes) > 80 {
normalized = string(runes[:80])
}
return normalized
}
func (s *SQLiteStore) migrate() error { func (s *SQLiteStore) migrate() error {
stmt := ` stmt := `
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -121,6 +271,15 @@ func (s *SQLiteStore) migrate() error {
created_at TIMESTAMP NOT NULL created_at TIMESTAMP NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id); CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
CREATE TABLE IF NOT EXISTS capability_gaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
user_id TEXT NOT NULL,
intent TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_capability_gaps_created_at ON capability_gaps(created_at);
` `
if _, err := s.db.Exec(stmt); err != nil { if _, err := s.db.Exec(stmt); err != nil {
return fmt.Errorf("migrate schema: %w", err) return fmt.Errorf("migrate schema: %w", err)

View File

@@ -0,0 +1,64 @@
package memory
import (
"path/filepath"
"testing"
"time"
)
func TestCapabilityGapStoreAndLoad(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := NewSQLiteStore(dbPath, nil)
if err != nil {
t.Fatalf("NewSQLiteStore error: %v", err)
}
defer store.Close()
if err := store.SaveCapabilityGap("c1", "u1", "intent-a", "reason-a"); err != nil {
t.Fatalf("SaveCapabilityGap error: %v", err)
}
if err := store.SaveCapabilityGap("c1", "u1", "intent-b", "reason-b"); err != nil {
t.Fatalf("SaveCapabilityGap error: %v", err)
}
items, err := store.TopCapabilityGaps(10)
if err != nil {
t.Fatalf("TopCapabilityGaps error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
if items[0].Intent != "intent-b" {
t.Fatalf("expected newest first, got first intent=%s", items[0].Intent)
}
}
func TestTopCapabilityGapClusters(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "cluster.db")
store, err := NewSQLiteStore(dbPath, nil)
if err != nil {
t.Fatalf("NewSQLiteStore error: %v", err)
}
defer store.Close()
if err := store.SaveCapabilityGap("c1", "u1", "帮我查询 data 目录", "no_skill_matched"); err != nil {
t.Fatalf("SaveCapabilityGap error: %v", err)
}
if err := store.SaveCapabilityGap("c1", "u2", "帮我 查询 data 目录", "no_skill_matched"); err != nil {
t.Fatalf("SaveCapabilityGap error: %v", err)
}
if err := store.SaveCapabilityGap("c2", "u3", "读取配置文件内容", "tool_call_failed:file"); err != nil {
t.Fatalf("SaveCapabilityGap error: %v", err)
}
clusters, err := store.TopCapabilityGapClusters(10, time.Now().UTC().Add(-1*time.Hour))
if err != nil {
t.Fatalf("TopCapabilityGapClusters error: %v", err)
}
if len(clusters) == 0 {
t.Fatalf("expected non-empty clusters")
}
if clusters[0].Count < 2 {
t.Fatalf("expected first cluster count >= 2, got %d", clusters[0].Count)
}
}

20
internal/memory/types.go Normal file
View File

@@ -0,0 +1,20 @@
package memory
import "time"
type CapabilityGap struct {
ID int64
ChatID string
UserID string
Intent string
Reason string
CreatedAt time.Time
}
type CapabilityGapCluster struct {
IntentKey string
SampleIntent string
Reason string
Count int
LastSeenAt time.Time
}

View File

@@ -0,0 +1,109 @@
package runtimews
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const envWorkspaceDir = "AGENT_WORKSPACE_DIR"
func PrepareFromEnv() (string, error) {
workspaceDir := strings.TrimSpace(os.Getenv(envWorkspaceDir))
if workspaceDir == "" {
workspaceDir = filepath.Join(".", "workspace", "agent_runtime")
}
absWorkspace, err := filepath.Abs(workspaceDir)
if err != nil {
return "", err
}
if err := os.MkdirAll(absWorkspace, 0o755); err != nil {
return "", err
}
if err := seedRuntimeWorkspace(absWorkspace); err != nil {
return "", err
}
if err := os.Setenv(envWorkspaceDir, absWorkspace); err != nil {
return "", err
}
_ = os.Setenv("CONFIG_ENV_FILE", filepath.Join(absWorkspace, "configs", "env"))
return absWorkspace, nil
}
func seedRuntimeWorkspace(workspaceRoot string) error {
seedDirs := []string{"configs", "data", "bot_context", "skills"}
for _, name := range seedDirs {
src := filepath.Join(".", name)
dst := filepath.Join(workspaceRoot, name)
if err := copyDirIfMissing(src, dst); err != nil {
return fmt.Errorf("seed %s failed: %w", name, err)
}
}
if err := os.MkdirAll(filepath.Join(workspaceRoot, "workspace"), 0o755); err != nil {
return err
}
return nil
}
func copyDirIfMissing(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if !info.IsDir() {
return nil
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
if _, err := os.Stat(target); err == nil {
return nil
}
if err := copyFile(path, target); err != nil {
return err
}
return nil
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}

303
internal/toolhost/client.go Normal file
View File

@@ -0,0 +1,303 @@
package toolhost
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"laodingbot/internal/logger"
)
type ClientConfig struct {
ExecutablePath string
Args []string
WorkDir string
Env []string
CallTimeout time.Duration
HeartbeatInterval time.Duration
MaxConcurrency int
}
type Client struct {
cfg ClientConfig
log *logger.Logger
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
decoder *json.Decoder
encoder *json.Encoder
seq int64
lifecycleMu sync.Mutex
ioMu sync.Mutex
sem chan struct{}
closed int32
}
func NewClient(cfg ClientConfig, log *logger.Logger) (*Client, error) {
if cfg.ExecutablePath == "" {
return nil, fmt.Errorf("empty executable path")
}
if cfg.CallTimeout <= 0 {
cfg.CallTimeout = 15 * time.Second
}
if cfg.HeartbeatInterval <= 0 {
cfg.HeartbeatInterval = 5 * time.Second
}
if cfg.MaxConcurrency <= 0 {
cfg.MaxConcurrency = 4
}
c := &Client{
cfg: cfg,
log: log,
sem: make(chan struct{}, cfg.MaxConcurrency),
}
if err := c.ensureStartedLocked(); err != nil {
return nil, err
}
go c.heartbeatLoop()
return c, nil
}
func (c *Client) Close() error {
atomic.StoreInt32(&c.closed, 1)
c.lifecycleMu.Lock()
defer c.lifecycleMu.Unlock()
return c.stopLocked()
}
func (c *Client) ToolList(ctx context.Context) ([]toolInfo, error) {
var out []toolInfo
if err := c.call(ctx, "tool.list", map[string]string{}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) ToolCall(ctx context.Context, name, input string) (string, error) {
var out toolCallResult
if err := c.call(ctx, "tool.call", toolCallParams{Name: name, Input: input}, &out); err != nil {
return "", err
}
if out.Error != "" {
return out.Output, fmt.Errorf(out.Error)
}
return out.Output, nil
}
func (c *Client) call(ctx context.Context, method string, params interface{}, result interface{}) error {
if atomic.LoadInt32(&c.closed) == 1 {
return fmt.Errorf("toolhost client is closed")
}
if ctx == nil {
ctx = context.Background()
}
select {
case c.sem <- struct{}{}:
defer func() { <-c.sem }()
case <-ctx.Done():
return ctx.Err()
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
err := c.callOnce(ctx, method, params, result)
if err == nil {
return nil
}
lastErr = err
if atomic.LoadInt32(&c.closed) == 1 {
return err
}
if c.log != nil {
c.log.Warnf("toolhost rpc call failed method=%s attempt=%d err=%v", method, attempt+1, err)
}
if restartErr := c.restart(); restartErr != nil {
return fmt.Errorf("rpc failed=%v; restart failed=%w", err, restartErr)
}
}
return fmt.Errorf("toolhost rpc call failed after retry method=%s err=%v", method, lastErr)
}
func (c *Client) callOnce(ctx context.Context, method string, params interface{}, result interface{}) error {
if err := c.ensureStarted(); err != nil {
return err
}
callCtx, cancel := context.WithTimeout(ctx, c.cfg.CallTimeout)
defer cancel()
if err := callCtx.Err(); err != nil {
return err
}
id := atomic.AddInt64(&c.seq, 1)
payload, err := json.Marshal(params)
if err != nil {
return err
}
req := rpcRequest{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: payload,
}
c.ioMu.Lock()
defer c.ioMu.Unlock()
if err := c.encoder.Encode(req); err != nil {
return err
}
var resp rpcResponse
if err := c.decoder.Decode(&resp); err != nil {
return err
}
if resp.ID != id {
return fmt.Errorf("rpc response id mismatch expected=%d got=%d", id, resp.ID)
}
if resp.Error != nil {
return fmt.Errorf("rpc error code=%d msg=%s", resp.Error.Code, resp.Error.Message)
}
if result == nil {
return nil
}
raw, err := json.Marshal(resp.Result)
if err != nil {
return err
}
return json.Unmarshal(raw, result)
}
func (c *Client) heartbeatLoop() {
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
defer ticker.Stop()
for range ticker.C {
if atomic.LoadInt32(&c.closed) == 1 {
return
}
hbCtx, cancel := context.WithTimeout(context.Background(), c.cfg.CallTimeout)
var out map[string]string
err := c.call(hbCtx, "ping", map[string]string{}, &out)
cancel()
if err == nil {
continue
}
if c.log != nil {
c.log.Warnf("toolhost heartbeat failed err=%v", err)
}
_ = c.restart()
}
}
func (c *Client) ensureStarted() error {
c.lifecycleMu.Lock()
defer c.lifecycleMu.Unlock()
return c.ensureStartedLocked()
}
func (c *Client) ensureStartedLocked() error {
if c.cmd != nil && c.cmd.Process != nil {
return nil
}
cmd := exec.Command(c.cfg.ExecutablePath, c.cfg.Args...)
cmd.Dir = c.cfg.WorkDir
if len(c.cfg.Env) > 0 {
cmd.Env = append(os.Environ(), c.cfg.Env...)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
go c.logStderr(stderr)
go func() {
_ = cmd.Wait()
c.lifecycleMu.Lock()
if c.cmd == cmd {
c.cmd = nil
c.stdin = nil
c.stdout = nil
c.encoder = nil
c.decoder = nil
}
c.lifecycleMu.Unlock()
}()
c.cmd = cmd
c.stdin = stdin
c.stdout = stdout
c.encoder = json.NewEncoder(stdin)
c.decoder = json.NewDecoder(bufio.NewReader(stdout))
if c.log != nil {
c.log.Infof("toolhost started pid=%d", cmd.Process.Pid)
}
return nil
}
func (c *Client) restart() error {
c.lifecycleMu.Lock()
defer c.lifecycleMu.Unlock()
if err := c.stopLocked(); err != nil {
if c.log != nil {
c.log.Warnf("toolhost stop during restart failed err=%v", err)
}
}
return c.ensureStartedLocked()
}
func (c *Client) stopLocked() error {
if c.cmd == nil || c.cmd.Process == nil {
return nil
}
proc := c.cmd.Process
if err := proc.Kill(); err != nil {
return err
}
c.cmd = nil
c.stdin = nil
c.stdout = nil
c.encoder = nil
c.decoder = nil
return nil
}
func (c *Client) logStderr(r io.Reader) {
if c.log == nil {
_, _ = io.Copy(io.Discard, r)
return
}
s := bufio.NewScanner(r)
for s.Scan() {
c.log.Warnf("toolhost stderr: %s", s.Text())
}
}

View File

@@ -0,0 +1,37 @@
package toolhost
import "encoding/json"
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type toolInfo struct {
Name string `json:"name"`
Description string `json:"description"`
}
type toolCallParams struct {
Name string `json:"name"`
Input string `json:"input"`
}
type toolCallResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}

View File

@@ -0,0 +1,36 @@
package toolhost
import (
"context"
"time"
)
type RemoteTool struct {
name string
description string
client *Client
callTimeout time.Duration
}
func NewRemoteTool(name, description string, callTimeout time.Duration, client *Client) *RemoteTool {
if callTimeout <= 0 {
callTimeout = 15 * time.Second
}
return &RemoteTool{name: name, description: description, client: client, callTimeout: callTimeout}
}
func (t *RemoteTool) Name() string { return t.name }
func (t *RemoteTool) Description() string { return t.description }
func (t *RemoteTool) Call(ctx context.Context, input string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, t.callTimeout)
defer cancel()
}
return t.client.ToolCall(ctx, t.name, input)
}

View File

@@ -0,0 +1,45 @@
package toolhost
import (
"context"
"fmt"
"time"
"laodingbot/internal/config"
"laodingbot/internal/logger"
"laodingbot/internal/tools"
"laodingbot/internal/tools/filetool"
"laodingbot/internal/tools/shelltool"
)
func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error {
var registryLog *logger.Logger
var fileLog *logger.Logger
var shellLog *logger.Logger
var serverLog *logger.Logger
if log != nil {
log.Infof("toolhost child starting")
registryLog = log.WithComponent("toolhost.registry")
fileLog = log.WithComponent("toolhost.file")
shellLog = log.WithComponent("toolhost.shell")
serverLog = log.WithComponent("toolhost.server")
}
registry := tools.NewRegistry(registryLog)
registry.Register(filetool.New(cfg.Security.AllowedDirs, cfg.ToolOutputMaxChars, fileLog))
registry.Register(shelltool.New(
cfg.Security.AllowedCommands,
cfg.Security.WorkDir,
time.Duration(cfg.ToolCallTimeoutSec)*time.Second,
cfg.ToolOutputMaxChars,
shellLog,
))
server := NewServer(registry, serverLog)
if err := server.Serve(ctx, stdin(), stdout()); err != nil && ctx.Err() == nil {
return fmt.Errorf("toolhost serve failed: %w", err)
}
if log != nil {
log.Infof("toolhost child stopped")
}
return nil
}

106
internal/toolhost/server.go Normal file
View File

@@ -0,0 +1,106 @@
package toolhost
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"sort"
"strings"
"sync"
"laodingbot/internal/logger"
"laodingbot/internal/tools"
)
type Server struct {
registry *tools.Registry
log *logger.Logger
writeMu sync.Mutex
}
func NewServer(registry *tools.Registry, log *logger.Logger) *Server {
return &Server{registry: registry, log: log}
}
func (s *Server) Serve(ctx context.Context, reader io.Reader, writer io.Writer) error {
dec := json.NewDecoder(bufio.NewReader(reader))
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
var req rpcRequest
if err := dec.Decode(&req); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
if s.log != nil {
s.log.Errorf("toolhost decode request failed err=%v", err)
}
return err
}
resp := s.handleRequest(ctx, req)
if err := s.writeResponse(writer, resp); err != nil {
if s.log != nil {
s.log.Errorf("toolhost write response failed err=%v", err)
}
return err
}
}
}
func (s *Server) handleRequest(ctx context.Context, req rpcRequest) rpcResponse {
resp := rpcResponse{JSONRPC: "2.0", ID: req.ID}
switch req.Method {
case "ping":
resp.Result = map[string]string{"status": "ok"}
return resp
case "tool.list":
list := s.registry.List()
sort.Slice(list, func(i, j int) bool {
return strings.ToLower(list[i].Name()) < strings.ToLower(list[j].Name())
})
infos := make([]toolInfo, 0, len(list))
for _, t := range list {
infos = append(infos, toolInfo{Name: t.Name(), Description: t.Description()})
}
resp.Result = infos
return resp
case "tool.call":
var p toolCallParams
if err := json.Unmarshal(req.Params, &p); err != nil {
resp.Error = &rpcError{Code: -32602, Message: "invalid params"}
return resp
}
name := strings.TrimSpace(strings.ToLower(p.Name))
tool, ok := s.registry.Get(name)
if !ok {
resp.Error = &rpcError{Code: -32004, Message: "tool not found"}
return resp
}
out, err := tool.Call(ctx, p.Input)
result := toolCallResult{Output: out}
if err != nil {
result.Error = err.Error()
}
resp.Result = result
return resp
default:
resp.Error = &rpcError{Code: -32601, Message: "method not found"}
return resp
}
}
func (s *Server) writeResponse(writer io.Writer, resp rpcResponse) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
enc := json.NewEncoder(writer)
return enc.Encode(resp)
}

View File

@@ -0,0 +1,14 @@
package toolhost
import (
"io"
"os"
)
func stdin() io.Reader {
return os.Stdin
}
func stdout() io.Writer {
return os.Stdout
}

View File

@@ -12,10 +12,11 @@ import (
type Tool struct { type Tool struct {
allowedDirs []string allowedDirs []string
maxOutputChars int
log *logger.Logger log *logger.Logger
} }
func New(allowedDirs []string, log *logger.Logger) *Tool { func New(allowedDirs []string, maxOutputChars int, log *logger.Logger) *Tool {
normalized := make([]string, 0, len(allowedDirs)) normalized := make([]string, 0, len(allowedDirs))
for _, dir := range allowedDirs { for _, dir := range allowedDirs {
abs, err := filepath.Abs(strings.TrimSpace(dir)) abs, err := filepath.Abs(strings.TrimSpace(dir))
@@ -23,16 +24,19 @@ func New(allowedDirs []string, log *logger.Logger) *Tool {
normalized = append(normalized, filepath.Clean(abs)) normalized = append(normalized, filepath.Clean(abs))
} }
} }
if log != nil { if maxOutputChars <= 0 {
log.Infof("file tool initialized allowed_dirs=%d", len(normalized)) maxOutputChars = 4000
} }
return &Tool{allowedDirs: normalized, log: log} if log != nil {
log.Infof("file tool initialized allowed_dirs=%d max_output_chars=%d", len(normalized), maxOutputChars)
}
return &Tool{allowedDirs: normalized, maxOutputChars: maxOutputChars, log: log}
} }
func (t *Tool) Name() string { return "file" } func (t *Tool) Name() string { return "file" }
func (t *Tool) Description() string { func (t *Tool) Description() string {
return "File operations with command format: read <path> | write <path>\\n<content>" return "File operations with command format: read <path> | list <path> | write <path>\\n<content>"
} }
func (t *Tool) Call(_ context.Context, input string) (string, error) { func (t *Tool) Call(_ context.Context, input string) (string, error) {
@@ -49,6 +53,16 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
} }
return "", err return "", err
} }
info, err := os.Stat(resolved)
if err != nil {
if t.log != nil {
t.log.Errorf("file read stat failed path=%s err=%v", resolved, err)
}
return "", err
}
if info.IsDir() {
return "", fmt.Errorf("PATH_IS_DIRECTORY: %s (use 'list <path>' first)", resolved)
}
b, err := os.ReadFile(resolved) b, err := os.ReadFile(resolved)
if err != nil { if err != nil {
if t.log != nil { if t.log != nil {
@@ -59,7 +73,49 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
if t.log != nil { if t.log != nil {
t.log.Infof("file read success path=%s bytes=%d", resolved, len(b)) t.log.Infof("file read success path=%s bytes=%d", resolved, len(b))
} }
return string(b), nil out := string(b)
if len(out) > t.maxOutputChars {
out = out[:t.maxOutputChars]
}
return out, nil
}
if strings.HasPrefix(input, "list ") {
path := strings.TrimSpace(strings.TrimPrefix(input, "list "))
resolved, err := t.resolveAllowed(path)
if err != nil {
if t.log != nil {
t.log.Warnf("file list denied path=%s err=%v", path, err)
}
return "", err
}
entries, err := os.ReadDir(resolved)
if err != nil {
if t.log != nil {
t.log.Errorf("file list failed path=%s err=%v", resolved, err)
}
return "", err
}
b := strings.Builder{}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
b.WriteString(name)
b.WriteString("\n")
if b.Len() >= t.maxOutputChars {
break
}
}
out := strings.TrimSpace(b.String())
if out == "" {
return "(empty)", nil
}
if len(out) > t.maxOutputChars {
out = out[:t.maxOutputChars]
}
return out, nil
} }
if strings.HasPrefix(input, "write ") { if strings.HasPrefix(input, "write ") {
@@ -97,10 +153,19 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
} }
func (t *Tool) resolveAllowed(path string) (string, error) { func (t *Tool) resolveAllowed(path string) (string, error) {
abs, err := filepath.Abs(path) base := strings.TrimSpace(os.Getenv("AGENT_WORKSPACE_DIR"))
var abs string
var err error
if filepath.IsAbs(path) {
abs = path
} else if base != "" {
abs = filepath.Join(base, path)
} else {
abs, err = filepath.Abs(path)
if err != nil { if err != nil {
return "", err return "", err
} }
}
abs = filepath.Clean(abs) abs = filepath.Clean(abs)
for _, allowed := range t.allowedDirs { for _, allowed := range t.allowedDirs {
if strings.HasPrefix(abs, allowed+string(filepath.Separator)) || abs == allowed { if strings.HasPrefix(abs, allowed+string(filepath.Separator)) || abs == allowed {

View File

@@ -0,0 +1,66 @@
package filetool
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestReadDeniedOutsideAllowedDir(t *testing.T) {
allowed := t.TempDir()
tool := New([]string{allowed}, 4000, nil)
_, err := tool.Call(context.Background(), "read ../outside.txt")
if err == nil {
t.Fatal("expected path denied error")
}
}
func TestWriteAndReadInsideAllowedDir(t *testing.T) {
allowed := t.TempDir()
tool := New([]string{allowed}, 4000, nil)
path := filepath.Join(allowed, "a.txt")
_, err := tool.Call(context.Background(), "write "+path+"\nhello")
if err != nil {
t.Fatalf("write error: %v", err)
}
out, err := tool.Call(context.Background(), "read "+path)
if err != nil {
t.Fatalf("read error: %v", err)
}
if out != "hello" {
t.Fatalf("unexpected read output: %q", out)
}
}
func TestReadDirectoryReturnsStructuredError(t *testing.T) {
allowed := t.TempDir()
tool := New([]string{allowed}, 4000, nil)
_, err := tool.Call(context.Background(), "read "+allowed)
if err == nil {
t.Fatal("expected directory read error")
}
if !strings.Contains(err.Error(), "PATH_IS_DIRECTORY") {
t.Fatalf("expected PATH_IS_DIRECTORY, got: %v", err)
}
}
func TestListDirectory(t *testing.T) {
allowed := t.TempDir()
tool := New([]string{allowed}, 4000, nil)
path := filepath.Join(allowed, "x.txt")
_, err := tool.Call(context.Background(), "write "+path+"\nhello")
if err != nil {
t.Fatalf("write error: %v", err)
}
out, err := tool.Call(context.Background(), "list "+allowed)
if err != nil {
t.Fatalf("list error: %v", err)
}
if !strings.Contains(out, "x.txt") {
t.Fatalf("expected x.txt in list output, got: %q", out)
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@@ -15,10 +16,11 @@ type Tool struct {
allowedCommands map[string]struct{} allowedCommands map[string]struct{}
workDir string workDir string
timeout time.Duration timeout time.Duration
maxOutputChars int
log *logger.Logger log *logger.Logger
} }
func New(allowed []string, workDir string, timeout time.Duration, log *logger.Logger) *Tool { func New(allowed []string, workDir string, timeout time.Duration, maxOutputChars int, log *logger.Logger) *Tool {
set := make(map[string]struct{}, len(allowed)) set := make(map[string]struct{}, len(allowed))
for _, c := range allowed { for _, c := range allowed {
cmd := strings.TrimSpace(c) cmd := strings.TrimSpace(c)
@@ -33,10 +35,13 @@ func New(allowed []string, workDir string, timeout time.Duration, log *logger.Lo
if timeout <= 0 { if timeout <= 0 {
timeout = 15 * time.Second timeout = 15 * time.Second
} }
if log != nil { if maxOutputChars <= 0 {
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s", len(set), absDir, timeout) maxOutputChars = 4000
} }
return &Tool{allowedCommands: set, workDir: absDir, timeout: timeout, log: log} if log != nil {
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s max_output_chars=%d", len(set), absDir, timeout, maxOutputChars)
}
return &Tool{allowedCommands: set, workDir: absDir, timeout: timeout, maxOutputChars: maxOutputChars, log: log}
} }
func (t *Tool) Name() string { return "shell" } func (t *Tool) Name() string { return "shell" }
@@ -72,14 +77,21 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
cmd := exec.CommandContext(runCtx, base, parts[1:]...) cmd := exec.CommandContext(runCtx, base, parts[1:]...)
cmd.Dir = t.workDir cmd.Dir = t.workDir
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
outText := string(out)
if len(outText) > t.maxOutputChars {
outText = outText[:t.maxOutputChars]
}
if err != nil { if err != nil {
if t.log != nil { if t.log != nil {
t.log.Errorf("shell command failed command=%s full_command=%q err=%v output_bytes=%d output=%q", base, trimmed, err, len(out), string(out)) t.log.Errorf("shell command failed command=%s full_command=%q err=%v output_bytes=%d output=%q", base, trimmed, err, len(out), outText)
} }
return string(out), err if runtime.GOOS == "windows" && strings.Contains(strings.ToLower(err.Error()), "executable file not found") {
return outText, fmt.Errorf("command not executable in current windows environment: %s", base)
}
return outText, err
} }
if t.log != nil { if t.log != nil {
t.log.Infof("shell command success command=%s full_command=%q output_bytes=%d output=%q", base, trimmed, len(out), string(out)) t.log.Infof("shell command success command=%s full_command=%q output_bytes=%d output=%q", base, trimmed, len(out), outText)
} }
return string(out), nil return outText, nil
} }

View File

@@ -0,0 +1,23 @@
package shelltool
import (
"context"
"testing"
"time"
)
func TestCallRejectsEmptyCommand(t *testing.T) {
tool := New([]string{"echo"}, ".", time.Second, 4000, nil)
_, err := tool.Call(context.Background(), " ")
if err == nil {
t.Fatal("expected error for empty command")
}
}
func TestCallRejectsNonAllowlistedCommand(t *testing.T) {
tool := New([]string{"echo"}, ".", time.Second, 4000, nil)
_, err := tool.Call(context.Background(), "cat test.txt")
if err == nil {
t.Fatal("expected allowlist rejection")
}
}

View File

@@ -1,19 +1,122 @@
# Skill: Filesystem Query ---
name: 文件系统查询专家
description: 查询文件系统中的文件和目录信息,支持基本的路径解析和权限检查。
---
用途: # Skill: 文件系统查询专家
- 查询目录下文件
- 检查路径是否存在
- 快速列出文件树
建议工具: ## 1. 适用范围
- `shell` 当用户提出以下诉求时触发本技能:
- 查询某个目录中有什么文件/子目录
- 查找特定名称或后缀的文件(如 `.md``.log`
- 检查文件或目录是否存在
- 读取指定文件内容并给出摘要
- 比较两个路径下的文件差异(仅基础层面)
不适用场景:
- 需要修改系统配置、安装软件、执行高风险命令
- 需要递归扫描超大目录并输出完整明细(应先收敛范围)
## 2. 可用工具与约束
优先工具:
- `shell`:用于目录枚举、检索、路径探测
- `file`:用于读取文件内容(必要时)
必须遵守:
- 仅在 `ALLOWED_DIRS` 白名单路径范围内操作
- 仅使用 `ALLOWED_COMMANDS` 中允许的命令
- 先做最小探测,再逐步扩大范围,避免一次性重命令
- 结果必须基于工具真实输出,禁止臆测
## 3. 执行策略ReAct 指南)
1. 明确用户目标:目录列表 / 文件查找 / 内容读取 / 存在性检查。
2. 抽取目标路径:若用户未给路径,默认从 `WORK_DIR` 开始,并向用户说明。
3. 先进行低成本探测:
- 目录查询可先列顶层
- 文件查询可先限定后缀与最大层级
4. 若输出过长:
- 先返回摘要(数量、关键文件、下一步建议)
- 再询问是否继续细化
5. 若命令失败:
- 返回错误原因(路径不存在、权限不足、命令受限等)
- 提供最短修复建议(改路径、缩范围、提供更具体条件)
## 4. 推荐动作模板
### 4.1 列出目录内容
- 目标:查看目录下文件与子目录
- 行为:先顶层,再按需递归
### 4.2 检索特定文件
- 目标:查找匹配名称/后缀的文件
- 行为:限制目录范围与深度,优先返回命中摘要
### 4.3 读取文件内容
- 目标:读取并总结关键信息
- 行为:先确认文件存在,再使用 `file` 工具读取
### 4.4 路径存在性检查
- 目标:确认路径是否存在、类型是文件还是目录
- 行为:给出明确判断与后续可执行动作
## 5. 输出规范
回复格式建议:
1. 结论:一句话说明查到了什么。
2. 依据:列出关键路径/文件名/数量。
3. 风险与限制:说明白名单、权限、输出截断等限制。
4. 下一步:给用户 1-2 个可选动作。
示例结构:
- 结论:已在 `<目标目录>` 找到 `<N>` 个匹配文件。
- 关键结果:`<文件1>``<文件2>``<文件3>`
- 限制说明:当前仅扫描到 `<层级/范围>`,如需可继续深度扫描。
- 下一步建议:是否按时间排序、按后缀分组或读取某个文件内容。
## 6. 失败回退策略
- 路径不合法或越界:明确提示“路径不在允许范围内”。
- 命令不可用:提示“该命令不在允许列表中”,并给出替代方案。
- 输出为空:说明“未命中”,并建议放宽条件(目录、后缀、关键词)。
- 结果不确定:必须标注不确定点,不得给出确定性结论。
## 7. 技能边界声明
本技能仅负责“文件系统信息查询与只读分析”,不主动写文件、不执行高风险操作。
若用户要求写入或修改文件,需切换到对应写入类技能或先征得明确授权。
## 8. Windows 适配版(重点)
### 8.1 环境事实
当前项目在 Windows 上运行时,`shell` 工具是通过子进程直接执行命令(`exec.Command`),不是通过 PowerShell 别名层执行。
因此:
- 在 PowerShell 里可用的别名(如 `ls``cat`)不一定能直接被 `shell` 工具调用。
- 技能在 Windows 上应优先采用“保守策略”:先尝试白名单命令,失败后回退到 `file` 工具读取。
### 8.2 Windows 下推荐执行优先级
1. **路径确认优先**:先确认用户给的是相对路径还是绝对路径。
2. **目录探测优先**:先做小范围探测,避免全盘递归。
3. **读取优先 `file` 工具**:当目标是读取具体文件内容时,优先使用 `file read <path>`
4. **命令失败即回退**:若 `shell` 命令在 Windows 不可执行,立即说明并切换 `file` 路径方案。
### 8.3 Windows 典型任务建议
#### A. 查询目录内容
- 首选:使用允许命令进行轻量目录探测。
- 若失败:提示“当前命令在 Windows 子进程环境不可用”,并让用户指定更具体文件路径,再用 `file` 工具读取。
#### B. 检索特定后缀文件
- 首选:在有限目录层级内检索,避免大范围扫描。
- 若失败:让用户给出更精确子目录,然后分段检索。
#### C. 读取文件内容
- 直接使用 `file read <path>`,这是 Windows 下最稳定路径。
### 8.4 Windows 输出补充规范
在回复中增加一行环境说明:
- “当前为 Windows 运行环境,部分 shell 命令可能受可执行文件与白名单限制影响。”
### 8.5 Windows 失败回退模板
- 命令不可执行:
- 说明:命令在当前 Windows 子进程中不可用或不在白名单。
- 回退:请提供更具体文件路径,我将改用 `file` 工具读取并汇总结果。
- 路径分隔符问题:
- 说明:路径格式可能不兼容。
- 回退:建议统一使用项目相对路径(如 `./workspace/...`)。
常见动作:
- 查看目录内容:`ls -la <dir>`
- 递归列出文件:`find <dir> -maxdepth 3 -type f`
- 查询特定后缀:`find <dir> -name "*.md"`
触发信号:
- 用户提问“某目录有什么文件”
- 用户提问“帮我查一下 data 目录内容”
- 用户提问“列出/检索/查找 文件”

View File

@@ -0,0 +1,52 @@
---
name: 创建skill
description: 当用户请求新增能力或系统发现能力缺口时,自动创建并完善 skill必要时生成新 tool 代码并完成测试。
---
# Skill: 创建skill
## 1. 触发条件
当出现以下任一情况时触发:
- 用户明确要求“创建/新增一个 skill”。
- 现有技能无法覆盖用户目标(无 skill 命中或执行失败)。
- 高频 capability_gap 指向同一类能力缺口。
## 2. 目标
1. 生成可执行的 `skills/<skill_name>/skill.md`
2. 若需要新工具,生成 `internal/tools/<tool_name>/` 下 Go 代码。
3. 生成或补充测试代码并执行 `go test ./...`
4. 输出结果中说明新增内容、测试结果与后续建议。
## 3. 可用工具
- `file`:创建目录与文件、写入 skill/tool/test 内容。
- `shell`:执行测试、检索代码位置、检查文件结构。
## 4. 执行流程
1. **澄清能力边界**:提炼该 skill 要解决的问题与触发信号。
2. **命名与路径规划**
- 技能路径:`skills/<skill_name>/skill.md`
- 工具路径(如需):`internal/tools/<tool_name>/...`
3. **创建 skill 文件**写入完整字段用途、触发、工具、ReAct 指南、失败回退、输出规范)。
4. **判断是否需要新 tool**
- 若现有 `shell/file` 足够,直接结束。
- 若不够,进入工具生成。
5. **生成 tool 代码(如需)**
- 实现 `Name/Description/Call`
- 保持白名单与安全边界。
- 在主注册逻辑或 toolhost 注册逻辑中接入。
6. **生成测试并执行**
- 补充 `*_test.go`
- 执行 `go test ./...`
7. **结果汇报**:给出新增文件清单、测试结果、风险说明。
## 5. 质量约束
- 不覆盖已有稳定 skill优先新增目录。
- 避免引入高风险命令;遵循 `ALLOWED_COMMANDS``ALLOWED_DIRS`
- 生成代码必须尽量小步、可测试、可回滚。
- 无法可靠完成时,明确说明缺失信息并给出最短下一步。
## 6. 输出模板
- 新增技能:`skills/<skill_name>/skill.md`
- 新增工具(可选):`internal/tools/<tool_name>/...`
- 测试结果:`go test ./...` 的通过/失败摘要
- 后续动作:是否需要热加载、是否需要补充环境变量