chore: refactor agent to skill-first; structured skills dirs; enhance ReAct and tool logs
This commit is contained in:
26
README.md
26
README.md
@@ -26,6 +26,7 @@ Now supports mutually exclusive message channels:
|
|||||||
- If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` empty.
|
- If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` empty.
|
||||||
- If `feishu`: set `FEISHU_APP_ID` and `FEISHU_APP_SECRET`, keep `TELEGRAM_BOT_TOKEN` empty.
|
- If `feishu`: set `FEISHU_APP_ID` and `FEISHU_APP_SECRET`, keep `TELEGRAM_BOT_TOKEN` empty.
|
||||||
3. Set log level with `LOG_LEVEL=debug|info|warn|error`.
|
3. Set log level with `LOG_LEVEL=debug|info|warn|error`.
|
||||||
|
- To inspect full skill/tool execution content and detailed ReAct step traces, use `LOG_LEVEL=debug`.
|
||||||
4. Configure knowledge and reasoning:
|
4. Configure knowledge and reasoning:
|
||||||
- `SOUL_PATH` for bot personality markdown.
|
- `SOUL_PATH` for bot personality markdown.
|
||||||
- `SKILLS_DIR` for skills markdown directory.
|
- `SKILLS_DIR` for skills markdown directory.
|
||||||
@@ -45,22 +46,13 @@ go run ./cmd/bot
|
|||||||
|
|
||||||
## Telegram Usage
|
## Telegram Usage
|
||||||
|
|
||||||
- Normal text: forwarded to LLM with compressed recent memory.
|
- Normal text enters unified agent pipeline:
|
||||||
- Agent uses ReAct loop and may call tools automatically before final answer.
|
1. Receive message and load recent memory context
|
||||||
- Tool call command:
|
2. Match relevant skill(s) from `skills/`
|
||||||
|
3. If no skill matched, respond via direct LLM
|
||||||
```text
|
4. If skill matched, run ReAct and call tools (`shell` / `file`) only when needed
|
||||||
/tool <name> <input>
|
5. Return final answer
|
||||||
```
|
- No `/tool ...` command is required for normal use.
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/tool shell pwd
|
|
||||||
/tool file read ./workspace/note.txt
|
|
||||||
/tool file write ./workspace/note.txt
|
|
||||||
hello world
|
|
||||||
```
|
|
||||||
|
|
||||||
## Feishu Usage
|
## Feishu Usage
|
||||||
|
|
||||||
@@ -71,7 +63,7 @@ hello world
|
|||||||
|
|
||||||
- Soul file default path: `bot_context/soul.md`
|
- Soul file default path: `bot_context/soul.md`
|
||||||
- Skills directory default path: `skills/`
|
- Skills directory default path: `skills/`
|
||||||
- Add new markdown files into `skills/` to describe capabilities; they are loaded at startup.
|
- Skill format uses subdirectories: `skills/<skill_name>/skill.md`
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ func main() {
|
|||||||
appLogger.Errorf("load skills failed dir=%s err=%v", cfg.SkillsDir, err)
|
appLogger.Errorf("load skills failed dir=%s err=%v", cfg.SkillsDir, err)
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
skillSet, err := knowledge.LoadSkillSet(cfg.SkillsDir)
|
||||||
|
if err != nil {
|
||||||
|
appLogger.Errorf("load skill set failed dir=%s err=%v", cfg.SkillsDir, err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
appLogger.Infof("knowledge loaded soul_path=%s skills_dir=%s", cfg.SoulPath, cfg.SkillsDir)
|
appLogger.Infof("knowledge loaded soul_path=%s skills_dir=%s", cfg.SoulPath, cfg.SkillsDir)
|
||||||
|
|
||||||
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
||||||
@@ -65,6 +70,7 @@ func main() {
|
|||||||
store,
|
store,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
soul,
|
soul,
|
||||||
|
skillSet,
|
||||||
skillsDoc,
|
skillsDoc,
|
||||||
cfg.ReactMaxSteps,
|
cfg.ReactMaxSteps,
|
||||||
appLogger.WithComponent("agent"),
|
appLogger.WithComponent("agent"),
|
||||||
|
|||||||
Binary file not shown.
@@ -4,8 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"laodingbot/internal/knowledge"
|
||||||
"laodingbot/internal/llm"
|
"laodingbot/internal/llm"
|
||||||
"laodingbot/internal/logger"
|
"laodingbot/internal/logger"
|
||||||
"laodingbot/internal/memory"
|
"laodingbot/internal/memory"
|
||||||
@@ -17,6 +19,7 @@ type Orchestrator struct {
|
|||||||
store *memory.SQLiteStore
|
store *memory.SQLiteStore
|
||||||
tools *tools.Registry
|
tools *tools.Registry
|
||||||
soul string
|
soul string
|
||||||
|
skills []knowledge.Skill
|
||||||
skillsDoc string
|
skillsDoc string
|
||||||
reactMaxStep int
|
reactMaxStep int
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
@@ -27,6 +30,7 @@ func NewOrchestrator(
|
|||||||
store *memory.SQLiteStore,
|
store *memory.SQLiteStore,
|
||||||
registry *tools.Registry,
|
registry *tools.Registry,
|
||||||
soul string,
|
soul string,
|
||||||
|
skills []knowledge.Skill,
|
||||||
skillsDoc string,
|
skillsDoc string,
|
||||||
reactMaxStep int,
|
reactMaxStep int,
|
||||||
log *logger.Logger,
|
log *logger.Logger,
|
||||||
@@ -39,6 +43,7 @@ func NewOrchestrator(
|
|||||||
store: store,
|
store: store,
|
||||||
tools: registry,
|
tools: registry,
|
||||||
soul: soul,
|
soul: soul,
|
||||||
|
skills: skills,
|
||||||
skillsDoc: skillsDoc,
|
skillsDoc: skillsDoc,
|
||||||
reactMaxStep: reactMaxStep,
|
reactMaxStep: reactMaxStep,
|
||||||
log: log,
|
log: log,
|
||||||
@@ -48,6 +53,7 @@ func NewOrchestrator(
|
|||||||
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) {
|
||||||
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("handle message chat_id=%s user_id=%s text_len=%d", chatID, userID, len(text))
|
||||||
|
o.log.Debugf("handle message text=%q", text)
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -56,29 +62,6 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(text), "/tool ") {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Debugf("detected tool command chat_id=%s", chatID)
|
|
||||||
}
|
|
||||||
response, err := o.handleToolCommand(ctx, strings.TrimSpace(strings.TrimPrefix(text, "/tool ")))
|
|
||||||
if err != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Errorf("tool command failed chat_id=%s err=%v", chatID, err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Errorf("save assistant tool response failed chat_id=%s err=%v", chatID, err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Infof("tool command success chat_id=%s response_len=%d", chatID, len(response))
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -91,10 +74,29 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
|
|||||||
o.log.Debugf("prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", chatID, len(recent), len(compressed))
|
o.log.Debugf("prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", chatID, len(recent), len(compressed))
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := o.runReAct(ctx, compressed, text)
|
matchedSkills := o.matchSkills(ctx, compressed, text)
|
||||||
|
|
||||||
|
var response string
|
||||||
|
if len(matchedSkills) == 0 {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("no skill matched; use direct llm chat_id=%s", chatID)
|
||||||
|
}
|
||||||
|
response, err = o.runDirectLLM(ctx, compressed, text)
|
||||||
|
} else {
|
||||||
|
if o.log != nil {
|
||||||
|
names := make([]string, 0, len(matchedSkills))
|
||||||
|
for _, s := range matchedSkills {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
o.log.Infof("skill selected name=%s source=%s", s.Name, s.Source)
|
||||||
|
o.log.Debugf("skill selected content name=%s content=%q", s.Name, s.Content)
|
||||||
|
}
|
||||||
|
o.log.Infof("skills matched chat_id=%s skills=%s", chatID, strings.Join(names, ","))
|
||||||
|
}
|
||||||
|
response, err = o.runReAct(ctx, compressed, text, matchedSkills)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Errorf("llm generate failed chat_id=%s err=%v", chatID, err)
|
o.log.Errorf("message generation failed chat_id=%s err=%v", chatID, err)
|
||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,26 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) runDirectLLM(ctx context.Context, compressedContext, userInput string) (string, error) {
|
||||||
|
systemPrompt := strings.Join([]string{
|
||||||
|
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
|
||||||
|
o.soul,
|
||||||
|
"",
|
||||||
|
"如果当前问题没有匹配到已定义技能,请直接回答用户。",
|
||||||
|
"当你判断必须依赖外部工具结果才能可靠回答时,请明确告知用户需要进一步操作信息。",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
userPrompt := strings.Join([]string{
|
||||||
|
"历史上下文:",
|
||||||
|
compressedContext,
|
||||||
|
"",
|
||||||
|
"用户问题:",
|
||||||
|
userInput,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
return o.llm.Generate(ctx, systemPrompt, userPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
type reactDecision struct {
|
type reactDecision struct {
|
||||||
Thought string `json:"thought"`
|
Thought string `json:"thought"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
@@ -118,26 +140,45 @@ type reactDecision struct {
|
|||||||
Final string `json:"final"`
|
Final string `json:"final"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInput string) (string, error) {
|
func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInput string, selectedSkills []knowledge.Skill) (string, error) {
|
||||||
|
selectedSkillsDoc := formatSkills(selectedSkills)
|
||||||
|
toolDoc := o.formatToolDoc()
|
||||||
|
if o.log != nil {
|
||||||
|
names := make([]string, 0, len(selectedSkills))
|
||||||
|
for _, s := range selectedSkills {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
o.log.Infof("react start steps=%d skills=%s", o.reactMaxStep, strings.Join(names, ","))
|
||||||
|
o.log.Debugf("react selected_skills_doc=%q", selectedSkillsDoc)
|
||||||
|
o.log.Debugf("react tools_doc=%q", toolDoc)
|
||||||
|
}
|
||||||
|
|
||||||
systemPrompt := strings.Join([]string{
|
systemPrompt := strings.Join([]string{
|
||||||
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
|
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
|
||||||
o.soul,
|
o.soul,
|
||||||
"",
|
"",
|
||||||
"当前可用 skills 文档:",
|
"已匹配到的 skills(只可按下列技能执行):",
|
||||||
o.skillsDoc,
|
selectedSkillsDoc,
|
||||||
|
"",
|
||||||
|
"可用工具:",
|
||||||
|
toolDoc,
|
||||||
"",
|
"",
|
||||||
"你必须使用 ReAct 模式做决策。",
|
"你必须使用 ReAct 模式做决策。",
|
||||||
"如果问题需要外部信息(如文件系统、目录内容、命令执行),优先通过工具获取证据再回答。",
|
"只有当技能明确需要工具能力时才调用工具。",
|
||||||
"当用户询问目录中文件时,应优先使用 shell 工具(例如 ls/find)。",
|
"如果问题可直接回答,不要调用工具。",
|
||||||
"你的输出必须是 JSON,对象字段为 thought, action, action_input, final。",
|
"你的输出必须是 JSON,对象字段为 thought, action, action_input, final。",
|
||||||
"规则:",
|
"规则:",
|
||||||
"1) 当需要调工具时:final 置空,action 为 shell 或 file,action_input 为工具输入。",
|
"1) 当需要调工具时:final 置空,action 必须是可用工具之一,action_input 为工具输入。",
|
||||||
"2) 当可以最终回答时:action 置 none,action_input 置空,final 填最终回复。",
|
"2) 当可以最终回答时:action 置 none,action_input 置空,final 填最终回复。",
|
||||||
"3) 不要输出 JSON 之外内容。",
|
"3) 不要输出 JSON 之外内容。",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
scratchpad := ""
|
scratchpad := ""
|
||||||
for step := 1; step <= o.reactMaxStep; step++ {
|
for step := 1; step <= o.reactMaxStep; step++ {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("react step start step=%d/%d", step, o.reactMaxStep)
|
||||||
|
o.log.Debugf("react scratchpad_before step=%d content=%q", step, scratchpad)
|
||||||
|
}
|
||||||
prompt := strings.Join([]string{
|
prompt := strings.Join([]string{
|
||||||
"历史上下文:",
|
"历史上下文:",
|
||||||
compressedContext,
|
compressedContext,
|
||||||
@@ -155,6 +196,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("react step llm output step=%d raw=%q", step, raw)
|
||||||
|
}
|
||||||
decision, err := parseDecision(raw)
|
decision, err := parseDecision(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
@@ -162,6 +206,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(raw), nil
|
return strings.TrimSpace(raw), 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)
|
||||||
|
}
|
||||||
|
|
||||||
action := strings.ToLower(strings.TrimSpace(decision.Action))
|
action := strings.ToLower(strings.TrimSpace(decision.Action))
|
||||||
if action == "" {
|
if action == "" {
|
||||||
@@ -173,16 +220,25 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
|
|||||||
if finalText == "" {
|
if finalText == "" {
|
||||||
finalText = "我已完成思考,但当前没有足够信息给出稳定结论。"
|
finalText = "我已完成思考,但当前没有足够信息给出稳定结论。"
|
||||||
}
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("react final step=%d final=%q", step, finalText)
|
||||||
|
}
|
||||||
return finalText, nil
|
return finalText, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tool, ok := o.tools.Get(action)
|
tool, ok := o.tools.Get(action)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("react step tool missing step=%d tool=%s", step, action)
|
||||||
|
}
|
||||||
scratchpad += fmt.Sprintf("Step %d Thought: %s\nStep %d Observation: tool %s 不存在\n", step, decision.Thought, step, action)
|
scratchpad += fmt.Sprintf("Step %d Thought: %s\nStep %d Observation: tool %s 不存在\n", step, decision.Thought, step, action)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
toolOut, toolErr := tool.Call(ctx, decision.ActionInput)
|
toolOut, toolErr := tool.Call(ctx, decision.ActionInput)
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("react step tool call step=%d tool=%s input=%q", step, action, decision.ActionInput)
|
||||||
|
}
|
||||||
obs := strings.TrimSpace(toolOut)
|
obs := strings.TrimSpace(toolOut)
|
||||||
if obs == "" {
|
if obs == "" {
|
||||||
obs = "(empty output)"
|
obs = "(empty output)"
|
||||||
@@ -190,6 +246,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
|
|||||||
if toolErr != nil {
|
if toolErr != nil {
|
||||||
obs = obs + "\nERROR: " + toolErr.Error()
|
obs = obs + "\nERROR: " + toolErr.Error()
|
||||||
}
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("react step observation step=%d tool=%s observation=%q", step, action, obs)
|
||||||
|
}
|
||||||
if len(obs) > 2000 {
|
if len(obs) > 2000 {
|
||||||
obs = obs[:2000]
|
obs = obs[:2000]
|
||||||
}
|
}
|
||||||
@@ -199,13 +258,88 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
|
|||||||
return "我尝试了多轮思考与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
|
return "我尝试了多轮思考与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDecision(raw string) (reactDecision, error) {
|
func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userInput string) []knowledge.Skill {
|
||||||
raw = strings.TrimSpace(raw)
|
if len(o.skills) == 0 {
|
||||||
raw = strings.TrimPrefix(raw, "```json")
|
return nil
|
||||||
raw = strings.TrimPrefix(raw, "```")
|
}
|
||||||
raw = strings.TrimSuffix(raw, "```")
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
|
|
||||||
|
type skillChoice struct {
|
||||||
|
Skills []string `json:"skills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := strings.Join([]string{
|
||||||
|
"你是技能路由器。",
|
||||||
|
"任务:根据用户问题,从候选技能中选择 0-2 个最相关技能名称。",
|
||||||
|
"输出必须是 JSON:{\"skills\":[\"name1\",\"name2\"]}",
|
||||||
|
"如果没有匹配技能,返回 {\"skills\":[]}。",
|
||||||
|
"不要输出 JSON 之外内容。",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
userPrompt := strings.Join([]string{
|
||||||
|
"候选技能:",
|
||||||
|
formatSkillCatalog(o.skills),
|
||||||
|
"",
|
||||||
|
"历史上下文:",
|
||||||
|
compressedContext,
|
||||||
|
"",
|
||||||
|
"用户问题:",
|
||||||
|
userInput,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
raw, err := o.llm.Generate(ctx, systemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("skill match llm failed err=%v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("skill router output raw=%q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = normalizeJSON(raw)
|
||||||
|
choice := skillChoice{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &choice); err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("skill match parse failed err=%v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
picked := make([]knowledge.Skill, 0, 2)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, name := range choice.Skills {
|
||||||
|
name = strings.TrimSpace(strings.ToLower(name))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, skill := range o.skills {
|
||||||
|
if strings.ToLower(strings.TrimSpace(skill.Name)) == name {
|
||||||
|
picked = append(picked, skill)
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(picked) >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
names := make([]string, 0, len(picked))
|
||||||
|
for _, s := range picked {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
o.log.Infof("skill router selected skills=%s", strings.Join(names, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
return picked
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDecision(raw string) (reactDecision, error) {
|
||||||
|
raw = normalizeJSON(raw)
|
||||||
start := strings.Index(raw, "{")
|
start := strings.Index(raw, "{")
|
||||||
end := strings.LastIndex(raw, "}")
|
end := strings.LastIndex(raw, "}")
|
||||||
if start < 0 || end < start {
|
if start < 0 || end < start {
|
||||||
@@ -220,25 +354,60 @@ func parseDecision(raw string) (reactDecision, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) handleToolCommand(ctx context.Context, payload string) (string, error) {
|
func normalizeJSON(raw string) string {
|
||||||
parts := strings.SplitN(payload, " ", 2)
|
raw = strings.TrimSpace(raw)
|
||||||
if len(parts) < 2 {
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
if o.log != nil {
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
o.log.Warnf("invalid tool command payload=%q", payload)
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("tool command format: /tool <name> <input>")
|
|
||||||
|
func formatSkills(skills []knowledge.Skill) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
for _, skill := range skills {
|
||||||
|
b.WriteString("## ")
|
||||||
|
b.WriteString(skill.Name)
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(skill.Content)
|
||||||
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(parts[0])
|
return strings.TrimSpace(b.String())
|
||||||
input := parts[1]
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Debugf("dispatch tool name=%s input_len=%d", name, len(input))
|
|
||||||
}
|
}
|
||||||
t, ok := o.tools.Get(name)
|
|
||||||
if !ok {
|
func formatSkillCatalog(skills []knowledge.Skill) string {
|
||||||
if o.log != nil {
|
b := strings.Builder{}
|
||||||
o.log.Warnf("unknown tool requested name=%s", name)
|
for _, skill := range skills {
|
||||||
|
summary := strings.ReplaceAll(skill.Content, "\n", " ")
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
if len(summary) > 220 {
|
||||||
|
summary = summary[:220]
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("unknown tool: %s", name)
|
b.WriteString("- ")
|
||||||
|
b.WriteString(skill.Name)
|
||||||
|
if summary != "" {
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(summary)
|
||||||
}
|
}
|
||||||
return t.Call(ctx, input)
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) formatToolDoc() string {
|
||||||
|
list := o.tools.List()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return "(none)"
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Name() < list[j].Name()
|
||||||
|
})
|
||||||
|
b := strings.Builder{}
|
||||||
|
for _, t := range list {
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(t.Name())
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(t.Description())
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Skill struct {
|
||||||
|
Name string
|
||||||
|
Content string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
func LoadSoul(path string) (string, error) {
|
func LoadSoul(path string) (string, error) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -21,37 +27,17 @@ func LoadSoul(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadSkills(dir string) (string, error) {
|
func LoadSkills(dir string) (string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
skills, err := LoadSkillSet(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read skills dir failed: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
files := make([]string, 0)
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if strings.HasSuffix(strings.ToLower(name), ".md") {
|
|
||||||
files = append(files, filepath.Join(dir, name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(files)
|
|
||||||
|
|
||||||
builder := strings.Builder{}
|
builder := strings.Builder{}
|
||||||
for _, file := range files {
|
for _, skill := range skills {
|
||||||
b, err := os.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read skill file failed: %w", err)
|
|
||||||
}
|
|
||||||
content := strings.TrimSpace(string(b))
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
builder.WriteString("## ")
|
builder.WriteString("## ")
|
||||||
builder.WriteString(filepath.Base(file))
|
builder.WriteString(skill.Name)
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
builder.WriteString(content)
|
builder.WriteString(skill.Content)
|
||||||
builder.WriteString("\n\n")
|
builder.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,3 +47,66 @@ func LoadSkills(dir string) (string, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoadSkillSet(dir string) ([]Skill, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read skills dir failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDirs := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
skillDirs = append(skillDirs, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(skillDirs)
|
||||||
|
|
||||||
|
out := make([]Skill, 0, len(skillDirs))
|
||||||
|
for _, skillDir := range skillDirs {
|
||||||
|
file := filepath.Join(dir, skillDir, "skill.md")
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read skill file failed: %w", err)
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(string(b))
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := extractSkillName(skillDir, content)
|
||||||
|
out = append(out, Skill{
|
||||||
|
Name: name,
|
||||||
|
Content: content,
|
||||||
|
Source: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid skills loaded from %s (expected: skills/<skill_name>/skill.md)", dir)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSkillName(fileName, markdown string) string {
|
||||||
|
for _, line := range strings.Split(markdown, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||||
|
title = strings.TrimSpace(strings.TrimPrefix(title, "Skill:"))
|
||||||
|
title = strings.TrimSpace(strings.TrimPrefix(title, "skill:"))
|
||||||
|
if title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||||
|
if base == "" {
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (t *Tool) Description() string {
|
|||||||
func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
if t.log != nil {
|
if t.log != nil {
|
||||||
t.log.Debugf("file tool call input_len=%d", len(input))
|
t.log.Infof("file tool call input_len=%d input=%q", len(input), input)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(input, "read ") {
|
if strings.HasPrefix(input, "read ") {
|
||||||
path := strings.TrimSpace(strings.TrimPrefix(input, "read "))
|
path := strings.TrimSpace(strings.TrimPrefix(input, "read "))
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
|||||||
base := parts[0]
|
base := parts[0]
|
||||||
if _, ok := t.allowedCommands[base]; !ok {
|
if _, ok := t.allowedCommands[base]; !ok {
|
||||||
if t.log != nil {
|
if t.log != nil {
|
||||||
t.log.Warnf("shell command denied command=%s", base)
|
t.log.Warnf("shell command denied command=%s full_command=%q", base, trimmed)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("command not allowed: %s", base)
|
return "", fmt.Errorf("command not allowed: %s", base)
|
||||||
}
|
}
|
||||||
if t.log != nil {
|
if t.log != nil {
|
||||||
t.log.Infof("shell command start command=%s args=%d", base, len(parts)-1)
|
t.log.Infof("shell command start command=%s args=%d full_command=%q", base, len(parts)-1, trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||||
@@ -74,12 +74,12 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
|||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if t.log != nil {
|
if t.log != nil {
|
||||||
t.log.Errorf("shell command failed command=%s err=%v output_bytes=%d", base, err, len(out))
|
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))
|
||||||
}
|
}
|
||||||
return string(out), err
|
return string(out), err
|
||||||
}
|
}
|
||||||
if t.log != nil {
|
if t.log != nil {
|
||||||
t.log.Debugf("shell command success command=%s output_bytes=%d", base, len(out))
|
t.log.Infof("shell command success command=%s full_command=%q output_bytes=%d output=%q", base, trimmed, len(out), string(out))
|
||||||
}
|
}
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
# Skills
|
# Skills
|
||||||
|
|
||||||
本目录中的 Markdown 文件用于描述 bot 已具备能力。
|
本目录中的技能采用标准目录结构:
|
||||||
程序启动时会自动加载这些文档,并将其注入到决策上下文中。
|
|
||||||
|
```text
|
||||||
|
skills/
|
||||||
|
<skill_name>/
|
||||||
|
skill.md
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 每个 skill 必须有独立目录(目录名即默认 skill 名称)。
|
||||||
|
- skill 内容写在 `skill.md`。
|
||||||
|
- 启动时会自动加载并注入到决策上下文中。
|
||||||
Reference in New Issue
Block a user