From e1c7822ed41b3e76ea495257cf7e43945700741f Mon Sep 17 00:00:00 2001 From: whlaoding Date: Sat, 21 Feb 2026 23:29:27 +0800 Subject: [PATCH] chore: refactor agent to skill-first; structured skills dirs; enhance ReAct and tool logs --- README.md | 26 +- cmd/bot/main.go | 6 + data/laodingbot.db | Bin 24576 -> 28672 bytes internal/agent/orchestrator.go | 285 ++++++++++++++---- internal/knowledge/loader.go | 99 ++++-- internal/tools/filetool/filetool.go | 2 +- internal/tools/shelltool/shelltool.go | 8 +- skills/README.md | 14 +- .../skill.md} | 0 9 files changed, 333 insertions(+), 107 deletions(-) rename skills/{filesystem_query.md => filesystem_query/skill.md} (100%) diff --git a/README.md b/README.md index 7cb6926..3842450 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Now supports mutually exclusive message channels: - If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` 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`. + - To inspect full skill/tool execution content and detailed ReAct step traces, use `LOG_LEVEL=debug`. 4. Configure knowledge and reasoning: - `SOUL_PATH` for bot personality markdown. - `SKILLS_DIR` for skills markdown directory. @@ -45,22 +46,13 @@ go run ./cmd/bot ## Telegram Usage -- Normal text: forwarded to LLM with compressed recent memory. -- Agent uses ReAct loop and may call tools automatically before final answer. -- Tool call command: - -```text -/tool -``` - -Examples: - -```text -/tool shell pwd -/tool file read ./workspace/note.txt -/tool file write ./workspace/note.txt -hello world -``` +- Normal text enters unified agent pipeline: + 1. Receive message and load recent memory context + 2. Match relevant skill(s) from `skills/` + 3. If no skill matched, respond via direct LLM + 4. If skill matched, run ReAct and call tools (`shell` / `file`) only when needed + 5. Return final answer +- No `/tool ...` command is required for normal use. ## Feishu Usage @@ -71,7 +63,7 @@ hello world - Soul file default path: `bot_context/soul.md` - 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.md` ## Security Notes diff --git a/cmd/bot/main.go b/cmd/bot/main.go index ac1754b..9a75ec5 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -57,6 +57,11 @@ func main() { appLogger.Errorf("load skills failed dir=%s err=%v", cfg.SkillsDir, 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) llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm")) @@ -65,6 +70,7 @@ func main() { store, toolRegistry, soul, + skillSet, skillsDoc, cfg.ReactMaxSteps, appLogger.WithComponent("agent"), diff --git a/data/laodingbot.db b/data/laodingbot.db index 08af2c0ad352162267282b1bcc1477af48f62aac..413e7899cc158d3dc3ddf732e43aad1d6f18609f 100644 GIT binary patch delta 3027 zcmb7GOKcm*8K#}eHrG;R7j}`lwKSG()p01xIqA?NXSS`Tt_hj zNr81JKvD`NQYIzJr1c{8phG=ug`{K|7DZ~MqKBM%E6{V0?948K7Ro7?T>9?}DN@p@ z1cBMznfYJe_x&^ezaR5|c`WwhhHE;V?&LW7<*{q)u1s^|@#b&ggYIPWcg^2!oH;|9 z8{q9k%f@%D-#c@vor9Nl{TK0*CdawczdHRx?myf=xGC-q_iOHg{u})l>wzA9{Ps)N zj<>hRV?Vog{OVOYT)9Gr%a`fU)<%a*m*{{H9a>xI(9%MOix;(FMiI|NJez4@+?z zjw;CSc$XLC%$mWPI^*Jskpb+KRIpq<|MetZ&9h7i# zfy@q;r(GfCYD+Vl5WVwl{X85C<--u8>=_oY<#{6WOJ%P)HT1; z^xr0PqviCne(Th~8pQQLO9MB2p`9`~@|!vab3V#dJP`uki6@?tE*p(HL9@zygu zG>wCccqoW-Tc}MVrx)Gz_H=i(X~-NVFvlCw_3L7XTm#6cV z7q$87EuvqbY8+Gm01=s7AR~+A#I(GdCV{->^MDnqkm@caol|p}O6{^!IJKgYjEV)^ zy9_;d>EB>Le3jOuRk0WY6z-n{AQ(aOnAW$Wu%ragZ z1G4f0&Q7W$0~&*Tu~m{<#cMkx`Vb_pe7;oa44gnKNnwx+qYMV66en}3x?*{IK_9x3AOVLR#mVSi=wJ z&iOo5aohXl+S5NHZA z!7<;cQp$qx)TMyDniYe*{)T{1Fcw}L}#9+KCMaCa3Zo0OuMjmx(NJ&oY#|KuZ zcG-q{gRtkdeaclQG`d632ZbkCoK}mG7X+JVF`4Rx+<8?~)%k!MRP}owP_7;lC@d3_ zT=2_<$2b_HVVgp zz6ysA;=pUOYW@MJ4`*gnDM$RXs((;ZJ#C~cE9z`WE=5V|G2WTPp*3Y?1At<+hfE+b zC_h*u$)_xH;FD5ZBD3?_ff@{`xdO~8rDc#D^GNeQcw#xU0yrRdyWP%PYCHEEaEf3TC6l>JTky>NZXdXWtRFY50=wkI`ifGpldXWSV8lCBN zQUfnFk8^XNL`BLSp(<~4S{z23U3?zy0)kzvl`YWPN~s!mxBTvnPkKE)`+V1EOC6E0 bAF$hRLM+HbsMwsm!)O*A4l^&*x4i!WVrKW8 delta 102 zcmZp8z}Rqrae_3f90LOb+e8I>M!Ag%OZd4M`F{fi7})rKZ{~A&%fI=zJc|H_3>Q#H zhC6FAyMg7#MlJ5i$1F8APqHo&5#wZD&A=zXJAvmJPZC!$=QXy+Y?jQcH#SaW-n=?E Glm`HV&>a{6 diff --git a/internal/agent/orchestrator.go b/internal/agent/orchestrator.go index 9362799..f55c6d1 100644 --- a/internal/agent/orchestrator.go +++ b/internal/agent/orchestrator.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" + "laodingbot/internal/knowledge" "laodingbot/internal/llm" "laodingbot/internal/logger" "laodingbot/internal/memory" @@ -17,6 +19,7 @@ type Orchestrator struct { store *memory.SQLiteStore tools *tools.Registry soul string + skills []knowledge.Skill skillsDoc string reactMaxStep int log *logger.Logger @@ -27,6 +30,7 @@ func NewOrchestrator( store *memory.SQLiteStore, registry *tools.Registry, soul string, + skills []knowledge.Skill, skillsDoc string, reactMaxStep int, log *logger.Logger, @@ -39,6 +43,7 @@ func NewOrchestrator( store: store, tools: registry, soul: soul, + skills: skills, skillsDoc: skillsDoc, reactMaxStep: reactMaxStep, log: log, @@ -48,6 +53,7 @@ func NewOrchestrator( func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) { if o.log != nil { 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 o.log != nil { @@ -56,29 +62,6 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s 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) if err != 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)) } - 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 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 } @@ -111,6 +113,26 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s 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 { Thought string `json:"thought"` Action string `json:"action"` @@ -118,26 +140,45 @@ type reactDecision struct { 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{ "你是一个个人自动化助手,必须遵循如下人格设定并保持一致:", o.soul, "", - "当前可用 skills 文档:", - o.skillsDoc, + "已匹配到的 skills(只可按下列技能执行):", + selectedSkillsDoc, + "", + "可用工具:", + toolDoc, "", "你必须使用 ReAct 模式做决策。", - "如果问题需要外部信息(如文件系统、目录内容、命令执行),优先通过工具获取证据再回答。", - "当用户询问目录中文件时,应优先使用 shell 工具(例如 ls/find)。", + "只有当技能明确需要工具能力时才调用工具。", + "如果问题可直接回答,不要调用工具。", "你的输出必须是 JSON,对象字段为 thought, action, action_input, final。", "规则:", - "1) 当需要调工具时:final 置空,action 为 shell 或 file,action_input 为工具输入。", + "1) 当需要调工具时:final 置空,action 必须是可用工具之一,action_input 为工具输入。", "2) 当可以最终回答时:action 置 none,action_input 置空,final 填最终回复。", "3) 不要输出 JSON 之外内容。", }, "\n") scratchpad := "" 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{ "历史上下文:", compressedContext, @@ -155,6 +196,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu if err != nil { return "", err } + if o.log != nil { + o.log.Infof("react step llm output step=%d raw=%q", step, raw) + } decision, err := parseDecision(raw) if err != nil { if o.log != nil { @@ -162,6 +206,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu } 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)) if action == "" { @@ -173,16 +220,25 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu if finalText == "" { finalText = "我已完成思考,但当前没有足够信息给出稳定结论。" } + if o.log != nil { + o.log.Infof("react final step=%d final=%q", step, finalText) + } return finalText, nil } tool, ok := o.tools.Get(action) 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) continue } 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) if obs == "" { obs = "(empty output)" @@ -190,6 +246,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu if toolErr != nil { 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 { obs = obs[:2000] } @@ -199,13 +258,88 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu return "我尝试了多轮思考与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil } -func parseDecision(raw string) (reactDecision, error) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```json") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) +func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userInput string) []knowledge.Skill { + if len(o.skills) == 0 { + return nil + } + 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, "{") end := strings.LastIndex(raw, "}") if start < 0 || end < start { @@ -220,25 +354,60 @@ func parseDecision(raw string) (reactDecision, error) { return out, nil } -func (o *Orchestrator) handleToolCommand(ctx context.Context, payload string) (string, error) { - parts := strings.SplitN(payload, " ", 2) - if len(parts) < 2 { - if o.log != nil { - o.log.Warnf("invalid tool command payload=%q", payload) - } - return "", fmt.Errorf("tool command format: /tool ") - } - name := strings.TrimSpace(parts[0]) - 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 { - if o.log != nil { - o.log.Warnf("unknown tool requested name=%s", name) - } - return "", fmt.Errorf("unknown tool: %s", name) - } - return t.Call(ctx, input) +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) +} + +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") + } + return strings.TrimSpace(b.String()) +} + +func formatSkillCatalog(skills []knowledge.Skill) string { + b := strings.Builder{} + for _, skill := range skills { + summary := strings.ReplaceAll(skill.Content, "\n", " ") + summary = strings.TrimSpace(summary) + if len(summary) > 220 { + summary = summary[:220] + } + b.WriteString("- ") + b.WriteString(skill.Name) + if summary != "" { + b.WriteString(": ") + b.WriteString(summary) + } + 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()) } diff --git a/internal/knowledge/loader.go b/internal/knowledge/loader.go index d420a30..d1034a6 100644 --- a/internal/knowledge/loader.go +++ b/internal/knowledge/loader.go @@ -8,6 +8,12 @@ import ( "strings" ) +type Skill struct { + Name string + Content string + Source string +} + func LoadSoul(path string) (string, error) { b, err := os.ReadFile(path) if err != nil { @@ -21,37 +27,17 @@ func LoadSoul(path string) (string, error) { } func LoadSkills(dir string) (string, error) { - entries, err := os.ReadDir(dir) + skills, err := LoadSkillSet(dir) 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{} - for _, file := range files { - 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 - } + for _, skill := range skills { builder.WriteString("## ") - builder.WriteString(filepath.Base(file)) + builder.WriteString(skill.Name) builder.WriteString("\n") - builder.WriteString(content) + builder.WriteString(skill.Content) builder.WriteString("\n\n") } @@ -61,3 +47,66 @@ func LoadSkills(dir string) (string, error) { } 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.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 +} diff --git a/internal/tools/filetool/filetool.go b/internal/tools/filetool/filetool.go index 1f0a31b..71c76ce 100644 --- a/internal/tools/filetool/filetool.go +++ b/internal/tools/filetool/filetool.go @@ -38,7 +38,7 @@ func (t *Tool) Description() string { func (t *Tool) Call(_ context.Context, input string) (string, error) { input = strings.TrimSpace(input) 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 ") { path := strings.TrimSpace(strings.TrimPrefix(input, "read ")) diff --git a/internal/tools/shelltool/shelltool.go b/internal/tools/shelltool/shelltool.go index 64070f1..5b5a0d6 100644 --- a/internal/tools/shelltool/shelltool.go +++ b/internal/tools/shelltool/shelltool.go @@ -58,12 +58,12 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) { base := parts[0] if _, ok := t.allowedCommands[base]; !ok { 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) } 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) @@ -74,12 +74,12 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) { out, err := cmd.CombinedOutput() if err != 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 } 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 } diff --git a/skills/README.md b/skills/README.md index 0e8fda7..dce2f17 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,4 +1,14 @@ # Skills -本目录中的 Markdown 文件用于描述 bot 已具备能力。 -程序启动时会自动加载这些文档,并将其注入到决策上下文中。 \ No newline at end of file +本目录中的技能采用标准目录结构: + +```text +skills/ + / + skill.md +``` + +要求: +- 每个 skill 必须有独立目录(目录名即默认 skill 名称)。 +- skill 内容写在 `skill.md`。 +- 启动时会自动加载并注入到决策上下文中。 \ No newline at end of file diff --git a/skills/filesystem_query.md b/skills/filesystem_query/skill.md similarity index 100% rename from skills/filesystem_query.md rename to skills/filesystem_query/skill.md