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

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

View File

@@ -5,7 +5,10 @@ import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"laodingbot/internal/knowledge"
"laodingbot/internal/llm"
@@ -20,9 +23,14 @@ type Orchestrator struct {
tools *tools.Registry
soul string
skills []knowledge.Skill
skillsDoc string
skillsDir string
autoSkillDir string
gapDraftTriggerCount int
gapLookbackDuration time.Duration
reactMaxStep int
enableCapabilityGap bool
log *logger.Logger
skillsMu sync.RWMutex
}
func NewOrchestrator(
@@ -31,33 +39,66 @@ func NewOrchestrator(
registry *tools.Registry,
soul string,
skills []knowledge.Skill,
skillsDoc string,
skillsDir string,
reactMaxStep int,
enableCapabilityGap bool,
autoSkillDir string,
gapDraftTriggerCount int,
gapLookbackDuration time.Duration,
log *logger.Logger,
) *Orchestrator {
if reactMaxStep <= 0 {
reactMaxStep = 4
}
if gapDraftTriggerCount <= 0 {
gapDraftTriggerCount = 3
}
if gapLookbackDuration <= 0 {
gapLookbackDuration = 7 * 24 * time.Hour
}
if strings.TrimSpace(autoSkillDir) == "" {
autoSkillDir = skillsDir
}
return &Orchestrator{
llm: llmClient,
store: store,
tools: registry,
soul: soul,
skills: skills,
skillsDoc: skillsDoc,
skillsDir: skillsDir,
autoSkillDir: autoSkillDir,
gapDraftTriggerCount: gapDraftTriggerCount,
gapLookbackDuration: gapLookbackDuration,
reactMaxStep: reactMaxStep,
enableCapabilityGap: enableCapabilityGap,
log: log,
}
}
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 {
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)
o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d", traceLogPrefix, chatID, userID, len(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 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
}
@@ -65,50 +106,59 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
recent, err := o.store.LoadRecent(chatID, 16)
if err != 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
}
compressed := memory.CompressForPrompt(recent, 6000)
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)
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
if len(matchedSkills) == 0 {
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)
} 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("%s skill selected name=%s source=%s", traceLogPrefix, s.Name, s.Source)
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 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
}
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != 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
}
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
}
@@ -140,7 +190,9 @@ type reactDecision struct {
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)
toolDoc := o.formatToolDoc()
if o.log != nil {
@@ -148,9 +200,9 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
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)
o.log.Infof("%s react start steps=%d skills=%s", traceLogPrefix, o.reactMaxStep, strings.Join(names, ","))
o.log.Debugf("%s react selected_skills_doc=%q", traceLogPrefix, selectedSkillsDoc)
o.log.Debugf("%s react tools_doc=%q", traceLogPrefix, toolDoc)
}
systemPrompt := strings.Join([]string{
@@ -176,8 +228,8 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
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)
o.log.Infof("%s react step start step=%d/%d", traceLogPrefix, step, o.reactMaxStep)
o.log.Debugf("%s react scratchpad_before step=%d content=%q", traceLogPrefix, step, scratchpad)
}
prompt := strings.Join([]string{
"历史上下文:",
@@ -197,17 +249,18 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
return "", err
}
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)
if err != 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 {
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))
@@ -221,7 +274,7 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
finalText = "我已完成思考,但当前没有足够信息给出稳定结论。"
}
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
}
@@ -229,37 +282,45 @@ func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInpu
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)
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
}
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)
o.log.Infof("%s react step tool call step=%d tool=%s input=%q", traceLogPrefix, step, action, decision.ActionInput)
}
obs := strings.TrimSpace(toolOut)
if obs == "" {
obs = "(empty output)"
}
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 {
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 {
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
}
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
}
@@ -277,7 +338,7 @@ func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userI
userPrompt := strings.Join([]string{
"候选技能:",
formatSkillCatalog(o.skills),
formatSkillCatalog(skills),
"",
"历史上下文:",
compressedContext,
@@ -316,7 +377,7 @@ func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userI
if _, ok := seen[name]; ok {
continue
}
for _, skill := range o.skills {
for _, skill := range skills {
if strings.ToLower(strings.TrimSpace(skill.Name)) == name {
picked = append(picked, skill)
seen[name] = struct{}{}
@@ -338,28 +399,132 @@ func (o *Orchestrator) matchSkills(ctx context.Context, compressedContext, userI
return picked
}
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")
func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string) {
if !o.enableCapabilityGap {
return
}
intent = strings.TrimSpace(intent)
reason = strings.TrimSpace(reason)
if intent == "" || reason == "" {
return
}
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
}
raw = raw[start : end+1]
var out reactDecision
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return reactDecision{}, err
clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration))
if err != nil {
if o.log != nil {
o.log.Warnf("query capability gap clusters failed err=%v", err)
}
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)
}
}
}
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)
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 {

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
SkillsDir string
ReactMaxSteps int
ToolCallTimeoutSec int
ToolOutputMaxChars int
EnableCapabilityGap bool
AutoSkillDir string
GapDraftTriggerCount int
GapClusterLookbackHours int
Telegram TelegramConfig
Feishu FeishuConfig
@@ -51,16 +57,25 @@ type SecurityConfig struct {
}
func Load() (Config, error) {
agentWorkspaceDir := resolveAgentWorkspaceDir()
if err := preloadEnvFiles(); err != nil {
return Config{}, err
}
defaultWorkSubdir := filepath.Join(agentWorkspaceDir, "workspace")
defaultDataDir := filepath.Join(agentWorkspaceDir, "data")
cfg := Config{
MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"),
LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"),
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), "./bot_context/soul.md"),
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), "./skills"),
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 4),
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), filepath.Join(agentWorkspaceDir, "bot_context", "soul.md")),
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
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{
Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30),
@@ -77,11 +92,11 @@ func Load() (Config, error) {
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
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{
AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), "./workspace,./data")),
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail")),
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), "./workspace"),
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,go")),
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
},
}
@@ -96,6 +111,18 @@ func Load() (Config, error) {
if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 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.Telegram.Token == "" {
@@ -119,28 +146,152 @@ func Load() (Config, error) {
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
}
func preloadEnvFiles() error {
paths := []string{}
if explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE")); explicit != "" {
paths = append(paths, explicit)
}
paths = append(paths, "configs/env", ".env")
for _, p := range paths {
if err := loadEnvFile(p); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return fmt.Errorf("load env file %s failed: %w", p, err)
explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE"))
if explicit != "" {
if err := tryLoadEnvFile(explicit, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", explicit, err)
}
}
workspaceDir := resolveAgentWorkspaceDir()
workspaceEnv := filepath.Join(workspaceDir, "configs", "env")
workspaceDotEnv := filepath.Join(workspaceDir, ".env")
if err := tryLoadEnvFile(workspaceEnv, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", workspaceEnv, 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
}
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)
if err != nil {
absPath = path
@@ -173,11 +324,14 @@ func loadEnvFile(path string) error {
val = val[1 : len(val)-1]
}
}
if _, exists := os.LookupEnv(key); !exists {
if err := os.Setenv(key, val); err != nil {
return err
if !override {
if _, exists := os.LookupEnv(key); exists {
continue
}
}
if err := os.Setenv(key, val); err != nil {
return err
}
}
return scanner.Err()
}
@@ -202,6 +356,20 @@ func intFromEnv(name string, d int) int {
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 {
parts := strings.Split(raw, ",")
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
}
func LoadSkills(dir string) (string, error) {
skills, err := LoadSkillSet(dir)
if err != nil {
return "", err
}
builder := strings.Builder{}
for _, skill := range skills {
builder.WriteString("## ")
builder.WriteString(skill.Name)
builder.WriteString("\n")
builder.WriteString(skill.Content)
builder.WriteString("\n\n")
}
out := strings.TrimSpace(builder.String())
if out == "" {
return "", fmt.Errorf("no non-empty markdown skills loaded from %s", dir)
}
return out, nil
}
func LoadSkillSet(dir string) ([]Skill, error) {
entries, err := os.ReadDir(dir)
if err != nil {

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"
"os"
"path/filepath"
"sort"
"strings"
"time"
"laodingbot/internal/logger"
@@ -110,6 +112,154 @@ func (s *SQLiteStore) LoadRecent(chatID string, limit int) ([]Message, error) {
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 {
stmt := `
CREATE TABLE IF NOT EXISTS messages (
@@ -121,6 +271,15 @@ func (s *SQLiteStore) migrate() error {
created_at TIMESTAMP NOT NULL
);
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 {
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 {
allowedDirs []string
maxOutputChars int
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))
for _, dir := range allowedDirs {
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))
}
}
if log != nil {
log.Infof("file tool initialized allowed_dirs=%d", len(normalized))
if maxOutputChars <= 0 {
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) 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) {
@@ -49,6 +53,16 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
}
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)
if err != nil {
if t.log != nil {
@@ -59,7 +73,49 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
if t.log != nil {
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 ") {
@@ -97,9 +153,18 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
}
func (t *Tool) resolveAllowed(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
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 {
return "", err
}
}
abs = filepath.Clean(abs)
for _, allowed := range t.allowedDirs {

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"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -15,10 +16,11 @@ type Tool struct {
allowedCommands map[string]struct{}
workDir string
timeout time.Duration
maxOutputChars int
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))
for _, c := range allowed {
cmd := strings.TrimSpace(c)
@@ -33,10 +35,13 @@ func New(allowed []string, workDir string, timeout time.Duration, log *logger.Lo
if timeout <= 0 {
timeout = 15 * time.Second
}
if log != nil {
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s", len(set), absDir, timeout)
if maxOutputChars <= 0 {
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" }
@@ -72,14 +77,21 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
cmd := exec.CommandContext(runCtx, base, parts[1:]...)
cmd.Dir = t.workDir
out, err := cmd.CombinedOutput()
outText := string(out)
if len(outText) > t.maxOutputChars {
outText = outText[:t.maxOutputChars]
}
if err != 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 {
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")
}
}