From ff27e05f71b9e99bb802787629beeab35f30ae36 Mon Sep 17 00:00:00 2001 From: lpf Date: Fri, 13 Feb 2026 17:09:09 +0800 Subject: [PATCH] fix bug --- README.md | 95 ++++++ cmd/clawgo/main.go | 223 ++++-------- pkg/agent/context.go | 7 +- pkg/agent/loop.go | 645 +++++++++++++---------------------- pkg/agent/memory.go | 87 ++++- pkg/bus/bus.go | 76 ++++- pkg/channels/base.go | 14 +- pkg/channels/dingtalk.go | 49 +-- pkg/channels/discord.go | 39 ++- pkg/channels/feishu.go | 37 +- pkg/channels/maixcam.go | 30 +- pkg/channels/manager.go | 35 +- pkg/channels/qq.go | 47 +-- pkg/channels/telegram.go | 147 ++++++-- pkg/channels/utils.go | 68 ++++ pkg/channels/whatsapp.go | 83 ++++- pkg/config/config.go | 54 +++ pkg/config/validate.go | 7 + pkg/configops/configops.go | 190 +++++++++++ pkg/cron/service.go | 48 +-- pkg/heartbeat/service.go | 48 +-- pkg/lifecycle/loop_runner.go | 60 ++++ pkg/logger/fields.go | 16 + pkg/sentinel/service.go | 165 +++++++++ pkg/server/server.go | 2 +- pkg/session/manager.go | 12 +- pkg/tools/memory.go | 79 ++++- pkg/tools/memory_index.go | 197 +++++++++++ pkg/tools/orchestrator.go | 340 ++++++++++++++++++ pkg/tools/pipeline_tools.go | 295 ++++++++++++++++ pkg/tools/registry.go | 12 +- pkg/tools/repo_map.go | 282 +++++++++++++++ pkg/tools/risk.go | 93 +++++ pkg/tools/shell.go | 137 ++++++-- pkg/tools/skill_exec.go | 138 ++++++++ pkg/tools/spawn.go | 20 +- pkg/tools/subagent.go | 43 ++- pkg/tools/web.go | 14 +- pkg/voice/transcriber.go | 30 +- 39 files changed, 3052 insertions(+), 912 deletions(-) create mode 100644 pkg/channels/utils.go create mode 100644 pkg/configops/configops.go create mode 100644 pkg/lifecycle/loop_runner.go create mode 100644 pkg/logger/fields.go create mode 100644 pkg/sentinel/service.go create mode 100644 pkg/tools/memory_index.go create mode 100644 pkg/tools/orchestrator.go create mode 100644 pkg/tools/pipeline_tools.go create mode 100644 pkg/tools/repo_map.go create mode 100644 pkg/tools/risk.go create mode 100644 pkg/tools/skill_exec.go diff --git a/README.md b/README.md index 162daf6..910513a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,16 @@ export CLAWGO_CONFIG=/path/to/config.json `config set` 采用原子写入,并在网关运行且热更新失败时自动回滚到备份,避免配置损坏导致服务不可用。 +也支持在聊天通道中使用斜杠命令: + +```text +/help +/status +/config get channels.telegram.enabled +/config set channels.telegram.enabled true +/reload +``` + ## 🧾 日志链路 默认启用文件日志,并支持自动分割和过期清理(默认保留 3 天): @@ -79,6 +89,91 @@ export CLAWGO_CONFIG=/path/to/config.json } ``` +当前通道与网关链路日志已统一为结构化字段,建议告警与检索统一使用: +- `channel` +- `chat_id` +- `sender_id` +- `preview` +- `error` +- `message_content_length` +- `assistant_content_length` +- `user_response_content_length` +- `fetched_content_length` +- `output_content_length` +- `transcript_length` + +字段常量已集中在 `pkg/logger/fields.go`,新增日志字段建议优先复用常量,避免命名漂移。 + +## 🛡️ Sentinel 与风险防护 + +Sentinel 会周期巡检关键运行资源(配置、memory、日志目录),支持自动修复与告警转发: + +```json +"sentinel": { + "enabled": true, + "interval_sec": 60, + "auto_heal": true, + "notify_channel": "", + "notify_chat_id": "" +} +``` + +Shell 工具默认启用 Risk Gate。检测到破坏性命令时,默认阻断并要求 `force=true`,可先做 dry-run: + +```json +"tools": { + "shell": { + "risk": { + "enabled": true, + "allow_destructive": false, + "require_dry_run": true, + "require_force_flag": true + } + } +} +``` + +## 🤖 多智能体编排 (Pipeline) + +新增标准化任务编排协议:`role + goal + depends_on + shared_state`。 + +可用工具: +- `pipeline_create`:创建任务图 +- `pipeline_status`:查看流水线状态 +- `pipeline_state_set`:写入共享状态 +- `pipeline_dispatch`:自动派发当前可执行任务 +- `spawn`:支持 `pipeline_id/task_id/role` 参数 + +通道内可查看状态: + +```text +/pipeline list +/pipeline status +/pipeline ready +``` + +## 🧠 记忆与索引增强 + +- `memory_search`:增加结构化索引(倒排索引 + 缓存),优先走索引检索。 +- 记忆分层:支持 `profile / project / procedures / recent notes`。 + +```json +"memory": { + "layered": true, + "recent_days": 3, + "layers": { + "profile": true, + "project": true, + "procedures": true + } +} +``` + +## 🗺️ Repo-Map 与原子技能 + +- `repo_map`:生成并查询代码全景地图,先定位目标文件再精读。 +- `skill_exec`:执行 `skills//scripts/*` 原子脚本,保持 Gateway 精简。 + ## 📦 迁移与技能 ClawGo 现在集成了原 OpenClaw 的所有核心扩展能力: diff --git a/cmd/clawgo/main.go b/cmd/clawgo/main.go index 615db51..cde9849 100644 --- a/cmd/clawgo/main.go +++ b/cmd/clawgo/main.go @@ -20,7 +20,6 @@ import ( "path/filepath" "reflect" "runtime" - "strconv" "strings" "syscall" "time" @@ -29,10 +28,12 @@ import ( "clawgo/pkg/bus" "clawgo/pkg/channels" "clawgo/pkg/config" + "clawgo/pkg/configops" "clawgo/pkg/cron" "clawgo/pkg/heartbeat" "clawgo/pkg/logger" "clawgo/pkg/providers" + "clawgo/pkg/sentinel" "clawgo/pkg/skills" "clawgo/pkg/voice" @@ -659,6 +660,21 @@ func gatewayCmd() { 30*60, true, ) + sentinelService := sentinel.NewService( + getConfigPath(), + cfg.WorkspacePath(), + cfg.Sentinel.IntervalSec, + cfg.Sentinel.AutoHeal, + func(message string) { + if cfg.Sentinel.NotifyChannel != "" && cfg.Sentinel.NotifyChatID != "" { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: cfg.Sentinel.NotifyChannel, + ChatID: cfg.Sentinel.NotifyChatID, + Content: "[Sentinel] " + message, + }) + } + }, + ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -695,6 +711,10 @@ func gatewayCmd() { fmt.Printf("Error starting heartbeat service: %v\n", err) } fmt.Println("✓ Heartbeat service started") + if cfg.Sentinel.Enabled { + sentinelService.Start() + fmt.Println("✓ Sentinel service started") + } if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) @@ -727,6 +747,25 @@ func gatewayCmd() { if runtimeSame { configureLogging(newCfg) + sentinelService.Stop() + sentinelService = sentinel.NewService( + getConfigPath(), + newCfg.WorkspacePath(), + newCfg.Sentinel.IntervalSec, + newCfg.Sentinel.AutoHeal, + func(message string) { + if newCfg.Sentinel.NotifyChannel != "" && newCfg.Sentinel.NotifyChatID != "" { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: newCfg.Sentinel.NotifyChannel, + ChatID: newCfg.Sentinel.NotifyChatID, + Content: "[Sentinel] " + message, + }) + } + }, + ) + if newCfg.Sentinel.Enabled { + sentinelService.Start() + } cfg = newCfg fmt.Println("✓ Config hot-reload applied (logging/metadata only)") continue @@ -744,6 +783,25 @@ func gatewayCmd() { channelManager = newChannelManager agentLoop = newAgentLoop cfg = newCfg + sentinelService.Stop() + sentinelService = sentinel.NewService( + getConfigPath(), + newCfg.WorkspacePath(), + newCfg.Sentinel.IntervalSec, + newCfg.Sentinel.AutoHeal, + func(message string) { + if newCfg.Sentinel.NotifyChannel != "" && newCfg.Sentinel.NotifyChatID != "" { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: newCfg.Sentinel.NotifyChannel, + ChatID: newCfg.Sentinel.NotifyChatID, + Content: "[Sentinel] " + message, + }) + } + }, + ) + if newCfg.Sentinel.Enabled { + sentinelService.Start() + } if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("✗ Reload failed (start channels): %v\n", err) @@ -755,6 +813,7 @@ func gatewayCmd() { fmt.Println("\nShutting down...") cancel() heartbeatService.Stop() + sentinelService.Stop() cronService.Stop() agentLoop.Stop() channelManager.StopAll(ctx) @@ -1111,181 +1170,35 @@ func configCheckCmd() { } func loadConfigAsMap(path string) (map[string]interface{}, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - defaultCfg := config.DefaultConfig() - defData, mErr := json.Marshal(defaultCfg) - if mErr != nil { - return nil, mErr - } - var cfgMap map[string]interface{} - if uErr := json.Unmarshal(defData, &cfgMap); uErr != nil { - return nil, uErr - } - return cfgMap, nil - } - return nil, err - } - - var cfgMap map[string]interface{} - if err := json.Unmarshal(data, &cfgMap); err != nil { - return nil, err - } - return cfgMap, nil + return configops.LoadConfigAsMap(path) } func normalizeConfigPath(path string) string { - p := strings.TrimSpace(path) - p = strings.Trim(p, ".") - parts := strings.Split(p, ".") - for i, part := range parts { - if part == "enable" { - parts[i] = "enabled" - } - } - return strings.Join(parts, ".") + return configops.NormalizeConfigPath(path) } func parseConfigValue(raw string) interface{} { - v := strings.TrimSpace(raw) - lv := strings.ToLower(v) - if lv == "true" { - return true - } - if lv == "false" { - return false - } - if lv == "null" { - return nil - } - if i, err := strconv.ParseInt(v, 10, 64); err == nil { - return i - } - if f, err := strconv.ParseFloat(v, 64); err == nil && strings.Contains(v, ".") { - return f - } - if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { - return v[1 : len(v)-1] - } - return v + return configops.ParseConfigValue(raw) } func setMapValueByPath(root map[string]interface{}, path string, value interface{}) error { - if path == "" { - return fmt.Errorf("path is empty") - } - parts := strings.Split(path, ".") - cur := root - for i := 0; i < len(parts)-1; i++ { - key := parts[i] - if key == "" { - return fmt.Errorf("invalid path: %s", path) - } - next, ok := cur[key] - if !ok { - child := map[string]interface{}{} - cur[key] = child - cur = child - continue - } - child, ok := next.(map[string]interface{}) - if !ok { - return fmt.Errorf("path segment is not object: %s", key) - } - cur = child - } - last := parts[len(parts)-1] - if last == "" { - return fmt.Errorf("invalid path: %s", path) - } - cur[last] = value - return nil + return configops.SetMapValueByPath(root, path, value) } func getMapValueByPath(root map[string]interface{}, path string) (interface{}, bool) { - if path == "" { - return nil, false - } - parts := strings.Split(path, ".") - var cur interface{} = root - for _, key := range parts { - obj, ok := cur.(map[string]interface{}) - if !ok { - return nil, false - } - next, ok := obj[key] - if !ok { - return nil, false - } - cur = next - } - return cur, true + return configops.GetMapValueByPath(root, path) } func writeConfigAtomicWithBackup(configPath string, data []byte) (string, error) { - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { - return "", err - } - - backupPath := configPath + ".bak" - if oldData, err := os.ReadFile(configPath); err == nil { - if err := os.WriteFile(backupPath, oldData, 0644); err != nil { - return "", fmt.Errorf("write backup failed: %w", err) - } - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("read existing config failed: %w", err) - } - - tmpPath := configPath + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - return "", fmt.Errorf("write temp config failed: %w", err) - } - if err := os.Rename(tmpPath, configPath); err != nil { - _ = os.Remove(tmpPath) - return "", fmt.Errorf("atomic replace config failed: %w", err) - } - return backupPath, nil + return configops.WriteConfigAtomicWithBackup(configPath, data) } func rollbackConfigFromBackup(configPath, backupPath string) error { - backupData, err := os.ReadFile(backupPath) - if err != nil { - return fmt.Errorf("read backup failed: %w", err) - } - - tmpPath := configPath + ".rollback.tmp" - if err := os.WriteFile(tmpPath, backupData, 0644); err != nil { - return fmt.Errorf("write rollback temp failed: %w", err) - } - if err := os.Rename(tmpPath, configPath); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("rollback replace failed: %w", err) - } - return nil + return configops.RollbackConfigFromBackup(configPath, backupPath) } func triggerGatewayReload() (bool, error) { - pidPath := filepath.Join(filepath.Dir(getConfigPath()), "gateway.pid") - data, err := os.ReadFile(pidPath) - if err != nil { - return false, fmt.Errorf("%w (pid file not found: %s)", errGatewayNotRunning, pidPath) - } - - pidStr := strings.TrimSpace(string(data)) - pid, err := strconv.Atoi(pidStr) - if err != nil || pid <= 0 { - return true, fmt.Errorf("invalid gateway pid: %q", pidStr) - } - - proc, err := os.FindProcess(pid) - if err != nil { - return true, fmt.Errorf("find process failed: %w", err) - } - if err := proc.Signal(syscall.SIGHUP); err != nil { - return true, fmt.Errorf("send SIGHUP failed: %w", err) - } - return true, nil + return configops.TriggerGatewayReload(getConfigPath(), errGatewayNotRunning) } func statusCmd() { diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 71ad6b4..b5d21c1 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "clawgo/pkg/config" "clawgo/pkg/logger" "clawgo/pkg/providers" "clawgo/pkg/skills" @@ -28,7 +29,7 @@ func getGlobalConfigDir() string { return filepath.Join(home, ".clawgo") } -func NewContextBuilder(workspace string, toolsSummaryFunc func() []string) *ContextBuilder { +func NewContextBuilder(workspace string, memCfg config.MemoryConfig, toolsSummaryFunc func() []string) *ContextBuilder { // builtin skills: 当前项目的 skills 目录 // 使用当前工作目录下的 skills/ 目录 wd, _ := os.Getwd() @@ -38,7 +39,7 @@ func NewContextBuilder(workspace string, toolsSummaryFunc func() []string) *Cont return &ContextBuilder{ workspace: workspace, skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - memory: NewMemoryStore(workspace), + memory: NewMemoryStore(workspace, memCfg), toolsSummary: toolsSummaryFunc, } } @@ -171,7 +172,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str } logger.DebugCF("agent", "System prompt preview", map[string]interface{}{ - "preview": preview, + logger.FieldPreview: preview, }) if summary != "" { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5089535..5faad24 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -14,13 +14,13 @@ import ( "os" "path/filepath" "regexp" - "strconv" "strings" - "syscall" + "sync/atomic" "time" "clawgo/pkg/bus" "clawgo/pkg/config" + "clawgo/pkg/configops" "clawgo/pkg/cron" "clawgo/pkg/logger" "clawgo/pkg/providers" @@ -42,7 +42,8 @@ type AgentLoop struct { sessions *session.SessionManager contextBuilder *ContextBuilder tools *tools.ToolRegistry - running bool + orchestrator *tools.Orchestrator + running atomic.Bool } func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, cs *cron.CronService) *AgentLoop { @@ -81,9 +82,14 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(messageTool) // Register spawn tool - subagentManager := tools.NewSubagentManager(provider, workspace, msgBus) + orchestrator := tools.NewOrchestrator() + subagentManager := tools.NewSubagentManager(provider, workspace, msgBus, orchestrator) spawnTool := tools.NewSpawnTool(subagentManager) toolsRegistry.Register(spawnTool) + toolsRegistry.Register(tools.NewPipelineCreateTool(orchestrator)) + toolsRegistry.Register(tools.NewPipelineStatusTool(orchestrator)) + toolsRegistry.Register(tools.NewPipelineStateSetTool(orchestrator)) + toolsRegistry.Register(tools.NewPipelineDispatchTool(orchestrator, subagentManager)) // Register edit file tool editFileTool := tools.NewEditFileTool(workspace) @@ -92,6 +98,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers // Register memory search tool memorySearchTool := tools.NewMemorySearchTool(workspace) toolsRegistry.Register(memorySearchTool) + toolsRegistry.Register(tools.NewRepoMapTool(workspace)) + toolsRegistry.Register(tools.NewSkillExecTool(workspace)) // Register parallel execution tool (leveraging Go's concurrency) toolsRegistry.Register(tools.NewParallelTool(toolsRegistry)) @@ -114,9 +122,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers modelFallbacks: cfg.Agents.Defaults.ModelFallbacks, maxIterations: cfg.Agents.Defaults.MaxToolIterations, sessions: sessionsManager, - contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }), + contextBuilder: NewContextBuilder(workspace, cfg.Memory, func() []string { return toolsRegistry.GetSummaries() }), tools: toolsRegistry, - running: false, + orchestrator: orchestrator, } // 注入递归运行逻辑,使 subagent 具备 full tool-calling 能力 @@ -129,16 +137,16 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } func (al *AgentLoop) Run(ctx context.Context) error { - al.running = true + al.running.Store(true) - for al.running { + for al.running.Load() { select { case <-ctx.Done(): return nil default: msg, ok := al.bus.ConsumeInbound(ctx) if !ok { - continue + return nil } response, err := al.processMessage(ctx, msg) @@ -160,7 +168,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } func (al *AgentLoop) Stop() { - al.running = false + al.running.Store(false) } func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { @@ -180,10 +188,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) preview := truncate(msg.Content, 80) logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview), map[string]interface{}{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "sender_id": msg.SenderID, - "session_key": msg.SessionKey, + logger.FieldChannel: msg.Channel, + logger.FieldChatID: msg.ChatID, + logger.FieldSenderID: msg.SenderID, + "session_key": msg.SessionKey, }) // Route system messages to processSystemMessage @@ -220,133 +228,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) msg.ChatID, ) - iteration := 0 - var finalContent string - - for iteration < al.maxIterations { - iteration++ - - logger.DebugCF("agent", "LLM iteration", - map[string]interface{}{ - "iteration": iteration, - "max": al.maxIterations, - }) - - providerToolDefs, err := buildProviderToolDefs(al.tools.GetDefinitions()) - if err != nil { - return "", fmt.Errorf("invalid tool definition: %w", err) - } - - // Log LLM request details - logger.DebugCF("agent", "LLM request", - map[string]interface{}{ - "iteration": iteration, - "model": al.model, - "messages_count": len(messages), - "tools_count": len(providerToolDefs), - "max_tokens": 8192, - "temperature": 0.7, - "system_prompt_len": len(messages[0].Content), - }) - - // Log full messages (detailed) - logger.DebugCF("agent", "Full LLM request", - map[string]interface{}{ - "iteration": iteration, - "messages_json": formatMessagesForLog(messages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - llmStart := time.Now() - llmCtx, cancelLLM := context.WithTimeout(ctx, llmCallTimeout) - response, err := al.callLLMWithModelFallback(llmCtx, messages, providerToolDefs, map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, - }) - cancelLLM() - llmElapsed := time.Since(llmStart) - - if err != nil { - logger.ErrorCF("agent", "LLM call failed", - map[string]interface{}{ - "iteration": iteration, - "error": err.Error(), - "elapsed": llmElapsed.String(), - }) - return "", fmt.Errorf("LLM call failed: %w", err) - } - logger.InfoCF("agent", "LLM call completed", - map[string]interface{}{ - "iteration": iteration, - "elapsed": llmElapsed.String(), - "model": al.model, - }) - - if len(response.ToolCalls) == 0 { - finalContent = response.Content - logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]interface{}{ - "iteration": iteration, - "content_chars": len(finalContent), - }) - break - } - - toolNames := make([]string, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - toolNames = append(toolNames, tc.Name) - } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]interface{}{ - "tools": toolNames, - "count": len(toolNames), - "iteration": iteration, - }) - - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - } - - for _, tc := range response.ToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: string(argumentsJSON), - }, - }) - } - messages = append(messages, assistantMsg) - // 持久化包含 ToolCalls 的助手消息 - al.sessions.AddMessageFull(msg.SessionKey, assistantMsg) - - for _, tc := range response.ToolCalls { - // Log tool call with arguments preview - argsJSON, _ := json.Marshal(tc.Arguments) - argsPreview := truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]interface{}{ - "tool": tc.Name, - "iteration": iteration, - }) - - result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments) - if err != nil { - result = fmt.Sprintf("Error: %v", err) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: result, - ToolCallID: tc.ID, - } - messages = append(messages, toolResultMsg) - // 持久化工具返回结果 - al.sessions.AddMessageFull(msg.SessionKey, toolResultMsg) - } + finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, msg.SessionKey, false) + if err != nil { + return "", err } if finalContent == "" { @@ -377,8 +261,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if err := al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey)); err != nil { logger.WarnCF("agent", "Failed to save session metadata", map[string]interface{}{ - "session_key": msg.SessionKey, - "error": err.Error(), + "session_key": msg.SessionKey, + logger.FieldError: err.Error(), }) } @@ -386,9 +270,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) responsePreview := truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response to %s:%s: %s", msg.Channel, msg.SenderID, responsePreview), map[string]interface{}{ - "iterations": iteration, - "final_length": len(finalContent), - "user_length": len(userContent), + "iterations": iteration, + logger.FieldAssistantContentLength: len(finalContent), + logger.FieldUserResponseContentLength: len(userContent), }) return userContent, nil @@ -402,8 +286,8 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe logger.InfoCF("agent", "Processing system message", map[string]interface{}{ - "sender_id": msg.SenderID, - "chat_id": msg.ChatID, + logger.FieldSenderID: msg.SenderID, + logger.FieldChatID: msg.ChatID, }) // Parse origin from chat_id (format: "channel:chat_id") @@ -444,102 +328,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe originChatID, ) - iteration := 0 - var finalContent string - - for iteration < al.maxIterations { - iteration++ - - providerToolDefs, err := buildProviderToolDefs(al.tools.GetDefinitions()) - if err != nil { - return "", fmt.Errorf("invalid tool definition: %w", err) - } - - // Log LLM request details - logger.DebugCF("agent", "LLM request", - map[string]interface{}{ - "iteration": iteration, - "model": al.model, - "messages_count": len(messages), - "tools_count": len(providerToolDefs), - "max_tokens": 8192, - "temperature": 0.7, - "system_prompt_len": len(messages[0].Content), - }) - - // Log full messages (detailed) - logger.DebugCF("agent", "Full LLM request", - map[string]interface{}{ - "iteration": iteration, - "messages_json": formatMessagesForLog(messages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - llmStart := time.Now() - llmCtx, cancelLLM := context.WithTimeout(ctx, llmCallTimeout) - response, err := al.callLLMWithModelFallback(llmCtx, messages, providerToolDefs, map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, - }) - cancelLLM() - llmElapsed := time.Since(llmStart) - - if err != nil { - logger.ErrorCF("agent", "LLM call failed in system message", - map[string]interface{}{ - "iteration": iteration, - "error": err.Error(), - "elapsed": llmElapsed.String(), - }) - return "", fmt.Errorf("LLM call failed: %w", err) - } - logger.InfoCF("agent", "LLM call completed (system message)", - map[string]interface{}{ - "iteration": iteration, - "elapsed": llmElapsed.String(), - "model": al.model, - }) - - if len(response.ToolCalls) == 0 { - finalContent = response.Content - break - } - - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - } - - for _, tc := range response.ToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: string(argumentsJSON), - }, - }) - } - messages = append(messages, assistantMsg) - // 持久化包含 ToolCalls 的助手消息 - al.sessions.AddMessageFull(sessionKey, assistantMsg) - - for _, tc := range response.ToolCalls { - result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments) - if err != nil { - result = fmt.Sprintf("Error: %v", err) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: result, - ToolCallID: tc.ID, - } - messages = append(messages, toolResultMsg) - // 持久化工具返回结果 - al.sessions.AddMessageFull(sessionKey, toolResultMsg) - } + finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, sessionKey, true) + if err != nil { + return "", err } if finalContent == "" { @@ -559,20 +350,166 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe if err := al.sessions.Save(al.sessions.GetOrCreate(sessionKey)); err != nil { logger.WarnCF("agent", "Failed to save session metadata", map[string]interface{}{ - "session_key": sessionKey, - "error": err.Error(), + "session_key": sessionKey, + logger.FieldError: err.Error(), }) } logger.InfoCF("agent", "System message processing completed", map[string]interface{}{ - "iterations": iteration, - "final_length": len(finalContent), + "iterations": iteration, + logger.FieldAssistantContentLength: len(finalContent), }) return finalContent, nil } +func (al *AgentLoop) runLLMToolLoop( + ctx context.Context, + messages []providers.Message, + sessionKey string, + systemMode bool, +) (string, int, error) { + iteration := 0 + var finalContent string + + for iteration < al.maxIterations { + iteration++ + + if !systemMode { + logger.DebugCF("agent", "LLM iteration", + map[string]interface{}{ + "iteration": iteration, + "max": al.maxIterations, + }) + } + + providerToolDefs, err := buildProviderToolDefs(al.tools.GetDefinitions()) + if err != nil { + return "", iteration, fmt.Errorf("invalid tool definition: %w", err) + } + + logger.DebugCF("agent", "LLM request", + map[string]interface{}{ + "iteration": iteration, + "model": al.model, + "messages_count": len(messages), + "tools_count": len(providerToolDefs), + "max_tokens": 8192, + "temperature": 0.7, + "system_prompt_len": len(messages[0].Content), + }) + logger.DebugCF("agent", "Full LLM request", + map[string]interface{}{ + "iteration": iteration, + "messages_json": formatMessagesForLog(messages), + "tools_json": formatToolsForLog(providerToolDefs), + }) + + llmStart := time.Now() + llmCtx, cancelLLM := context.WithTimeout(ctx, llmCallTimeout) + response, err := al.callLLMWithModelFallback(llmCtx, messages, providerToolDefs, map[string]interface{}{ + "max_tokens": 8192, + "temperature": 0.7, + }) + cancelLLM() + llmElapsed := time.Since(llmStart) + + if err != nil { + errLog := "LLM call failed" + if systemMode { + errLog = "LLM call failed in system message" + } + logger.ErrorCF("agent", errLog, + map[string]interface{}{ + "iteration": iteration, + logger.FieldError: err.Error(), + "elapsed": llmElapsed.String(), + }) + return "", iteration, fmt.Errorf("LLM call failed: %w", err) + } + + doneLog := "LLM call completed" + if systemMode { + doneLog = "LLM call completed (system message)" + } + logger.InfoCF("agent", doneLog, + map[string]interface{}{ + "iteration": iteration, + "elapsed": llmElapsed.String(), + "model": al.model, + }) + + if len(response.ToolCalls) == 0 { + finalContent = response.Content + if !systemMode { + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + map[string]interface{}{ + "iteration": iteration, + logger.FieldAssistantContentLength: len(finalContent), + }) + } + break + } + + toolNames := make([]string, 0, len(response.ToolCalls)) + for _, tc := range response.ToolCalls { + toolNames = append(toolNames, tc.Name) + } + logger.InfoCF("agent", "LLM requested tool calls", + map[string]interface{}{ + "tools": toolNames, + "count": len(toolNames), + "iteration": iteration, + }) + + assistantMsg := providers.Message{ + Role: "assistant", + Content: response.Content, + } + for _, tc := range response.ToolCalls { + argumentsJSON, _ := json.Marshal(tc.Arguments) + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: string(argumentsJSON), + }, + }) + } + messages = append(messages, assistantMsg) + al.sessions.AddMessageFull(sessionKey, assistantMsg) + + for _, tc := range response.ToolCalls { + if !systemMode { + argsJSON, _ := json.Marshal(tc.Arguments) + argsPreview := truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + map[string]interface{}{ + "tool": tc.Name, + "iteration": iteration, + }) + } + + result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments) + if err != nil { + result = fmt.Sprintf("Error: %v", err) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: result, + ToolCallID: tc.ID, + } + messages = append(messages, toolResultMsg) + al.sessions.AddMessageFull(sessionKey, toolResultMsg) + } + } + + return finalContent, iteration, nil +} + // truncate returns a truncated version of s with at most maxLen characters. // If the string is truncated, "..." is appended to indicate truncation. // If the string fits within maxLen, it is returned unchanged. @@ -665,9 +602,9 @@ func (al *AgentLoop) callLLMWithModelFallback( if idx < len(candidates)-1 { logger.WarnCF("agent", "Model quota/rate-limit reached, trying fallback model", map[string]interface{}{ - "failed_model": model, - "next_model": candidates[idx+1], - "error": err.Error(), + "failed_model": model, + "next_model": candidates[idx+1], + logger.FieldError: err.Error(), }) continue } @@ -794,7 +731,7 @@ func (al *AgentLoop) handleSlashCommand(content string) (bool, string, error) { switch fields[0] { case "/help": - return true, "Slash commands:\n/help\n/status\n/config get \n/config set \n/reload", nil + return true, "Slash commands:\n/help\n/status\n/config get \n/config set \n/reload\n/pipeline list\n/pipeline status \n/pipeline ready ", nil case "/status": cfg, err := config.LoadConfig(al.getConfigPathForCommands()) if err != nil { @@ -878,6 +815,51 @@ func (al *AgentLoop) handleSlashCommand(content string) (bool, string, error) { default: return true, "Usage: /config get | /config set ", nil } + case "/pipeline": + if al.orchestrator == nil { + return true, "Pipeline orchestrator not enabled.", nil + } + if len(fields) < 2 { + return true, "Usage: /pipeline list | /pipeline status | /pipeline ready ", nil + } + switch fields[1] { + case "list": + items := al.orchestrator.ListPipelines() + if len(items) == 0 { + return true, "No pipelines found.", nil + } + var sb strings.Builder + sb.WriteString("Pipelines:\n") + for _, p := range items { + sb.WriteString(fmt.Sprintf("- %s [%s] %s\n", p.ID, p.Status, p.Label)) + } + return true, sb.String(), nil + case "status": + if len(fields) < 3 { + return true, "Usage: /pipeline status ", nil + } + s, err := al.orchestrator.SnapshotJSON(fields[2]) + return true, s, err + case "ready": + if len(fields) < 3 { + return true, "Usage: /pipeline ready ", nil + } + ready, err := al.orchestrator.ReadyTasks(fields[2]) + if err != nil { + return true, "", err + } + if len(ready) == 0 { + return true, "No ready tasks.", nil + } + var sb strings.Builder + sb.WriteString("Ready tasks:\n") + for _, task := range ready { + sb.WriteString(fmt.Sprintf("- %s (%s) %s\n", task.ID, task.Role, task.Goal)) + } + return true, sb.String(), nil + default: + return true, "Usage: /pipeline list | /pipeline status | /pipeline ready ", nil + } default: return false, "", nil } @@ -891,178 +873,35 @@ func (al *AgentLoop) getConfigPathForCommands() string { } func (al *AgentLoop) normalizeConfigPathForAgent(path string) string { - p := strings.TrimSpace(path) - p = strings.Trim(p, ".") - parts := strings.Split(p, ".") - for i, part := range parts { - if part == "enable" { - parts[i] = "enabled" - } - } - return strings.Join(parts, ".") + return configops.NormalizeConfigPath(path) } func (al *AgentLoop) parseConfigValueForAgent(raw string) interface{} { - v := strings.TrimSpace(raw) - lv := strings.ToLower(v) - if lv == "true" { - return true - } - if lv == "false" { - return false - } - if lv == "null" { - return nil - } - if i, err := strconv.ParseInt(v, 10, 64); err == nil { - return i - } - if f, err := strconv.ParseFloat(v, 64); err == nil && strings.Contains(v, ".") { - return f - } - if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { - return v[1 : len(v)-1] - } - return v + return configops.ParseConfigValue(raw) } func (al *AgentLoop) loadConfigAsMapForAgent() (map[string]interface{}, error) { - configPath := al.getConfigPathForCommands() - data, err := os.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - defaultCfg := config.DefaultConfig() - defData, mErr := json.Marshal(defaultCfg) - if mErr != nil { - return nil, mErr - } - var cfgMap map[string]interface{} - if uErr := json.Unmarshal(defData, &cfgMap); uErr != nil { - return nil, uErr - } - return cfgMap, nil - } - return nil, err - } - var cfgMap map[string]interface{} - if err := json.Unmarshal(data, &cfgMap); err != nil { - return nil, err - } - return cfgMap, nil + return configops.LoadConfigAsMap(al.getConfigPathForCommands()) } func (al *AgentLoop) setMapValueByPathForAgent(root map[string]interface{}, path string, value interface{}) error { - if path == "" { - return fmt.Errorf("path is empty") - } - parts := strings.Split(path, ".") - cur := root - for i := 0; i < len(parts)-1; i++ { - key := parts[i] - if key == "" { - return fmt.Errorf("invalid path: %s", path) - } - next, ok := cur[key] - if !ok { - child := map[string]interface{}{} - cur[key] = child - cur = child - continue - } - child, ok := next.(map[string]interface{}) - if !ok { - return fmt.Errorf("path segment is not object: %s", key) - } - cur = child - } - last := parts[len(parts)-1] - if last == "" { - return fmt.Errorf("invalid path: %s", path) - } - cur[last] = value - return nil + return configops.SetMapValueByPath(root, path, value) } func (al *AgentLoop) getMapValueByPathForAgent(root map[string]interface{}, path string) (interface{}, bool) { - if path == "" { - return nil, false - } - parts := strings.Split(path, ".") - var cur interface{} = root - for _, key := range parts { - obj, ok := cur.(map[string]interface{}) - if !ok { - return nil, false - } - next, ok := obj[key] - if !ok { - return nil, false - } - cur = next - } - return cur, true + return configops.GetMapValueByPath(root, path) } func (al *AgentLoop) writeConfigAtomicWithBackupForAgent(configPath string, data []byte) (string, error) { - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { - return "", err - } - - backupPath := configPath + ".bak" - if oldData, err := os.ReadFile(configPath); err == nil { - if err := os.WriteFile(backupPath, oldData, 0644); err != nil { - return "", fmt.Errorf("write backup failed: %w", err) - } - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("read existing config failed: %w", err) - } - - tmpPath := configPath + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - return "", fmt.Errorf("write temp config failed: %w", err) - } - if err := os.Rename(tmpPath, configPath); err != nil { - _ = os.Remove(tmpPath) - return "", fmt.Errorf("atomic replace config failed: %w", err) - } - return backupPath, nil + return configops.WriteConfigAtomicWithBackup(configPath, data) } func (al *AgentLoop) rollbackConfigFromBackupForAgent(configPath, backupPath string) error { - backupData, err := os.ReadFile(backupPath) - if err != nil { - return fmt.Errorf("read backup failed: %w", err) - } - tmpPath := configPath + ".rollback.tmp" - if err := os.WriteFile(tmpPath, backupData, 0644); err != nil { - return fmt.Errorf("write rollback temp failed: %w", err) - } - if err := os.Rename(tmpPath, configPath); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("rollback replace failed: %w", err) - } - return nil + return configops.RollbackConfigFromBackup(configPath, backupPath) } func (al *AgentLoop) triggerGatewayReloadFromAgent() (bool, error) { - pidPath := filepath.Join(filepath.Dir(al.getConfigPathForCommands()), "gateway.pid") - data, err := os.ReadFile(pidPath) - if err != nil { - return false, fmt.Errorf("%w (pid file not found: %s)", errGatewayNotRunningSlash, pidPath) - } - pidStr := strings.TrimSpace(string(data)) - pid, err := strconv.Atoi(pidStr) - if err != nil || pid <= 0 { - return true, fmt.Errorf("invalid gateway pid: %q", pidStr) - } - proc, err := os.FindProcess(pid) - if err != nil { - return true, fmt.Errorf("find process failed: %w", err) - } - if err := proc.Signal(syscall.SIGHUP); err != nil { - return true, fmt.Errorf("send SIGHUP failed: %w", err) - } - return true, nil + return configops.TriggerGatewayReload(al.getConfigPathForCommands(), errGatewayNotRunningSlash) } // truncateString truncates a string to max length diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index bc95a07..7dbd269 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -10,8 +10,10 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" + "clawgo/pkg/config" "clawgo/pkg/logger" ) @@ -19,22 +21,27 @@ import ( // - Long-term memory: memory/MEMORY.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md type MemoryStore struct { - workspace string - memoryDir string - memoryFile string + workspace string + memoryDir string + memoryFile string + layered bool + recentDays int + includeProfile bool + includeProject bool + includeProcedure bool } // NewMemoryStore creates a new MemoryStore with the given workspace path. // It ensures the memory directory exists. -func NewMemoryStore(workspace string) *MemoryStore { +func NewMemoryStore(workspace string, cfg config.MemoryConfig) *MemoryStore { memoryDir := filepath.Join(workspace, "memory") memoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists if err := os.MkdirAll(memoryDir, 0755); err != nil { logger.ErrorCF("memory", "Failed to create memory directory", map[string]interface{}{ - "memory_dir": memoryDir, - "error": err.Error(), + "memory_dir": memoryDir, + logger.FieldError: err.Error(), }) } @@ -58,16 +65,39 @@ This file stores important information that should persist across sessions. ` if writeErr := os.WriteFile(memoryFile, []byte(initial), 0644); writeErr != nil { logger.ErrorCF("memory", "Failed to initialize MEMORY.md", map[string]interface{}{ - "memory_file": memoryFile, - "error": writeErr.Error(), + "memory_file": memoryFile, + logger.FieldError: writeErr.Error(), }) } } + if cfg.Layered { + _ = os.MkdirAll(filepath.Join(memoryDir, "layers"), 0755) + ensureLayerFile(filepath.Join(memoryDir, "layers", "profile.md"), "# User Profile\n\nStable user profile, preferences, identity traits.\n") + ensureLayerFile(filepath.Join(memoryDir, "layers", "project.md"), "# Project Memory\n\nProject-specific architecture decisions and constraints.\n") + ensureLayerFile(filepath.Join(memoryDir, "layers", "procedures.md"), "# Procedures Memory\n\nReusable workflows, command recipes, and runbooks.\n") + } + + recentDays := cfg.RecentDays + if recentDays <= 0 { + recentDays = 3 + } + return &MemoryStore{ - workspace: workspace, - memoryDir: memoryDir, - memoryFile: memoryFile, + workspace: workspace, + memoryDir: memoryDir, + memoryFile: memoryFile, + layered: cfg.Layered, + recentDays: recentDays, + includeProfile: cfg.Layers.Profile, + includeProject: cfg.Layers.Project, + includeProcedure: cfg.Layers.Procedures, + } +} + +func ensureLayerFile(path, initial string) { + if _, err := os.Stat(path); os.IsNotExist(err) { + _ = os.WriteFile(path, []byte(initial), 0644) } } @@ -171,14 +201,19 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { func (ms *MemoryStore) GetMemoryContext() string { var parts []string + if ms.layered { + layerParts := ms.getLayeredContext() + parts = append(parts, layerParts...) + } + // Long-term memory longTerm := ms.ReadLongTerm() if longTerm != "" { parts = append(parts, "## Long-term Memory\n\n"+longTerm) } - // Recent daily notes (last 3 days) - recentNotes := ms.GetRecentDailyNotes(3) + // Recent daily notes + recentNotes := ms.GetRecentDailyNotes(ms.recentDays) if recentNotes != "" { parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) } @@ -197,3 +232,29 @@ func (ms *MemoryStore) GetMemoryContext() string { } return fmt.Sprintf("# Memory\n\n%s", result) } + +func (ms *MemoryStore) getLayeredContext() []string { + parts := []string{} + readLayer := func(filename, title string) { + data, err := os.ReadFile(filepath.Join(ms.memoryDir, "layers", filename)) + if err != nil { + return + } + content := string(data) + if strings.TrimSpace(content) == "" { + return + } + parts = append(parts, fmt.Sprintf("## %s\n\n%s", title, content)) + } + + if ms.includeProfile { + readLayer("profile.md", "Memory Layer: Profile") + } + if ms.includeProject { + readLayer("project.md", "Memory Layer: Project") + } + if ms.includeProcedure { + readLayer("procedures.md", "Memory Layer: Procedures") + } + return parts +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 3175296..f9d9136 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -8,10 +8,12 @@ import ( ) type MessageBus struct { - inbound chan InboundMessage - outbound chan OutboundMessage - handlers map[string]MessageHandler - mu sync.RWMutex + inbound chan InboundMessage + outbound chan OutboundMessage + handlers map[string]MessageHandler + mu sync.RWMutex + closed bool + closeOnce sync.Once } const queueWriteTimeout = 2 * time.Second @@ -25,41 +27,76 @@ func NewMessageBus() *MessageBus { } func (mb *MessageBus) PublishInbound(msg InboundMessage) { + mb.mu.RLock() + if mb.closed { + mb.mu.RUnlock() + return + } + ch := mb.inbound + mb.mu.RUnlock() + + defer func() { + if recover() != nil { + logger.WarnCF("bus", "PublishInbound on closed channel recovered", map[string]interface{}{ + logger.FieldChannel: msg.Channel, + logger.FieldChatID: msg.ChatID, + "session_key": msg.SessionKey, + }) + } + }() + select { - case mb.inbound <- msg: + case ch <- msg: case <-time.After(queueWriteTimeout): logger.ErrorCF("bus", "PublishInbound timeout (queue full)", map[string]interface{}{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "session_key": msg.SessionKey, + logger.FieldChannel: msg.Channel, + logger.FieldChatID: msg.ChatID, + "session_key": msg.SessionKey, }) } } func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) { select { - case msg := <-mb.inbound: - return msg, true + case msg, ok := <-mb.inbound: + return msg, ok case <-ctx.Done(): return InboundMessage{}, false } } func (mb *MessageBus) PublishOutbound(msg OutboundMessage) { + mb.mu.RLock() + if mb.closed { + mb.mu.RUnlock() + return + } + ch := mb.outbound + mb.mu.RUnlock() + + defer func() { + if recover() != nil { + logger.WarnCF("bus", "PublishOutbound on closed channel recovered", map[string]interface{}{ + logger.FieldChannel: msg.Channel, + logger.FieldChatID: msg.ChatID, + }) + } + }() + select { - case mb.outbound <- msg: + case ch <- msg: case <-time.After(queueWriteTimeout): logger.ErrorCF("bus", "PublishOutbound timeout (queue full)", map[string]interface{}{ - "channel": msg.Channel, - "chat_id": msg.ChatID, + logger.FieldChannel: msg.Channel, + logger.FieldChatID: msg.ChatID, }) } } func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { select { - case msg := <-mb.outbound: - return msg, true + case msg, ok := <-mb.outbound: + return msg, ok case <-ctx.Done(): return OutboundMessage{}, false } @@ -79,6 +116,11 @@ func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) { } func (mb *MessageBus) Close() { - close(mb.inbound) - close(mb.outbound) + mb.closeOnce.Do(func() { + mb.mu.Lock() + mb.closed = true + close(mb.inbound) + close(mb.outbound) + mb.mu.Unlock() + }) } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 0772e2b..54a4d6d 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync/atomic" "clawgo/pkg/bus" "clawgo/pkg/logger" @@ -21,7 +22,7 @@ type Channel interface { type BaseChannel struct { config interface{} bus *bus.MessageBus - running bool + running atomic.Bool name string allowList []string } @@ -32,7 +33,6 @@ func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowL bus: bus, name: name, allowList: allowList, - running: false, } } @@ -41,7 +41,7 @@ func (c *BaseChannel) Name() string { } func (c *BaseChannel) IsRunning() bool { - return c.running + return c.running.Load() } func (c *BaseChannel) IsAllowed(senderID string) bool { @@ -67,9 +67,9 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) { if !c.IsAllowed(senderID) { logger.WarnCF("channels", "Message rejected by allowlist", map[string]interface{}{ - "channel": c.name, - "sender_id": senderID, - "chat_id": chatID, + logger.FieldChannel: c.name, + logger.FieldSenderID: senderID, + logger.FieldChatID: chatID, }) return } @@ -91,5 +91,5 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st } func (c *BaseChannel) setRunning(running bool) { - c.running = running + c.running.Store(running) } diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go index b75f683..102ff94 100644 --- a/pkg/channels/dingtalk.go +++ b/pkg/channels/dingtalk.go @@ -6,11 +6,11 @@ package channels import ( "context" "fmt" - "log" "sync" "clawgo/pkg/bus" "clawgo/pkg/config" + "clawgo/pkg/logger" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" ) @@ -23,8 +23,7 @@ type DingTalkChannel struct { clientID string clientSecret string streamClient *client.StreamClient - ctx context.Context - cancel context.CancelFunc + runCancel cancelGuard // Map to store session webhooks for each chat sessionWebhooks sync.Map // chatID -> sessionWebhook } @@ -47,9 +46,13 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( // Start initializes the DingTalk channel with Stream Mode func (c *DingTalkChannel) Start(ctx context.Context) error { - log.Printf("Starting DingTalk channel (Stream Mode)...") + if c.IsRunning() { + return nil + } + logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)") - c.ctx, c.cancel = context.WithCancel(ctx) + runCtx, cancel := context.WithCancel(ctx) + c.runCancel.set(cancel) // Create credential config cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret) @@ -64,29 +67,30 @@ func (c *DingTalkChannel) Start(ctx context.Context) error { c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived) // Start the stream client - if err := c.streamClient.Start(c.ctx); err != nil { + if err := c.streamClient.Start(runCtx); err != nil { return fmt.Errorf("failed to start stream client: %w", err) } c.setRunning(true) - log.Println("DingTalk channel started (Stream Mode)") + logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)") return nil } // Stop gracefully stops the DingTalk channel func (c *DingTalkChannel) Stop(ctx context.Context) error { - log.Println("Stopping DingTalk channel...") - - if c.cancel != nil { - c.cancel() + if !c.IsRunning() { + return nil } + logger.InfoC("dingtalk", "Stopping DingTalk channel") + + c.runCancel.cancelAndClear() if c.streamClient != nil { c.streamClient.Close() } c.setRunning(false) - log.Println("DingTalk channel stopped") + logger.InfoC("dingtalk", "DingTalk channel stopped") return nil } @@ -107,7 +111,11 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } - log.Printf("DingTalk message to %s: %s", msg.ChatID, truncateStringDingTalk(msg.Content, 100)) + logger.InfoCF("dingtalk", "DingTalk outbound message", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + logger.FieldPreview: truncateString(msg.Content, 100), + "platform": "dingtalk", + }) // Use the session webhook to send the reply return c.SendDirectReply(sessionWebhook, msg.Content) @@ -151,7 +159,12 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch "session_webhook": data.SessionWebhook, } - log.Printf("DingTalk message from %s (%s): %s", senderNick, senderID, truncateStringDingTalk(content, 50)) + logger.InfoCF("dingtalk", "DingTalk inbound message", map[string]interface{}{ + "sender_name": senderNick, + logger.FieldSenderID: senderID, + logger.FieldChatID: chatID, + logger.FieldPreview: truncateString(content, 50), + }) // Handle the message through the base channel c.HandleMessage(senderID, chatID, content, nil, metadata) @@ -183,11 +196,3 @@ func (c *DingTalkChannel) SendDirectReply(sessionWebhook, content string) error return nil } - -// truncateStringDingTalk truncates a string to max length for logging (avoiding name collision with telegram.go) -func truncateStringDingTalk(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] -} diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 3e032e9..b16f269 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log" "net/http" "os" "path/filepath" @@ -131,11 +130,15 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag result, err := c.transcriber.Transcribe(ctx, localPath) if err != nil { - log.Printf("Voice transcription failed: %v", err) + logger.WarnCF("discord", "Voice transcription failed", map[string]interface{}{ + logger.FieldError: err.Error(), + }) transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", localPath) } else { transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text) - log.Printf("Audio transcribed successfully: %s", result.Text) + logger.InfoCF("discord", "Audio transcribed successfully", map[string]interface{}{ + "text_preview": truncateString(result.Text, 120), + }) } } else { transcribedText = fmt.Sprintf("[audio: %s]", localPath) @@ -170,9 +173,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag } logger.DebugCF("discord", "Received message", map[string]interface{}{ - "sender_name": senderName, - "sender_id": senderID, - "preview": truncateString(content, 50), + "sender_name": senderName, + logger.FieldSenderID: senderID, + logger.FieldPreview: truncateString(content, 50), }) metadata := map[string]string{ @@ -210,7 +213,9 @@ func isAudioFile(filename, contentType string) bool { func (c *DiscordChannel) downloadAttachment(url, filename string) string { mediaDir := filepath.Join(os.TempDir(), "clawgo_media") if err := os.MkdirAll(mediaDir, 0755); err != nil { - log.Printf("Failed to create media directory: %v", err) + logger.WarnCF("discord", "Failed to create media directory", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } @@ -218,29 +223,39 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string { resp, err := http.Get(url) if err != nil { - log.Printf("Failed to download attachment: %v", err) + logger.WarnCF("discord", "Failed to download attachment", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - log.Printf("Failed to download attachment, status: %d", resp.StatusCode) + logger.WarnCF("discord", "Attachment download returned non-200", map[string]interface{}{ + "status_code": resp.StatusCode, + }) return "" } out, err := os.Create(localPath) if err != nil { - log.Printf("Failed to create file: %v", err) + logger.WarnCF("discord", "Failed to create local attachment file", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } defer out.Close() _, err = io.Copy(out, resp.Body) if err != nil { - log.Printf("Failed to write file: %v", err) + logger.WarnCF("discord", "Failed to write local attachment file", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } - log.Printf("Attachment downloaded successfully to: %s", localPath) + logger.DebugCF("discord", "Attachment downloaded successfully", map[string]interface{}{ + "path": localPath, + }) return localPath } diff --git a/pkg/channels/feishu.go b/pkg/channels/feishu.go index 9797e2e..5a8319f 100644 --- a/pkg/channels/feishu.go +++ b/pkg/channels/feishu.go @@ -23,8 +23,8 @@ type FeishuChannel struct { client *lark.Client wsClient *larkws.Client - mu sync.Mutex - cancel context.CancelFunc + mu sync.Mutex + runCancel cancelGuard } func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { @@ -38,6 +38,9 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan } func (c *FeishuChannel) Start(ctx context.Context) error { + if c.IsRunning() { + return nil + } if c.config.AppID == "" || c.config.AppSecret == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } @@ -46,9 +49,9 @@ func (c *FeishuChannel) Start(ctx context.Context) error { OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) + c.runCancel.set(cancel) c.mu.Lock() - c.cancel = cancel c.wsClient = larkws.NewClient( c.config.AppID, c.config.AppSecret, @@ -60,25 +63,23 @@ func (c *FeishuChannel) Start(ctx context.Context) error { c.setRunning(true) logger.InfoC("feishu", "Feishu channel started (websocket mode)") - go func() { - if err := wsClient.Start(runCtx); err != nil { - logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{ - "error": err.Error(), - }) - } - }() + runChannelTask("feishu", "websocket", func() error { + return wsClient.Start(runCtx) + }, func(_ error) { + c.setRunning(false) + }) return nil } func (c *FeishuChannel) Stop(ctx context.Context) error { - c.mu.Lock() - if c.cancel != nil { - c.cancel() - c.cancel = nil + if !c.IsRunning() { + return nil } + c.mu.Lock() c.wsClient = nil c.mu.Unlock() + c.runCancel.cancelAndClear() c.setRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -119,7 +120,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error } logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{ - "chat_id": msg.ChatID, + logger.FieldChatID: msg.ChatID, }) return nil @@ -163,9 +164,9 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 } logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{ - "sender_id": senderID, - "chat_id": chatID, - "preview": truncateString(content, 80), + logger.FieldSenderID: senderID, + logger.FieldChatID: chatID, + logger.FieldPreview: truncateString(content, 80), }) c.HandleMessage(senderID, chatID, content, nil, metadata) diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index c00e726..7c3cb9a 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -18,7 +18,6 @@ type MaixCamChannel struct { listener net.Listener clients map[net.Conn]bool clientsMux sync.RWMutex - running bool } type MaixCamMessage struct { @@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC BaseChannel: base, config: cfg, clients: make(map[net.Conn]bool), - running: false, }, nil } @@ -72,9 +70,9 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) { default: conn, err := c.listener.Accept() if err != nil { - if c.running { + if c.IsRunning() { logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } return @@ -115,7 +113,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { if err := decoder.Decode(&msg); err != nil { if err.Error() != "EOF" { logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } return @@ -136,15 +134,17 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { c.handleStatusUpdate(msg) default: logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{ - "type": msg.Type, + "message_type": msg.Type, }) } } func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { - logger.InfoCF("maixcam", "", map[string]interface{}{ - "timestamp": msg.Timestamp, - "data": msg.Data, + logger.InfoCF("maixcam", "Person detected event", map[string]interface{}{ + logger.FieldSenderID: "maixcam", + logger.FieldChatID: "default", + "timestamp": msg.Timestamp, + "data": msg.Data, }) senderID := "maixcam" @@ -217,10 +217,10 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro } response := map[string]interface{}{ - "type": "command", - "timestamp": float64(0), - "message": msg.Content, - "chat_id": msg.ChatID, + "type": "command", + "timestamp": float64(0), + "message": msg.Content, + logger.FieldChatID: msg.ChatID, } data, err := json.Marshal(response) @@ -232,8 +232,8 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro for conn := range c.clients { if _, err := conn.Write(data); err != nil { logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{ - "client": conn.RemoteAddr().String(), - "error": err.Error(), + "client": conn.RemoteAddr().String(), + logger.FieldError: err.Error(), }) sendErr = err } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index a87d596..f602f92 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -58,7 +58,7 @@ func (m *Manager) initChannels() error { telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["telegram"] = telegram @@ -74,7 +74,7 @@ func (m *Manager) initChannels() error { whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["whatsapp"] = whatsapp @@ -87,7 +87,7 @@ func (m *Manager) initChannels() error { feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["feishu"] = feishu @@ -102,7 +102,7 @@ func (m *Manager) initChannels() error { discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["discord"] = discord @@ -115,7 +115,7 @@ func (m *Manager) initChannels() error { maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["maixcam"] = maixcam @@ -127,7 +127,7 @@ func (m *Manager) initChannels() error { qq, err := NewQQChannel(m.config.Channels.QQ, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["qq"] = qq @@ -142,7 +142,7 @@ func (m *Manager) initChannels() error { dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } else { m.channels["dingtalk"] = dingtalk @@ -176,12 +176,12 @@ func (m *Manager) StartAll(ctx context.Context) error { for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]interface{}{ - "channel": name, + logger.FieldChannel: name, }) if err := channel.Start(ctx); err != nil { logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{ - "channel": name, - "error": err.Error(), + logger.FieldChannel: name, + logger.FieldError: err.Error(), }) } } @@ -203,12 +203,12 @@ func (m *Manager) StopAll(ctx context.Context) error { for name, channel := range m.channels { logger.InfoCF("channels", "Stopping channel", map[string]interface{}{ - "channel": name, + logger.FieldChannel: name, }) if err := channel.Stop(ctx); err != nil { logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{ - "channel": name, - "error": err.Error(), + logger.FieldChannel: name, + logger.FieldError: err.Error(), }) } } @@ -228,7 +228,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { default: msg, ok := m.bus.SubscribeOutbound(ctx) if !ok { - continue + logger.InfoC("channels", "Outbound dispatcher stopped (bus closed)") + return } m.mu.RLock() @@ -237,7 +238,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { if !exists { logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{ - "channel": msg.Channel, + logger.FieldChannel: msg.Channel, }) continue } @@ -248,8 +249,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { defer func() { <-m.dispatchSem }() if err := c.Send(ctx, outbound); err != nil { logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{ - "channel": outbound.Channel, - "error": err.Error(), + logger.FieldChannel: outbound.Channel, + logger.FieldError: err.Error(), }) } }(channel, msg) diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go index 290d4da..4512d38 100644 --- a/pkg/channels/qq.go +++ b/pkg/channels/qq.go @@ -23,8 +23,7 @@ type QQChannel struct { config config.QQConfig api openapi.OpenAPI tokenSource oauth2.TokenSource - ctx context.Context - cancel context.CancelFunc + runCancel cancelGuard sessionManager botgo.SessionManager processedIDs map[string]bool mu sync.RWMutex @@ -41,6 +40,9 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, } func (c *QQChannel) Start(ctx context.Context) error { + if c.IsRunning() { + return nil + } if c.config.AppID == "" || c.config.AppSecret == "" { return fmt.Errorf("QQ app_id and app_secret not configured") } @@ -55,10 +57,11 @@ func (c *QQChannel) Start(ctx context.Context) error { c.tokenSource = token.NewQQBotTokenSource(credentials) // 创建子 context - c.ctx, c.cancel = context.WithCancel(ctx) + runCtx, cancel := context.WithCancel(ctx) + c.runCancel.set(cancel) // 启动自动刷新 token 协程 - if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil { + if err := token.StartRefreshAccessToken(runCtx, c.tokenSource); err != nil { return fmt.Errorf("failed to start token refresh: %w", err) } @@ -72,7 +75,7 @@ func (c *QQChannel) Start(ctx context.Context) error { ) // 获取 WebSocket 接入点 - wsInfo, err := c.api.WS(c.ctx, nil, "") + wsInfo, err := c.api.WS(runCtx, nil, "") if err != nil { return fmt.Errorf("failed to get websocket info: %w", err) } @@ -85,14 +88,11 @@ func (c *QQChannel) Start(ctx context.Context) error { c.sessionManager = botgo.NewSessionManager() // 在 goroutine 中启动 WebSocket 连接,避免阻塞 - go func() { - if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { - logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{ - "error": err.Error(), - }) - c.setRunning(false) - } - }() + runChannelTask("qq", "websocket session", func() error { + return c.sessionManager.Start(wsInfo, c.tokenSource, &intent) + }, func(_ error) { + c.setRunning(false) + }) c.setRunning(true) logger.InfoC("qq", "QQ bot started successfully") @@ -101,13 +101,13 @@ func (c *QQChannel) Start(ctx context.Context) error { } func (c *QQChannel) Stop(ctx context.Context) error { + if !c.IsRunning() { + return nil + } logger.InfoC("qq", "Stopping QQ bot") c.setRunning(false) - if c.cancel != nil { - c.cancel() - } - + c.runCancel.cancelAndClear() return nil } @@ -125,7 +125,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) return err } @@ -158,8 +158,9 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { } logger.InfoCF("qq", "Received C2C message", map[string]interface{}{ - "sender": senderID, - "length": len(content), + logger.FieldSenderID: senderID, + logger.FieldChatID: senderID, + logger.FieldMessageContentLength: len(content), }) // 转发到消息总线 @@ -198,9 +199,9 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } logger.InfoCF("qq", "Received group AT message", map[string]interface{}{ - "sender": senderID, - "group": data.GroupID, - "length": len(content), + logger.FieldSenderID: senderID, + "group_id": data.GroupID, + logger.FieldMessageContentLength: len(content), }) // 转发到消息总线(使用 GroupID 作为 ChatID) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index a3ea408..6a0dc91 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log" "net/http" "os" "path/filepath" @@ -18,6 +17,7 @@ import ( "clawgo/pkg/bus" "clawgo/pkg/config" + "clawgo/pkg/logger" "clawgo/pkg/voice" ) @@ -27,6 +27,7 @@ type TelegramChannel struct { config config.TelegramConfig chatIDs map[string]int64 updates <-chan telego.Update + runCancel cancelGuard transcriber *voice.GroqTranscriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> chan struct{} @@ -56,9 +57,15 @@ func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { } func (c *TelegramChannel) Start(ctx context.Context) error { - log.Printf("Starting Telegram bot (polling mode)...") + if c.IsRunning() { + return nil + } + logger.InfoC("telegram", "Starting Telegram bot (polling mode)") - updates, err := c.bot.UpdatesViaLongPolling(ctx, nil) + runCtx, cancel := context.WithCancel(ctx) + c.runCancel.set(cancel) + + updates, err := c.bot.UpdatesViaLongPolling(runCtx, nil) if err != nil { return fmt.Errorf("failed to start updates polling: %w", err) } @@ -70,16 +77,18 @@ func (c *TelegramChannel) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to get bot info: %w", err) } - log.Printf("Telegram bot @%s connected", botInfo.Username) + logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{ + "username": botInfo.Username, + }) go func() { for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case update, ok := <-updates: if !ok { - log.Printf("Updates channel closed") + logger.InfoC("telegram", "Updates channel closed") return } if update.Message != nil { @@ -93,8 +102,22 @@ func (c *TelegramChannel) Start(ctx context.Context) error { } func (c *TelegramChannel) Stop(ctx context.Context) error { - log.Println("Stopping Telegram bot...") + if !c.IsRunning() { + return nil + } + logger.InfoC("telegram", "Stopping Telegram bot") c.setRunning(false) + c.runCancel.cancelAndClear() + + c.stopThinking.Range(func(key, value interface{}) bool { + safeCloseSignal(value) + c.stopThinking.Delete(key) + return true + }) + c.placeholders.Range(func(key, _ interface{}) bool { + c.placeholders.Delete(key) + return true + }) // In telego v1.x, the long polling is stopped by canceling the context // passed to UpdatesViaLongPolling. We don't need a separate Stop call @@ -116,11 +139,15 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // Stop thinking animation if stop, ok := c.stopThinking.Load(msg.ChatID); ok { - log.Printf("Telegram thinking stop signal: chat_id=%s", msg.ChatID) - close(stop.(chan struct{})) + logger.DebugCF("telegram", "Telegram thinking stop signal", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + }) + safeCloseSignal(stop) c.stopThinking.Delete(msg.ChatID) } else { - log.Printf("Telegram thinking stop skipped: no stop channel found for chat_id=%s", msg.ChatID) + logger.DebugCF("telegram", "Telegram thinking stop skipped (not found)", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + }) } htmlContent := sanitizeTelegramHTML(markdownToTelegramHTML(msg.Content)) @@ -128,7 +155,10 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // Try to edit placeholder if pID, ok := c.placeholders.Load(msg.ChatID); ok { c.placeholders.Delete(msg.ChatID) - log.Printf("Telegram editing thinking placeholder: chat_id=%s message_id=%d", msg.ChatID, pID.(int)) + logger.DebugCF("telegram", "Telegram editing thinking placeholder", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + "message_id": pID.(int), + }) _, err := c.bot.EditMessageText(ctx, &telego.EditMessageTextParams{ ChatID: chatID, @@ -138,23 +168,35 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err }) if err == nil { - log.Printf("Telegram placeholder updated successfully: chat_id=%s", msg.ChatID) + logger.DebugCF("telegram", "Telegram placeholder updated", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + }) return nil } - log.Printf("Telegram placeholder update failed, fallback to new message: chat_id=%s err=%v", msg.ChatID, err) + logger.WarnCF("telegram", "Telegram placeholder update failed; fallback to new message", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + logger.FieldError: err.Error(), + }) // Fallback to new message if edit fails } else { - log.Printf("Telegram placeholder not found, sending new message: chat_id=%s", msg.ChatID) + logger.DebugCF("telegram", "Telegram placeholder not found, sending new message", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + }) } _, err = c.bot.SendMessage(ctx, telegoutil.Message(chatID, htmlContent).WithParseMode(telego.ModeHTML)) if err != nil { - log.Printf("HTML parse failed, falling back to plain text: %v", err) + logger.WarnCF("telegram", "HTML parse failed, fallback to plain text", map[string]interface{}{ + logger.FieldError: err.Error(), + }) plain := plainTextFromTelegramHTML(htmlContent) _, err = c.bot.SendMessage(ctx, telegoutil.Message(chatID, plain)) if err != nil { - log.Printf("Telegram plain-text fallback send failed: chat_id=%s err=%v", msg.ChatID, err) + logger.ErrorCF("telegram", "Telegram plain-text fallback send failed", map[string]interface{}{ + logger.FieldChatID: msg.ChatID, + logger.FieldError: err.Error(), + }) } return err } @@ -215,11 +257,15 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) { result, err := c.transcriber.Transcribe(ctx, voicePath) if err != nil { - log.Printf("Voice transcription failed: %v", err) + logger.WarnCF("telegram", "Voice transcription failed", map[string]interface{}{ + logger.FieldError: err.Error(), + }) transcribedText = fmt.Sprintf("[voice: %s (transcription failed)]", voicePath) } else { transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - log.Printf("Voice transcribed successfully: %s", result.Text) + logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{ + "text_preview": truncateString(result.Text, 120), + }) } } else { transcribedText = fmt.Sprintf("[voice: %s]", voicePath) @@ -258,10 +304,16 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) { content = "[empty message]" } - log.Printf("Telegram message from %s: %s...", senderID, truncateString(content, 50)) + logger.InfoCF("telegram", "Telegram message received", map[string]interface{}{ + logger.FieldSenderID: senderID, + logger.FieldPreview: truncateString(content, 50), + }) if !c.IsAllowed(senderID) { - log.Printf("Telegram message rejected by allowlist: sender=%s chat=%d", senderID, chatID) + logger.WarnCF("telegram", "Telegram message rejected by allowlist", map[string]interface{}{ + logger.FieldSenderID: senderID, + logger.FieldChatID: chatID, + }) return } @@ -272,14 +324,23 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) { }) stopChan := make(chan struct{}) + if prev, ok := c.stopThinking.LoadAndDelete(fmt.Sprintf("%d", chatID)); ok { + // Ensure previous animation loop exits before replacing channel. + safeCloseSignal(prev) + } c.stopThinking.Store(fmt.Sprintf("%d", chatID), stopChan) - log.Printf("Telegram thinking started: chat_id=%d", chatID) + logger.DebugCF("telegram", "Telegram thinking started", map[string]interface{}{ + logger.FieldChatID: chatID, + }) pMsg, err := c.bot.SendMessage(context.Background(), telegoutil.Message(telegoutil.ID(chatID), "Thinking... 💭")) if err == nil { pID := pMsg.MessageID c.placeholders.Store(fmt.Sprintf("%d", chatID), pID) - log.Printf("Telegram thinking placeholder created: chat_id=%d message_id=%d", chatID, pID) + logger.DebugCF("telegram", "Telegram thinking placeholder created", map[string]interface{}{ + logger.FieldChatID: chatID, + "message_id": pID, + }) go func(cid int64, mid int, stop <-chan struct{}) { dots := []string{".", "..", "..."} @@ -290,7 +351,9 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) { for { select { case <-stop: - log.Printf("Telegram thinking animation stopped: chat_id=%d", cid) + logger.DebugCF("telegram", "Telegram thinking animation stopped", map[string]interface{}{ + logger.FieldChatID: cid, + }) return case <-ticker.C: i++ @@ -300,13 +363,20 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) { MessageID: mid, Text: text, }); err != nil { - log.Printf("Telegram thinking animation edit failed: chat_id=%d message_id=%d err=%v", cid, mid, err) + logger.DebugCF("telegram", "Telegram thinking animation edit failed", map[string]interface{}{ + logger.FieldChatID: cid, + "message_id": mid, + logger.FieldError: err.Error(), + }) } } } }(chatID, pID, stopChan) } else { - log.Printf("Telegram thinking placeholder create failed: chat_id=%d err=%v", chatID, err) + logger.WarnCF("telegram", "Telegram thinking placeholder create failed", map[string]interface{}{ + logger.FieldChatID: chatID, + logger.FieldError: err.Error(), + }) } metadata := map[string]string{ @@ -323,7 +393,9 @@ func (c *TelegramChannel) handleMessage(message *telego.Message) { func (c *TelegramChannel) downloadFile(fileID, ext string) string { file, err := c.bot.GetFile(context.Background(), &telego.GetFileParams{FileID: fileID}) if err != nil { - log.Printf("Failed to get file: %v", err) + logger.WarnCF("telegram", "Failed to get file", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } @@ -333,18 +405,24 @@ func (c *TelegramChannel) downloadFile(fileID, ext string) string { // In telego, we can use Link() or just build the URL url := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", c.config.Token, file.FilePath) - log.Printf("File URL: %s", url) + logger.DebugCF("telegram", "Telegram file URL resolved", map[string]interface{}{ + "url": url, + }) mediaDir := filepath.Join(os.TempDir(), "clawgo_media") if err := os.MkdirAll(mediaDir, 0755); err != nil { - log.Printf("Failed to create media directory: %v", err) + logger.WarnCF("telegram", "Failed to create media directory", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } localPath := filepath.Join(mediaDir, fileID[:min(16, len(fileID))]+ext) if err := c.downloadFromURL(url, localPath); err != nil { - log.Printf("Failed to download file: %v", err) + logger.WarnCF("telegram", "Failed to download file", map[string]interface{}{ + logger.FieldError: err.Error(), + }) return "" } @@ -380,7 +458,9 @@ func (c *TelegramChannel) downloadFromURL(url, localPath string) error { return fmt.Errorf("failed to write file: %w", err) } - log.Printf("File downloaded successfully to: %s", localPath) + logger.DebugCF("telegram", "File downloaded successfully", map[string]interface{}{ + "path": localPath, + }) return nil } @@ -390,13 +470,6 @@ func parseChatID(chatIDStr string) (int64, error) { return id, err } -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] -} - func markdownToTelegramHTML(text string) string { if text == "" { return "" diff --git a/pkg/channels/utils.go b/pkg/channels/utils.go new file mode 100644 index 0000000..adfa5ee --- /dev/null +++ b/pkg/channels/utils.go @@ -0,0 +1,68 @@ +package channels + +import ( + "context" + "errors" + "sync" + + "clawgo/pkg/logger" +) + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 0 { + return "" + } + return s[:maxLen] +} + +func safeCloseSignal(v interface{}) { + ch, ok := v.(chan struct{}) + if !ok || ch == nil { + return + } + defer func() { _ = recover() }() + close(ch) +} + +type cancelGuard struct { + mu sync.Mutex + cancel context.CancelFunc +} + +func (g *cancelGuard) set(cancel context.CancelFunc) { + g.mu.Lock() + g.cancel = cancel + g.mu.Unlock() +} + +func (g *cancelGuard) cancelAndClear() { + g.mu.Lock() + cancel := g.cancel + g.cancel = nil + g.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func runChannelTask(name, taskName string, task func() error, onFailure func(error)) { + go func() { + if err := task(); err != nil { + if errors.Is(err, context.Canceled) { + logger.InfoCF(name, taskName+" stopped", map[string]interface{}{ + "reason": "context canceled", + }) + return + } + logger.ErrorCF(name, taskName+" failed", map[string]interface{}{ + logger.FieldError: err.Error(), + }) + if onFailure != nil { + onFailure(err) + } + } + }() +} diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go index 24fb09f..5c20866 100644 --- a/pkg/channels/whatsapp.go +++ b/pkg/channels/whatsapp.go @@ -3,8 +3,9 @@ package channels import ( "context" "encoding/json" + "errors" "fmt" - "log" + "net" "sync" "time" @@ -12,6 +13,7 @@ import ( "clawgo/pkg/bus" "clawgo/pkg/config" + "clawgo/pkg/logger" ) type WhatsAppChannel struct { @@ -19,6 +21,7 @@ type WhatsAppChannel struct { conn *websocket.Conn config config.WhatsAppConfig url string + runCancel cancelGuard mu sync.Mutex connected bool } @@ -35,7 +38,14 @@ func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsA } func (c *WhatsAppChannel) Start(ctx context.Context) error { - log.Printf("Starting WhatsApp channel connecting to %s...", c.url) + if c.IsRunning() { + return nil + } + logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]interface{}{ + "url": c.url, + }) + runCtx, cancel := context.WithCancel(ctx) + c.runCancel.set(cancel) dialer := websocket.DefaultDialer dialer.HandshakeTimeout = 10 * time.Second @@ -51,22 +61,28 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error { c.mu.Unlock() c.setRunning(true) - log.Println("WhatsApp channel connected") + logger.InfoC("whatsapp", "WhatsApp channel connected") - go c.listen(ctx) + go c.listen(runCtx) return nil } func (c *WhatsAppChannel) Stop(ctx context.Context) error { - log.Println("Stopping WhatsApp channel...") + if !c.IsRunning() { + return nil + } + logger.InfoC("whatsapp", "Stopping WhatsApp channel") + c.runCancel.cancelAndClear() c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { if err := c.conn.Close(); err != nil { - log.Printf("Error closing WhatsApp connection: %v", err) + logger.WarnCF("whatsapp", "Error closing WhatsApp connection", map[string]interface{}{ + logger.FieldError: err.Error(), + }) } c.conn = nil } @@ -104,6 +120,9 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } func (c *WhatsAppChannel) listen(ctx context.Context) { + backoff := 200 * time.Millisecond + const maxBackoff = 3 * time.Second + for { select { case <-ctx.Done(): @@ -114,20 +133,40 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { c.mu.Unlock() if conn == nil { - time.Sleep(1 * time.Second) + if !sleepWithContext(ctx, backoff) { + return + } + backoff = nextBackoff(backoff, maxBackoff) continue } _, message, err := conn.ReadMessage() if err != nil { - log.Printf("WhatsApp read error: %v", err) - time.Sleep(2 * time.Second) + if ctx.Err() != nil { + return + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || errors.Is(err, net.ErrClosed) { + logger.InfoCF("whatsapp", "WhatsApp connection closed", map[string]interface{}{ + logger.FieldError: err.Error(), + }) + return + } + logger.WarnCF("whatsapp", "WhatsApp read error", map[string]interface{}{ + logger.FieldError: err.Error(), + }) + if !sleepWithContext(ctx, backoff) { + return + } + backoff = nextBackoff(backoff, maxBackoff) continue } + backoff = 200 * time.Millisecond var msg map[string]interface{} if err := json.Unmarshal(message, &msg); err != nil { - log.Printf("Failed to unmarshal WhatsApp message: %v", err) + logger.WarnCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]interface{}{ + logger.FieldError: err.Error(), + }) continue } @@ -177,7 +216,29 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { metadata["user_name"] = userName } - log.Printf("WhatsApp message from %s: %s...", senderID, truncateString(content, 50)) + logger.InfoCF("whatsapp", "WhatsApp message received", map[string]interface{}{ + logger.FieldSenderID: senderID, + logger.FieldPreview: truncateString(content, 50), + }) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) } + +func nextBackoff(current, max time.Duration) time.Duration { + next := current * 2 + if next > max { + return max + } + return next +} + +func sleepWithContext(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4e86a8f..f2450ea 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,8 @@ type Config struct { Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` Logging LoggingConfig `json:"logging"` + Sentinel SentinelConfig `json:"sentinel"` + Memory MemoryConfig `json:"memory"` mu sync.RWMutex } @@ -122,9 +124,17 @@ type ShellConfig struct { DeniedCmds []string `json:"denied_cmds" env:"CLAWGO_TOOLS_SHELL_DENIED_CMDS"` AllowedCmds []string `json:"allowed_cmds" env:"CLAWGO_TOOLS_SHELL_ALLOWED_CMDS"` Sandbox SandboxConfig `json:"sandbox"` + Risk RiskConfig `json:"risk"` RestrictPath bool `json:"restrict_path" env:"CLAWGO_TOOLS_SHELL_RESTRICT_PATH"` } +type RiskConfig struct { + Enabled bool `json:"enabled" env:"CLAWGO_TOOLS_SHELL_RISK_ENABLED"` + AllowDestructive bool `json:"allow_destructive" env:"CLAWGO_TOOLS_SHELL_RISK_ALLOW_DESTRUCTIVE"` + RequireDryRun bool `json:"require_dry_run" env:"CLAWGO_TOOLS_SHELL_RISK_REQUIRE_DRY_RUN"` + RequireForceFlag bool `json:"require_force_flag" env:"CLAWGO_TOOLS_SHELL_RISK_REQUIRE_FORCE_FLAG"` +} + type SandboxConfig struct { Enabled bool `json:"enabled" env:"CLAWGO_TOOLS_SHELL_SANDBOX_ENABLED"` Image string `json:"image" env:"CLAWGO_TOOLS_SHELL_SANDBOX_IMAGE"` @@ -149,6 +159,24 @@ type LoggingConfig struct { RetentionDays int `json:"retention_days" env:"CLAWGO_LOGGING_RETENTION_DAYS"` } +type SentinelConfig struct { + Enabled bool `json:"enabled" env:"CLAWGO_SENTINEL_ENABLED"` + IntervalSec int `json:"interval_sec" env:"CLAWGO_SENTINEL_INTERVAL_SEC"` + AutoHeal bool `json:"auto_heal" env:"CLAWGO_SENTINEL_AUTO_HEAL"` + NotifyChannel string `json:"notify_channel" env:"CLAWGO_SENTINEL_NOTIFY_CHANNEL"` + NotifyChatID string `json:"notify_chat_id" env:"CLAWGO_SENTINEL_NOTIFY_CHAT_ID"` +} + +type MemoryConfig struct { + Layered bool `json:"layered" env:"CLAWGO_MEMORY_LAYERED"` + RecentDays int `json:"recent_days" env:"CLAWGO_MEMORY_RECENT_DAYS"` + Layers struct { + Profile bool `json:"profile" env:"CLAWGO_MEMORY_LAYERS_PROFILE"` + Project bool `json:"project" env:"CLAWGO_MEMORY_LAYERS_PROJECT"` + Procedures bool `json:"procedures" env:"CLAWGO_MEMORY_LAYERS_PROCEDURES"` + } `json:"layers"` +} + var ( isDebug bool muDebug sync.RWMutex @@ -256,6 +284,12 @@ func DefaultConfig() *Config { Enabled: false, Image: "golang:alpine", }, + Risk: RiskConfig{ + Enabled: true, + AllowDestructive: false, + RequireDryRun: true, + RequireForceFlag: true, + }, }, Filesystem: FilesystemConfig{ AllowedPaths: []string{}, @@ -269,6 +303,26 @@ func DefaultConfig() *Config { MaxSizeMB: 20, RetentionDays: 3, }, + Sentinel: SentinelConfig{ + Enabled: true, + IntervalSec: 60, + AutoHeal: true, + NotifyChannel: "", + NotifyChatID: "", + }, + Memory: MemoryConfig{ + Layered: true, + RecentDays: 3, + Layers: struct { + Profile bool `json:"profile" env:"CLAWGO_MEMORY_LAYERS_PROFILE"` + Project bool `json:"project" env:"CLAWGO_MEMORY_LAYERS_PROJECT"` + Procedures bool `json:"procedures" env:"CLAWGO_MEMORY_LAYERS_PROCEDURES"` + }{ + Profile: true, + Project: true, + Procedures: true, + }, + }, } } diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 5b66e06..a1271ac 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -41,6 +41,13 @@ func Validate(cfg *Config) []error { } } + if cfg.Sentinel.Enabled && cfg.Sentinel.IntervalSec <= 0 { + errs = append(errs, fmt.Errorf("sentinel.interval_sec must be > 0 when sentinel.enabled=true")) + } + if cfg.Memory.RecentDays <= 0 { + errs = append(errs, fmt.Errorf("memory.recent_days must be > 0")) + } + if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { errs = append(errs, fmt.Errorf("channels.telegram.token is required when channels.telegram.enabled=true")) } diff --git a/pkg/configops/configops.go b/pkg/configops/configops.go new file mode 100644 index 0000000..371c954 --- /dev/null +++ b/pkg/configops/configops.go @@ -0,0 +1,190 @@ +package configops + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "clawgo/pkg/config" +) + +func LoadConfigAsMap(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + defaultCfg := config.DefaultConfig() + defData, mErr := json.Marshal(defaultCfg) + if mErr != nil { + return nil, mErr + } + var cfgMap map[string]interface{} + if uErr := json.Unmarshal(defData, &cfgMap); uErr != nil { + return nil, uErr + } + return cfgMap, nil + } + return nil, err + } + + var cfgMap map[string]interface{} + if err := json.Unmarshal(data, &cfgMap); err != nil { + return nil, err + } + return cfgMap, nil +} + +func NormalizeConfigPath(path string) string { + p := strings.TrimSpace(path) + p = strings.Trim(p, ".") + parts := strings.Split(p, ".") + for i, part := range parts { + if part == "enable" { + parts[i] = "enabled" + } + } + return strings.Join(parts, ".") +} + +func ParseConfigValue(raw string) interface{} { + v := strings.TrimSpace(raw) + lv := strings.ToLower(v) + if lv == "true" { + return true + } + if lv == "false" { + return false + } + if lv == "null" { + return nil + } + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(v, 64); err == nil && strings.Contains(v, ".") { + return f + } + if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { + return v[1 : len(v)-1] + } + return v +} + +func SetMapValueByPath(root map[string]interface{}, path string, value interface{}) error { + if path == "" { + return fmt.Errorf("path is empty") + } + parts := strings.Split(path, ".") + cur := root + for i := 0; i < len(parts)-1; i++ { + key := parts[i] + if key == "" { + return fmt.Errorf("invalid path: %s", path) + } + next, ok := cur[key] + if !ok { + child := map[string]interface{}{} + cur[key] = child + cur = child + continue + } + child, ok := next.(map[string]interface{}) + if !ok { + return fmt.Errorf("path segment is not object: %s", key) + } + cur = child + } + last := parts[len(parts)-1] + if last == "" { + return fmt.Errorf("invalid path: %s", path) + } + cur[last] = value + return nil +} + +func GetMapValueByPath(root map[string]interface{}, path string) (interface{}, bool) { + if path == "" { + return nil, false + } + parts := strings.Split(path, ".") + var cur interface{} = root + for _, key := range parts { + obj, ok := cur.(map[string]interface{}) + if !ok { + return nil, false + } + next, ok := obj[key] + if !ok { + return nil, false + } + cur = next + } + return cur, true +} + +func WriteConfigAtomicWithBackup(configPath string, data []byte) (string, error) { + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return "", err + } + + backupPath := configPath + ".bak" + if oldData, err := os.ReadFile(configPath); err == nil { + if err := os.WriteFile(backupPath, oldData, 0644); err != nil { + return "", fmt.Errorf("write backup failed: %w", err) + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("read existing config failed: %w", err) + } + + tmpPath := configPath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return "", fmt.Errorf("write temp config failed: %w", err) + } + if err := os.Rename(tmpPath, configPath); err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("atomic replace config failed: %w", err) + } + return backupPath, nil +} + +func RollbackConfigFromBackup(configPath, backupPath string) error { + backupData, err := os.ReadFile(backupPath) + if err != nil { + return fmt.Errorf("read backup failed: %w", err) + } + tmpPath := configPath + ".rollback.tmp" + if err := os.WriteFile(tmpPath, backupData, 0644); err != nil { + return fmt.Errorf("write rollback temp failed: %w", err) + } + if err := os.Rename(tmpPath, configPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("rollback replace failed: %w", err) + } + return nil +} + +func TriggerGatewayReload(configPath string, notRunningErr error) (bool, error) { + pidPath := filepath.Join(filepath.Dir(configPath), "gateway.pid") + data, err := os.ReadFile(pidPath) + if err != nil { + return false, fmt.Errorf("%w (pid file not found: %s)", notRunningErr, pidPath) + } + + pidStr := strings.TrimSpace(string(data)) + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + return true, fmt.Errorf("invalid gateway pid: %q", pidStr) + } + + proc, err := os.FindProcess(pid) + if err != nil { + return true, fmt.Errorf("find process failed: %w", err) + } + if err := proc.Signal(syscall.SIGHUP); err != nil { + return true, fmt.Errorf("send SIGHUP failed: %w", err) + } + return true, nil +} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 209a1bc..d22c2bc 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sync" "time" + + "clawgo/pkg/lifecycle" ) type CronSchedule struct { @@ -56,34 +58,26 @@ type CronService struct { store *CronStore onJob JobHandler mu sync.RWMutex - wg sync.WaitGroup - running bool - stopChan chan struct{} + runner *lifecycle.LoopRunner } func NewCronService(storePath string, onJob JobHandler) *CronService { cs := &CronService{ storePath: storePath, onJob: onJob, - stopChan: make(chan struct{}), + runner: lifecycle.NewLoopRunner(), } cs.loadStore() return cs } func (cs *CronService) Start() error { - cs.mu.Lock() - defer cs.mu.Unlock() - - if cs.running { + if cs.runner.Running() { return nil } - select { - case <-cs.stopChan: - cs.stopChan = make(chan struct{}) - default: - } + cs.mu.Lock() + defer cs.mu.Unlock() if err := cs.loadStore(); err != nil { return fmt.Errorf("failed to load store: %w", err) @@ -94,35 +88,22 @@ func (cs *CronService) Start() error { return fmt.Errorf("failed to save store: %w", err) } - cs.running = true - cs.wg.Add(1) - go cs.runLoop() + cs.runner.Start(cs.runLoop) return nil } func (cs *CronService) Stop() { - cs.mu.Lock() - if !cs.running { - cs.mu.Unlock() - return - } - - cs.running = false - close(cs.stopChan) - cs.mu.Unlock() - - cs.wg.Wait() + cs.runner.Stop() } -func (cs *CronService) runLoop() { - defer cs.wg.Done() +func (cs *CronService) runLoop(stopCh <-chan struct{}) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { - case <-cs.stopChan: + case <-stopCh: return case <-ticker.C: cs.checkJobs() @@ -131,12 +112,11 @@ func (cs *CronService) runLoop() { } func (cs *CronService) checkJobs() { - cs.mu.RLock() - if !cs.running { - cs.mu.RUnlock() + if !cs.runner.Running() { return } + cs.mu.RLock() now := time.Now().UnixMilli() var dueJobs []*CronJob @@ -390,7 +370,7 @@ func (cs *CronService) Status() map[string]interface{} { } return map[string]interface{}{ - "enabled": cs.running, + "enabled": cs.runner.Running(), "jobs": len(cs.store.Jobs), "nextWakeAtMS": cs.getNextWakeMS(), } diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index fb0cdd5..9c10b3c 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -4,8 +4,9 @@ import ( "fmt" "os" "path/filepath" - "sync" "time" + + "clawgo/pkg/lifecycle" ) type HeartbeatService struct { @@ -13,10 +14,7 @@ type HeartbeatService struct { onHeartbeat func(string) (string, error) interval time.Duration enabled bool - mu sync.RWMutex - wg sync.WaitGroup - runningFlag bool - stopChan chan struct{} + runner *lifecycle.LoopRunner } func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, error), intervalS int, enabled bool) *HeartbeatService { @@ -25,58 +23,33 @@ func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, err onHeartbeat: onHeartbeat, interval: time.Duration(intervalS) * time.Second, enabled: enabled, - stopChan: make(chan struct{}), + runner: lifecycle.NewLoopRunner(), } } func (hs *HeartbeatService) Start() error { - hs.mu.Lock() - defer hs.mu.Unlock() - - if hs.runningFlag { - return nil - } - if !hs.enabled { return fmt.Errorf("heartbeat service is disabled") } - - hs.stopChan = make(chan struct{}) - hs.runningFlag = true - hs.wg.Add(1) - go hs.runLoop() - + hs.runner.Start(hs.runLoop) return nil } func (hs *HeartbeatService) Stop() { - hs.mu.Lock() - if !hs.runningFlag { - hs.mu.Unlock() - return - } - - hs.runningFlag = false - close(hs.stopChan) - hs.mu.Unlock() - - hs.wg.Wait() + hs.runner.Stop() } func (hs *HeartbeatService) running() bool { - hs.mu.RLock() - defer hs.mu.RUnlock() - return hs.runningFlag + return hs.runner.Running() } -func (hs *HeartbeatService) runLoop() { - defer hs.wg.Done() +func (hs *HeartbeatService) runLoop(stopCh <-chan struct{}) { ticker := time.NewTicker(hs.interval) defer ticker.Stop() for { select { - case <-hs.stopChan: + case <-stopCh: return case <-ticker.C: hs.checkHeartbeat() @@ -85,12 +58,9 @@ func (hs *HeartbeatService) runLoop() { } func (hs *HeartbeatService) checkHeartbeat() { - hs.mu.RLock() if !hs.enabled || !hs.running() { - hs.mu.RUnlock() return } - hs.mu.RUnlock() prompt := hs.buildPrompt() diff --git a/pkg/lifecycle/loop_runner.go b/pkg/lifecycle/loop_runner.go new file mode 100644 index 0000000..fe55543 --- /dev/null +++ b/pkg/lifecycle/loop_runner.go @@ -0,0 +1,60 @@ +package lifecycle + +import "sync" + +// LoopRunner provides a reusable start/stop lifecycle for background loops. +// It guarantees idempotent start/stop and waits for loop exit on stop. +type LoopRunner struct { + mu sync.RWMutex + wg sync.WaitGroup + running bool + stopCh chan struct{} +} + +func NewLoopRunner() *LoopRunner { + return &LoopRunner{} +} + +func (r *LoopRunner) Start(loop func(stop <-chan struct{})) bool { + if loop == nil { + return false + } + + r.mu.Lock() + defer r.mu.Unlock() + if r.running { + return false + } + + stopCh := make(chan struct{}) + r.stopCh = stopCh + r.running = true + r.wg.Add(1) + go func() { + defer r.wg.Done() + loop(stopCh) + }() + return true +} + +func (r *LoopRunner) Stop() bool { + r.mu.Lock() + if !r.running { + r.mu.Unlock() + return false + } + stopCh := r.stopCh + r.stopCh = nil + r.running = false + close(stopCh) + r.mu.Unlock() + + r.wg.Wait() + return true +} + +func (r *LoopRunner) Running() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.running +} diff --git a/pkg/logger/fields.go b/pkg/logger/fields.go new file mode 100644 index 0000000..6e5fec1 --- /dev/null +++ b/pkg/logger/fields.go @@ -0,0 +1,16 @@ +package logger + +const ( + FieldChannel = "channel" + FieldChatID = "chat_id" + FieldSenderID = "sender_id" + FieldPreview = "preview" + FieldError = "error" + + FieldMessageContentLength = "message_content_length" + FieldAssistantContentLength = "assistant_content_length" + FieldUserResponseContentLength = "user_response_content_length" + FieldFetchedContentLength = "fetched_content_length" + FieldOutputContentLength = "output_content_length" + FieldTranscriptLength = "transcript_length" +) diff --git a/pkg/sentinel/service.go b/pkg/sentinel/service.go new file mode 100644 index 0000000..77cb04a --- /dev/null +++ b/pkg/sentinel/service.go @@ -0,0 +1,165 @@ +package sentinel + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "clawgo/pkg/config" + "clawgo/pkg/lifecycle" + "clawgo/pkg/logger" +) + +type AlertFunc func(msg string) + +type Service struct { + cfgPath string + workspace string + interval time.Duration + autoHeal bool + onAlert AlertFunc + runner *lifecycle.LoopRunner + mu sync.RWMutex + lastAlerts map[string]time.Time +} + +func NewService(cfgPath, workspace string, intervalSec int, autoHeal bool, onAlert AlertFunc) *Service { + if intervalSec <= 0 { + intervalSec = 60 + } + return &Service{ + cfgPath: cfgPath, + workspace: workspace, + interval: time.Duration(intervalSec) * time.Second, + autoHeal: autoHeal, + onAlert: onAlert, + runner: lifecycle.NewLoopRunner(), + lastAlerts: map[string]time.Time{}, + } +} + +func (s *Service) Start() { + if !s.runner.Start(s.loop) { + return + } + logger.InfoCF("sentinel", "Sentinel started", map[string]interface{}{ + "interval": s.interval.String(), + "auto_heal": s.autoHeal, + }) +} + +func (s *Service) Stop() { + if !s.runner.Stop() { + return + } + logger.InfoC("sentinel", "Sentinel stopped") +} + +func (s *Service) loop(stopCh <-chan struct{}) { + tk := time.NewTicker(s.interval) + defer tk.Stop() + + s.runChecks() + for { + select { + case <-stopCh: + return + case <-tk.C: + s.runChecks() + } + } +} + +func (s *Service) runChecks() { + issues := s.checkConfig() + issues = append(issues, s.checkMemory()...) + issues = append(issues, s.checkLogs()...) + + if len(issues) == 0 { + return + } + + for _, issue := range issues { + s.alert(issue) + } +} + +func (s *Service) checkConfig() []string { + _, err := os.Stat(s.cfgPath) + if err != nil { + return []string{fmt.Sprintf("sentinel: config file missing: %s", s.cfgPath)} + } + + cfg, err := config.LoadConfig(s.cfgPath) + if err != nil { + return []string{fmt.Sprintf("sentinel: config parse failed: %v", err)} + } + + verrs := config.Validate(cfg) + out := make([]string, 0, len(verrs)) + for _, e := range verrs { + out = append(out, fmt.Sprintf("sentinel: config validation issue: %v", e)) + } + return out +} + +func (s *Service) checkMemory() []string { + memoryDir := filepath.Join(s.workspace, "memory") + memoryFile := filepath.Join(memoryDir, "MEMORY.md") + + if _, err := os.Stat(memoryDir); err != nil { + if s.autoHeal { + if mkErr := os.MkdirAll(memoryDir, 0755); mkErr == nil { + return []string{"sentinel: memory dir missing, auto-healed"} + } + } + return []string{fmt.Sprintf("sentinel: memory dir missing: %s", memoryDir)} + } + + if _, err := os.Stat(memoryFile); err != nil { + if s.autoHeal { + content := "# Long-term Memory\n\n(auto-created by sentinel)\n" + if wrErr := os.WriteFile(memoryFile, []byte(content), 0644); wrErr == nil { + return []string{"sentinel: MEMORY.md missing, auto-healed"} + } + } + return []string{fmt.Sprintf("sentinel: MEMORY.md missing: %s", memoryFile)} + } + return nil +} + +func (s *Service) checkLogs() []string { + cfg, err := config.LoadConfig(s.cfgPath) + if err != nil || !cfg.Logging.Enabled { + return nil + } + logDir := filepath.Clean(filepath.Dir(cfg.LogFilePath())) + if _, err := os.Stat(logDir); err != nil { + if s.autoHeal { + if mkErr := os.MkdirAll(logDir, 0755); mkErr == nil { + return []string{"sentinel: log dir missing, auto-healed"} + } + } + return []string{fmt.Sprintf("sentinel: log dir missing: %s", logDir)} + } + return nil +} + +func (s *Service) alert(msg string) { + now := time.Now() + s.mu.Lock() + last, ok := s.lastAlerts[msg] + if ok && now.Sub(last) < 5*time.Minute { + s.mu.Unlock() + return + } + s.lastAlerts[msg] = now + s.mu.Unlock() + + logger.WarnCF("sentinel", msg, nil) + if s.onAlert != nil { + s.onAlert(msg) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 48e8e53..8494e3a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -42,7 +42,7 @@ func (s *Server) Start() error { go func() { if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.ErrorCF("server", "HTTP server failed", map[string]interface{}{ - "error": err.Error(), + logger.FieldError: err.Error(), }) } }() diff --git a/pkg/session/manager.go b/pkg/session/manager.go index d7694ff..9206afc 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -37,8 +37,8 @@ func NewSessionManager(storage string) *SessionManager { if storage != "" { if err := os.MkdirAll(storage, 0755); err != nil { logger.ErrorCF("session", "Failed to create session storage", map[string]interface{}{ - "storage": storage, - "error": err.Error(), + "storage": storage, + logger.FieldError: err.Error(), }) } sm.loadSessions() @@ -93,8 +93,8 @@ func (sm *SessionManager) AddMessageFull(sessionKey string, msg providers.Messag // 立即持久化 (Append-only) if err := sm.appendMessage(sessionKey, msg); err != nil { logger.ErrorCF("session", "Failed to persist session message", map[string]interface{}{ - "session_key": sessionKey, - "error": err.Error(), + "session_key": sessionKey, + logger.FieldError: err.Error(), }) } } @@ -237,8 +237,8 @@ func (sm *SessionManager) loadSessions() error { session.mu.Unlock() if err := scanner.Err(); err != nil { logger.WarnCF("session", "Error while scanning session history", map[string]interface{}{ - "file": file.Name(), - "error": err.Error(), + "file": file.Name(), + logger.FieldError: err.Error(), }) } _ = f.Close() diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go index 178063c..d88b43f 100644 --- a/pkg/tools/memory.go +++ b/pkg/tools/memory.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" ) @@ -70,6 +71,15 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac } files := t.getMemoryFiles() + if len(files) == 0 { + return fmt.Sprintf("No memory files found for query: %s", query), nil + } + + // Fast path: structured memory index. + if idx, err := t.loadOrBuildIndex(files); err == nil && idx != nil { + results := t.searchInIndex(idx, keywords) + return t.renderSearchResults(query, results, maxResults), nil + } resultsChan := make(chan []searchResult, len(files)) var wg sync.WaitGroup @@ -97,21 +107,64 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac allResults = append(allResults, matches...) } - // Simple ranking: sort by score (number of keyword matches) desc - for i := 0; i < len(allResults); i++ { - for j := i + 1; j < len(allResults); j++ { - if allResults[j].score > allResults[i].score { - allResults[i], allResults[j] = allResults[j], allResults[i] - } + return t.renderSearchResults(query, allResults, maxResults), nil +} + +func (t *MemorySearchTool) searchInIndex(idx *memoryIndex, keywords []string) []searchResult { + type scoreItem struct { + entry memoryIndexEntry + score int + } + acc := make(map[int]int) + for _, kw := range keywords { + token := strings.ToLower(strings.TrimSpace(kw)) + for _, entryID := range idx.Inverted[token] { + acc[entryID]++ } } + out := make([]scoreItem, 0, len(acc)) + for entryID, score := range acc { + if entryID < 0 || entryID >= len(idx.Entries) || score <= 0 { + continue + } + out = append(out, scoreItem{ + entry: idx.Entries[entryID], + score: score, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].score == out[j].score { + return out[i].entry.LineNum < out[j].entry.LineNum + } + return out[i].score > out[j].score + }) + + results := make([]searchResult, 0, len(out)) + for _, item := range out { + results = append(results, searchResult{ + file: item.entry.File, + lineNum: item.entry.LineNum, + content: item.entry.Content, + score: item.score, + }) + } + return results +} + +func (t *MemorySearchTool) renderSearchResults(query string, allResults []searchResult, maxResults int) string { + sort.Slice(allResults, func(i, j int) bool { + if allResults[i].score == allResults[j].score { + return allResults[i].lineNum < allResults[j].lineNum + } + return allResults[i].score > allResults[j].score + }) + if len(allResults) > maxResults { allResults = allResults[:maxResults] } - if len(allResults) == 0 { - return fmt.Sprintf("No memory found for query: %s", query), nil + return fmt.Sprintf("No memory found for query: %s", query) } var sb strings.Builder @@ -120,8 +173,7 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac relPath, _ := filepath.Rel(t.workspace, res.file) sb.WriteString(fmt.Sprintf("--- Source: %s:%d ---\n%s\n\n", relPath, res.lineNum, res.content)) } - - return sb.String(), nil + return sb.String() } func (t *MemorySearchTool) getMemoryFiles() []string { @@ -201,7 +253,12 @@ func (t *MemorySearchTool) searchFile(path string, keywords []string) ([]searchR } } - // Only keep if at least one keyword matched + // Keep all blocks when keywords are empty (index build). + if len(keywords) == 0 { + score = 1 + } + + // Only keep if at least one keyword matched. if score > 0 { results = append(results, searchResult{ file: path, diff --git a/pkg/tools/memory_index.go b/pkg/tools/memory_index.go new file mode 100644 index 0000000..c44a995 --- /dev/null +++ b/pkg/tools/memory_index.go @@ -0,0 +1,197 @@ +package tools + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type memoryIndex struct { + UpdatedAt int64 `json:"updated_at"` + Files map[string]int64 `json:"files"` + Entries []memoryIndexEntry `json:"entries"` + Inverted map[string][]int `json:"inverted"` + Meta map[string]map[string][]string `json:"meta"` +} + +type memoryIndexEntry struct { + ID string `json:"id"` + File string `json:"file"` + LineNum int `json:"line_num"` + Content string `json:"content"` +} + +func (t *MemorySearchTool) indexPath() string { + return filepath.Join(t.workspace, "memory", ".index.json") +} + +func (t *MemorySearchTool) loadOrBuildIndex(files []string) (*memoryIndex, error) { + path := t.indexPath() + + if idx, ok := t.loadIndex(path); ok && !t.shouldRebuildIndex(idx, files) { + return idx, nil + } + return t.buildAndSaveIndex(path, files) +} + +func (t *MemorySearchTool) loadIndex(path string) (*memoryIndex, bool) { + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var idx memoryIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, false + } + if idx.Files == nil || idx.Inverted == nil { + return nil, false + } + return &idx, true +} + +func (t *MemorySearchTool) shouldRebuildIndex(idx *memoryIndex, files []string) bool { + if idx == nil { + return true + } + if len(idx.Files) != len(files) { + return true + } + for _, file := range files { + st, err := os.Stat(file) + if err != nil { + return true + } + if prev, ok := idx.Files[file]; !ok || prev != st.ModTime().UnixMilli() { + return true + } + } + return false +} + +func (t *MemorySearchTool) buildAndSaveIndex(path string, files []string) (*memoryIndex, error) { + idx := &memoryIndex{ + UpdatedAt: time.Now().UnixMilli(), + Files: make(map[string]int64, len(files)), + Entries: []memoryIndexEntry{}, + Inverted: map[string][]int{}, + Meta: map[string]map[string][]string{"sections": {}}, + } + + for _, file := range files { + st, err := os.Stat(file) + if err != nil { + continue + } + idx.Files[file] = st.ModTime().UnixMilli() + + blocks, sections, err := t.extractBlocks(file) + if err != nil { + continue + } + idx.Meta["sections"][file] = sections + + for _, block := range blocks { + entry := memoryIndexEntry{ + ID: hashEntryID(file, block.lineNum, block.content), + File: file, + LineNum: block.lineNum, + Content: block.content, + } + entryPos := len(idx.Entries) + idx.Entries = append(idx.Entries, entry) + + tokens := tokenize(block.content) + seen := map[string]struct{}{} + for _, token := range tokens { + if _, ok := seen[token]; ok { + continue + } + seen[token] = struct{}{} + idx.Inverted[token] = append(idx.Inverted[token], entryPos) + } + } + } + + for token, ids := range idx.Inverted { + sort.Ints(ids) + idx.Inverted[token] = uniqueInt(ids) + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return idx, nil + } + if data, err := json.Marshal(idx); err == nil { + _ = os.WriteFile(path, data, 0644) + } + return idx, nil +} + +func (t *MemorySearchTool) extractBlocks(path string) ([]searchResult, []string, error) { + results, err := t.searchFile(path, []string{}) + if err != nil { + return nil, nil, err + } + sections := []string{} + for _, res := range results { + if strings.HasPrefix(strings.TrimSpace(res.content), "[") { + end := strings.Index(res.content, "]") + if end > 1 { + sections = append(sections, strings.TrimSpace(res.content[1:end])) + } + } + } + sections = uniqueStrings(sections) + return results, sections, nil +} + +func hashEntryID(file string, line int, content string) string { + sum := sha1.Sum([]byte(file + ":" + strconv.Itoa(line) + ":" + content)) + return hex.EncodeToString(sum[:8]) +} + +func tokenize(s string) []string { + normalized := strings.ToLower(s) + repl := []string{",", ".", ";", ":", "\n", "\t", "(", ")", "[", "]", "{", "}", "\"", "'", "`", "/", "\\", "|", "-", "_"} + for _, r := range repl { + normalized = strings.ReplaceAll(normalized, r, " ") + } + parts := strings.Fields(normalized) + return uniqueStrings(parts) +} + +func uniqueInt(in []int) []int { + if len(in) < 2 { + return in + } + out := in[:1] + for i := 1; i < len(in); i++ { + if in[i] != in[i-1] { + out = append(out, in[i]) + } + } + return out +} + +func uniqueStrings(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/pkg/tools/orchestrator.go b/pkg/tools/orchestrator.go new file mode 100644 index 0000000..de9c032 --- /dev/null +++ b/pkg/tools/orchestrator.go @@ -0,0 +1,340 @@ +package tools + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +type PipelineStatus string + +const ( + PipelinePending PipelineStatus = "pending" + PipelineRunning PipelineStatus = "running" + PipelineCompleted PipelineStatus = "completed" + PipelineFailed PipelineStatus = "failed" +) + +type TaskStatus string + +const ( + TaskPending TaskStatus = "pending" + TaskRunning TaskStatus = "running" + TaskCompleted TaskStatus = "completed" + TaskFailed TaskStatus = "failed" +) + +type PipelineTask struct { + ID string `json:"id"` + Role string `json:"role"` + Goal string `json:"goal"` + DependsOn []string `json:"depends_on,omitempty"` + Status TaskStatus `json:"status"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type Pipeline struct { + ID string `json:"id"` + Label string `json:"label"` + Objective string `json:"objective"` + Status PipelineStatus `json:"status"` + OriginChannel string `json:"origin_channel"` + OriginChatID string `json:"origin_chat_id"` + SharedState map[string]interface{} `json:"shared_state"` + Tasks map[string]*PipelineTask + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PipelineSpec struct { + ID string `json:"id"` + Role string `json:"role"` + Goal string `json:"goal"` + DependsOn []string `json:"depends_on,omitempty"` +} + +type Orchestrator struct { + mu sync.RWMutex + pipelines map[string]*Pipeline + nextID int +} + +func NewOrchestrator() *Orchestrator { + return &Orchestrator{ + pipelines: make(map[string]*Pipeline), + nextID: 1, + } +} + +func (o *Orchestrator) CreatePipeline(label, objective, originChannel, originChatID string, tasks []PipelineSpec) (*Pipeline, error) { + o.mu.Lock() + defer o.mu.Unlock() + + if strings.TrimSpace(objective) == "" { + return nil, fmt.Errorf("objective is required") + } + if len(tasks) == 0 { + return nil, fmt.Errorf("at least one task is required") + } + + id := fmt.Sprintf("pipe-%d", o.nextID) + o.nextID++ + + now := time.Now().UnixMilli() + p := &Pipeline{ + ID: id, + Label: strings.TrimSpace(label), + Objective: strings.TrimSpace(objective), + Status: PipelinePending, + OriginChannel: originChannel, + OriginChatID: originChatID, + SharedState: make(map[string]interface{}), + Tasks: make(map[string]*PipelineTask, len(tasks)), + CreatedAt: now, + UpdatedAt: now, + } + + for _, task := range tasks { + taskID := strings.TrimSpace(task.ID) + if taskID == "" { + return nil, fmt.Errorf("task id is required") + } + if _, exists := p.Tasks[taskID]; exists { + return nil, fmt.Errorf("duplicate task id: %s", taskID) + } + + p.Tasks[taskID] = &PipelineTask{ + ID: taskID, + Role: strings.TrimSpace(task.Role), + Goal: strings.TrimSpace(task.Goal), + DependsOn: normalizeDepends(task.DependsOn), + Status: TaskPending, + CreatedAt: now, + UpdatedAt: now, + } + } + + for taskID, task := range p.Tasks { + for _, dep := range task.DependsOn { + if dep == taskID { + return nil, fmt.Errorf("task %s cannot depend on itself", taskID) + } + if _, exists := p.Tasks[dep]; !exists { + return nil, fmt.Errorf("task %s depends on missing task %s", taskID, dep) + } + } + } + + o.pipelines[p.ID] = p + return clonePipeline(p), nil +} + +func (o *Orchestrator) MarkTaskRunning(pipelineID, taskID string) error { + o.mu.Lock() + defer o.mu.Unlock() + + p, t, err := o.getTaskLocked(pipelineID, taskID) + if err != nil { + return err + } + if t.Status == TaskCompleted || t.Status == TaskFailed { + return nil + } + + t.Status = TaskRunning + t.UpdatedAt = time.Now().UnixMilli() + p.Status = PipelineRunning + p.UpdatedAt = t.UpdatedAt + return nil +} + +func (o *Orchestrator) MarkTaskDone(pipelineID, taskID, result string, runErr error) error { + o.mu.Lock() + defer o.mu.Unlock() + + p, t, err := o.getTaskLocked(pipelineID, taskID) + if err != nil { + return err + } + + now := time.Now().UnixMilli() + t.UpdatedAt = now + t.Result = strings.TrimSpace(result) + if runErr != nil { + t.Status = TaskFailed + t.Error = runErr.Error() + p.Status = PipelineFailed + } else { + t.Status = TaskCompleted + t.Error = "" + p.Status = o.computePipelineStatusLocked(p) + } + p.UpdatedAt = now + return nil +} + +func (o *Orchestrator) SetSharedState(pipelineID, key string, value interface{}) error { + o.mu.Lock() + defer o.mu.Unlock() + + p, ok := o.pipelines[pipelineID] + if !ok { + return fmt.Errorf("pipeline not found: %s", pipelineID) + } + k := strings.TrimSpace(key) + if k == "" { + return fmt.Errorf("state key is required") + } + p.SharedState[k] = value + p.UpdatedAt = time.Now().UnixMilli() + return nil +} + +func (o *Orchestrator) GetPipeline(pipelineID string) (*Pipeline, bool) { + o.mu.RLock() + defer o.mu.RUnlock() + + p, ok := o.pipelines[pipelineID] + if !ok { + return nil, false + } + return clonePipeline(p), true +} + +func (o *Orchestrator) ListPipelines() []*Pipeline { + o.mu.RLock() + defer o.mu.RUnlock() + + items := make([]*Pipeline, 0, len(o.pipelines)) + for _, p := range o.pipelines { + items = append(items, clonePipeline(p)) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].CreatedAt > items[j].CreatedAt + }) + return items +} + +func (o *Orchestrator) ReadyTasks(pipelineID string) ([]*PipelineTask, error) { + o.mu.RLock() + defer o.mu.RUnlock() + + p, ok := o.pipelines[pipelineID] + if !ok { + return nil, fmt.Errorf("pipeline not found: %s", pipelineID) + } + + var ready []*PipelineTask + for _, t := range p.Tasks { + if t.Status != TaskPending { + continue + } + if depsDone(p, t.DependsOn) { + ready = append(ready, cloneTask(t)) + } + } + sort.Slice(ready, func(i, j int) bool { return ready[i].ID < ready[j].ID }) + return ready, nil +} + +func (o *Orchestrator) SnapshotJSON(pipelineID string) (string, error) { + p, ok := o.GetPipeline(pipelineID) + if !ok { + return "", fmt.Errorf("pipeline not found: %s", pipelineID) + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +func (o *Orchestrator) getTaskLocked(pipelineID, taskID string) (*Pipeline, *PipelineTask, error) { + p, ok := o.pipelines[pipelineID] + if !ok { + return nil, nil, fmt.Errorf("pipeline not found: %s", pipelineID) + } + t, ok := p.Tasks[taskID] + if !ok { + return nil, nil, fmt.Errorf("task %s not found in pipeline %s", taskID, pipelineID) + } + return p, t, nil +} + +func (o *Orchestrator) computePipelineStatusLocked(p *Pipeline) PipelineStatus { + allDone := true + for _, t := range p.Tasks { + if t.Status == TaskFailed { + return PipelineFailed + } + if t.Status != TaskCompleted { + allDone = false + } + } + if allDone { + return PipelineCompleted + } + return PipelineRunning +} + +func depsDone(p *Pipeline, dependsOn []string) bool { + for _, dep := range dependsOn { + t, ok := p.Tasks[dep] + if !ok || t.Status != TaskCompleted { + return false + } + } + return true +} + +func normalizeDepends(in []string) []string { + uniq := make(map[string]struct{}) + out := make([]string, 0, len(in)) + for _, item := range in { + v := strings.TrimSpace(item) + if v == "" { + continue + } + if _, ok := uniq[v]; ok { + continue + } + uniq[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +func cloneTask(in *PipelineTask) *PipelineTask { + if in == nil { + return nil + } + deps := make([]string, len(in.DependsOn)) + copy(deps, in.DependsOn) + out := *in + out.DependsOn = deps + return &out +} + +func clonePipeline(in *Pipeline) *Pipeline { + if in == nil { + return nil + } + out := *in + out.SharedState = make(map[string]interface{}, len(in.SharedState)) + for k, v := range in.SharedState { + out.SharedState[k] = v + } + out.Tasks = make(map[string]*PipelineTask, len(in.Tasks)) + for id, t := range in.Tasks { + out.Tasks[id] = cloneTask(t) + } + return &out +} diff --git a/pkg/tools/pipeline_tools.go b/pkg/tools/pipeline_tools.go new file mode 100644 index 0000000..ece69ad --- /dev/null +++ b/pkg/tools/pipeline_tools.go @@ -0,0 +1,295 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type PipelineCreateTool struct { + orc *Orchestrator +} + +func NewPipelineCreateTool(orc *Orchestrator) *PipelineCreateTool { + return &PipelineCreateTool{orc: orc} +} + +func (t *PipelineCreateTool) Name() string { return "pipeline_create" } + +func (t *PipelineCreateTool) Description() string { + return "Create a multi-agent pipeline with standardized task protocol (role/goal/dependencies/shared state)." +} + +func (t *PipelineCreateTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "label": map[string]interface{}{ + "type": "string", + "description": "Optional short pipeline label", + }, + "objective": map[string]interface{}{ + "type": "string", + "description": "Top-level objective for this pipeline", + }, + "tasks": map[string]interface{}{ + "type": "array", + "description": "Task list with id/role/goal/depends_on", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + "role": map[string]interface{}{ + "type": "string", + "description": "Agent role, e.g. research/coding/testing", + }, + "goal": map[string]interface{}{"type": "string"}, + "depends_on": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"id", "goal"}, + }, + }, + }, + "required": []string{"objective", "tasks"}, + } +} + +func (t *PipelineCreateTool) Execute(_ context.Context, args map[string]interface{}) (string, error) { + if t.orc == nil { + return "", fmt.Errorf("orchestrator is not configured") + } + + objective, _ := args["objective"].(string) + label, _ := args["label"].(string) + + rawTasks, ok := args["tasks"].([]interface{}) + if !ok || len(rawTasks) == 0 { + return "", fmt.Errorf("tasks is required") + } + + specs := make([]PipelineSpec, 0, len(rawTasks)) + for i, item := range rawTasks { + m, ok := item.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("tasks[%d] must be object", i) + } + id, _ := m["id"].(string) + role, _ := m["role"].(string) + goal, _ := m["goal"].(string) + + deps := make([]string, 0) + if rawDeps, ok := m["depends_on"].([]interface{}); ok { + for _, dep := range rawDeps { + if depS, ok := dep.(string); ok { + deps = append(deps, depS) + } + } + } + specs = append(specs, PipelineSpec{ + ID: id, + Role: role, + Goal: goal, + DependsOn: deps, + }) + } + + p, err := t.orc.CreatePipeline(label, objective, "tool", "tool", specs) + if err != nil { + return "", err + } + + return fmt.Sprintf("Pipeline created: %s (%d tasks)\nUse spawn with pipeline_id/task_id to run tasks.\nUse pipeline_dispatch to dispatch ready tasks.", + p.ID, len(p.Tasks)), nil +} + +type PipelineStatusTool struct { + orc *Orchestrator +} + +func NewPipelineStatusTool(orc *Orchestrator) *PipelineStatusTool { + return &PipelineStatusTool{orc: orc} +} + +func (t *PipelineStatusTool) Name() string { return "pipeline_status" } + +func (t *PipelineStatusTool) Description() string { + return "Get pipeline status, tasks progress, and shared state. If pipeline_id is empty, list recent pipelines." +} + +func (t *PipelineStatusTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "pipeline_id": map[string]interface{}{ + "type": "string", + "description": "Pipeline ID", + }, + }, + } +} + +func (t *PipelineStatusTool) Execute(_ context.Context, args map[string]interface{}) (string, error) { + if t.orc == nil { + return "", fmt.Errorf("orchestrator is not configured") + } + pipelineID, _ := args["pipeline_id"].(string) + pipelineID = strings.TrimSpace(pipelineID) + + if pipelineID == "" { + items := t.orc.ListPipelines() + if len(items) == 0 { + return "No pipelines found.", nil + } + var sb strings.Builder + sb.WriteString("Pipelines:\n") + for _, p := range items { + sb.WriteString(fmt.Sprintf("- %s [%s] %s\n", p.ID, p.Status, p.Label)) + } + return sb.String(), nil + } + + return t.orc.SnapshotJSON(pipelineID) +} + +type PipelineStateSetTool struct { + orc *Orchestrator +} + +func NewPipelineStateSetTool(orc *Orchestrator) *PipelineStateSetTool { + return &PipelineStateSetTool{orc: orc} +} + +func (t *PipelineStateSetTool) Name() string { return "pipeline_state_set" } + +func (t *PipelineStateSetTool) Description() string { + return "Set shared state key/value for a pipeline, allowing sub-agents to share intermediate results." +} + +func (t *PipelineStateSetTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "pipeline_id": map[string]interface{}{"type": "string"}, + "key": map[string]interface{}{"type": "string"}, + "value": map[string]interface{}{ + "description": "Any JSON-serializable value", + }, + }, + "required": []string{"pipeline_id", "key", "value"}, + } +} + +func (t *PipelineStateSetTool) Execute(_ context.Context, args map[string]interface{}) (string, error) { + if t.orc == nil { + return "", fmt.Errorf("orchestrator is not configured") + } + pipelineID, _ := args["pipeline_id"].(string) + key, _ := args["key"].(string) + value, ok := args["value"] + if !ok { + return "", fmt.Errorf("value is required") + } + if err := t.orc.SetSharedState(strings.TrimSpace(pipelineID), strings.TrimSpace(key), value); err != nil { + return "", err + } + return fmt.Sprintf("Updated pipeline shared state: %s.%s", pipelineID, key), nil +} + +type PipelineDispatchTool struct { + orc *Orchestrator + spawn *SubagentManager +} + +func NewPipelineDispatchTool(orc *Orchestrator, spawn *SubagentManager) *PipelineDispatchTool { + return &PipelineDispatchTool{orc: orc, spawn: spawn} +} + +func (t *PipelineDispatchTool) Name() string { return "pipeline_dispatch" } + +func (t *PipelineDispatchTool) Description() string { + return "Dispatch all dependency-ready tasks in a pipeline by spawning subagents automatically." +} + +func (t *PipelineDispatchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "pipeline_id": map[string]interface{}{ + "type": "string", + "description": "Pipeline ID", + }, + "max_dispatch": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of tasks to dispatch in this call (default 3)", + "default": 3, + }, + }, + "required": []string{"pipeline_id"}, + } +} + +func (t *PipelineDispatchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + if t.orc == nil || t.spawn == nil { + return "", fmt.Errorf("pipeline dispatcher is not configured") + } + + pipelineID, _ := args["pipeline_id"].(string) + pipelineID = strings.TrimSpace(pipelineID) + if pipelineID == "" { + return "", fmt.Errorf("pipeline_id is required") + } + + maxDispatch := 3 + if raw, ok := args["max_dispatch"].(float64); ok && raw > 0 { + maxDispatch = int(raw) + } + + ready, err := t.orc.ReadyTasks(pipelineID) + if err != nil { + return "", err + } + if len(ready) == 0 { + return fmt.Sprintf("No ready tasks for pipeline %s", pipelineID), nil + } + + dispatched := 0 + var lines []string + + for _, task := range ready { + if dispatched >= maxDispatch { + break + } + shared := map[string]interface{}{} + if p, ok := t.orc.GetPipeline(pipelineID); ok { + for k, v := range p.SharedState { + shared[k] = v + } + } + + payload := task.Goal + if len(shared) > 0 { + sharedJSON, _ := json.Marshal(shared) + payload = fmt.Sprintf("%s\n\nShared State:\n%s", payload, string(sharedJSON)) + } + + label := task.ID + if task.Role != "" { + label = fmt.Sprintf("%s:%s", task.Role, task.ID) + } + if _, err := t.spawn.Spawn(ctx, payload, label, "tool", "tool", pipelineID, task.ID); err != nil { + lines = append(lines, fmt.Sprintf("- %s failed: %v", task.ID, err)) + continue + } + dispatched++ + lines = append(lines, fmt.Sprintf("- %s dispatched", task.ID)) + } + + if len(lines) == 0 { + return "No tasks dispatched.", nil + } + return fmt.Sprintf("Pipeline %s dispatch result:\n%s", pipelineID, strings.Join(lines, "\n")), nil +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 006333d..53bcb51 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -56,16 +56,16 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string if err != nil { logger.ErrorCF("tool", "Tool execution failed", map[string]interface{}{ - "tool": name, - "duration": duration.Milliseconds(), - "error": err.Error(), + "tool": name, + "duration": duration.Milliseconds(), + logger.FieldError: err.Error(), }) } else { logger.InfoCF("tool", "Tool execution completed", map[string]interface{}{ - "tool": name, - "duration_ms": duration.Milliseconds(), - "result_length": len(result), + "tool": name, + "duration_ms": duration.Milliseconds(), + logger.FieldOutputContentLength: len(result), }) } diff --git a/pkg/tools/repo_map.go b/pkg/tools/repo_map.go new file mode 100644 index 0000000..fca9b79 --- /dev/null +++ b/pkg/tools/repo_map.go @@ -0,0 +1,282 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +type RepoMapTool struct { + workspace string +} + +func NewRepoMapTool(workspace string) *RepoMapTool { + return &RepoMapTool{workspace: workspace} +} + +func (t *RepoMapTool) Name() string { + return "repo_map" +} + +func (t *RepoMapTool) Description() string { + return "Build and query repository map to quickly locate target files/symbols before reading source." +} + +func (t *RepoMapTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search file path or symbol keyword", + }, + "max_results": map[string]interface{}{ + "type": "integer", + "default": 20, + "description": "Maximum results to return", + }, + "refresh": map[string]interface{}{ + "type": "boolean", + "description": "Force rebuild map cache", + "default": false, + }, + }, + } +} + +type repoMapCache struct { + Workspace string `json:"workspace"` + UpdatedAt int64 `json:"updated_at"` + Files []repoMapEntry `json:"files"` +} + +type repoMapEntry struct { + Path string `json:"path"` + Lang string `json:"lang"` + Size int64 `json:"size"` + ModTime int64 `json:"mod_time"` + Symbols []string `json:"symbols,omitempty"` +} + +func (t *RepoMapTool) Execute(_ context.Context, args map[string]interface{}) (string, error) { + query, _ := args["query"].(string) + maxResults := 20 + if raw, ok := args["max_results"].(float64); ok && raw > 0 { + maxResults = int(raw) + } + forceRefresh, _ := args["refresh"].(bool) + + cache, err := t.loadOrBuildMap(forceRefresh) + if err != nil { + return "", err + } + if len(cache.Files) == 0 { + return "Repo map is empty.", nil + } + + results := t.filterRepoMap(cache.Files, query) + if len(results) > maxResults { + results = results[:maxResults] + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Repo Map (updated: %s)\n", time.UnixMilli(cache.UpdatedAt).Format("2006-01-02 15:04:05"))) + sb.WriteString(fmt.Sprintf("Workspace: %s\n", cache.Workspace)) + sb.WriteString(fmt.Sprintf("Matched files: %d\n\n", len(results))) + + for _, item := range results { + sb.WriteString(fmt.Sprintf("- %s [%s] (%d bytes)\n", item.Path, item.Lang, item.Size)) + if len(item.Symbols) > 0 { + sb.WriteString(fmt.Sprintf(" symbols: %s\n", strings.Join(item.Symbols, ", "))) + } + } + if len(results) == 0 { + return "No files matched query.", nil + } + return sb.String(), nil +} + +func (t *RepoMapTool) cachePath() string { + return filepath.Join(t.workspace, ".clawgo", "repo_map.json") +} + +func (t *RepoMapTool) loadOrBuildMap(force bool) (*repoMapCache, error) { + if !force { + if data, err := os.ReadFile(t.cachePath()); err == nil { + var cache repoMapCache + if err := json.Unmarshal(data, &cache); err == nil { + if cache.Workspace == t.workspace && (time.Now().UnixMilli()-cache.UpdatedAt) < int64((10*time.Minute)/time.Millisecond) { + return &cache, nil + } + } + } + } + + cache := &repoMapCache{ + Workspace: t.workspace, + UpdatedAt: time.Now().UnixMilli(), + Files: []repoMapEntry{}, + } + + err := filepath.Walk(t.workspace, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if info.IsDir() { + name := info.Name() + if name == ".git" || name == "node_modules" || name == ".clawgo" || name == "vendor" { + return filepath.SkipDir + } + return nil + } + if info.Size() > 512*1024 { + return nil + } + + rel, err := filepath.Rel(t.workspace, path) + if err != nil { + return nil + } + lang := langFromPath(rel) + if lang == "" { + return nil + } + + entry := repoMapEntry{ + Path: rel, + Lang: lang, + Size: info.Size(), + ModTime: info.ModTime().UnixMilli(), + Symbols: extractSymbols(path, lang), + } + cache.Files = append(cache.Files, entry) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(cache.Files, func(i, j int) bool { + return cache.Files[i].Path < cache.Files[j].Path + }) + + if err := os.MkdirAll(filepath.Dir(t.cachePath()), 0755); err == nil { + if data, err := json.Marshal(cache); err == nil { + _ = os.WriteFile(t.cachePath(), data, 0644) + } + } + return cache, nil +} + +func (t *RepoMapTool) filterRepoMap(files []repoMapEntry, query string) []repoMapEntry { + q := strings.ToLower(strings.TrimSpace(query)) + if q == "" { + return files + } + + type scored struct { + item repoMapEntry + score int + } + items := []scored{} + for _, file := range files { + score := 0 + p := strings.ToLower(file.Path) + if strings.Contains(p, q) { + score += 5 + } + for _, sym := range file.Symbols { + if strings.Contains(strings.ToLower(sym), q) { + score += 3 + } + } + if score > 0 { + items = append(items, scored{item: file, score: score}) + } + } + sort.Slice(items, func(i, j int) bool { + if items[i].score == items[j].score { + return items[i].item.Path < items[j].item.Path + } + return items[i].score > items[j].score + }) + + out := make([]repoMapEntry, 0, len(items)) + for _, item := range items { + out = append(out, item.item) + } + return out +} + +func langFromPath(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".go": + return "go" + case ".md": + return "markdown" + case ".json": + return "json" + case ".yaml", ".yml": + return "yaml" + case ".sh": + return "shell" + case ".py": + return "python" + case ".js": + return "javascript" + case ".ts": + return "typescript" + default: + return "" + } +} + +func extractSymbols(path, lang string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + content := string(data) + out := []string{} + + switch lang { + case "go": + re := regexp.MustCompile(`(?m)^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) + for _, m := range re.FindAllStringSubmatch(content, 12) { + if len(m) > 1 { + out = append(out, m[1]) + } + } + typeRe := regexp.MustCompile(`(?m)^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`) + for _, m := range typeRe.FindAllStringSubmatch(content, 12) { + if len(m) > 1 { + out = append(out, m[1]) + } + } + case "python": + re := regexp.MustCompile(`(?m)^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) + for _, m := range re.FindAllStringSubmatch(content, 12) { + if len(m) > 1 { + out = append(out, m[1]) + } + } + } + + if len(out) == 0 { + return nil + } + sort.Strings(out) + uniq := out[:1] + for i := 1; i < len(out); i++ { + if out[i] != out[i-1] { + uniq = append(uniq, out[i]) + } + } + return uniq +} diff --git a/pkg/tools/risk.go b/pkg/tools/risk.go new file mode 100644 index 0000000..3f86c67 --- /dev/null +++ b/pkg/tools/risk.go @@ -0,0 +1,93 @@ +package tools + +import ( + "regexp" + "strings" +) + +type RiskLevel string + +const ( + RiskSafe RiskLevel = "safe" + RiskModerate RiskLevel = "moderate" + RiskDestructive RiskLevel = "destructive" +) + +type RiskAssessment struct { + Level RiskLevel + Reasons []string +} + +var destructivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-rf\b`), + regexp.MustCompile(`\bmkfs(\.| )`), + regexp.MustCompile(`\bdd\s+if=`), + regexp.MustCompile(`\bshutdown\b`), + regexp.MustCompile(`\breboot\b`), + regexp.MustCompile(`\buserdel\b`), + regexp.MustCompile(`\bchown\b.+\s+/`), + regexp.MustCompile(`\bclawgo\s+uninstall\b`), + regexp.MustCompile(`\bdbt\s+drop\b`), +} + +var moderatePatterns = []*regexp.Regexp{ + regexp.MustCompile(`\bgit\s+reset\s+--hard\b`), + regexp.MustCompile(`\bgit\s+clean\b`), + regexp.MustCompile(`\bdocker\s+system\s+prune\b`), + regexp.MustCompile(`\bapt(-get)?\s+install\b`), + regexp.MustCompile(`\byum\s+install\b`), + regexp.MustCompile(`\bpip\s+install\b`), +} + +func assessCommandRisk(command string) RiskAssessment { + cmd := strings.ToLower(strings.TrimSpace(command)) + out := RiskAssessment{Level: RiskSafe, Reasons: []string{}} + + for _, re := range destructivePatterns { + if re.MatchString(cmd) { + out.Level = RiskDestructive + out.Reasons = append(out.Reasons, "destructive pattern: "+re.String()) + } + } + if out.Level == RiskDestructive { + return out + } + + for _, re := range moderatePatterns { + if re.MatchString(cmd) { + out.Level = RiskModerate + out.Reasons = append(out.Reasons, "moderate pattern: "+re.String()) + } + } + return out +} + +func buildDryRunCommand(command string) (string, bool) { + trimmed := strings.TrimSpace(command) + lower := strings.ToLower(trimmed) + + switch { + case strings.HasPrefix(lower, "apt ") || strings.HasPrefix(lower, "apt-get "): + if strings.Contains(lower, "--dry-run") || strings.Contains(lower, "-s ") { + return trimmed, true + } + return trimmed + " --dry-run", true + case strings.HasPrefix(lower, "yum "): + if strings.Contains(lower, "--assumeno") { + return trimmed, true + } + return trimmed + " --assumeno", true + case strings.HasPrefix(lower, "dnf "): + if strings.Contains(lower, "--assumeno") { + return trimmed, true + } + return trimmed + " --assumeno", true + case strings.HasPrefix(lower, "git clean"): + if strings.Contains(lower, "-n") || strings.Contains(lower, "--dry-run") { + return trimmed, true + } + return trimmed + " -n", true + default: + return "", false + } +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index f8daa78..baddcbe 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -12,6 +12,7 @@ import ( "time" "clawgo/pkg/config" + "clawgo/pkg/logger" ) type ExecTool struct { @@ -22,6 +23,7 @@ type ExecTool struct { restrictToWorkspace bool sandboxEnabled bool sandboxImage string + riskCfg config.RiskConfig } func NewExecTool(cfg config.ShellConfig, workspace string) *ExecTool { @@ -37,6 +39,7 @@ func NewExecTool(cfg config.ShellConfig, workspace string) *ExecTool { restrictToWorkspace: cfg.RestrictPath, sandboxEnabled: cfg.Sandbox.Enabled, sandboxImage: cfg.Sandbox.Image, + riskCfg: cfg.Risk, } } @@ -60,6 +63,10 @@ func (t *ExecTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional working directory for the command", }, + "force": map[string]interface{}{ + "type": "boolean", + "description": "Bypass risk gate for destructive operations (still strongly discouraged).", + }, }, "required": []string{"command"}, } @@ -87,45 +94,20 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st return fmt.Sprintf("Error: %s", guardError), nil } + force, _ := args["force"].(bool) + if blockMsg, dryRunCmd := t.applyRiskGate(command, force); blockMsg != "" { + if dryRunCmd != "" { + dryRunResult, _ := t.executeCommand(ctx, dryRunCmd, cwd) + return fmt.Sprintf("%s\n\nDry-run command: %s\nDry-run output:\n%s", blockMsg, dryRunCmd, dryRunResult), nil + } + return blockMsg, nil + } + if t.sandboxEnabled { return t.executeInSandbox(ctx, command, cwd) } - cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) - defer cancel() - - cmd := exec.CommandContext(cmdCtx, "sh", "-c", command) - if cwd != "" { - cmd.Dir = cwd - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - output := stdout.String() - if stderr.Len() > 0 { - output += "\nSTDERR:\n" + stderr.String() - } - - if err != nil { - if cmdCtx.Err() == context.DeadlineExceeded { - return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil - } - output += fmt.Sprintf("\nExit code: %v", err) - } - - if output == "" { - output = "(no output)" - } - - maxLen := 10000 - if len(output) > maxLen { - output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) - } - - return output, nil + return t.executeCommand(ctx, command, cwd) } func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd string) (string, error) { @@ -235,3 +217,88 @@ func (t *ExecTool) SetAllowPatterns(patterns []string) error { } return nil } + +func (t *ExecTool) applyRiskGate(command string, force bool) (string, string) { + if !t.riskCfg.Enabled { + return "", "" + } + + assessment := assessCommandRisk(command) + logger.InfoCF("risk", "Command risk assessed", map[string]interface{}{ + "level": assessment.Level, + "command": truncateCmd(command, 200), + "reasons": assessment.Reasons, + }) + + if assessment.Level != RiskDestructive { + return "", "" + } + + if t.riskCfg.RequireForceFlag && !force { + msg := "Error: destructive command blocked by risk gate. Re-run with force=true if intentional." + if t.riskCfg.RequireDryRun { + if dryRunCmd, ok := buildDryRunCommand(command); ok { + return msg, dryRunCmd + } + } + return msg, "" + } + + if !t.riskCfg.AllowDestructive { + return "Error: destructive command is disabled by policy (tools.shell.risk.allow_destructive=false).", "" + } + + if t.riskCfg.RequireDryRun { + if dryRunCmd, ok := buildDryRunCommand(command); ok { + return "Risk gate: dry-run required first. Review output, then execute intentionally with force=true.", dryRunCmd + } + } + return "", "" +} + +func (t *ExecTool) executeCommand(ctx context.Context, command, cwd string) (string, error) { + cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, "sh", "-c", command) + if cwd != "" { + cmd.Dir = cwd + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + output := stdout.String() + if stderr.Len() > 0 { + output += "\nSTDERR:\n" + stderr.String() + } + + if err != nil { + if cmdCtx.Err() == context.DeadlineExceeded { + return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil + } + output += fmt.Sprintf("\nExit code: %v", err) + } + + if output == "" { + output = "(no output)" + } + + maxLen := 10000 + if len(output) > maxLen { + output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) + } + return output, nil +} + +func truncateCmd(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} diff --git a/pkg/tools/skill_exec.go b/pkg/tools/skill_exec.go new file mode 100644 index 0000000..9e6b2f3 --- /dev/null +++ b/pkg/tools/skill_exec.go @@ -0,0 +1,138 @@ +package tools + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "clawgo/pkg/config" +) + +type SkillExecTool struct { + workspace string +} + +func NewSkillExecTool(workspace string) *SkillExecTool { + return &SkillExecTool{workspace: workspace} +} + +func (t *SkillExecTool) Name() string { return "skill_exec" } + +func (t *SkillExecTool) Description() string { + return "Execute an atomic skill script from skills//scripts/*. Keeps core agent lean by delegating complex logic to scripts." +} + +func (t *SkillExecTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "skill": map[string]interface{}{ + "type": "string", + "description": "Skill name under workspace/global skills", + }, + "script": map[string]interface{}{ + "type": "string", + "description": "Script path relative to skill root, usually scripts/*.sh|*.py", + }, + "args": map[string]interface{}{ + "type": "array", + "description": "String arguments", + "items": map[string]interface{}{"type": "string"}, + }, + "timeout_sec": map[string]interface{}{ + "type": "integer", + "default": 60, + "description": "Execution timeout in seconds", + }, + }, + "required": []string{"skill", "script"}, + } +} + +func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + skill, _ := args["skill"].(string) + script, _ := args["script"].(string) + if strings.TrimSpace(skill) == "" || strings.TrimSpace(script) == "" { + return "", fmt.Errorf("skill and script are required") + } + + timeoutSec := 60 + if raw, ok := args["timeout_sec"].(float64); ok && raw > 0 { + timeoutSec = int(raw) + } + + skillDir, err := t.resolveSkillDir(skill) + if err != nil { + return "", err + } + + relScript := filepath.Clean(script) + if strings.Contains(relScript, "..") || filepath.IsAbs(relScript) { + return "", fmt.Errorf("script must be relative path inside skill directory") + } + if !strings.HasPrefix(relScript, "scripts"+string(os.PathSeparator)) { + return "", fmt.Errorf("script must be under scripts/ directory") + } + + scriptPath := filepath.Join(skillDir, relScript) + if _, err := os.Stat(scriptPath); err != nil { + return "", fmt.Errorf("script not found: %s", scriptPath) + } + + cmdArgs := []string{} + if rawArgs, ok := args["args"].([]interface{}); ok { + for _, item := range rawArgs { + if s, ok := item.(string); ok { + cmdArgs = append(cmdArgs, s) + } + } + } + + runCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) + defer cancel() + + cmd, err := buildSkillCommand(runCtx, scriptPath, cmdArgs) + if err != nil { + return "", err + } + cmd.Dir = skillDir + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("skill execution failed: %w\n%s", err, string(output)) + } + + out := strings.TrimSpace(string(output)) + if out == "" { + out = "(no output)" + } + return out, nil +} + +func (t *SkillExecTool) resolveSkillDir(skill string) (string, error) { + candidates := []string{ + filepath.Join(t.workspace, "skills", skill), + filepath.Join(config.GetConfigDir(), "skills", skill), + } + for _, dir := range candidates { + if st, err := os.Stat(dir); err == nil && st.IsDir() { + return dir, nil + } + } + return "", fmt.Errorf("skill not found: %s", skill) +} + +func buildSkillCommand(ctx context.Context, scriptPath string, args []string) (*exec.Cmd, error) { + ext := strings.ToLower(filepath.Ext(scriptPath)) + switch ext { + case ".sh": + return exec.CommandContext(ctx, "bash", append([]string{scriptPath}, args...)...), nil + case ".py": + return exec.CommandContext(ctx, "python3", append([]string{scriptPath}, args...)...), nil + default: + return nil, fmt.Errorf("unsupported script extension: %s", ext) + } +} diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index 1bd7ac4..b597e26 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -39,6 +39,18 @@ func (t *SpawnTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional short label for the task (for display)", }, + "role": map[string]interface{}{ + "type": "string", + "description": "Optional role for this subagent, e.g. research/coding/testing", + }, + "pipeline_id": map[string]interface{}{ + "type": "string", + "description": "Optional pipeline ID for orchestrated multi-agent workflow", + }, + "task_id": map[string]interface{}{ + "type": "string", + "description": "Optional task ID under the pipeline", + }, }, "required": []string{"task"}, } @@ -56,12 +68,18 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s } label, _ := args["label"].(string) + role, _ := args["role"].(string) + pipelineID, _ := args["pipeline_id"].(string) + taskID, _ := args["task_id"].(string) + if label == "" && role != "" { + label = role + } if t.manager == nil { return "Error: Subagent manager not configured", nil } - result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID) + result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID, pipelineID, taskID) if err != nil { return "", fmt.Errorf("failed to spawn subagent: %w", err) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 2d98479..a70132d 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -14,6 +14,10 @@ type SubagentTask struct { ID string Task string Label string + Role string + PipelineID string + PipelineTask string + SharedState map[string]interface{} OriginChannel string OriginChatID string Status string @@ -26,22 +30,24 @@ type SubagentManager struct { mu sync.RWMutex provider providers.LLMProvider bus *bus.MessageBus + orc *Orchestrator workspace string nextID int runFunc SubagentRunFunc } -func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *SubagentManager { +func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus, orc *Orchestrator) *SubagentManager { return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, bus: bus, + orc: orc, workspace: workspace, nextID: 1, } } -func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string) (string, error) { +func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID, pipelineID, pipelineTask string) (string, error) { sm.mu.Lock() defer sm.mu.Unlock() @@ -52,6 +58,8 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel ID: taskID, Task: task, Label: label, + PipelineID: pipelineID, + PipelineTask: pipelineTask, OriginChannel: originChannel, OriginChatID: originChatID, Status: "running", @@ -61,20 +69,30 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel go sm.runTask(ctx, subagentTask) + desc := fmt.Sprintf("Spawned subagent for task: %s", task) if label != "" { - return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil + desc = fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task) } - return fmt.Sprintf("Spawned subagent for task: %s", task), nil + if pipelineID != "" && pipelineTask != "" { + desc += fmt.Sprintf(" (pipeline=%s task=%s)", pipelineID, pipelineTask) + } + return desc, nil } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { + sm.mu.Lock() task.Status = "running" task.Created = time.Now().UnixMilli() + sm.mu.Unlock() + + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskRunning(task.PipelineID, task.PipelineTask) + } // 1. 独立 Agent 逻辑:支持递归工具调用 // 这里简单实现:通过共享 AgentLoop 的逻辑来实现 full subagent 能力 // 但目前 subagent.go 不方便反向依赖 agent 包,我们暂时通过 Inject 方式解决 - + // 如果没有注入 RunFunc,则退化为简单的一步 Chat if sm.runFunc != nil { result, err := sm.runFunc(ctx, task.Task, task.OriginChannel, task.OriginChatID) @@ -82,9 +100,15 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { if err != nil { task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, err) + } } else { task.Status = "completed" task.Result = result + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, nil) + } } sm.mu.Unlock() } else { @@ -108,9 +132,15 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { if err != nil { task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, err) + } } else { task.Status = "completed" task.Result = response.Content + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, nil) + } } sm.mu.Unlock() } @@ -122,6 +152,9 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { prefix = fmt.Sprintf("Task '%s' completed", task.Label) } announceContent := fmt.Sprintf("%s.\n\nResult:\n%s", prefix, task.Result) + if task.PipelineID != "" && task.PipelineTask != "" { + announceContent += fmt.Sprintf("\n\nPipeline: %s\nPipeline Task: %s", task.PipelineID, task.PipelineTask) + } sm.bus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("subagent:%s", task.ID), diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 462d83d..69724ac 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -10,6 +10,8 @@ import ( "regexp" "strings" "time" + + "clawgo/pkg/logger" ) const ( @@ -265,12 +267,12 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) } result := map[string]interface{}{ - "url": urlStr, - "status": resp.StatusCode, - "extractor": extractor, - "truncated": truncated, - "length": len(text), - "text": text, + "url": urlStr, + "status": resp.StatusCode, + "extractor": extractor, + "truncated": truncated, + logger.FieldFetchedContentLength: len(text), + "text": text, } resultJSON, _ := json.MarshalIndent(result, "", " ") diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index 6fe23c8..082ddd3 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -45,14 +45,14 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) audioFile, err := os.Open(audioFilePath) if err != nil { - logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err}) + logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, logger.FieldError: err}) return nil, fmt.Errorf("failed to open audio file: %w", err) } defer audioFile.Close() fileInfo, err := audioFile.Stat() if err != nil { - logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err}) + logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, logger.FieldError: err}) return nil, fmt.Errorf("failed to get file info: %w", err) } @@ -66,37 +66,37 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to create form file: %w", err) } copied, err := io.Copy(part, audioFile) if err != nil { - logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to copy file content: %w", err) } logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied}) if err := writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to write model field: %w", err) } if err := writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to write response_format field: %w", err) } if err := writer.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } url := t.apiBase + "/audio/transcriptions" req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to create request: %w", err) } @@ -111,14 +111,14 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) resp, err := t.httpClient.Do(req) if err != nil { - logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to read response: %w", err) } @@ -137,15 +137,15 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) var result TranscriptionResponse if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{logger.FieldError: err}) return nil, fmt.Errorf("failed to unmarshal response: %w", err) } logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{ - "text_length": len(result.Text), - "language": result.Language, - "duration_seconds": result.Duration, - "transcription_preview": truncateText(result.Text, 50), + logger.FieldTranscriptLength: len(result.Text), + "language": result.Language, + "duration_seconds": result.Duration, + "transcription_preview": truncateText(result.Text, 50), }) return &result, nil