This commit is contained in:
lpf
2026-02-19 10:00:59 +08:00
parent 27c718def9
commit 1f38852f49
14 changed files with 201 additions and 148 deletions

View File

@@ -145,7 +145,7 @@ func main() {
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
// 获取全局配置目录和内置 skills 目录
// Get global config directory and built-in skills directory
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "clawgo", "skills")
@@ -1779,7 +1779,7 @@ func skillsCmd() {
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
// 获取全局配置目录和内置 skills 目录
// Get global config directory and built-in skills directory
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "clawgo", "skills")

View File

@@ -30,8 +30,8 @@ func getGlobalConfigDir() string {
}
func NewContextBuilder(workspace string, memCfg config.MemoryConfig, toolsSummaryFunc func() []string) *ContextBuilder {
// builtin skills: 当前项目的 skills 目录
// 使用当前工作目录下的 skills/ 目录
// Built-in skills: the current project's skills directory.
// Use the skills/ directory under the current working directory.
wd, _ := os.Getwd()
builtinSkillsDir := filepath.Join(wd, "skills")
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")

View File

@@ -129,6 +129,7 @@ type taskExecutionDirectivesLLMResponse struct {
type stageReporter struct {
onUpdate func(content string)
localize func(content string) string
}
type StartupSelfCheckReport struct {
@@ -140,17 +141,38 @@ func (sr *stageReporter) Publish(stage int, total int, status string, detail str
if sr == nil || sr.onUpdate == nil {
return
}
_ = stage
_ = total
detail = strings.TrimSpace(detail)
if detail == "" {
detail = "-"
if detail != "" {
if sr.localize != nil {
detail = sr.localize(detail)
}
sr.onUpdate(detail)
return
}
status = strings.TrimSpace(status)
if status == "" {
status = "进度更新"
if status != "" {
if sr.localize != nil {
status = sr.localize(status)
}
sr.onUpdate(status)
return
}
sr.onUpdate(fmt.Sprintf("[进度] %s%s", status, detail))
fallback := "Processing update"
if sr.localize != nil {
fallback = sr.localize(fallback)
}
sr.onUpdate(fallback)
}
type userLanguageHint struct {
sessionKey string
content string
}
type userLanguageHintKey struct{}
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, cs *cron.CronService) *AgentLoop {
workspace := cfg.WorkspacePath()
os.MkdirAll(workspace, 0755)
@@ -267,9 +289,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
autonomyBySess: make(map[string]*autonomySession),
}
// 注入递归运行逻辑,使 subagent 具备 full tool-calling 能力
// Inject recursive run logic so subagent has full tool-calling capability.
subagentManager.SetRunFunc(func(ctx context.Context, task, channel, chatID string) (string, error) {
sessionKey := fmt.Sprintf("subagent:%d", os.Getpid()) // 改用 PID 或随机数,避免 sessionKey 冲突
sessionKey := fmt.Sprintf("subagent:%d", os.Getpid()) // Use PID/random value to avoid session key collisions.
return loop.ProcessDirect(ctx, task, sessionKey)
})
@@ -345,7 +367,7 @@ func (al *AgentLoop) enqueueMessage(ctx context.Context, msg bus.InboundMessage)
Buttons: nil,
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: al.naturalizeUserFacingText(ctx, "消息队列当前较忙,请稍后再试。"),
Content: al.localizeUserFacingText(ctx, msg.SessionKey, msg.Content, "The message queue is currently busy. Please try again shortly."),
})
}
}
@@ -403,7 +425,7 @@ func (al *AgentLoop) runSessionWorker(ctx context.Context, sessionKey string, wo
if errors.Is(err, context.Canceled) {
return
}
response = al.formatProcessingErrorMessage(msg, err)
response = al.formatProcessingErrorMessage(taskCtx, msg, err)
}
if response != "" && shouldPublishSyntheticResponse(msg) {
@@ -425,11 +447,8 @@ func (al *AgentLoop) clearWorkerCancel(worker *sessionWorker) {
worker.cancelMu.Unlock()
}
func (al *AgentLoop) formatProcessingErrorMessage(msg bus.InboundMessage, err error) string {
if al.preferChineseUserFacingText(msg.SessionKey, msg.Content) {
return err.Error()
}
return err.Error()
func (al *AgentLoop) formatProcessingErrorMessage(ctx context.Context, msg bus.InboundMessage, err error) string {
return al.localizeUserFacingText(ctx, msg.SessionKey, msg.Content, fmt.Sprintf("Error processing message: %v", err))
}
func (al *AgentLoop) preferChineseUserFacingText(sessionKey, currentContent string) bool {
@@ -520,7 +539,7 @@ func (al *AgentLoop) stopAllAutonomySessions() {
func (al *AgentLoop) startAutonomy(ctx context.Context, msg bus.InboundMessage, idleInterval time.Duration, focus string) string {
if msg.Channel == "cli" {
return al.naturalizeUserFacingText(ctx, "自主模式需要在 gateway 运行模式下使用(持续消息循环)。")
return al.naturalizeUserFacingText(ctx, "Autonomy mode requires gateway runtime mode (continuous message loop).")
}
if idleInterval <= 0 {
@@ -538,7 +557,8 @@ func (al *AgentLoop) startAutonomy(ctx context.Context, msg bus.InboundMessage,
delete(al.autonomyBySess, msg.SessionKey)
}
sessionCtx, cancel := context.WithCancel(context.Background())
langCtx := withUserLanguageHint(context.Background(), msg.SessionKey, msg.Content)
sessionCtx, cancel := context.WithCancel(langCtx)
s := &autonomySession{
cancel: cancel,
started: time.Now(),
@@ -566,9 +586,9 @@ func (al *AgentLoop) startAutonomy(ctx context.Context, msg bus.InboundMessage,
})
}
if s.focus != "" {
return al.naturalizeUserFacingText(ctx, fmt.Sprintf("自主模式已开启,当前研究方向:%s。系统会持续静默推进并每 %s 汇报一次进度或结果。", s.focus, idleInterval.Truncate(time.Second)))
return al.naturalizeUserFacingText(ctx, fmt.Sprintf("Autonomy mode is enabled. Current focus: %s. The system will continue in the background and report progress or results every %s.", s.focus, idleInterval.Truncate(time.Second)))
}
return al.naturalizeUserFacingText(ctx, fmt.Sprintf("自主模式已开启:自动拆解执行 + 静默推进;每 %s 汇报一次进度或结果。", idleInterval.Truncate(time.Second)))
return al.naturalizeUserFacingText(ctx, fmt.Sprintf("Autonomy mode is enabled: automatic decomposition + background execution; reports progress or results every %s.", idleInterval.Truncate(time.Second)))
}
func (al *AgentLoop) stopAutonomy(sessionKey string) bool {
@@ -625,21 +645,21 @@ func (al *AgentLoop) autonomyStatus(ctx context.Context, sessionKey string) stri
s, ok := al.autonomyBySess[sessionKey]
if !ok || s == nil {
return al.naturalizeUserFacingText(ctx, "自主模式未开启。")
return al.naturalizeUserFacingText(ctx, "Autonomy mode is not enabled.")
}
uptime := time.Since(s.started).Truncate(time.Second)
idle := time.Since(s.lastUserAt).Truncate(time.Second)
focus := strings.TrimSpace(s.focus)
if focus == "" {
focus = "未设置"
focus = "not set"
}
fallback := fmt.Sprintf("自主模式运行中:汇报周期 %s已运行 %s最近用户活跃距今 %s自动推进 %d 轮。",
fallback := fmt.Sprintf("Autonomy mode is running: report interval %s, uptime %s, time since last user activity %s, automatic rounds %d.",
s.idleInterval.Truncate(time.Second),
uptime,
idle,
s.rounds,
) + fmt.Sprintf(" 当前研究方向:%s", focus)
) + fmt.Sprintf(" Current focus: %s.", focus)
return al.naturalizeUserFacingText(ctx, fallback)
}
@@ -713,25 +733,25 @@ func (al *AgentLoop) finishAutonomyRound(sessionKey string) {
func buildAutonomyFollowUpPrompt(round int, focus string, reportDue bool) string {
focus = strings.TrimSpace(focus)
if focus == "" && reportDue {
return fmt.Sprintf("自主模式第 %d 轮推进:用户暂时未继续输入。请基于当前会话上下文和已完成工作,自主完成一个高价值下一步,并用自然语言给出进度或结果。", round)
return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Based on the current session context and completed work, autonomously complete one high-value next step and report progress or results in natural language.", round)
}
if focus == "" && !reportDue {
return fmt.Sprintf("自主模式第 %d 轮推进:用户暂时未继续输入。请基于当前会话上下文和已完成工作,自主完成一个高价值下一步。本轮仅执行,不对外回复。", round)
return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Based on the current session context and completed work, autonomously complete one high-value next step. This round is execution-only; do not send an external reply.", round)
}
if reportDue {
return fmt.Sprintf("自主模式第 %d 轮推进:用户暂时未继续输入。请优先围绕研究方向“%s”推进如果该方向已完成请说明并转向其他高价值下一步。完成后用自然语言给出进度或结果。", round, focus)
return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Prioritize progress around the focus \"%s\"; if that focus is complete, explain and move to another high-value next step. After completion, report progress or results in natural language.", round, focus)
}
return fmt.Sprintf("自主模式第 %d 轮推进:用户暂时未继续输入。请优先围绕研究方向“%s”推进如果该方向已完成请说明并转向其他高价值下一步。本轮仅执行不对外回复。", round, focus)
return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Prioritize progress around the focus \"%s\"; if that focus is complete, explain and move to another high-value next step. This round is execution-only; do not send an external reply.", round, focus)
}
func buildAutonomyFocusPrompt(focus string) string {
focus = strings.TrimSpace(focus)
return fmt.Sprintf("自主模式已启动,本轮请优先围绕研究方向“%s”展开先明确本轮目标再执行并汇报阶段性进展与结果。", focus)
return fmt.Sprintf("Autonomy mode started. For this round, prioritize the focus \"%s\": clarify the round goal first, then execute and report progress and results.", focus)
}
func (al *AgentLoop) startAutoLearner(ctx context.Context, msg bus.InboundMessage, interval time.Duration) string {
if msg.Channel == "cli" {
return al.naturalizeUserFacingText(ctx, "自动学习需要在 gateway 运行模式下使用(持续消息循环)。")
return al.naturalizeUserFacingText(ctx, "Auto-learn requires gateway runtime mode (continuous message loop).")
}
if interval <= 0 {
@@ -749,7 +769,8 @@ func (al *AgentLoop) startAutoLearner(ctx context.Context, msg bus.InboundMessag
delete(al.autoLearners, msg.SessionKey)
}
learnerCtx, cancel := context.WithCancel(context.Background())
langCtx := withUserLanguageHint(context.Background(), msg.SessionKey, msg.Content)
learnerCtx, cancel := context.WithCancel(langCtx)
learner := &autoLearner{
cancel: cancel,
started: time.Now(),
@@ -760,7 +781,7 @@ func (al *AgentLoop) startAutoLearner(ctx context.Context, msg bus.InboundMessag
go al.runAutoLearnerLoop(learnerCtx, msg)
return al.naturalizeUserFacingText(ctx, fmt.Sprintf("自动学习已开启:每 %s 执行 1 轮。使用 /autolearn stop 可停止。", interval.Truncate(time.Second)))
return al.naturalizeUserFacingText(ctx, fmt.Sprintf("Auto-learn is enabled: one round every %s. Use /autolearn stop to stop it.", interval.Truncate(time.Second)))
}
func (al *AgentLoop) runAutoLearnerLoop(ctx context.Context, msg bus.InboundMessage) {
@@ -773,7 +794,7 @@ func (al *AgentLoop) runAutoLearnerLoop(ctx context.Context, msg bus.InboundMess
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: al.naturalizeUserFacingText(ctx, fmt.Sprintf("自动学习第 %d 轮开始。", round)),
Content: al.naturalizeUserFacingText(ctx, fmt.Sprintf("Auto-learn round %d started.", round)),
})
al.bus.PublishInbound(bus.InboundMessage{
@@ -853,11 +874,11 @@ func (al *AgentLoop) autoLearnerStatus(ctx context.Context, sessionKey string) s
learner, ok := al.autoLearners[sessionKey]
if !ok || learner == nil {
return al.naturalizeUserFacingText(ctx, "自动学习未开启。使用 /autolearn start 可开启。")
return al.naturalizeUserFacingText(ctx, "Auto-learn is not enabled. Use /autolearn start to enable it.")
}
uptime := time.Since(learner.started).Truncate(time.Second)
fallback := fmt.Sprintf("自动学习运行中:每 %s 一轮,已运行 %s累计 %d 轮。",
fallback := fmt.Sprintf("Auto-learn is running: one round every %s, uptime %s, total rounds %d.",
learner.interval.Truncate(time.Second),
uptime,
learner.rounds,
@@ -866,11 +887,11 @@ func (al *AgentLoop) autoLearnerStatus(ctx context.Context, sessionKey string) s
}
func buildAutoLearnPrompt(round int) string {
return fmt.Sprintf("自动学习模式第 %d 轮无需用户下发任务。请基于当前会话与项目上下文自主选择一个高价值的小任务并完成。要求1) 明确本轮学习目标2) 必要时调用工具执行3) 将关键结论写入 memory/MEMORY.md4) 输出简短进展报告。", round)
return fmt.Sprintf("Auto-learn round %d: no user task is required. Based on current session and project context, choose and complete one high-value small task autonomously. Requirements: 1) define the learning goal for this round; 2) call tools when needed; 3) write key conclusions to memory/MEMORY.md; 4) output a concise progress report.", round)
}
func buildAutonomyTaskPrompt(task string) string {
return fmt.Sprintf("开启自主执行策略。请直接推进任务并在关键节点自然汇报进展,最后给出结果与下一步建议。\n\n用户任务%s", strings.TrimSpace(task))
return fmt.Sprintf("Enable autonomous execution strategy. Proceed with the task directly, report progress naturally at key points, and finally provide results plus next-step suggestions.\n\nUser task: %s", strings.TrimSpace(task))
}
func isSyntheticMessage(msg bus.InboundMessage) bool {
@@ -898,21 +919,47 @@ func shouldHandleControlIntents(msg bus.InboundMessage) bool {
return !isSyntheticMessage(msg)
}
func withUserLanguageHint(ctx context.Context, sessionKey, content string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, userLanguageHintKey{}, userLanguageHint{
sessionKey: strings.TrimSpace(sessionKey),
content: content,
})
}
func (al *AgentLoop) localizeUserFacingText(ctx context.Context, sessionKey, currentContent, fallback string) string {
return al.naturalizeUserFacingText(withUserLanguageHint(ctx, sessionKey, currentContent), fallback)
}
func (al *AgentLoop) naturalizeUserFacingText(ctx context.Context, fallback string) string {
text := strings.TrimSpace(fallback)
if text == "" || ctx == nil {
return fallback
}
if al == nil || (al.provider == nil && len(al.providersByProxy) == 0) {
return fallback
}
targetLanguage := "English"
if al != nil {
if hint, ok := ctx.Value(userLanguageHintKey{}).(userLanguageHint); ok {
if al.preferChineseUserFacingText(hint.sessionKey, hint.content) {
targetLanguage = "Simplified Chinese"
}
}
}
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
systemPrompt := al.withBootstrapPolicy(`You rewrite assistant control/status replies in natural conversational Chinese.
systemPrompt := al.withBootstrapPolicy(fmt.Sprintf(`You rewrite assistant control/status replies in natural conversational %s.
Rules:
- Keep factual meaning unchanged.
- Use concise natural wording, no rigid templates.
- No markdown, no code block, no extra explanation.
- Return plain text only.`)
- Return plain text only.`, targetLanguage))
resp, err := al.callLLMWithModelFallback(llmCtx, []providers.Message{
{Role: "system", Content: systemPrompt},
@@ -958,6 +1005,8 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
ctx = withUserLanguageHint(ctx, msg.SessionKey, msg.Content)
// Add message preview to log
preview := truncate(msg.Content, 80)
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview),
@@ -1001,14 +1050,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.startAutonomy(ctx, msg, idle, intent.focus), nil
case "clear_focus":
if al.clearAutonomyFocus(msg.SessionKey) {
return al.naturalizeUserFacingText(ctx, "已确认:当前研究方向已完成,后续自主推进将转向其他高价值任务。"), nil
return al.naturalizeUserFacingText(ctx, "Confirmed: the current focus is complete. Subsequent autonomous rounds will shift to other high-value tasks."), nil
}
return al.naturalizeUserFacingText(ctx, "自主模式当前未运行,无法清空研究方向。"), nil
return al.naturalizeUserFacingText(ctx, "Autonomy mode is not running, so the focus cannot be cleared."), nil
case "stop":
if al.stopAutonomy(msg.SessionKey) {
return al.naturalizeUserFacingText(ctx, "自主模式已关闭。"), nil
return al.naturalizeUserFacingText(ctx, "Autonomy mode stopped."), nil
}
return al.naturalizeUserFacingText(ctx, "自主模式当前未运行。"), nil
return al.naturalizeUserFacingText(ctx, "Autonomy mode is not running."), nil
case "status":
return al.autonomyStatus(ctx, msg.SessionKey), nil
}
@@ -1024,9 +1073,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.startAutoLearner(ctx, msg, interval), nil
case "stop":
if al.stopAutoLearner(msg.SessionKey) {
return al.naturalizeUserFacingText(ctx, "自动学习已停止。"), nil
return al.naturalizeUserFacingText(ctx, "Auto-learn stopped."), nil
}
return al.naturalizeUserFacingText(ctx, "自动学习当前未运行。"), nil
return al.naturalizeUserFacingText(ctx, "Auto-learn is not running."), nil
case "status":
return al.autoLearnerStatus(ctx, msg.SessionKey), nil
}
@@ -1056,6 +1105,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
var progress *stageReporter
if directives.stageReport {
progress = &stageReporter{
localize: func(content string) string {
return al.localizeUserFacingText(ctx, msg.SessionKey, msg.Content, content)
},
onUpdate: func(content string) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
@@ -1064,8 +1116,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
},
}
progress.Publish(1, 5, "开始", "已接收任务")
progress.Publish(2, 5, "分析", "正在构建上下文")
progress.Publish(1, 5, "start", "I received your task and will clarify the goal and constraints first.")
progress.Publish(2, 5, "analysis", "I am building the context needed for execution.")
}
// Update tool contexts
@@ -1093,13 +1145,13 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
)
if progress != nil {
progress.Publish(3, 5, "执行", "正在运行任务")
progress.Publish(3, 5, "execution", "I am starting step-by-step execution.")
}
finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, msg.SessionKey, false, progress)
if err != nil {
if progress != nil {
progress.Publish(5, 5, "失败", err.Error())
progress.Publish(5, 5, "failure", err.Error())
}
return "", err
}
@@ -1124,7 +1176,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
// 使用 AddMessageFull 存储包含思考过程或工具调用的完整助手消息
// Use AddMessageFull to persist the complete assistant message, including thoughts/tool calls.
al.sessions.AddMessageFull(msg.SessionKey, providers.Message{
Role: "assistant",
Content: userContent,
@@ -1147,8 +1199,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
if progress != nil {
progress.Publish(4, 5, "收敛", "已生成最终回复")
progress.Publish(5, 5, "完成", fmt.Sprintf("任务执行结束(迭代 %d", iteration))
progress.Publish(4, 5, "finalization", "Final response is ready.")
progress.Publish(5, 5, "done", fmt.Sprintf("Completed after %d iterations.", iteration))
}
return userContent, nil
@@ -1216,9 +1268,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Save to session with system message marker
al.sessions.AddMessage(sessionKey, "user", fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content))
// 如果 finalContent 中没有包含 tool calls (即最后一次 LLM 返回的结果)
// 我们已经通过循环内部的 AddMessageFull 存储了前面的步骤
// 这里的 AddMessageFull 会存储最终回复
// If finalContent has no tool calls (i.e., the final LLM output),
// earlier steps were already stored via AddMessageFull in the loop.
// This AddMessageFull stores the final reply.
al.sessions.AddMessageFull(sessionKey, providers.Message{
Role: "assistant",
Content: finalContent,
@@ -1256,7 +1308,7 @@ func (al *AgentLoop) runLLMToolLoop(
for iteration < al.maxIterations {
iteration++
if progress != nil {
progress.Publish(3, 5, "执行", fmt.Sprintf("第 %d 轮推理", iteration))
progress.Publish(3, 5, "execution", fmt.Sprintf("Running iteration %d.", iteration))
}
if !systemMode {
@@ -1387,9 +1439,9 @@ func (al *AgentLoop) runLLMToolLoop(
}
if progress != nil {
if err != nil {
progress.Publish(3, 5, "执行", fmt.Sprintf("工具 %s 失败: %v", tc.Name, err))
progress.Publish(3, 5, "execution", fmt.Sprintf("Tool %s failed: %v", tc.Name, err))
} else {
progress.Publish(3, 5, "执行", fmt.Sprintf("工具 %s 完成", tc.Name))
progress.Publish(3, 5, "execution", fmt.Sprintf("Tool %s completed.", tc.Name))
}
}
lastToolResult = result
@@ -1596,7 +1648,7 @@ func (al *AgentLoop) RunStartupSelfCheckAllSessions(ctx context.Context) Startup
}
report.TotalSessions = len(sessions)
// 启动阶段只做历史会话压缩检测,避免额外触发自检任务。
// During startup, only run historical-session compaction checks to avoid extra self-check tasks.
for _, sessionKey := range sessions {
select {
case <-ctx.Done():
@@ -2627,7 +2679,7 @@ func (al *AgentLoop) handleSlashCommand(ctx context.Context, msg bus.InboundMess
), nil
case "/autolearn":
if len(fields) < 2 {
return true, al.naturalizeUserFacingText(ctx, "用法:/autolearn start [interval] | /autolearn stop | /autolearn status"), nil
return true, al.naturalizeUserFacingText(ctx, "Usage: /autolearn start [interval] | /autolearn stop | /autolearn status"), nil
}
switch strings.ToLower(fields[1]) {
case "start":
@@ -2642,17 +2694,17 @@ func (al *AgentLoop) handleSlashCommand(ctx context.Context, msg bus.InboundMess
return true, al.startAutoLearner(ctx, msg, interval), nil
case "stop":
if al.stopAutoLearner(msg.SessionKey) {
return true, al.naturalizeUserFacingText(ctx, "自动学习已停止。"), nil
return true, al.naturalizeUserFacingText(ctx, "Auto-learn stopped."), nil
}
return true, al.naturalizeUserFacingText(ctx, "自动学习当前未运行。"), nil
return true, al.naturalizeUserFacingText(ctx, "Auto-learn is not running."), nil
case "status":
return true, al.autoLearnerStatus(ctx, msg.SessionKey), nil
default:
return true, al.naturalizeUserFacingText(ctx, "用法:/autolearn start [interval] | /autolearn stop | /autolearn status"), nil
return true, al.naturalizeUserFacingText(ctx, "Usage: /autolearn start [interval] | /autolearn stop | /autolearn status"), nil
}
case "/autonomy":
if len(fields) < 2 {
return true, al.naturalizeUserFacingText(ctx, "用法:/autonomy start [idle] | /autonomy stop | /autonomy status"), nil
return true, al.naturalizeUserFacingText(ctx, "Usage: /autonomy start [idle] | /autonomy stop | /autonomy status"), nil
}
switch strings.ToLower(fields[1]) {
case "start":
@@ -2672,13 +2724,13 @@ func (al *AgentLoop) handleSlashCommand(ctx context.Context, msg bus.InboundMess
return true, al.startAutonomy(ctx, msg, idle, focus), nil
case "stop":
if al.stopAutonomy(msg.SessionKey) {
return true, al.naturalizeUserFacingText(ctx, "自主模式已关闭。"), nil
return true, al.naturalizeUserFacingText(ctx, "Autonomy mode stopped."), nil
}
return true, al.naturalizeUserFacingText(ctx, "自主模式当前未运行。"), nil
return true, al.naturalizeUserFacingText(ctx, "Autonomy mode is not running."), nil
case "status":
return true, al.autonomyStatus(ctx, msg.SessionKey), nil
default:
return true, al.naturalizeUserFacingText(ctx, "用法:/autonomy start [idle] | /autonomy stop | /autonomy status"), nil
return true, al.naturalizeUserFacingText(ctx, "Usage: /autonomy start [idle] | /autonomy stop | /autonomy status"), nil
}
case "/reload":
running, err := al.triggerGatewayReloadFromAgent()

View File

@@ -8,8 +8,8 @@ import (
)
func TestParseTaskExecutionDirectives_RunCommand(t *testing.T) {
d := parseTaskExecutionDirectives("/run 修复构建脚本 --stage-report")
if d.task != "修复构建脚本" {
d := parseTaskExecutionDirectives("/run fix build script --stage-report")
if d.task != "fix build script" {
t.Fatalf("unexpected task: %q", d.task)
}
if !d.stageReport {
@@ -18,8 +18,8 @@ func TestParseTaskExecutionDirectives_RunCommand(t *testing.T) {
}
func TestParseTaskExecutionDirectives_Default(t *testing.T) {
d := parseTaskExecutionDirectives("帮我看看今天的日志异常")
if d.task != "帮我看看今天的日志异常" {
d := parseTaskExecutionDirectives("Please check today's log anomalies")
if d.task != "Please check today's log anomalies" {
t.Fatalf("unexpected task: %q", d.task)
}
if d.stageReport {
@@ -75,7 +75,7 @@ func TestParseAutoLearnIntent_StopFallbackCommand(t *testing.T) {
}
func TestParseAutoLearnIntent_NoNaturalLanguageFallback(t *testing.T) {
if _, ok := parseAutoLearnIntent("请开始自动学习"); ok {
if _, ok := parseAutoLearnIntent("please start auto learning"); ok {
t.Fatalf("expected no fallback match")
}
}
@@ -127,7 +127,7 @@ func TestParseAutonomyIdleInterval(t *testing.T) {
}
func TestParseAutonomyIntent_NoNaturalLanguageFallback(t *testing.T) {
if intent, ok := parseAutonomyIntent("请自动执行这个任务"); ok {
if intent, ok := parseAutonomyIntent("please run this task automatically"); ok {
t.Fatalf("expected no intent, got: %+v", intent)
}
}
@@ -158,7 +158,7 @@ func TestExtractJSONObject_Invalid(t *testing.T) {
func TestShouldHandleControlIntents_UserMessage(t *testing.T) {
msg := bus.InboundMessage{
SenderID: "user",
Content: "请进入自主模式",
Content: "please enter autonomy mode",
}
if !shouldHandleControlIntents(msg) {
t.Fatalf("expected user message to be control-eligible")
@@ -168,7 +168,7 @@ func TestShouldHandleControlIntents_UserMessage(t *testing.T) {
func TestShouldHandleControlIntents_AutonomySyntheticSender(t *testing.T) {
msg := bus.InboundMessage{
SenderID: "autonomy",
Content: "自主模式第 1 轮推进",
Content: "autonomy round 1",
}
if shouldHandleControlIntents(msg) {
t.Fatalf("expected autonomy synthetic message to be ignored for control intents")
@@ -178,7 +178,7 @@ func TestShouldHandleControlIntents_AutonomySyntheticSender(t *testing.T) {
func TestShouldHandleControlIntents_AutoLearnSyntheticMetadata(t *testing.T) {
msg := bus.InboundMessage{
SenderID: "gateway",
Content: "自动学习第 1 轮",
Content: "auto-learn round 1",
Metadata: map[string]string{
"source": "autolearn",
},

View File

@@ -1,24 +1,24 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"clawgo/pkg/bus"
"clawgo/pkg/session"
)
func TestFormatProcessingErrorMessage_ChineseCurrentMessage(t *testing.T) {
func TestFormatProcessingErrorMessage_CurrentMessage(t *testing.T) {
al := &AgentLoop{}
msg := bus.InboundMessage{
SessionKey: "s-zh-current",
Content: "请帮我看一下这个错误",
SessionKey: "s-current",
Content: "Please help check this error",
}
out := al.formatProcessingErrorMessage(msg, errors.New("boom"))
if !strings.HasPrefix(out, "处理消息时发生错误:") {
t.Fatalf("expected Chinese error prefix, got %q", out)
out := al.formatProcessingErrorMessage(context.Background(), msg, errors.New("boom"))
if out != "Error processing message: boom" {
t.Fatalf("expected formatted error message, got %q", out)
}
}
@@ -29,15 +29,15 @@ func TestFormatProcessingErrorMessage_EnglishCurrentMessage(t *testing.T) {
Content: "Please help debug this issue",
}
out := al.formatProcessingErrorMessage(msg, errors.New("boom"))
if !strings.HasPrefix(out, "Error processing message:") {
t.Fatalf("expected English error prefix, got %q", out)
out := al.formatProcessingErrorMessage(context.Background(), msg, errors.New("boom"))
if out != "Error processing message: boom" {
t.Fatalf("expected formatted error message, got %q", out)
}
}
func TestFormatProcessingErrorMessage_UsesSessionHistoryLanguage(t *testing.T) {
func TestFormatProcessingErrorMessage_UsesSessionHistory(t *testing.T) {
sm := session.NewSessionManager(t.TempDir())
sm.AddMessage("s-history", "user", "请继续,按这个方向修复")
sm.AddMessage("s-history", "user", "Please continue fixing in this direction")
al := &AgentLoop{sessions: sm}
msg := bus.InboundMessage{
@@ -45,8 +45,8 @@ func TestFormatProcessingErrorMessage_UsesSessionHistoryLanguage(t *testing.T) {
Content: "ok",
}
out := al.formatProcessingErrorMessage(msg, errors.New("boom"))
if !strings.HasPrefix(out, "处理消息时发生错误:") {
t.Fatalf("expected Chinese error prefix from session history, got %q", out)
out := al.formatProcessingErrorMessage(context.Background(), msg, errors.New("boom"))
if out != "Error processing message: boom" {
t.Fatalf("expected formatted error message from session history, got %q", out)
}
}

View File

@@ -82,7 +82,7 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
return
}
// 生成 SessionKey: channel:chatID
// Build session key: channel:chatID
sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
msg := bus.InboundMessage{

View File

@@ -15,7 +15,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
)
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
// DingTalkChannel implements the Channel interface for DingTalk.
// It uses WebSocket for receiving messages via stream mode and API for sending
type DingTalkChannel struct {
*BaseChannel

View File

@@ -49,32 +49,32 @@ func (c *QQChannel) Start(ctx context.Context) error {
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// 创建 token source
// Create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret,
}
c.tokenSource = token.NewQQBotTokenSource(credentials)
// 创建子 context
// Create child context
runCtx, cancel := context.WithCancel(ctx)
c.runCancel.set(cancel)
// 启动自动刷新 token 协程
// Start token auto-refresh goroutine
if err := token.StartRefreshAccessToken(runCtx, c.tokenSource); err != nil {
return fmt.Errorf("failed to start token refresh: %w", err)
}
// 初始化 OpenAPI 客户端
// Initialize OpenAPI client
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
// 注册事件处理器
// Register event handlers
intent := event.RegisterHandlers(
c.handleC2CMessage(),
c.handleGroupATMessage(),
)
// 获取 WebSocket 接入点
// Get WebSocket endpoint
wsInfo, err := c.api.WS(runCtx, nil, "")
if err != nil {
return fmt.Errorf("failed to get websocket info: %w", err)
@@ -84,10 +84,10 @@ func (c *QQChannel) Start(ctx context.Context) error {
"shards": wsInfo.Shards,
})
// 创建并保存 sessionManager
// Create and store session manager
c.sessionManager = botgo.NewSessionManager()
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
// Start WebSocket connection in a goroutine to avoid blocking
runChannelTask("qq", "websocket session", func() error {
return c.sessionManager.Start(wsInfo, c.tokenSource, &intent)
}, func(_ error) {
@@ -116,12 +116,12 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return fmt.Errorf("QQ bot not running")
}
// 构造消息
// Build message
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
}
// C2C 消息发送
// Send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
@@ -133,15 +133,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return nil
}
// handleC2CMessage 处理 QQ 私聊消息
// handleC2CMessage handles QQ private messages
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
// 去重检查
// Deduplication check
if c.isDuplicate(data.ID) {
return nil
}
// 提取用户信息
// Extract sender information
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -150,7 +150,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
// 提取消息内容
// Extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
@@ -163,7 +163,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
logger.FieldMessageContentLength: len(content),
})
// 转发到消息总线
// Forward to message bus
metadata := map[string]string{
"message_id": data.ID,
}
@@ -174,15 +174,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
}
}
// handleGroupATMessage 处理群@消息
// handleGroupATMessage handles group @ messages
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// 去重检查
// Deduplication check
if c.isDuplicate(data.ID) {
return nil
}
// 提取用户信息
// Extract sender information
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -191,7 +191,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
// 提取消息内容(去掉 @ 机器人部分)
// Extract message content (remove bot mention)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
@@ -204,7 +204,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
logger.FieldMessageContentLength: len(content),
})
// 转发到消息总线(使用 GroupID 作为 ChatID
// Forward to message bus (use GroupID as ChatID)
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
@@ -216,7 +216,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
// isDuplicate 检查消息是否重复
// isDuplicate checks whether a message is duplicated
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -227,9 +227,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
c.processedIDs[messageID] = true
// 简单清理:限制 map 大小
// Simple cleanup: limit map size
if len(c.processedIDs) > 10000 {
// 清空一半
// Remove half of the entries
count := 0
for id := range c.processedIDs {
if count >= 5000 {

View File

@@ -6,8 +6,8 @@ import (
"time"
)
// SimpleCronParser 实现了一个极简的 Cron 表达式解析器 (minute hour day month dayOfWeek)
// 借鉴 goclaw 可能使用的标准 cron 逻辑,补全 ClawGo 缺失的 Expr 调度能力
// SimpleCronParser is a minimal cron expression parser (minute hour day month dayOfWeek).
// It approximates standard cron behavior to provide missing Expr scheduling support.
type SimpleCronParser struct {
expr string
}
@@ -19,14 +19,15 @@ func NewSimpleCronParser(expr string) *SimpleCronParser {
func (p *SimpleCronParser) Next(from time.Time) time.Time {
fields := strings.Fields(p.expr)
if len(fields) != 5 {
return time.Time{} // 格式错误
return time.Time{} // Invalid format
}
// 这是一个极简实现,仅支持 * 和 数字
// 生产环境下建议引入 github.com/robfig/cron/v3
// This minimal implementation only supports "*" and exact numbers.
// For production, use github.com/robfig/cron/v3.
next := from.Add(1 * time.Minute).Truncate(time.Minute)
// 这里逻辑简化:如果不是 * 且不匹配,则递增直到匹配 (最大搜索 1 年)
// Simplified logic: if it is not "*" and does not match, keep incrementing until matched
// (up to one year of search).
for i := 0; i < 525600; i++ {
if p.match(next, fields) {
return next

View File

@@ -92,7 +92,7 @@ func (sm *SessionManager) AddMessageFull(sessionKey string, msg providers.Messag
session.Updated = time.Now()
session.mu.Unlock()
// 立即持久化 (Append-only)
// Persist immediately (append-only)
if err := sm.appendMessage(sessionKey, msg); err != nil {
logger.ErrorCF("session", "Failed to persist session message", map[string]interface{}{
"session_key": sessionKey,
@@ -277,8 +277,8 @@ func (sm *SessionManager) CompactHistory(key, summary string, keepLast int) (int
}
func (sm *SessionManager) Save(session *Session) error {
// 现已通过 AddMessageFull 实时增量持久化
// 这里保留 Save 方法用于更新 Summary 等元数据
// Messages are now persisted incrementally via AddMessageFull.
// Keep Save for summary and other metadata updates.
if sm.storage == "" {
return nil
}
@@ -307,7 +307,7 @@ func (sm *SessionManager) loadSessions() error {
continue
}
// 处理 JSONL 历史消息
// Load JSONL history messages
if filepath.Ext(file.Name()) == ".jsonl" {
sessionKey := strings.TrimSuffix(file.Name(), ".jsonl")
session := sm.GetOrCreate(sessionKey)
@@ -334,7 +334,7 @@ func (sm *SessionManager) loadSessions() error {
_ = f.Close()
}
// 处理元数据
// Load metadata
if filepath.Ext(file.Name()) == ".meta" {
sessionKey := strings.TrimSuffix(file.Name(), ".meta")
session := sm.GetOrCreate(sessionKey)

View File

@@ -23,9 +23,9 @@ type SkillInfo struct {
type SkillsLoader struct {
workspace string
workspaceSkills string // workspace skills (项目级别)
globalSkills string // 全局 skills (~/.clawgo/skills)
builtinSkills string // 内置 skills
workspaceSkills string // workspace-level skills
globalSkills string // global skills (~/.clawgo/skills)
builtinSkills string // built-in skills
}
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
@@ -62,14 +62,14 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
}
}
// 全局 skills (~/.clawgo/skills) - workspace skills 覆盖
// Global skills (~/.clawgo/skills) - overridden by workspace skills
if sl.globalSkills != "" {
if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
for _, dir := range dirs {
if dir.IsDir() {
skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
// 检查是否已被 workspace skills 覆盖
// Check whether overridden by workspace skills
exists := false
for _, s := range skills {
if s.Name == dir.Name() && s.Source == "workspace" {
@@ -103,7 +103,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
if dir.IsDir() {
skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
// 检查是否已被 workspace global skills 覆盖
// Check whether overridden by workspace or global skills
exists := false
for _, s := range skills {
if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
@@ -135,7 +135,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
}
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
// 1. 优先从 workspace skills 加载(项目级别)
// 1. Prefer workspace skills (project-level)
if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -143,7 +143,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
// 2. 其次从全局 skills 加载 (~/.clawgo/skills)
// 2. Then load from global skills (~/.clawgo/skills)
if sl.globalSkills != "" {
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -151,7 +151,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
// 3. 最后从内置 skills 加载
// 3. Finally load from built-in skills
if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {

View File

@@ -84,7 +84,7 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac
resultsChan := make(chan []searchResult, len(files))
var wg sync.WaitGroup
// 并发搜索所有文件
// Search all files concurrently
for _, file := range files {
wg.Add(1)
go func(f string) {
@@ -96,7 +96,7 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac
}(file)
}
// 异步关闭通道
// Close channel asynchronously
go func() {
wg.Wait()
close(resultsChan)

View File

@@ -119,7 +119,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st
}
func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd string) (string, error) {
// 实现 Docker 沙箱执行逻辑
// Execute command inside Docker sandbox
absCwd, _ := filepath.Abs(cwd)
dockerArgs := []string{
"run", "--rm",

View File

@@ -89,11 +89,11 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
_ = sm.orc.MarkTaskRunning(task.PipelineID, task.PipelineTask)
}
// 1. 独立 Agent 逻辑:支持递归工具调用
// 这里简单实现:通过共享 AgentLoop 的逻辑来实现 full subagent 能力
// 但目前 subagent.go 不方便反向依赖 agent 包,我们暂时通过 Inject 方式解决
// 1. Independent agent logic: supports recursive tool calling.
// This lightweight approach reuses AgentLoop logic for full subagent capability.
// subagent.go cannot depend on agent package inversely, so use function injection.
// 如果没有注入 RunFunc则退化为简单的一步 Chat
// Fall back to one-shot chat when RunFunc is not injected.
if sm.runFunc != nil {
result, err := sm.runFunc(ctx, task.Task, task.OriginChannel, task.OriginChatID)
sm.mu.Lock()
@@ -112,7 +112,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
}
sm.mu.Unlock()
} else {
// 原有的 One-shot 逻辑
// Original one-shot logic
messages := []providers.Message{
{
Role: "system",
@@ -145,7 +145,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
sm.mu.Unlock()
}
// 2. 结果广播 (原有逻辑保持)
// 2. Result broadcast (keep existing behavior)
if sm.bus != nil {
prefix := "Task completed"
if task.Label != "" {