diff --git a/cmd/clawgo/cli_common.go b/cmd/clawgo/cli_common.go index ca8780b..c14d2f7 100644 --- a/cmd/clawgo/cli_common.go +++ b/cmd/clawgo/cli_common.go @@ -108,7 +108,6 @@ func printHelp() { fmt.Println(" clawgo gateway # register service") fmt.Println(" clawgo gateway start|stop|restart|status") fmt.Println(" clawgo gateway run # run foreground") - fmt.Println(" clawgo gateway autonomy on|off|status") fmt.Println() fmt.Println("Uninstall:") fmt.Println(" clawgo uninstall # remove gateway service") diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index 4e6b45b..66b6a4e 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "fmt" "os" "os/exec" @@ -15,7 +14,6 @@ import ( "clawgo/pkg/agent" "clawgo/pkg/api" - "clawgo/pkg/autonomy" "clawgo/pkg/bus" "clawgo/pkg/channels" "clawgo/pkg/config" @@ -46,15 +44,9 @@ func gatewayCmd() { os.Exit(1) } return - case "autonomy": - if err := gatewayAutonomyControlCmd(args[1:]); err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - return default: fmt.Printf("Unknown gateway command: %s\n", args[0]) - fmt.Println("Usage: clawgo gateway [run|start|stop|restart|status|autonomy on|off|status]") + fmt.Println("Usage: clawgo gateway [run|start|stop|restart|status]") return } @@ -75,7 +67,6 @@ func gatewayCmd() { }) configureCronServiceRuntime(cronService, cfg) heartbeatService := buildHeartbeatService(cfg, msgBus) - autonomyEngine := buildAutonomyEngine(cfg, msgBus) sentinelService := sentinel.NewService( getConfigPath(), cfg.WorkspacePath(), @@ -128,10 +119,6 @@ func gatewayCmd() { fmt.Printf("Error starting heartbeat service: %v\n", err) } fmt.Println("✓ Heartbeat service started") - autonomyEngine.Start() - if cfg.Agents.Defaults.Autonomy.Enabled { - fmt.Println("✓ Autonomy engine started") - } if cfg.Sentinel.Enabled { sentinelService.Start() fmt.Println("✓ Sentinel service started") @@ -170,6 +157,12 @@ func gatewayCmd() { registryServer.SetConfigAfterHook(func() { _ = requestGatewayReloadSignal() }) + registryServer.SetSubagentHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + return agentLoop.HandleSubagentRuntime(cctx, action, args) + }) + registryServer.SetPipelineHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + return agentLoop.HandlePipelineRuntime(cctx, action, args) + }) registryServer.SetCronHandler(func(action string, args map[string]interface{}) (interface{}, error) { getStr := func(k string) string { v, _ := args[k].(string) @@ -334,13 +327,10 @@ func gatewayCmd() { } configureCronServiceRuntime(cronService, newCfg) heartbeatService.Stop() - autonomyEngine.Stop() heartbeatService = buildHeartbeatService(newCfg, msgBus) - autonomyEngine = buildAutonomyEngine(newCfg, msgBus) if err := heartbeatService.Start(); err != nil { fmt.Printf("Error starting heartbeat service: %v\n", err) } - autonomyEngine.Start() if reflect.DeepEqual(cfg, newCfg) { fmt.Println("✓ Config unchanged, skip reload") @@ -352,8 +342,6 @@ func gatewayCmd() { reflect.DeepEqual(cfg.Tools, newCfg.Tools) && reflect.DeepEqual(cfg.Channels, newCfg.Channels) - autonomyChanges := summarizeAutonomyChanges(cfg, newCfg) - if runtimeSame { configureLogging(newCfg) sentinelService.Stop() @@ -378,9 +366,6 @@ func gatewayCmd() { } cfg = newCfg runtimecfg.Set(cfg) - if len(autonomyChanges) > 0 { - fmt.Printf("↻ Autonomy changes: %s\n", strings.Join(autonomyChanges, ", ")) - } fmt.Println("✓ Config hot-reload applied (logging/metadata only)") continue } @@ -424,15 +409,11 @@ func gatewayCmd() { continue } go agentLoop.Run(ctx) - if len(autonomyChanges) > 0 { - fmt.Printf("↻ Autonomy changes: %s\n", strings.Join(autonomyChanges, ", ")) - } fmt.Println("✓ Config hot-reload applied") default: fmt.Println("\nShutting down...") cancel() heartbeatService.Stop() - autonomyEngine.Stop() sentinelService.Stop() cronService.Stop() agentLoop.Stop() @@ -443,126 +424,6 @@ func gatewayCmd() { } } -func gatewayAutonomyControlCmd(args []string) error { - if len(args) < 1 { - return fmt.Errorf("usage: clawgo gateway autonomy [on|off|status]") - } - cfg, err := loadConfig() - if err != nil { - return err - } - memDir := filepath.Join(cfg.WorkspacePath(), "memory") - if err := os.MkdirAll(memDir, 0755); err != nil { - return err - } - pausePath := filepath.Join(memDir, "autonomy.pause") - ctrlPath := filepath.Join(memDir, "autonomy.control.json") - - type autonomyControl struct { - Enabled bool `json:"enabled"` - UpdatedAt string `json:"updated_at"` - Source string `json:"source"` - } - - writeControl := func(enabled bool) error { - c := autonomyControl{Enabled: enabled, UpdatedAt: time.Now().UTC().Format(time.RFC3339), Source: "manual_cli"} - data, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - return os.WriteFile(ctrlPath, append(data, '\n'), 0644) - } - - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "on": - _ = os.Remove(pausePath) - if err := writeControl(true); err != nil { - return err - } - fmt.Println("✓ Autonomy enabled") - return nil - case "off": - if err := writeControl(false); err != nil { - return err - } - if err := os.WriteFile(pausePath, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0644); err != nil { - return err - } - fmt.Println("✓ Autonomy disabled (paused)") - return nil - case "status": - enabled := true - reason := "default" - updatedAt := "" - source := "" - if data, err := os.ReadFile(ctrlPath); err == nil { - var c autonomyControl - if json.Unmarshal(data, &c) == nil { - enabled = c.Enabled - updatedAt = c.UpdatedAt - source = c.Source - if !c.Enabled { - reason = "control_file" - } - } - } - if _, err := os.Stat(pausePath); err == nil { - enabled = false - reason = "pause_file" - } - fmt.Printf("Autonomy status: %v (%s)\n", enabled, reason) - if strings.TrimSpace(updatedAt) != "" { - fmt.Printf("Last switch: %s", updatedAt) - if strings.TrimSpace(source) != "" { - fmt.Printf(" via %s", source) - } - fmt.Println() - } - fmt.Printf("Control file: %s\n", ctrlPath) - fmt.Printf("Pause file: %s\n", pausePath) - return nil - default: - return fmt.Errorf("usage: clawgo gateway autonomy [on|off|status]") - } -} - -func summarizeAutonomyChanges(oldCfg, newCfg *config.Config) []string { - if oldCfg == nil || newCfg == nil { - return nil - } - o := oldCfg.Agents.Defaults.Autonomy - n := newCfg.Agents.Defaults.Autonomy - changes := make([]string, 0) - if o.Enabled != n.Enabled { - changes = append(changes, "enabled") - } - if o.TickIntervalSec != n.TickIntervalSec { - changes = append(changes, "tick_interval_sec") - } - if o.MinRunIntervalSec != n.MinRunIntervalSec { - changes = append(changes, "min_run_interval_sec") - } - if o.UserIdleResumeSec != n.UserIdleResumeSec { - changes = append(changes, "user_idle_resume_sec") - } - if o.WaitingResumeDebounceSec != n.WaitingResumeDebounceSec { - changes = append(changes, "waiting_resume_debounce_sec") - } - if o.IdleRoundBudgetReleaseSec != n.IdleRoundBudgetReleaseSec { - changes = append(changes, "idle_round_budget_release_sec") - } - if strings.TrimSpace(o.QuietHours) != strings.TrimSpace(n.QuietHours) { - changes = append(changes, "quiet_hours") - } - if o.NotifyCooldownSec != n.NotifyCooldownSec { - changes = append(changes, "notify_cooldown_sec") - } - if o.NotifySameReasonCooldownSec != n.NotifySameReasonCooldownSec { - changes = append(changes, "notify_same_reason_cooldown_sec") - } - return changes -} - func runGatewayStartupCompactionCheck(parent context.Context, agentLoop *agent.AgentLoop) { if agentLoop == nil { return @@ -910,74 +771,3 @@ func buildHeartbeatService(cfg *config.Config, msgBus *bus.MessageBus) *heartbea return "queued", nil }, hbInterval, cfg.Agents.Defaults.Heartbeat.Enabled, cfg.Agents.Defaults.Heartbeat.PromptTemplate) } - -func buildAutonomyEngine(cfg *config.Config, msgBus *bus.MessageBus) *autonomy.Engine { - a := cfg.Agents.Defaults.Autonomy - idleRoundBudgetReleaseSec := a.IdleRoundBudgetReleaseSec - if idleRoundBudgetReleaseSec == 0 { - idleRoundBudgetReleaseSec = 1800 - } - notifyChannel, notifyChatID := inferAutonomyNotifyTarget(cfg) - notifyAllowFrom := []string{} - switch notifyChannel { - case "telegram": - notifyAllowFrom = append(notifyAllowFrom, cfg.Channels.Telegram.AllowFrom...) - case "feishu": - notifyAllowFrom = append(notifyAllowFrom, cfg.Channels.Feishu.AllowFrom...) - case "whatsapp": - notifyAllowFrom = append(notifyAllowFrom, cfg.Channels.WhatsApp.AllowFrom...) - case "discord": - notifyAllowFrom = append(notifyAllowFrom, cfg.Channels.Discord.AllowFrom...) - case "qq": - notifyAllowFrom = append(notifyAllowFrom, cfg.Channels.QQ.AllowFrom...) - case "dingtalk": - notifyAllowFrom = append(notifyAllowFrom, cfg.Channels.DingTalk.AllowFrom...) - } - return autonomy.NewEngine(autonomy.Options{ - Enabled: a.Enabled, - TickIntervalSec: a.TickIntervalSec, - MinRunIntervalSec: a.MinRunIntervalSec, - MaxPendingDurationSec: a.MaxPendingDurationSec, - MaxConsecutiveStalls: a.MaxConsecutiveStalls, - MaxDispatchPerTick: a.MaxDispatchPerTick, - NotifyCooldownSec: a.NotifyCooldownSec, - NotifySameReasonCooldownSec: a.NotifySameReasonCooldownSec, - QuietHours: a.QuietHours, - UserIdleResumeSec: a.UserIdleResumeSec, - MaxRoundsWithoutUser: a.MaxRoundsWithoutUser, - TaskHistoryRetentionDays: a.TaskHistoryRetentionDays, - WaitingResumeDebounceSec: a.WaitingResumeDebounceSec, - IdleRoundBudgetReleaseSec: idleRoundBudgetReleaseSec, - AllowedTaskKeywords: a.AllowedTaskKeywords, - EKGConsecutiveErrorThreshold: a.EKGConsecutiveErrorThreshold, - Workspace: cfg.WorkspacePath(), - DefaultNotifyChannel: notifyChannel, - DefaultNotifyChatID: notifyChatID, - NotifyAllowFrom: notifyAllowFrom, - }, msgBus) -} - -func inferAutonomyNotifyTarget(cfg *config.Config) (string, string) { - if cfg == nil { - return "", "" - } - if cfg.Channels.Telegram.Enabled && len(cfg.Channels.Telegram.AllowFrom) > 0 { - return "telegram", strings.TrimSpace(cfg.Channels.Telegram.AllowFrom[0]) - } - if cfg.Channels.Feishu.Enabled && len(cfg.Channels.Feishu.AllowFrom) > 0 { - return "feishu", strings.TrimSpace(cfg.Channels.Feishu.AllowFrom[0]) - } - if cfg.Channels.WhatsApp.Enabled && len(cfg.Channels.WhatsApp.AllowFrom) > 0 { - return "whatsapp", strings.TrimSpace(cfg.Channels.WhatsApp.AllowFrom[0]) - } - if cfg.Channels.Discord.Enabled && len(cfg.Channels.Discord.AllowFrom) > 0 { - return "discord", strings.TrimSpace(cfg.Channels.Discord.AllowFrom[0]) - } - if cfg.Channels.QQ.Enabled && len(cfg.Channels.QQ.AllowFrom) > 0 { - return "qq", strings.TrimSpace(cfg.Channels.QQ.AllowFrom[0]) - } - if cfg.Channels.DingTalk.Enabled && len(cfg.Channels.DingTalk.AllowFrom) > 0 { - return "dingtalk", strings.TrimSpace(cfg.Channels.DingTalk.AllowFrom[0]) - } - return "", "" -} diff --git a/cmd/clawgo/cmd_status.go b/cmd/clawgo/cmd_status.go index 5d51546..e6b6a29 100644 --- a/cmd/clawgo/cmd_status.go +++ b/cmd/clawgo/cmd_status.go @@ -94,9 +94,6 @@ func statusCmd() { triggerStats := filepath.Join(workspace, "memory", "trigger-stats.json") if data, err := os.ReadFile(triggerStats); err == nil { fmt.Printf("Trigger Stats: %s\n", strings.TrimSpace(string(data))) - if summary := summarizeAutonomyActions(data); summary != "" { - fmt.Printf("Autonomy Action Stats: %s\n", summary) - } } auditPath := filepath.Join(workspace, "memory", "trigger-audit.jsonl") if errs, err := collectRecentTriggerErrors(auditPath, 5); err == nil && len(errs) > 0 { @@ -138,26 +135,6 @@ func statusCmd() { fmt.Printf(" - %s\n", key) } } - fmt.Printf("Autonomy Config: idle_resume=%ds waiting_debounce=%ds notify_cooldown=%ds same_reason_cooldown=%ds\n", - cfg.Agents.Defaults.Autonomy.UserIdleResumeSec, - cfg.Agents.Defaults.Autonomy.WaitingResumeDebounceSec, - cfg.Agents.Defaults.Autonomy.NotifyCooldownSec, - cfg.Agents.Defaults.Autonomy.NotifySameReasonCooldownSec, - ) - if summary, prio, reasons, nextRetry, dedupeHits, waitingLocks, lockKeys, err := collectAutonomyTaskSummary(filepath.Join(workspace, "memory", "tasks.json")); err == nil { - fmt.Printf("Autonomy Tasks: todo=%d doing=%d waiting=%d blocked=%d done=%d dedupe_hits=%d\n", summary["todo"], summary["doing"], summary["waiting"], summary["blocked"], summary["done"], dedupeHits) - fmt.Printf("Autonomy Priority: high=%d normal=%d low=%d\n", prio["high"], prio["normal"], prio["low"]) - if reasons["active_user"] > 0 || reasons["manual_pause"] > 0 || reasons["max_consecutive_stalls"] > 0 || reasons["resource_lock"] > 0 { - fmt.Printf("Autonomy Block Reasons: active_user=%d manual_pause=%d max_stalls=%d resource_lock=%d\n", reasons["active_user"], reasons["manual_pause"], reasons["max_consecutive_stalls"], reasons["resource_lock"]) - } - if waitingLocks > 0 || lockKeys > 0 { - fmt.Printf("Autonomy Locks: waiting=%d unique_keys=%d\n", waitingLocks, lockKeys) - } - if nextRetry != "" { - fmt.Printf("Autonomy Next Retry: %s\n", nextRetry) - } - fmt.Printf("Autonomy Control: %s\n", autonomyControlState(workspace)) - } ns := nodes.DefaultManager().List() if len(ns) > 0 { online := 0 @@ -197,81 +174,6 @@ func statusCmd() { } } -func summarizeAutonomyActions(statsJSON []byte) string { - var payload struct { - Counts map[string]int `json:"counts"` - } - if err := json.Unmarshal(statsJSON, &payload); err != nil || payload.Counts == nil { - return "" - } - keys := []string{"autonomy:dispatch", "autonomy:waiting", "autonomy:resume", "autonomy:blocked", "autonomy:complete"} - parts := make([]string, 0, len(keys)+1) - total := 0 - for _, k := range keys { - if v, ok := payload.Counts[k]; ok { - parts = append(parts, fmt.Sprintf("%s=%d", strings.TrimPrefix(k, "autonomy:"), v)) - total += v - } - } - if total > 0 { - d := payload.Counts["autonomy:dispatch"] - w := payload.Counts["autonomy:waiting"] - b := payload.Counts["autonomy:blocked"] - parts = append(parts, fmt.Sprintf("ratios(dispatch/waiting/blocked)=%.2f/%.2f/%.2f", float64(d)/float64(total), float64(w)/float64(total), float64(b)/float64(total))) - } - wa := payload.Counts["autonomy:waiting:active_user"] - wm := payload.Counts["autonomy:waiting:manual_pause"] - ra := payload.Counts["autonomy:resume:active_user"] - rm := payload.Counts["autonomy:resume:manual_pause"] - if wa+wm+ra+rm > 0 { - parts = append(parts, fmt.Sprintf("wait_resume(active_user=%d/%d manual_pause=%d/%d)", wa, ra, wm, rm)) - waitTotal := wa + wm - resumeTotal := ra + rm - if waitTotal >= 8 { - parts = append(parts, fmt.Sprintf("flap_risk=%s", flapRisk(waitTotal, resumeTotal))) - } - } - return strings.Join(parts, " ") -} - -func flapRisk(waitTotal, resumeTotal int) string { - if waitTotal <= 0 { - return "low" - } - if resumeTotal == 0 { - return "high(no_resume)" - } - ratio := float64(waitTotal) / float64(resumeTotal) - if ratio >= 2.0 || ratio <= 0.5 { - return "high" - } - if ratio >= 1.5 || ratio <= 0.67 { - return "medium" - } - return "low" -} - -func autonomyControlState(workspace string) string { - memDir := filepath.Join(workspace, "memory") - pausePath := filepath.Join(memDir, "autonomy.pause") - if _, err := os.Stat(pausePath); err == nil { - return "paused (autonomy.pause)" - } - ctrlPath := filepath.Join(memDir, "autonomy.control.json") - if data, err := os.ReadFile(ctrlPath); err == nil { - var c struct { - Enabled bool `json:"enabled"` - } - if json.Unmarshal(data, &c) == nil { - if c.Enabled { - return "enabled" - } - return "disabled (control file)" - } - } - return "default" -} - func collectSessionKindCounts(sessionsDir string) (map[string]int, error) { indexPath := filepath.Join(sessionsDir, "sessions.json") data, err := os.ReadFile(indexPath) @@ -360,70 +262,6 @@ func collectTriggerErrorCounts(path string) (map[string]int, error) { return counts, nil } -func collectAutonomyTaskSummary(path string) (map[string]int, map[string]int, map[string]int, string, int, int, int, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return map[string]int{"todo": 0, "doing": 0, "waiting": 0, "blocked": 0, "done": 0}, map[string]int{"high": 0, "normal": 0, "low": 0}, map[string]int{"active_user": 0, "manual_pause": 0, "max_consecutive_stalls": 0, "resource_lock": 0}, "", 0, 0, 0, nil - } - return nil, nil, nil, "", 0, 0, 0, err - } - var items []struct { - Status string `json:"status"` - Priority string `json:"priority"` - BlockReason string `json:"block_reason"` - RetryAfter string `json:"retry_after"` - DedupeHits int `json:"dedupe_hits"` - ResourceKeys []string `json:"resource_keys"` - } - if err := json.Unmarshal(data, &items); err != nil { - return nil, nil, nil, "", 0, 0, 0, err - } - summary := map[string]int{"todo": 0, "doing": 0, "waiting": 0, "blocked": 0, "done": 0} - priorities := map[string]int{"high": 0, "normal": 0, "low": 0} - reasons := map[string]int{"active_user": 0, "manual_pause": 0, "max_consecutive_stalls": 0, "resource_lock": 0} - nextRetry := "" - nextRetryAt := time.Time{} - totalDedupe := 0 - waitingLocks := 0 - lockKeySet := map[string]struct{}{} - for _, it := range items { - s := strings.ToLower(strings.TrimSpace(it.Status)) - if _, ok := summary[s]; ok { - summary[s]++ - } - totalDedupe += it.DedupeHits - r := strings.ToLower(strings.TrimSpace(it.BlockReason)) - if _, ok := reasons[r]; ok { - reasons[r]++ - } - if s == "waiting" && r == "resource_lock" { - waitingLocks++ - for _, k := range it.ResourceKeys { - kk := strings.TrimSpace(strings.ToLower(k)) - if kk != "" { - lockKeySet[kk] = struct{}{} - } - } - } - p := strings.ToLower(strings.TrimSpace(it.Priority)) - if _, ok := priorities[p]; ok { - priorities[p]++ - } else { - priorities["normal"]++ - } - if strings.TrimSpace(it.RetryAfter) != "" { - if t, err := time.Parse(time.RFC3339, it.RetryAfter); err == nil { - if nextRetryAt.IsZero() || t.Before(nextRetryAt) { - nextRetryAt = t - nextRetry = t.Format(time.RFC3339) - } - } - } - } - return summary, priorities, reasons, nextRetry, totalDedupe, waitingLocks, len(lockKeySet), nil -} - func collectNodeDispatchStats(path string) (int, int, int, string, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/config.example.json b/config.example.json index 210f501..e6fe4f5 100644 --- a/config.example.json +++ b/config.example.json @@ -13,24 +13,6 @@ "ack_max_chars": 64, "prompt_template": "" }, - "autonomy": { - "enabled": false, - "tick_interval_sec": 30, - "min_run_interval_sec": 20, - "max_pending_duration_sec": 900, - "max_consecutive_stalls": 3, - "max_dispatch_per_tick": 0, - "notify_cooldown_sec": 300, - "notify_same_reason_cooldown_sec": 900, - "quiet_hours": "23:00-08:00", - "user_idle_resume_sec": 20, - "max_rounds_without_user": 0, - "task_history_retention_days": 3, - "waiting_resume_debounce_sec": 5, - "idle_round_budget_release_sec": 1800, - "allowed_task_keywords": [], - "ekg_consecutive_error_threshold": 3 - }, "context_compaction": { "enabled": true, "mode": "summary", @@ -41,12 +23,6 @@ }, "runtime_control": { "intent_max_input_chars": 1200, - "autonomy_tick_interval_sec": 20, - "autonomy_min_run_interval_sec": 20, - "autonomy_idle_threshold_sec": 20, - "autonomy_max_rounds_without_user": 0, - "autonomy_max_pending_duration_sec": 900, - "autonomy_max_consecutive_stalls": 3, "autolearn_max_rounds_without_user": 200, "run_state_ttl_seconds": 1800, "run_state_max": 500, diff --git a/docs/master-subagent-development.md b/docs/master-subagent-development.md index 1216e2f..b289abb 100644 --- a/docs/master-subagent-development.md +++ b/docs/master-subagent-development.md @@ -251,12 +251,21 @@ - 安全治理: - `tool_allowlist` 已在执行期强制拦截 - `parallel` 工具的内部子调用也会被白名单校验 + - `tool_allowlist` 已支持分组策略(`group:` / `@` / 组名直写) - disabled profile 会阻止 `spawn` +- 稳定性治理: + - 子 Agent 已支持 profile 级重试/退避/超时/配额控制(`max_retries` / `retry_backoff_ms` / `timeout_sec` / `max_task_chars` / `max_result_chars`) + - 子任务元数据与 system 回填中包含重试/超时信息,便于审计追踪 +- WebUI 可视化: + - 已提供 subagent profile 管理页 + - 已提供 subagent runtime 列表/详情/控制页(spawn/kill/resume/steer) + - 已提供 pipeline 列表/详情/dispatch/创建入口 ### 13.2 待继续增强 -- `tool_allowlist` 目前为精确匹配(支持 `*`/`all`),尚未支持分组策略(如“只读文件工具组”)。 -- WebUI 尚未提供 profile 的图形化管理入口。 +- (当前版本)无阻塞项;可继续按需增强: + - allowlist 分组支持自定义组配置(当前为内置组)。 + - pipeline / subagent 运行态持久化与历史回放(当前为进程内实时视图)。 --- diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6328d29..75e7e2c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -59,6 +59,8 @@ type AgentLoop struct { sessionProvider map[string]string streamMu sync.Mutex sessionStreamed map[string]bool + subagentManager *tools.SubagentManager + orchestrator *tools.Orchestrator } // StartupCompactionReport provides startup memory/session maintenance stats. @@ -236,6 +238,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers sessionStreamed: map[string]bool{}, providerResponses: map[string]config.ProviderResponsesConfig{}, telegramStreaming: cfg.Channels.Telegram.Streaming, + subagentManager: subagentManager, + orchestrator: orchestrator, } // Initialize provider fallback chain (primary + proxy_fallbacks). loop.providerPool = map[string]providers.LLMProvider{} @@ -479,14 +483,6 @@ func buildAuditTaskID(msg bus.InboundMessage) string { sessionPart = "default" } return "heartbeat:" + sessionPart - case "autonomy": - norm := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(msg.Content, "\n", " "))) - if len(norm) > 180 { - norm = norm[:180] - } - h := fnv.New32a() - _, _ = h.Write([]byte(msg.SessionKey + "|" + norm)) - return fmt.Sprintf("autonomy:%08x", h.Sum32()) default: return fmt.Sprintf("%s-%d", sessionPart, time.Now().Unix()%100000) } @@ -524,7 +520,7 @@ func (al *AgentLoop) appendTaskAuditEvent(taskID string, msg bus.InboundMessage, "sender_id": msg.SenderID, "status": status, "source": source, - "idle_run": source == "autonomy", + "idle_run": false, "duration_ms": durationMs, "suppressed": suppressed, "retry_count": 0, @@ -1719,11 +1715,12 @@ func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]in } func normalizeToolAllowlist(in []string) map[string]struct{} { - if len(in) == 0 { + expanded := tools.ExpandToolAllowlistEntries(in) + if len(expanded) == 0 { return nil } - out := make(map[string]struct{}, len(in)) - for _, item := range in { + out := make(map[string]struct{}, len(expanded)) + for _, item := range expanded { name := strings.ToLower(strings.TrimSpace(item)) if name == "" { continue diff --git a/pkg/agent/loop_allowlist_test.go b/pkg/agent/loop_allowlist_test.go index 2239d89..ee5e174 100644 --- a/pkg/agent/loop_allowlist_test.go +++ b/pkg/agent/loop_allowlist_test.go @@ -42,3 +42,23 @@ func TestEnsureToolAllowedByContextParallelNested(t *testing.T) { t.Fatalf("expected parallel with disallowed nested tool to fail") } } + +func TestEnsureToolAllowedByContext_GroupAllowlist(t *testing.T) { + ctx := withToolAllowlistContext(context.Background(), []string{"group:files_read"}) + if err := ensureToolAllowedByContext(ctx, "read_file", map[string]interface{}{}); err != nil { + t.Fatalf("expected files_read group to allow read_file, got: %v", err) + } + if err := ensureToolAllowedByContext(ctx, "write_file", map[string]interface{}{}); err == nil { + t.Fatalf("expected files_read group to block write_file") + } +} + +func TestEnsureToolAllowedByContext_GroupAliasToken(t *testing.T) { + ctx := withToolAllowlistContext(context.Background(), []string{"@pipeline"}) + if err := ensureToolAllowedByContext(ctx, "pipeline_status", map[string]interface{}{}); err != nil { + t.Fatalf("expected @pipeline to allow pipeline_status, got: %v", err) + } + if err := ensureToolAllowedByContext(ctx, "memory_search", map[string]interface{}{}); err == nil { + t.Fatalf("expected @pipeline to block memory_search") + } +} diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go new file mode 100644 index 0000000..b5937d4 --- /dev/null +++ b/pkg/agent/runtime_admin.go @@ -0,0 +1,296 @@ +package agent + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "clawgo/pkg/tools" +) + +func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + if al == nil || al.subagentManager == nil { + return nil, fmt.Errorf("subagent runtime is not configured") + } + action = strings.ToLower(strings.TrimSpace(action)) + if action == "" { + action = "list" + } + + sm := al.subagentManager + switch action { + case "list": + tasks := sm.ListTasks() + items := make([]*tools.SubagentTask, 0, len(tasks)) + for _, task := range tasks { + items = append(items, cloneSubagentTask(task)) + } + sort.Slice(items, func(i, j int) bool { return items[i].Created > items[j].Created }) + return map[string]interface{}{"items": items}, nil + case "get", "info": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + task, ok := sm.GetTask(taskID) + if !ok { + return map[string]interface{}{"found": false}, nil + } + return map[string]interface{}{"found": true, "task": cloneSubagentTask(task)}, nil + case "spawn", "create": + taskInput := runtimeStringArg(args, "task") + if taskInput == "" { + return nil, fmt.Errorf("task is required") + } + msg, err := sm.Spawn(ctx, tools.SubagentSpawnOptions{ + Task: taskInput, + Label: runtimeStringArg(args, "label"), + Role: runtimeStringArg(args, "role"), + AgentID: runtimeStringArg(args, "agent_id"), + MaxRetries: runtimeIntArg(args, "max_retries", 0), + RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0), + TimeoutSec: runtimeIntArg(args, "timeout_sec", 0), + MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0), + MaxResultChars: runtimeIntArg(args, "max_result_chars", 0), + OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"), + OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"), + PipelineID: runtimeStringArg(args, "pipeline_id"), + PipelineTask: runtimeStringArg(args, "task_id"), + }) + if err != nil { + return nil, err + } + return map[string]interface{}{"message": msg}, nil + case "kill": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + ok := sm.KillTask(taskID) + return map[string]interface{}{"ok": ok}, nil + case "resume": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + label, ok := sm.ResumeTask(ctx, taskID) + return map[string]interface{}{"ok": ok, "label": label}, nil + case "steer", "send": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + msg := runtimeStringArg(args, "message") + if msg == "" { + return nil, fmt.Errorf("message is required") + } + ok := sm.SteerTask(taskID, msg) + return map[string]interface{}{"ok": ok}, nil + default: + return nil, fmt.Errorf("unsupported action: %s", action) + } +} + +func (al *AgentLoop) HandlePipelineRuntime(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + if al == nil || al.orchestrator == nil { + return nil, fmt.Errorf("pipeline runtime is not configured") + } + action = strings.ToLower(strings.TrimSpace(action)) + if action == "" { + action = "list" + } + + switch action { + case "list": + return map[string]interface{}{"items": al.orchestrator.ListPipelines()}, nil + case "get", "status": + pipelineID := fallbackString(runtimeStringArg(args, "pipeline_id"), runtimeStringArg(args, "id")) + if strings.TrimSpace(pipelineID) == "" { + return nil, fmt.Errorf("pipeline_id is required") + } + p, ok := al.orchestrator.GetPipeline(strings.TrimSpace(pipelineID)) + if !ok { + return map[string]interface{}{"found": false}, nil + } + return map[string]interface{}{"found": true, "pipeline": p}, nil + case "ready": + pipelineID := fallbackString(runtimeStringArg(args, "pipeline_id"), runtimeStringArg(args, "id")) + if strings.TrimSpace(pipelineID) == "" { + return nil, fmt.Errorf("pipeline_id is required") + } + items, err := al.orchestrator.ReadyTasks(strings.TrimSpace(pipelineID)) + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items}, nil + case "create": + objective := runtimeStringArg(args, "objective") + if objective == "" { + return nil, fmt.Errorf("objective is required") + } + specs, err := parsePipelineSpecsForRuntime(args["tasks"]) + if err != nil { + return nil, err + } + label := runtimeStringArg(args, "label") + p, err := al.orchestrator.CreatePipeline(label, objective, "webui", "webui", specs) + if err != nil { + return nil, err + } + return map[string]interface{}{"pipeline": p}, nil + case "state_set": + pipelineID := fallbackString(runtimeStringArg(args, "pipeline_id"), runtimeStringArg(args, "id")) + key := runtimeStringArg(args, "key") + if strings.TrimSpace(pipelineID) == "" || strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("pipeline_id and key are required") + } + value, ok := args["value"] + if !ok { + return nil, fmt.Errorf("value is required") + } + if err := al.orchestrator.SetSharedState(strings.TrimSpace(pipelineID), strings.TrimSpace(key), value); err != nil { + return nil, err + } + p, _ := al.orchestrator.GetPipeline(strings.TrimSpace(pipelineID)) + return map[string]interface{}{"ok": true, "pipeline": p}, nil + case "dispatch": + pipelineID := fallbackString(runtimeStringArg(args, "pipeline_id"), runtimeStringArg(args, "id")) + if strings.TrimSpace(pipelineID) == "" { + return nil, fmt.Errorf("pipeline_id is required") + } + maxDispatch := runtimeIntArg(args, "max_dispatch", 3) + dispatchTool := tools.NewPipelineDispatchTool(al.orchestrator, al.subagentManager) + result, err := dispatchTool.Execute(ctx, map[string]interface{}{ + "pipeline_id": strings.TrimSpace(pipelineID), + "max_dispatch": float64(maxDispatch), + }) + if err != nil { + return nil, err + } + p, _ := al.orchestrator.GetPipeline(strings.TrimSpace(pipelineID)) + return map[string]interface{}{"message": result, "pipeline": p}, nil + default: + return nil, fmt.Errorf("unsupported action: %s", action) + } +} + +func cloneSubagentTask(in *tools.SubagentTask) *tools.SubagentTask { + if in == nil { + return nil + } + out := *in + if len(in.ToolAllowlist) > 0 { + out.ToolAllowlist = append([]string(nil), in.ToolAllowlist...) + } + if len(in.Steering) > 0 { + out.Steering = append([]string(nil), in.Steering...) + } + if in.SharedState != nil { + out.SharedState = make(map[string]interface{}, len(in.SharedState)) + for k, v := range in.SharedState { + out.SharedState[k] = v + } + } + return &out +} + +func resolveSubagentTaskIDForRuntime(sm *tools.SubagentManager, raw string) (string, error) { + id := strings.TrimSpace(raw) + if id == "" { + return "", fmt.Errorf("id is required") + } + if !strings.HasPrefix(id, "#") { + return id, nil + } + idx, err := strconv.Atoi(strings.TrimPrefix(id, "#")) + if err != nil || idx <= 0 { + return "", fmt.Errorf("invalid subagent index") + } + tasks := sm.ListTasks() + if len(tasks) == 0 { + return "", fmt.Errorf("no subagents") + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) + if idx > len(tasks) { + return "", fmt.Errorf("subagent index out of range") + } + return tasks[idx-1].ID, nil +} + +func parsePipelineSpecsForRuntime(raw interface{}) ([]tools.PipelineSpec, error) { + items, ok := raw.([]interface{}) + if !ok || len(items) == 0 { + return nil, fmt.Errorf("tasks is required") + } + specs := make([]tools.PipelineSpec, 0, len(items)) + for i, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("tasks[%d] must be object", i) + } + id := runtimeStringArg(m, "id") + if id == "" { + return nil, fmt.Errorf("tasks[%d].id is required", i) + } + goal := runtimeStringArg(m, "goal") + if goal == "" { + return nil, fmt.Errorf("tasks[%d].goal is required", i) + } + spec := tools.PipelineSpec{ + ID: id, + Role: runtimeStringArg(m, "role"), + Goal: goal, + } + if deps, ok := m["depends_on"].([]interface{}); ok { + spec.DependsOn = make([]string, 0, len(deps)) + for _, dep := range deps { + d, _ := dep.(string) + d = strings.TrimSpace(d) + if d == "" { + continue + } + spec.DependsOn = append(spec.DependsOn, d) + } + } + specs = append(specs, spec) + } + return specs, nil +} + +func runtimeStringArg(args map[string]interface{}, key string) string { + if args == nil { + return "" + } + v, _ := args[key].(string) + return strings.TrimSpace(v) +} + +func runtimeIntArg(args map[string]interface{}, key string, fallback int) int { + if args == nil { + return fallback + } + switch v := args[key].(type) { + case int: + if v > 0 { + return v + } + case int64: + if v > 0 { + return int(v) + } + case float64: + if v > 0 { + return int(v) + } + } + return fallback +} + +func fallbackString(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return strings.TrimSpace(v) +} diff --git a/pkg/api/server.go b/pkg/api/server.go index 57e0db1..e8d8ef2 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -43,6 +43,8 @@ type Server struct { onChatHistory func(sessionKey string) []map[string]interface{} onConfigAfter func() onCron func(action string, args map[string]interface{}) (interface{}, error) + onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) + onPipelines func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) webUIDir string ekgCacheMu sync.Mutex ekgCachePath string @@ -75,6 +77,12 @@ func (s *Server) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } +func (s *Server) SetSubagentHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) { + s.onSubagents = fn +} +func (s *Server) SetPipelineHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) { + s.onPipelines = fn +} func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) } func (s *Server) Start(ctx context.Context) error { @@ -102,10 +110,11 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/sessions", s.handleWebUISessions) mux.HandleFunc("/webui/api/memory", s.handleWebUIMemory) mux.HandleFunc("/webui/api/subagent_profiles", s.handleWebUISubagentProfiles) + mux.HandleFunc("/webui/api/subagents_runtime", s.handleWebUISubagentsRuntime) + mux.HandleFunc("/webui/api/pipelines", s.handleWebUIPipelines) + mux.HandleFunc("/webui/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups) mux.HandleFunc("/webui/api/task_audit", s.handleWebUITaskAudit) mux.HandleFunc("/webui/api/task_queue", s.handleWebUITaskQueue) - mux.HandleFunc("/webui/api/tasks", s.handleWebUITasks) - mux.HandleFunc("/webui/api/task_daily_summary", s.handleWebUITaskDailySummary) mux.HandleFunc("/webui/api/ekg_stats", s.handleWebUIEKGStats) mux.HandleFunc("/webui/api/exec_approvals", s.handleWebUIExecApprovals) mux.HandleFunc("/webui/api/logs/stream", s.handleWebUILogsStream) @@ -1804,6 +1813,13 @@ func anyToString(v interface{}) string { } } +func derefInt(v *int) int { + if v == nil { + return 0 + } + return *v +} + func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -1903,6 +1919,11 @@ func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Requ MemoryNamespace string `json:"memory_namespace"` Status string `json:"status"` ToolAllowlist []string `json:"tool_allowlist"` + MaxRetries *int `json:"max_retries"` + RetryBackoffMS *int `json:"retry_backoff_ms"` + TimeoutSec *int `json:"timeout_sec"` + MaxTaskChars *int `json:"max_task_chars"` + MaxResultChars *int `json:"max_result_chars"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) @@ -1935,6 +1956,11 @@ func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Requ MemoryNamespace: body.MemoryNamespace, Status: body.Status, ToolAllowlist: body.ToolAllowlist, + MaxRetries: derefInt(body.MaxRetries), + RetryBackoff: derefInt(body.RetryBackoffMS), + TimeoutSec: derefInt(body.TimeoutSec), + MaxTaskChars: derefInt(body.MaxTaskChars), + MaxResultChars: derefInt(body.MaxResultChars), }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -1962,6 +1988,21 @@ func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Requ if body.ToolAllowlist != nil { next.ToolAllowlist = body.ToolAllowlist } + if body.MaxRetries != nil { + next.MaxRetries = *body.MaxRetries + } + if body.RetryBackoffMS != nil { + next.RetryBackoff = *body.RetryBackoffMS + } + if body.TimeoutSec != nil { + next.TimeoutSec = *body.TimeoutSec + } + if body.MaxTaskChars != nil { + next.MaxTaskChars = *body.MaxTaskChars + } + if body.MaxResultChars != nil { + next.MaxResultChars = *body.MaxResultChars + } profile, err := store.Upsert(next) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -2004,6 +2045,11 @@ func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Requ MemoryNamespace: body.MemoryNamespace, Status: body.Status, ToolAllowlist: body.ToolAllowlist, + MaxRetries: derefInt(body.MaxRetries), + RetryBackoff: derefInt(body.RetryBackoffMS), + TimeoutSec: derefInt(body.TimeoutSec), + MaxTaskChars: derefInt(body.MaxTaskChars), + MaxResultChars: derefInt(body.MaxResultChars), }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -2018,6 +2064,125 @@ func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Requ } } +func (s *Server) handleWebUIToolAllowlistGroups(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "groups": tools.ToolAllowlistGroups(), + }) +} + +func (s *Server) handleWebUISubagentsRuntime(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if s.onSubagents == nil { + http.Error(w, "subagent runtime handler not configured", http.StatusServiceUnavailable) + return + } + + action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action"))) + args := map[string]interface{}{} + switch r.Method { + case http.MethodGet: + if action == "" { + action = "list" + } + for key, values := range r.URL.Query() { + if key == "action" || key == "token" || len(values) == 0 { + continue + } + args[key] = strings.TrimSpace(values[0]) + } + case http.MethodPost: + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if body == nil { + body = map[string]interface{}{} + } + if action == "" { + if raw, _ := body["action"].(string); raw != "" { + action = strings.ToLower(strings.TrimSpace(raw)) + } + } + delete(body, "action") + args = body + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + result, err := s.onSubagents(r.Context(), action, args) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": result}) +} + +func (s *Server) handleWebUIPipelines(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if s.onPipelines == nil { + http.Error(w, "pipeline runtime handler not configured", http.StatusServiceUnavailable) + return + } + + action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action"))) + args := map[string]interface{}{} + switch r.Method { + case http.MethodGet: + if action == "" { + action = "list" + } + for key, values := range r.URL.Query() { + if key == "action" || key == "token" || len(values) == 0 { + continue + } + args[key] = strings.TrimSpace(values[0]) + } + case http.MethodPost: + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if body == nil { + body = map[string]interface{}{} + } + if action == "" { + if raw, _ := body["action"].(string); raw != "" { + action = strings.ToLower(strings.TrimSpace(raw)) + } + } + delete(body, "action") + args = body + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + result, err := s.onPipelines(r.Context(), action, args) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": result}) +} + func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -2098,77 +2263,10 @@ func (s *Server) handleWebUITaskAudit(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - if r.Method != http.MethodGet && r.Method != http.MethodPost { + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if r.Method == http.MethodPost { - var body map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - action := fmt.Sprintf("%v", body["action"]) - taskID := fmt.Sprintf("%v", body["task_id"]) - if taskID == "" { - http.Error(w, "task_id required", http.StatusBadRequest) - return - } - tasksPath := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "tasks.json") - tb, err := os.ReadFile(tasksPath) - if err != nil { - http.Error(w, "tasks not found", http.StatusNotFound) - return - } - var tasks []map[string]interface{} - if err := json.Unmarshal(tb, &tasks); err != nil { - http.Error(w, "invalid tasks file", http.StatusInternalServerError) - return - } - now := time.Now().UTC().Format(time.RFC3339) - updated := false - for _, t := range tasks { - if fmt.Sprintf("%v", t["id"]) != taskID { - continue - } - switch action { - case "pause": - t["status"] = "waiting" - t["block_reason"] = "manual_pause" - t["last_pause_reason"] = "manual_pause" - t["last_pause_at"] = now - case "retry": - t["status"] = "todo" - t["block_reason"] = "" - t["retry_after"] = "" - case "complete": - t["status"] = "done" - t["block_reason"] = "manual_complete" - case "ignore": - t["status"] = "blocked" - t["block_reason"] = "manual_ignore" - t["retry_after"] = "2099-01-01T00:00:00Z" - default: - http.Error(w, "unsupported action", http.StatusBadRequest) - return - } - t["updated_at"] = now - updated = true - break - } - if !updated { - http.Error(w, "task not found", http.StatusNotFound) - return - } - out, _ := json.MarshalIndent(tasks, "", " ") - if err := os.WriteFile(tasksPath, out, 0644); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) - return - } - path := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "task-audit.jsonl") includeHeartbeat := r.URL.Query().Get("include_heartbeat") == "1" limit := 100 @@ -2274,44 +2372,6 @@ func (s *Server) handleWebUITaskQueue(w http.ResponseWriter, r *http.Request) { } } - // Merge autonomy queue states (including waiting/blocked-by-user) for full audit visibility. - tasksPath := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "tasks.json") - if tb, err := os.ReadFile(tasksPath); err == nil { - var tasks []map[string]interface{} - if json.Unmarshal(tb, &tasks) == nil { - seen := map[string]struct{}{} - for _, it := range items { - seen[fmt.Sprintf("%v", it["task_id"])] = struct{}{} - } - for _, t := range tasks { - id := fmt.Sprintf("%v", t["id"]) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - row := map[string]interface{}{ - "task_id": id, - "time": t["updated_at"], - "status": t["status"], - "source": t["source"], - "idle_run": true, - "input_preview": t["content"], - "block_reason": t["block_reason"], - "last_pause_reason": t["last_pause_reason"], - "last_pause_at": t["last_pause_at"], - "logs": []string{fmt.Sprintf("autonomy state: %v", t["status"])}, - "retry_count": 0, - } - items = append(items, row) - if fmt.Sprintf("%v", row["status"]) == "running" { - running = append(running, row) - } - } - } - } - // Merge command watchdog queue from memory/task_queue.json for visibility. queuePath := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "task_queue.json") if qb, qErr := os.ReadFile(queuePath); qErr == nil { @@ -2411,53 +2471,10 @@ func (s *Server) handleWebUITaskQueue(w http.ResponseWriter, r *http.Request) { } sort.Slice(items, func(i, j int) bool { return fmt.Sprintf("%v", items[i]["time"]) > fmt.Sprintf("%v", items[j]["time"]) }) - stats := map[string]int{"total": len(items), "running": len(running), "idle_round_budget": 0, "active_user": 0, "manual_pause": 0} - for _, it := range items { - reason := fmt.Sprintf("%v", it["block_reason"]) - switch reason { - case "idle_round_budget": - stats["idle_round_budget"]++ - case "active_user": - stats["active_user"]++ - case "manual_pause": - stats["manual_pause"]++ - } - } + stats := map[string]int{"total": len(items), "running": len(running)} _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "running": running, "items": items, "stats": stats}) } -func (s *Server) handleWebUITaskDailySummary(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - date := r.URL.Query().Get("date") - if date == "" { - date = time.Now().UTC().Format("2006-01-02") - } - path := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", date+".md") - b, err := os.ReadFile(path) - if err != nil { - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "date": date, "report": ""}) - return - } - text := string(b) - marker := "## Autonomy Daily Report (" + date + ")" - idx := strings.Index(text, marker) - report := "" - if idx >= 0 { - report = text[idx:] - if n := strings.Index(report[len(marker):], "\n## "); n > 0 { - report = report[:len(marker)+n] - } - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "date": date, "report": report}) -} - func (s *Server) loadEKGRowsCached(path string, maxLines int) []map[string]interface{} { path = strings.TrimSpace(path) if path == "" { @@ -2612,18 +2629,6 @@ func (s *Server) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) { } return out } - escalations := 0 - tasksPath := filepath.Join(workspace, "memory", "tasks.json") - if tb, err := os.ReadFile(tasksPath); err == nil { - var tasks []map[string]interface{} - if json.Unmarshal(tb, &tasks) == nil { - for _, t := range tasks { - if strings.TrimSpace(fmt.Sprintf("%v", t["block_reason"])) == "repeated_error_signature" { - escalations++ - } - } - } - } _ = json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "window": selectedWindow, @@ -2634,117 +2639,10 @@ func (s *Server) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) { "errsig_top_workload": toTopCount(errSigWorkload, 5), "source_stats": sourceStats, "channel_stats": channelStats, - "escalation_count": escalations, + "escalation_count": 0, }) } -func (s *Server) handleWebUITasks(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - tasksPath := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "tasks.json") - if r.Method == http.MethodGet { - b, err := os.ReadFile(tasksPath) - if err != nil { - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "items": []map[string]interface{}{}}) - return - } - var items []map[string]interface{} - if err := json.Unmarshal(b, &items); err != nil { - http.Error(w, "invalid tasks file", http.StatusInternalServerError) - return - } - sort.Slice(items, func(i, j int) bool { - return fmt.Sprintf("%v", items[i]["updated_at"]) > fmt.Sprintf("%v", items[j]["updated_at"]) - }) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "items": items}) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - action := fmt.Sprintf("%v", body["action"]) - now := time.Now().UTC().Format(time.RFC3339) - items := []map[string]interface{}{} - if b, err := os.ReadFile(tasksPath); err == nil { - _ = json.Unmarshal(b, &items) - } - switch action { - case "create": - it, _ := body["item"].(map[string]interface{}) - if it == nil { - http.Error(w, "item required", http.StatusBadRequest) - return - } - id := fmt.Sprintf("%v", it["id"]) - if id == "" { - id = fmt.Sprintf("task_%d", time.Now().UnixNano()) - } - it["id"] = id - if fmt.Sprintf("%v", it["status"]) == "" { - it["status"] = "todo" - } - if fmt.Sprintf("%v", it["source"]) == "" { - it["source"] = "manual" - } - it["updated_at"] = now - items = append(items, it) - case "update": - id := fmt.Sprintf("%v", body["id"]) - it, _ := body["item"].(map[string]interface{}) - if id == "" || it == nil { - http.Error(w, "id and item required", http.StatusBadRequest) - return - } - updated := false - for _, row := range items { - if fmt.Sprintf("%v", row["id"]) == id { - for k, v := range it { - row[k] = v - } - row["id"] = id - row["updated_at"] = now - updated = true - break - } - } - if !updated { - http.Error(w, "task not found", http.StatusNotFound) - return - } - case "delete": - id := fmt.Sprintf("%v", body["id"]) - if id == "" { - http.Error(w, "id required", http.StatusBadRequest) - return - } - filtered := make([]map[string]interface{}, 0, len(items)) - for _, row := range items { - if fmt.Sprintf("%v", row["id"]) != id { - filtered = append(filtered, row) - } - } - items = filtered - default: - http.Error(w, "unsupported action", http.StatusBadRequest) - return - } - _ = os.MkdirAll(filepath.Dir(tasksPath), 0755) - out, _ := json.MarshalIndent(items, "", " ") - if err := os.WriteFile(tasksPath, out, 0644); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) -} - func (s *Server) handleWebUIExecApprovals(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -2953,7 +2851,6 @@ func hotReloadFieldInfo() []map[string]interface{} { {"path": "channels.*", "name": "Channels", "description": "Telegram and other channel settings"}, {"path": "cron.*", "name": "Cron", "description": "Global cron runtime settings"}, {"path": "agents.defaults.heartbeat.*", "name": "Heartbeat", "description": "Heartbeat interval and prompt template"}, - {"path": "agents.defaults.autonomy.*", "name": "Autonomy", "description": "Autonomy toggles and throttling"}, {"path": "gateway.*", "name": "Gateway", "description": "Mostly hot-reloadable; host/port may require restart"}, } } diff --git a/pkg/autonomy/engine.go b/pkg/autonomy/engine.go deleted file mode 100644 index 20b3de5..0000000 --- a/pkg/autonomy/engine.go +++ /dev/null @@ -1,1507 +0,0 @@ -package autonomy - -import ( - "bufio" - "context" - "crypto/sha1" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - "clawgo/pkg/bus" - "clawgo/pkg/ekg" - "clawgo/pkg/lifecycle" - "clawgo/pkg/scheduling" -) - -type Options struct { - Enabled bool - TickIntervalSec int - MinRunIntervalSec int - MaxPendingDurationSec int - MaxConsecutiveStalls int - MaxDispatchPerTick int - Workspace string - DefaultNotifyChannel string - DefaultNotifyChatID string - NotifyAllowFrom []string - NotifyCooldownSec int - NotifySameReasonCooldownSec int - QuietHours string - UserIdleResumeSec int - WaitingResumeDebounceSec int - IdleRoundBudgetReleaseSec int - MaxRoundsWithoutUser int - TaskHistoryRetentionDays int - AllowedTaskKeywords []string - EKGConsecutiveErrorThreshold int -} - -type taskState struct { - ID string - Content string - Priority string - DueAt string - Status string // idle|running|waiting|blocked|completed - BlockReason string - WaitingSince time.Time - LastRunAt time.Time - LastAutonomyAt time.Time - RetryAfter time.Time - ConsecutiveStall int - DedupeHits int - ResourceKeys []string - WaitAttempts int - LastPauseReason string - LastPauseAt time.Time -} - -type Engine struct { - opts Options - bus *bus.MessageBus - runner *lifecycle.LoopRunner - taskStore *TaskStore - - mu sync.Mutex - state map[string]*taskState - lastNotify map[string]time.Time - lockOwners map[string]string - roundsWithoutUser int - lastDailyReportDate string - lastHistoryCleanupAt time.Time - ekg *ekg.Engine -} - -func NewEngine(opts Options, msgBus *bus.MessageBus) *Engine { - if opts.TickIntervalSec <= 0 { - opts.TickIntervalSec = 30 - } - if opts.MinRunIntervalSec <= 0 { - opts.MinRunIntervalSec = 20 - } - if opts.MaxPendingDurationSec <= 0 { - opts.MaxPendingDurationSec = 180 - } - if opts.MaxConsecutiveStalls <= 0 { - opts.MaxConsecutiveStalls = 3 - } - // max_dispatch_per_tick <= 0 means "unlimited dispatch per tick". - if opts.MaxDispatchPerTick < 0 { - opts.MaxDispatchPerTick = 2 - } - if opts.NotifyCooldownSec <= 0 { - opts.NotifyCooldownSec = 300 - } - if opts.UserIdleResumeSec <= 0 { - opts.UserIdleResumeSec = 20 - } - if opts.NotifySameReasonCooldownSec <= 0 { - opts.NotifySameReasonCooldownSec = 900 - } - if opts.WaitingResumeDebounceSec <= 0 { - opts.WaitingResumeDebounceSec = 5 - } - if opts.MaxRoundsWithoutUser < 0 { - opts.MaxRoundsWithoutUser = 0 - } - if opts.IdleRoundBudgetReleaseSec < 0 { - opts.IdleRoundBudgetReleaseSec = 0 - } - if opts.TaskHistoryRetentionDays <= 0 { - opts.TaskHistoryRetentionDays = 3 - } - if opts.EKGConsecutiveErrorThreshold <= 0 { - opts.EKGConsecutiveErrorThreshold = 3 - } - eng := &Engine{ - opts: opts, - bus: msgBus, - runner: lifecycle.NewLoopRunner(), - taskStore: NewTaskStore(opts.Workspace), - state: map[string]*taskState{}, - lastNotify: map[string]time.Time{}, - lockOwners: map[string]string{}, - ekg: ekg.New(opts.Workspace), - } - if eng.ekg != nil { - eng.ekg.SetConsecutiveErrorThreshold(opts.EKGConsecutiveErrorThreshold) - } - return eng -} - -func (e *Engine) Start() { - if !e.opts.Enabled { - return - } - e.runner.Start(e.runLoop) -} - -func (e *Engine) Stop() { - e.runner.Stop() -} - -func (e *Engine) runLoop(stopCh <-chan struct{}) { - ticker := time.NewTicker(time.Duration(e.opts.TickIntervalSec) * time.Second) - defer ticker.Stop() - for { - select { - case <-stopCh: - return - case <-ticker.C: - e.tick() - } - } -} - -func (e *Engine) tick() { - todos := e.scanTodos() - now := time.Now() - stored, _ := e.taskStore.Load() - - e.mu.Lock() - if e.hasManualPause() { - for _, st := range e.state { - if st.Status == "running" { - e.releaseLocksLocked(st.ID) - st.Status = "waiting" - st.BlockReason = "manual_pause" - st.WaitingSince = now - st.LastPauseReason = "manual_pause" - st.LastPauseAt = now - e.writeReflectLog("waiting", st, "paused by manual switch") - e.writeTriggerAudit("waiting", st, "manual_pause") - } - } - e.roundsWithoutUser = 0 - e.persistStateLocked() - e.mu.Unlock() - return - } - if e.hasRecentUserActivity(now) { - for _, st := range e.state { - if st.Status == "running" { - e.releaseLocksLocked(st.ID) - st.Status = "waiting" - st.BlockReason = "active_user" - st.WaitingSince = now - st.LastPauseReason = "active_user" - st.LastPauseAt = now - e.writeReflectLog("waiting", st, "paused due to active user conversation") - e.writeTriggerAudit("waiting", st, "active_user") - } - } - e.persistStateLocked() - e.mu.Unlock() - return - } - e.mu.Unlock() - - e.mu.Lock() - defer e.mu.Unlock() - - storedMap := map[string]TaskItem{} - for _, it := range stored { - storedMap[it.ID] = it - } - - known := map[string]struct{}{} - for _, t := range todos { - known[t.ID] = struct{}{} - } - // Merge structured tasks.json entries as todo source too (for WebUI CRUD-created tasks). - for _, old := range stored { - if old.ID == "" { - continue - } - if _, ok := known[old.ID]; ok { - continue - } - st := strings.ToLower(old.Status) - if st == "done" || st == "completed" { - continue - } - todos = append(todos, todoItem{ID: old.ID, Content: old.Content, Priority: old.Priority, DueAt: old.DueAt}) - known[old.ID] = struct{}{} - } - for _, t := range todos { - st, ok := e.state[t.ID] - if !ok { - status := "idle" - retryAfter := time.Time{} - resourceKeys := deriveResourceKeys(t.Content) - lastPauseReason := "" - lastPauseAt := time.Time{} - if old, ok := storedMap[t.ID]; ok { - if old.Status == "blocked" { - status = "blocked" - } - if strings.TrimSpace(old.RetryAfter) != "" { - if rt, err := time.Parse(time.RFC3339, old.RetryAfter); err == nil { - retryAfter = rt - } - } - if len(old.ResourceKeys) > 0 { - resourceKeys = append([]string(nil), old.ResourceKeys...) - } - lastPauseReason = old.LastPauseReason - if old.LastPauseAt != "" { - if pt, err := time.Parse(time.RFC3339, old.LastPauseAt); err == nil { - lastPauseAt = pt - } - } - } - e.state[t.ID] = &taskState{ID: t.ID, Content: t.Content, Priority: t.Priority, DueAt: t.DueAt, Status: status, RetryAfter: retryAfter, DedupeHits: t.DedupeHits, ResourceKeys: resourceKeys, LastPauseReason: lastPauseReason, LastPauseAt: lastPauseAt} - continue - } - st.Content = t.Content - st.Priority = t.Priority - st.DueAt = t.DueAt - st.DedupeHits = t.DedupeHits - st.ResourceKeys = deriveResourceKeys(t.Content) - } - - // completed when removed from todo source - for id, st := range e.state { - if _, ok := known[id]; !ok { - if st.Status != "completed" { - e.releaseLocksLocked(st.ID) - st.Status = "completed" - e.sendCompletionNotification(st) - e.enqueueInferredNextTasksLocked(st) - } - } - } - - ordered := make([]*taskState, 0, len(e.state)) - for _, st := range e.state { - ordered = append(ordered, st) - } - sort.Slice(ordered, func(i, j int) bool { - si := schedulingScore(ordered[i], now) - sj := schedulingScore(ordered[j], now) - if si != sj { - return si > sj - } - return ordered[i].ID < ordered[j].ID - }) - - dispatched := 0 - for _, st := range ordered { - if e.opts.MaxDispatchPerTick > 0 && dispatched >= e.opts.MaxDispatchPerTick { - break - } - if st.Status == "completed" { - continue - } - if st.Status == "waiting" { - if st.BlockReason == "resource_lock" && !st.RetryAfter.IsZero() && now.Before(st.RetryAfter) { - continue - } - if st.BlockReason == "idle_round_budget" && e.opts.MaxRoundsWithoutUser > 0 && e.roundsWithoutUser >= e.opts.MaxRoundsWithoutUser { - // Optional auto-release without user dialog: allow one round after configured cooldown. - if e.opts.IdleRoundBudgetReleaseSec > 0 && !st.WaitingSince.IsZero() && now.Sub(st.WaitingSince) >= time.Duration(e.opts.IdleRoundBudgetReleaseSec)*time.Second { - e.roundsWithoutUser = e.opts.MaxRoundsWithoutUser - 1 - e.writeReflectLog("resume", st, fmt.Sprintf("autonomy auto-resumed from idle round budget after %ds", e.opts.IdleRoundBudgetReleaseSec)) - e.writeTriggerAudit("resume", st, "idle_round_budget_auto_release") - } else { - // Stay waiting until user activity resets round budget. - continue - } - } - // Debounce waiting/resume flapping - if !st.WaitingSince.IsZero() && now.Sub(st.WaitingSince) < time.Duration(e.opts.WaitingResumeDebounceSec)*time.Second { - continue - } - reason := st.BlockReason - st.Status = "idle" - st.BlockReason = "" - st.WaitingSince = time.Time{} - pausedFor := 0 - if !st.LastPauseAt.IsZero() { - pausedFor = int(now.Sub(st.LastPauseAt).Seconds()) - } - e.writeReflectLog("resume", st, fmt.Sprintf("autonomy resumed from waiting (reason=%s paused_for=%ds)", reason, pausedFor)) - e.writeTriggerAudit("resume", st, reason) - } - if st.Status == "running" { - if outcome, ok := e.detectRunOutcome(st.ID, st.LastRunAt); ok { - e.releaseLocksLocked(st.ID) - if outcome == "success" || outcome == "suppressed" { - st.Status = "completed" - e.appendTaskAttemptLocked(st.ID, "done", "autonomy:"+st.ID, "run outcome success") - e.writeReflectLog("complete", st, "marked completed by run outcome") - e.enqueueInferredNextTasksLocked(st) - continue - } - if outcome == "error" { - errSig := e.latestErrorSignature(st.ID, st.LastRunAt) - advice := ekg.Advice{} - if e.ekg != nil { - advice = e.ekg.GetAdvice(ekg.SignalContext{TaskID: st.ID, ErrSig: errSig, Source: "autonomy", Channel: "system"}) - } - st.Status = "blocked" - if advice.ShouldEscalate { - st.BlockReason = "repeated_error_signature" - st.RetryAfter = now.Add(5 * time.Minute) - e.enqueueAutoRepairTaskLocked(st, errSig) - e.appendMemoryIncidentLocked(st, errSig, advice.Reason) - e.sendFailureNotification(st, "repeated error signature detected; escalate") - continue - } - st.BlockReason = "last_run_error" - e.appendTaskAttemptLocked(st.ID, "blocked", "autonomy:"+st.ID, "run outcome error") - st.RetryAfter = now.Add(blockedRetryBackoff(st.ConsecutiveStall+1, e.opts.MinRunIntervalSec)) - e.sendFailureNotification(st, "last run ended with error") - continue - } - } - // Keep running tasks intact across ticks/restarts until pending timeout, - // so a gateway restart won't immediately redispatch from scratch. - if !st.LastRunAt.IsZero() && now.Sub(st.LastRunAt) <= time.Duration(e.opts.MaxPendingDurationSec)*time.Second { - continue - } - } - if st.Status == "blocked" { - e.releaseLocksLocked(st.ID) - if !st.RetryAfter.IsZero() && now.Before(st.RetryAfter) { - continue - } - if now.Sub(st.LastRunAt) >= blockedRetryBackoff(st.ConsecutiveStall, e.opts.MinRunIntervalSec) { - st.Status = "idle" - st.BlockReason = "" - } else { - continue - } - } - if !st.LastRunAt.IsZero() && now.Sub(st.LastRunAt) < time.Duration(e.opts.MinRunIntervalSec)*time.Second { - continue - } - if !e.allowTaskByPolicy(st.Content) { - continue - } - if st.Status == "running" && now.Sub(st.LastRunAt) > time.Duration(e.opts.MaxPendingDurationSec)*time.Second { - st.ConsecutiveStall++ - if st.ConsecutiveStall > e.opts.MaxConsecutiveStalls { - st.Status = "blocked" - st.BlockReason = "max_consecutive_stalls" - st.RetryAfter = now.Add(blockedRetryBackoff(st.ConsecutiveStall, e.opts.MinRunIntervalSec)) - e.sendFailureNotification(st, "max consecutive stalls reached") - continue - } - } - - if e.opts.MaxRoundsWithoutUser > 0 && e.roundsWithoutUser >= e.opts.MaxRoundsWithoutUser { - if !(st.Status == "waiting" && st.BlockReason == "idle_round_budget") { - st.Status = "waiting" - st.BlockReason = "idle_round_budget" - st.WaitingSince = now - e.writeReflectLog("waiting", st, fmt.Sprintf("paused by idle round budget (%d)", e.opts.MaxRoundsWithoutUser)) - e.writeTriggerAudit("waiting", st, "idle_round_budget") - } - continue - } - if !e.tryAcquireLocksLocked(st) { - st.Status = "waiting" - st.BlockReason = "resource_lock" - st.WaitingSince = now - st.RetryAfter = now.Add(30 * time.Second) - st.WaitAttempts++ - e.writeTriggerAudit("waiting", st, "resource_lock") - continue - } - e.dispatchTask(st) - e.appendTaskAttemptLocked(st.ID, "running", "autonomy:"+st.ID, "dispatch") - st.Status = "running" - st.WaitAttempts = 0 - st.BlockReason = "" - st.WaitingSince = time.Time{} - st.LastPauseReason = "" - st.LastRunAt = now - st.LastAutonomyAt = now - e.writeReflectLog("dispatch", st, "task dispatched to agent loop") - e.writeTriggerAudit("dispatch", st, "") - e.roundsWithoutUser++ - dispatched++ - } - e.persistStateLocked() - e.maybeWriteDailyReportLocked(now) - e.maybeCleanupTaskHistoryLocked(now) -} - -func (e *Engine) tryAcquireLocksLocked(st *taskState) bool { - if st == nil { - return false - } - keys := st.ResourceKeys - if len(keys) == 0 { - keys = []string{"task:" + st.ID} - } - for _, k := range keys { - if owner, ok := e.lockOwners[k]; ok && owner != "" && owner != st.ID { - return false - } - } - for _, k := range keys { - e.lockOwners[k] = st.ID - } - return true -} - -func (e *Engine) releaseLocksLocked(taskID string) { - if strings.TrimSpace(taskID) == "" { - return - } - for k, v := range e.lockOwners { - if v == taskID { - delete(e.lockOwners, k) - } - } -} - -func schedulingScore(st *taskState, now time.Time) int { - if st == nil { - return 0 - } - score := priorityWeight(st.Priority)*100 + int(dueWeight(st.DueAt))*10 - if st.Status == "waiting" && st.BlockReason == "resource_lock" && !st.WaitingSince.IsZero() { - waitSec := int(now.Sub(st.WaitingSince).Seconds()) - if waitSec > 0 { - score += waitSec / 10 - } - score += st.WaitAttempts * 5 - } - return score -} - -func deriveResourceKeys(content string) []string { - return scheduling.DeriveResourceKeys(content) -} - -type todoItem struct { - ID string - Content string - Priority string - DueAt string - DedupeHits int -} - -func (e *Engine) scanTodos() []todoItem { - if strings.TrimSpace(e.opts.Workspace) == "" { - return nil - } - merged := map[string]todoItem{} - storedItems, _ := e.taskStore.Load() - doneIDs := map[string]bool{} - for _, it := range storedItems { - status := strings.ToLower(strings.TrimSpace(it.Status)) - if status == "done" || status == "completed" { - doneIDs[strings.TrimSpace(it.ID)] = true - } - } - merge := func(it todoItem) { - if strings.TrimSpace(it.ID) == "" || strings.TrimSpace(it.Content) == "" { - return - } - if doneIDs[strings.TrimSpace(it.ID)] { - return - } - if cur, ok := merged[it.ID]; ok { - if priorityWeight(it.Priority) > priorityWeight(cur.Priority) { - cur.Priority = it.Priority - } - if cur.DueAt == "" && it.DueAt != "" { - cur.DueAt = it.DueAt - } - cur.DedupeHits++ - merged[it.ID] = cur - return - } - merged[it.ID] = it - } - - // 1) Parse markdown todos from MEMORY + today's daily memory. - paths := []string{ - filepath.Join(e.opts.Workspace, "MEMORY.md"), - filepath.Join(e.opts.Workspace, "memory", time.Now().Format("2006-01-02")+".md"), - } - for _, p := range paths { - data, err := os.ReadFile(p) - if err != nil { - continue - } - for _, line := range strings.Split(string(data), "\n") { - t := strings.TrimSpace(line) - if strings.HasPrefix(t, "- [ ]") { - content := strings.TrimPrefix(t, "- [ ]") - priority, dueAt, normalized := parseTodoAttributes(content) - merge(todoItem{ID: hashID(normalized), Content: normalized, Priority: priority, DueAt: dueAt}) - continue - } - if strings.HasPrefix(strings.ToLower(t), "todo:") { - content := t[5:] - priority, dueAt, normalized := parseTodoAttributes(content) - merge(todoItem{ID: hashID(normalized), Content: normalized, Priority: priority, DueAt: dueAt}) - } - } - } - - // 2) Merge structured tasks.json items (manual injections / prior state). - for _, it := range storedItems { - status := strings.ToLower(strings.TrimSpace(it.Status)) - if status == "done" { - continue - } - content := it.Content - if content == "" { - continue - } - id := strings.TrimSpace(it.ID) - if id == "" { - id = hashID(content) - } - priority := strings.TrimSpace(it.Priority) - if priority == "" { - priority = "normal" - } - merge(todoItem{ID: id, Content: content, Priority: priority, DueAt: it.DueAt}) - } - - out := make([]todoItem, 0, len(merged)) - for _, it := range merged { - out = append(out, it) - } - return out -} - -func (e *Engine) dispatchTask(st *taskState) { - content := fmt.Sprintf("Autonomy task:\n%s", st.Content) - e.bus.PublishInbound(bus.InboundMessage{ - Channel: "system", - SenderID: "autonomy", - ChatID: "internal:autonomy", - Content: content, - SessionKey: "autonomy:" + st.ID, - Metadata: map[string]string{ - "trigger": "autonomy", - "task_id": st.ID, - "source": "memory_todo", - }, - }) -} - -func (e *Engine) sendCompletionNotification(st *taskState) { - e.writeReflectLog("complete", st, "task marked completed") - e.writeTriggerAudit("complete", st, "") - if !e.shouldNotify("done:"+st.ID, "") { - return - } - tpl := "✅ Completed: %s\nNext step: reply \"continue %s\" if you want me to proceed." - e.bus.PublishOutbound(bus.OutboundMessage{ - Channel: e.opts.DefaultNotifyChannel, - ChatID: e.opts.DefaultNotifyChatID, - Content: fmt.Sprintf(tpl, shortTask(st.Content), shortTask(st.Content)), - }) -} - -func (e *Engine) enqueueInferredNextTasksLocked(st *taskState) { - if st == nil { - return - } - content := strings.TrimSpace(st.Content) - if content == "" { - return - } - if strings.Contains(content, "[auto-next]") { - return - } - c := strings.ToLower(content) - looksLikeStudy := strings.Contains(c, "学习") || strings.Contains(c, "研究") || strings.Contains(c, "analy") || strings.Contains(c, "study") || strings.Contains(c, "代码") || strings.Contains(c, "codebase") - if !looksLikeStudy { - return - } - - candidates := []string{ - fmt.Sprintf("[auto-next] 基于「%s」输出架构摘要与改进点 Top5(含收益/风险评估)", shortTask(content)), - fmt.Sprintf("[auto-next] 基于「%s」拆解 3-5 个可执行开发任务(含优先级、验收标准),并写入任务队列", shortTask(content)), - } - - existing := map[string]bool{} - for _, cur := range e.state { - existing[strings.TrimSpace(cur.Content)] = true - } - items, _ := e.taskStore.Load() - for _, it := range items { - existing[strings.TrimSpace(it.Content)] = true - } - - now := nowRFC3339() - added := 0 - for _, candidate := range candidates { - candidate = strings.TrimSpace(candidate) - if candidate == "" || existing[candidate] { - continue - } - id := hashID(candidate) - e.state[id] = &taskState{ID: id, Content: candidate, Priority: "normal", Status: "idle"} - items = append(items, TaskItem{ID: id, ParentTaskID: st.ID, Content: candidate, Priority: "normal", Status: "todo", Source: "autonomy_infer", UpdatedAt: now}) - existing[candidate] = true - added++ - } - if added > 0 { - _ = e.taskStore.Save(items) - e.writeReflectLog("infer", st, fmt.Sprintf("generated %d follow-up task(s)", added)) - } -} - -func (e *Engine) enqueueAutoRepairTaskLocked(st *taskState, errSig string) { - if st == nil { - return - } - errSig = strings.TrimSpace(errSig) - if errSig == "" { - errSig = "unknown_error_signature" - } - content := fmt.Sprintf("[auto-repair] 排查任务 %s 的重复错误签名并给出修复步骤(errsig=%s)", shortTask(st.Content), shortTask(errSig)) - existing := map[string]bool{} - for _, cur := range e.state { - existing[strings.TrimSpace(cur.Content)] = true - } - items, _ := e.taskStore.Load() - for _, it := range items { - existing[strings.TrimSpace(it.Content)] = true - } - if existing[content] { - return - } - id := hashID(content) - e.state[id] = &taskState{ID: id, Content: content, Priority: "high", Status: "idle"} - items = append(items, TaskItem{ID: id, ParentTaskID: st.ID, Content: content, Priority: "high", Status: "todo", Source: "autonomy_repair", UpdatedAt: nowRFC3339()}) - _ = e.taskStore.Save(items) - e.writeReflectLog("infer", st, "generated auto-repair task due to repeated error signature") -} - -func (e *Engine) appendMemoryIncidentLocked(st *taskState, errSig string, reasons []string) { - if st == nil || strings.TrimSpace(e.opts.Workspace) == "" { - return - } - errSig = ekg.NormalizeErrorSignature(errSig) - if errSig == "" { - errSig = "unknown_error_signature" - } - marker := "[EKG_INCIDENT] errsig=" + errSig - now := time.Now().UTC() - line := fmt.Sprintf("- [EKG_INCIDENT] errsig=%s task=%s reason=%s time=%s", errSig, shortTask(st.Content), strings.Join(reasons, ";"), now.Format(time.RFC3339)) - cooldown := 6 * time.Hour - hasRecentIncident := func(content string) bool { - for _, ln := range strings.Split(content, "\n") { - if !strings.Contains(ln, marker) { - continue - } - idx := strings.LastIndex(ln, "time=") - if idx < 0 { - return true - } - ts := strings.TrimSpace(ln[idx+len("time="):]) - if tm, err := time.Parse(time.RFC3339, ts); err == nil { - if now.Sub(tm) < cooldown { - return true - } - continue - } - return true - } - return false - } - appendIfDue := func(path string) { - _ = os.MkdirAll(filepath.Dir(path), 0755) - b, _ := os.ReadFile(path) - if hasRecentIncident(string(b)) { - return - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - _, _ = f.WriteString(line + "\n") - } - dayPath := filepath.Join(e.opts.Workspace, "memory", now.Format("2006-01-02")+".md") - memoryMain := filepath.Join(e.opts.Workspace, "MEMORY.md") - appendIfDue(dayPath) - appendIfDue(memoryMain) - items, _ := e.taskStore.Load() - for i := range items { - if items[i].ID != st.ID { - continue - } - items[i].MemoryRefs = append(items[i].MemoryRefs, dayPath, memoryMain) - if len(items[i].MemoryRefs) > 10 { - items[i].MemoryRefs = items[i].MemoryRefs[len(items[i].MemoryRefs)-10:] - } - break - } - _ = e.taskStore.Save(items) -} - -func (e *Engine) appendTaskAttemptLocked(taskID, status, session, note string) { - taskID = strings.TrimSpace(taskID) - if taskID == "" { - return - } - items, _ := e.taskStore.Load() - for i := range items { - if items[i].ID != taskID { - continue - } - items[i].Attempts = append(items[i].Attempts, TaskAttempt{ - Time: time.Now().UTC().Format(time.RFC3339), - Status: strings.TrimSpace(status), - Session: strings.TrimSpace(session), - Note: shortTask(note), - }) - if len(items[i].Attempts) > 30 { - items[i].Attempts = items[i].Attempts[len(items[i].Attempts)-30:] - } - items[i].AuditRefs = append(items[i].AuditRefs, time.Now().UTC().Format(time.RFC3339)) - if len(items[i].AuditRefs) > 60 { - items[i].AuditRefs = items[i].AuditRefs[len(items[i].AuditRefs)-60:] - } - items[i].UpdatedAt = nowRFC3339() - break - } - _ = e.taskStore.Save(items) -} - -func (e *Engine) sendFailureNotification(st *taskState, reason string) { - e.writeReflectLog("blocked", st, reason) - e.writeTriggerAudit("blocked", st, reason) - if !e.shouldNotify("blocked:"+st.ID, reason) { - return - } - tpl := "⚠️ Task blocked: %s\nReason: %s\nSuggestion: reply \"continue %s\" and I will retry from current state." - e.bus.PublishOutbound(bus.OutboundMessage{ - Channel: e.opts.DefaultNotifyChannel, - ChatID: e.opts.DefaultNotifyChatID, - Content: fmt.Sprintf(tpl, shortTask(st.Content), reason, shortTask(st.Content)), - }) -} - -func (e *Engine) shouldNotify(key string, reason string) bool { - if strings.TrimSpace(e.opts.DefaultNotifyChannel) == "" || strings.TrimSpace(e.opts.DefaultNotifyChatID) == "" { - return false - } - if len(e.opts.NotifyAllowFrom) > 0 { - allowed := false - for _, c := range e.opts.NotifyAllowFrom { - if c == e.opts.DefaultNotifyChatID { - allowed = true - break - } - } - if !allowed { - return false - } - } - now := time.Now() - if inQuietHours(now, e.opts.QuietHours) { - return false - } - if last, ok := e.lastNotify[key]; ok { - if now.Sub(last) < time.Duration(e.opts.NotifyCooldownSec)*time.Second { - return false - } - } - r := strings.ToLower(strings.TrimSpace(reason)) - if r != "" { - rk := key + ":reason:" + strings.ReplaceAll(r, " ", "_") - if last, ok := e.lastNotify[rk]; ok { - if now.Sub(last) < time.Duration(e.opts.NotifySameReasonCooldownSec)*time.Second { - return false - } - } - e.lastNotify[rk] = now - } - e.lastNotify[key] = now - return true -} - -func (e *Engine) writeTriggerAudit(action string, st *taskState, errText string) { - if strings.TrimSpace(e.opts.Workspace) == "" || st == nil { - return - } - memDir := filepath.Join(e.opts.Workspace, "memory") - path := filepath.Join(memDir, "trigger-audit.jsonl") - _ = os.MkdirAll(filepath.Dir(path), 0755) - row := map[string]interface{}{ - "time": time.Now().UTC().Format(time.RFC3339), - "trigger": "autonomy", - "action": action, - "session": "autonomy:" + st.ID, - } - if errText != "" { - row["error"] = errText - } - if b, err := json.Marshal(row); err == nil { - f, oErr := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if oErr == nil { - _, _ = f.Write(append(b, '\n')) - _ = f.Close() - } - } - - statsPath := filepath.Join(memDir, "trigger-stats.json") - stats := struct { - UpdatedAt string `json:"updated_at"` - Counts map[string]int `json:"counts"` - }{Counts: map[string]int{}} - if raw, rErr := os.ReadFile(statsPath); rErr == nil { - _ = json.Unmarshal(raw, &stats) - if stats.Counts == nil { - stats.Counts = map[string]int{} - } - } - stats.Counts["autonomy"]++ - act := strings.ToLower(strings.TrimSpace(action)) - if act != "" { - stats.Counts["autonomy:"+act]++ - reason := strings.ToLower(errText) - if reason != "" { - reason = strings.ReplaceAll(reason, " ", "_") - reason = strings.ReplaceAll(reason, ":", "_") - stats.Counts["autonomy:"+act+":"+reason]++ - } - } - stats.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - if raw, mErr := json.MarshalIndent(stats, "", " "); mErr == nil { - _ = os.WriteFile(statsPath, raw, 0644) - } -} - -func (e *Engine) writeReflectLog(stage string, st *taskState, outcome string) { - if strings.TrimSpace(e.opts.Workspace) == "" || st == nil { - return - } - memDir := filepath.Join(e.opts.Workspace, "memory") - _ = os.MkdirAll(memDir, 0755) - path := filepath.Join(memDir, time.Now().Format("2006-01-02")+".md") - line := fmt.Sprintf("- [%s] [autonomy][%s] task=%s status=%s outcome=%s\n", time.Now().Format("15:04"), stage, st.Content, st.Status, outcome) - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - _, _ = f.WriteString(line) -} - -func inQuietHours(now time.Time, spec string) bool { - spec = strings.TrimSpace(spec) - if spec == "" { - return false - } - parts := strings.Split(spec, "-") - if len(parts) != 2 { - return false - } - parseHM := func(v string) (int, bool) { - hm := strings.Split(strings.TrimSpace(v), ":") - if len(hm) != 2 { - return 0, false - } - h, err1 := strconv.Atoi(hm[0]) - m, err2 := strconv.Atoi(hm[1]) - if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { - return 0, false - } - return h*60 + m, true - } - start, ok1 := parseHM(parts[0]) - end, ok2 := parseHM(parts[1]) - if !ok1 || !ok2 { - return false - } - nowMin := now.Hour()*60 + now.Minute() - if start <= end { - return nowMin >= start && nowMin <= end - } - return nowMin >= start || nowMin <= end -} - -func (e *Engine) persistStateLocked() { - existing, _ := e.taskStore.Load() - existingMap := map[string]TaskItem{} - for _, it := range existing { - existingMap[it.ID] = it - } - items := make([]TaskItem, 0, len(e.state)) - built := map[string]struct{}{} - for _, st := range e.state { - status := "todo" - switch st.Status { - case "running": - status = "doing" - case "waiting": - status = "waiting" - case "blocked": - status = "blocked" - case "completed": - status = "done" - default: - status = "todo" - } - retryAfter := "" - if !st.RetryAfter.IsZero() { - retryAfter = st.RetryAfter.UTC().Format(time.RFC3339) - } - lastPauseAt := "" - if !st.LastPauseAt.IsZero() { - lastPauseAt = st.LastPauseAt.UTC().Format(time.RFC3339) - } - prev := existingMap[st.ID] - source := prev.Source - if strings.TrimSpace(source) == "" { - source = "memory_todo" - } - built[st.ID] = struct{}{} - items = append(items, TaskItem{ - ID: st.ID, - ParentTaskID: prev.ParentTaskID, - Content: st.Content, - Priority: st.Priority, - DueAt: st.DueAt, - Status: status, - BlockReason: st.BlockReason, - RetryAfter: retryAfter, - Source: source, - DedupeHits: st.DedupeHits, - ResourceKeys: append([]string(nil), st.ResourceKeys...), - LastPauseReason: st.LastPauseReason, - LastPauseAt: lastPauseAt, - MemoryRefs: append([]string(nil), prev.MemoryRefs...), - AuditRefs: append([]string(nil), prev.AuditRefs...), - Attempts: append([]TaskAttempt(nil), prev.Attempts...), - UpdatedAt: nowRFC3339(), - }) - } - for _, old := range existing { - if old.ID == "" { - continue - } - if _, ok := built[old.ID]; ok { - continue - } - st := strings.ToLower(strings.TrimSpace(old.Status)) - if st == "done" || st == "canceled" || st == "paused" { - items = append(items, old) - } - } - _ = e.taskStore.Save(items) -} - -func (e *Engine) maybeWriteDailyReportLocked(now time.Time) { - date := now.UTC().Format("2006-01-02") - if e.lastDailyReportDate == date { - return - } - workspace := e.opts.Workspace - if workspace == "" { - return - } - auditPath := filepath.Join(workspace, "memory", "task-audit.jsonl") - f, err := os.Open(auditPath) - if err != nil { - return - } - defer f.Close() - counts := map[string]int{"total": 0, "success": 0, "error": 0, "suppressed": 0, "running": 0} - errorReasons := map[string]int{} - type topTask struct { - TaskID string - Duration int - Status string - } - top := make([]topTask, 0, 32) - s := bufio.NewScanner(f) - for s.Scan() { - line := s.Bytes() - var row map[string]interface{} - if json.Unmarshal(line, &row) != nil { - continue - } - if fmt.Sprintf("%v", row["source"]) != "autonomy" { - continue - } - ts := fmt.Sprintf("%v", row["time"]) - if len(ts) < 10 || ts[:10] != date { - continue - } - counts["total"]++ - st := fmt.Sprintf("%v", row["status"]) - if _, ok := counts[st]; ok { - counts[st]++ - } - if st == "error" { - errText := fmt.Sprintf("%v", row["error"]) - if errText == "" { - errText = fmt.Sprintf("%v", row["log"]) - } - errText = shortTask(strings.ReplaceAll(errText, "\n", " ")) - if errText != "" { - errorReasons[errText]++ - } - } - dur := 0 - switch v := row["duration_ms"].(type) { - case float64: - dur = int(v) - case int: - dur = v - case string: - if n, err := strconv.Atoi(v); err == nil { - dur = n - } - } - top = append(top, topTask{TaskID: fmt.Sprintf("%v", row["task_id"]), Duration: dur, Status: st}) - } - if counts["total"] == 0 { - e.lastDailyReportDate = date - return - } - sort.Slice(top, func(i, j int) bool { return top[i].Duration > top[j].Duration }) - maxTop := 3 - if len(top) < maxTop { - maxTop = len(top) - } - topLines := make([]string, 0, maxTop) - for i := 0; i < maxTop; i++ { - if top[i].TaskID == "" { - continue - } - topLines = append(topLines, fmt.Sprintf("- %s (%dms, %s)", top[i].TaskID, top[i].Duration, top[i].Status)) - } - type kv struct { - K string - V int - } - reasons := make([]kv, 0, len(errorReasons)) - for k, v := range errorReasons { - reasons = append(reasons, kv{K: k, V: v}) - } - sort.Slice(reasons, func(i, j int) bool { return reasons[i].V > reasons[j].V }) - maxR := 3 - if len(reasons) < maxR { - maxR = len(reasons) - } - reasonLines := make([]string, 0, maxR) - for i := 0; i < maxR; i++ { - reasonLines = append(reasonLines, fmt.Sprintf("- %s (x%d)", reasons[i].K, reasons[i].V)) - } - reportLine := fmt.Sprintf("\n## Autonomy Daily Report (%s)\n- total: %d\n- success: %d\n- error: %d\n- suppressed: %d\n- running: %d\n\n### Top Duration Tasks\n%s\n\n### Top Error Reasons\n%s\n", date, counts["total"], counts["success"], counts["error"], counts["suppressed"], counts["running"], strings.Join(topLines, "\n"), strings.Join(reasonLines, "\n")) - dailyPath := filepath.Join(workspace, "memory", date+".md") - _ = os.MkdirAll(filepath.Dir(dailyPath), 0755) - _ = appendUniqueReport(dailyPath, reportLine, date) - e.lastDailyReportDate = date -} - -func appendUniqueReport(path, content, date string) error { - existing, _ := os.ReadFile(path) - marker := "Autonomy Daily Report (" + date + ")" - if strings.Contains(string(existing), marker) { - return nil - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(content) - return err -} - -func (e *Engine) maybeCleanupTaskHistoryLocked(now time.Time) { - if e.opts.TaskHistoryRetentionDays <= 0 { - return - } - if !e.lastHistoryCleanupAt.IsZero() && now.Sub(e.lastHistoryCleanupAt) < time.Hour { - return - } - workspace := e.opts.Workspace - if workspace == "" { - return - } - cutoff := now.AddDate(0, 0, -e.opts.TaskHistoryRetentionDays) - - // Cleanup task-audit.jsonl by event time - auditPath := filepath.Join(workspace, "memory", "task-audit.jsonl") - if b, err := os.ReadFile(auditPath); err == nil { - lines := strings.Split(string(b), "\n") - kept := make([]string, 0, len(lines)) - for _, ln := range lines { - if ln == "" { - continue - } - var row map[string]interface{} - if json.Unmarshal([]byte(ln), &row) != nil { - continue - } - ts := fmt.Sprintf("%v", row["time"]) - tm, err := time.Parse(time.RFC3339, ts) - if err != nil || tm.After(cutoff) { - kept = append(kept, ln) - } - } - _ = os.WriteFile(auditPath, []byte(strings.Join(kept, "\n")+"\n"), 0644) - } - - // Cleanup tasks.json old terminal states - tasksPath := filepath.Join(workspace, "memory", "tasks.json") - if b, err := os.ReadFile(tasksPath); err == nil { - var items []TaskItem - if json.Unmarshal(b, &items) == nil { - kept := make([]TaskItem, 0, len(items)) - for _, it := range items { - st := strings.ToLower(it.Status) - terminal := st == "done" || st == "completed" || st == "suppressed" || st == "error" - ts := strings.TrimSpace(it.UpdatedAt) - if !terminal || ts == "" { - kept = append(kept, it) - continue - } - tm, err := time.Parse(time.RFC3339, ts) - if err != nil || tm.After(cutoff) { - kept = append(kept, it) - } - } - if out, err := json.MarshalIndent(kept, "", " "); err == nil { - _ = os.WriteFile(tasksPath, out, 0644) - } - } - } - e.lastHistoryCleanupAt = now -} - -func (e *Engine) detectRunOutcome(taskID string, since time.Time) (string, bool) { - if e.opts.Workspace == "" || taskID == "" { - return "", false - } - path := filepath.Join(e.opts.Workspace, "memory", "task-audit.jsonl") - f, err := os.Open(path) - if err != nil { - return "", false - } - defer f.Close() - sessionKey := "autonomy:" + taskID - latest := "" - latestAt := time.Time{} - s := bufio.NewScanner(f) - for s.Scan() { - var row map[string]interface{} - if json.Unmarshal(s.Bytes(), &row) != nil { - continue - } - if fmt.Sprintf("%v", row["session"]) != sessionKey { - continue - } - st := fmt.Sprintf("%v", row["status"]) - if st == "" || st == "running" { - continue - } - ts := fmt.Sprintf("%v", row["time"]) - tm, err := time.Parse(time.RFC3339, ts) - if err != nil { - continue - } - if !since.IsZero() && tm.Before(since) { - continue - } - if latestAt.IsZero() || tm.After(latestAt) { - latestAt = tm - latest = st - } - } - if latest == "" { - return "", false - } - return latest, true -} - -func (e *Engine) latestErrorSignature(taskID string, since time.Time) string { - if e.opts.Workspace == "" || taskID == "" { - return "" - } - path := filepath.Join(e.opts.Workspace, "memory", "task-audit.jsonl") - f, err := os.Open(path) - if err != nil { - return "" - } - defer f.Close() - sessionKey := "autonomy:" + taskID - latestAt := time.Time{} - latestErr := "" - s := bufio.NewScanner(f) - for s.Scan() { - var row map[string]interface{} - if json.Unmarshal(s.Bytes(), &row) != nil { - continue - } - if fmt.Sprintf("%v", row["session"]) != sessionKey { - continue - } - if fmt.Sprintf("%v", row["status"]) != "error" { - continue - } - ts := fmt.Sprintf("%v", row["time"]) - tm, err := time.Parse(time.RFC3339, ts) - if err != nil { - continue - } - if !since.IsZero() && tm.Before(since) { - continue - } - if latestAt.IsZero() || tm.After(latestAt) { - latestAt = tm - latestErr = fmt.Sprintf("%v", row["log"]) - } - } - return ekg.NormalizeErrorSignature(latestErr) -} - -func parseTodoAttributes(content string) (priority, dueAt, normalized string) { - priority = "normal" - normalized = content - l := strings.ToLower(normalized) - if strings.HasPrefix(l, "[high]") || strings.HasPrefix(l, "p1:") { - priority = "high" - normalized = strings.TrimSpace(normalized[6:]) - } else if strings.HasPrefix(l, "[low]") || strings.HasPrefix(l, "p3:") { - priority = "low" - normalized = strings.TrimSpace(normalized[5:]) - } else if strings.HasPrefix(l, "[medium]") || strings.HasPrefix(l, "p2:") { - priority = "normal" - if strings.HasPrefix(l, "[medium]") { - normalized = strings.TrimSpace(normalized[8:]) - } else { - normalized = strings.TrimSpace(normalized[3:]) - } - } - if idx := strings.Index(strings.ToLower(normalized), " due:"); idx > 0 { - dueAt = strings.TrimSpace(normalized[idx+5:]) - normalized = strings.TrimSpace(normalized[:idx]) - } - if normalized == "" { - normalized = content - } - return priority, dueAt, normalized -} - -func priorityWeight(p string) int { - switch strings.ToLower(strings.TrimSpace(p)) { - case "high": - return 3 - case "normal", "medium": - return 2 - case "low": - return 1 - default: - return 2 - } -} - -func dueWeight(dueAt string) int64 { - dueAt = strings.TrimSpace(dueAt) - if dueAt == "" { - return 0 - } - layouts := []string{"2006-01-02", time.RFC3339, time.RFC3339Nano} - for _, layout := range layouts { - if t, err := time.Parse(layout, dueAt); err == nil { - return -t.Unix() // earlier due => bigger score after descending sort - } - } - return 0 -} - -func (e *Engine) pauseFilePath() string { - if strings.TrimSpace(e.opts.Workspace) == "" { - return "" - } - return filepath.Join(e.opts.Workspace, "memory", "autonomy.pause") -} - -func (e *Engine) controlFilePath() string { - if strings.TrimSpace(e.opts.Workspace) == "" { - return "" - } - return filepath.Join(e.opts.Workspace, "memory", "autonomy.control.json") -} - -func (e *Engine) hasManualPause() bool { - p := e.pauseFilePath() - if p == "" { - return false - } - _, err := os.Stat(p) - if err == nil { - return true - } - ctrl := e.controlFilePath() - if ctrl == "" { - return false - } - data, rErr := os.ReadFile(ctrl) - if rErr != nil { - return false - } - var c struct { - Enabled bool `json:"enabled"` - } - if jErr := json.Unmarshal(data, &c); jErr != nil { - return false - } - return !c.Enabled -} - -func (e *Engine) hasRecentUserActivity(now time.Time) bool { - if e.opts.UserIdleResumeSec <= 0 || strings.TrimSpace(e.opts.Workspace) == "" { - return false - } - sessionsPath := filepath.Join(filepath.Dir(e.opts.Workspace), "agents", "main", "sessions", "sessions.json") - data, err := os.ReadFile(sessionsPath) - if err != nil { - legacy := filepath.Join(filepath.Dir(e.opts.Workspace), "sessions", "sessions.json") - data, err = os.ReadFile(legacy) - if err != nil { - return false - } - } - var index map[string]struct { - Kind string `json:"kind"` - SessionFile string `json:"sessionFile"` - } - if err := json.Unmarshal(data, &index); err != nil { - return false - } - cutoff := now.Add(-time.Duration(e.opts.UserIdleResumeSec) * time.Second) - for _, row := range index { - if strings.ToLower(strings.TrimSpace(row.Kind)) != "main" { - continue - } - if strings.TrimSpace(row.SessionFile) == "" { - continue - } - if ts := latestUserMessageTime(row.SessionFile); !ts.IsZero() && ts.After(cutoff) { - return true - } - } - return false -} - -func (e *Engine) allowTaskByPolicy(content string) bool { - if len(e.opts.AllowedTaskKeywords) == 0 { - return true - } - v := strings.ToLower(content) - for _, kw := range e.opts.AllowedTaskKeywords { - if kw == "" { - continue - } - if strings.Contains(v, strings.ToLower(kw)) { - return true - } - } - return false -} - -func latestUserMessageTime(path string) time.Time { - f, err := os.Open(path) - if err != nil { - return time.Time{} - } - defer f.Close() - - var latest time.Time - s := bufio.NewScanner(f) - for s.Scan() { - line := s.Bytes() - - // OpenClaw-like event line - var ev struct { - Type string `json:"type"` - Timestamp string `json:"timestamp"` - Message *struct { - Role string `json:"role"` - } `json:"message"` - } - if err := json.Unmarshal(line, &ev); err == nil && ev.Message != nil { - if strings.ToLower(strings.TrimSpace(ev.Message.Role)) == "user" { - if t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(ev.Timestamp)); err == nil && t.After(latest) { - latest = t - } else if t, err := time.Parse(time.RFC3339, strings.TrimSpace(ev.Timestamp)); err == nil && t.After(latest) { - latest = t - } - } - continue - } - - // Legacy line - var msg struct { - Role string `json:"role"` - } - if err := json.Unmarshal(line, &msg); err == nil { - if strings.ToLower(strings.TrimSpace(msg.Role)) == "user" { - latest = time.Now().UTC() - } - } - } - return latest -} - -func blockedRetryBackoff(stalls int, minRunIntervalSec int) time.Duration { - if minRunIntervalSec <= 0 { - minRunIntervalSec = 20 - } - if stalls < 1 { - stalls = 1 - } - base := time.Duration(minRunIntervalSec) * time.Second - factor := 1 << _min(stalls, 5) - return time.Duration(factor) * base -} - -func _min(a, b int) int { - if a < b { - return a - } - return b -} - -func shortTask(s string) string { - s = strings.TrimSpace(s) - if len(s) <= 32 { - return s - } - return s[:32] + "..." -} - -func hashID(s string) string { - sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(s)))) - return hex.EncodeToString(sum[:])[:12] -} - -func RunOnce(ctx context.Context, engine *Engine) { - if engine == nil { - return - } - select { - case <-ctx.Done(): - return - default: - engine.tick() - } -} diff --git a/pkg/autonomy/engine_notify_allowlist_test.go b/pkg/autonomy/engine_notify_allowlist_test.go deleted file mode 100644 index e4fbaea..0000000 --- a/pkg/autonomy/engine_notify_allowlist_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package autonomy - -import ( - "testing" - "time" -) - -func TestShouldNotify_RespectsNotifyAllowFrom(t *testing.T) { - e := &Engine{opts: Options{ - DefaultNotifyChannel: "telegram", - DefaultNotifyChatID: "chat-1", - NotifyAllowFrom: []string{"chat-2", "chat-3"}, - NotifyCooldownSec: 1, - NotifySameReasonCooldownSec: 1, - }, lastNotify: map[string]time.Time{}} - if e.shouldNotify("k1", "") { - t.Fatalf("expected notify to be blocked when chat not in allowlist") - } - - e.opts.NotifyAllowFrom = []string{"chat-1"} - if !e.shouldNotify("k2", "") { - t.Fatalf("expected notify to pass when chat in allowlist") - } -} diff --git a/pkg/autonomy/task_store.go b/pkg/autonomy/task_store.go deleted file mode 100644 index c141717..0000000 --- a/pkg/autonomy/task_store.go +++ /dev/null @@ -1,88 +0,0 @@ -package autonomy - -import ( - "encoding/json" - "os" - "path/filepath" - "sort" - "strings" - "time" -) - -type TaskAttempt struct { - Time string `json:"time"` - Status string `json:"status"` - Session string `json:"session,omitempty"` - Note string `json:"note,omitempty"` -} - -type TaskItem struct { - ID string `json:"id"` - ParentTaskID string `json:"parent_task_id,omitempty"` - Content string `json:"content"` - Priority string `json:"priority"` - DueAt string `json:"due_at,omitempty"` - Status string `json:"status"` // todo|doing|waiting|blocked|done|paused|canceled - BlockReason string `json:"block_reason,omitempty"` - RetryAfter string `json:"retry_after,omitempty"` - Source string `json:"source"` - DedupeHits int `json:"dedupe_hits,omitempty"` - ResourceKeys []string `json:"resource_keys,omitempty"` - LastPauseReason string `json:"last_pause_reason,omitempty"` - LastPauseAt string `json:"last_pause_at,omitempty"` - MemoryRefs []string `json:"memory_refs,omitempty"` - AuditRefs []string `json:"audit_refs,omitempty"` - Attempts []TaskAttempt `json:"attempts,omitempty"` - UpdatedAt string `json:"updated_at"` -} - -type TaskStore struct { - workspace string -} - -func NewTaskStore(workspace string) *TaskStore { - return &TaskStore{workspace: workspace} -} - -func (s *TaskStore) path() string { - return filepath.Join(s.workspace, "memory", "tasks.json") -} - -func (s *TaskStore) Load() ([]TaskItem, error) { - data, err := os.ReadFile(s.path()) - if err != nil { - if os.IsNotExist(err) { - return []TaskItem{}, nil - } - return nil, err - } - var items []TaskItem - if err := json.Unmarshal(data, &items); err != nil { - return nil, err - } - return items, nil -} - -func (s *TaskStore) Save(items []TaskItem) error { - _ = os.MkdirAll(filepath.Dir(s.path()), 0755) - sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt > items[j].UpdatedAt }) - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - return os.WriteFile(s.path(), data, 0644) -} - -func normalizeStatus(v string) string { - s := strings.ToLower(strings.TrimSpace(v)) - switch s { - case "todo", "doing", "waiting", "blocked", "done", "paused", "canceled": - return s - default: - return "todo" - } -} - -func nowRFC3339() string { - return time.Now().UTC().Format(time.RFC3339) -} diff --git a/pkg/config/config.go b/pkg/config/config.go index be1e569..97a835f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -38,34 +38,10 @@ type AgentDefaults struct { Temperature float64 `json:"temperature" env:"CLAWGO_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` Heartbeat HeartbeatConfig `json:"heartbeat"` - Autonomy AutonomyConfig `json:"autonomy"` ContextCompaction ContextCompactionConfig `json:"context_compaction"` RuntimeControl RuntimeControlConfig `json:"runtime_control"` } -type AutonomyConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_ENABLED"` - TickIntervalSec int `json:"tick_interval_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_TICK_INTERVAL_SEC"` - MinRunIntervalSec int `json:"min_run_interval_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MIN_RUN_INTERVAL_SEC"` - MaxPendingDurationSec int `json:"max_pending_duration_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_PENDING_DURATION_SEC"` - MaxConsecutiveStalls int `json:"max_consecutive_stalls" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_CONSECUTIVE_STALLS"` - MaxDispatchPerTick int `json:"max_dispatch_per_tick" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_DISPATCH_PER_TICK"` - NotifyCooldownSec int `json:"notify_cooldown_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_COOLDOWN_SEC"` - NotifySameReasonCooldownSec int `json:"notify_same_reason_cooldown_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_NOTIFY_SAME_REASON_COOLDOWN_SEC"` - QuietHours string `json:"quiet_hours" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_QUIET_HOURS"` - UserIdleResumeSec int `json:"user_idle_resume_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_USER_IDLE_RESUME_SEC"` - MaxRoundsWithoutUser int `json:"max_rounds_without_user" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_MAX_ROUNDS_WITHOUT_USER"` - TaskHistoryRetentionDays int `json:"task_history_retention_days" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_TASK_HISTORY_RETENTION_DAYS"` - WaitingResumeDebounceSec int `json:"waiting_resume_debounce_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_WAITING_RESUME_DEBOUNCE_SEC"` - IdleRoundBudgetReleaseSec int `json:"idle_round_budget_release_sec" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_IDLE_ROUND_BUDGET_RELEASE_SEC"` - AllowedTaskKeywords []string `json:"allowed_task_keywords" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_ALLOWED_TASK_KEYWORDS"` - EKGConsecutiveErrorThreshold int `json:"ekg_consecutive_error_threshold" env:"CLAWGO_AGENTS_DEFAULTS_AUTONOMY_EKG_CONSECUTIVE_ERROR_THRESHOLD"` - // Deprecated: kept for backward compatibility with existing config files. - NotifyChannel string `json:"notify_channel,omitempty"` - // Deprecated: kept for backward compatibility with existing config files. - NotifyChatID string `json:"notify_chat_id,omitempty"` -} - type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_ENABLED"` EverySec int `json:"every_sec" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_EVERY_SEC"` @@ -75,12 +51,6 @@ type HeartbeatConfig struct { type RuntimeControlConfig struct { IntentMaxInputChars int `json:"intent_max_input_chars" env:"CLAWGO_INTENT_MAX_INPUT_CHARS"` - AutonomyTickIntervalSec int `json:"autonomy_tick_interval_sec" env:"CLAWGO_AUTONOMY_TICK_INTERVAL_SEC"` - AutonomyMinRunIntervalSec int `json:"autonomy_min_run_interval_sec" env:"CLAWGO_AUTONOMY_MIN_RUN_INTERVAL_SEC"` - AutonomyIdleThresholdSec int `json:"autonomy_idle_threshold_sec" env:"CLAWGO_AUTONOMY_IDLE_THRESHOLD_SEC"` - AutonomyMaxRoundsWithoutUser int `json:"autonomy_max_rounds_without_user" env:"CLAWGO_AUTONOMY_MAX_ROUNDS_WITHOUT_USER"` - AutonomyMaxPendingDurationSec int `json:"autonomy_max_pending_duration_sec" env:"CLAWGO_AUTONOMY_MAX_PENDING_DURATION_SEC"` - AutonomyMaxConsecutiveStalls int `json:"autonomy_max_consecutive_stalls" env:"CLAWGO_AUTONOMY_MAX_STALLS"` AutoLearnMaxRoundsWithoutUser int `json:"autolearn_max_rounds_without_user" env:"CLAWGO_AUTOLEARN_MAX_ROUNDS_WITHOUT_USER"` RunStateTTLSeconds int `json:"run_state_ttl_seconds" env:"CLAWGO_RUN_STATE_TTL_SECONDS"` RunStateMax int `json:"run_state_max" env:"CLAWGO_RUN_STATE_MAX"` @@ -356,24 +326,6 @@ func DefaultConfig() *Config { AckMaxChars: 64, PromptTemplate: "", }, - Autonomy: AutonomyConfig{ - Enabled: false, - TickIntervalSec: 30, - MinRunIntervalSec: 20, - MaxPendingDurationSec: 180, - MaxConsecutiveStalls: 3, - MaxDispatchPerTick: 2, - NotifyCooldownSec: 300, - NotifySameReasonCooldownSec: 900, - QuietHours: "23:00-08:00", - UserIdleResumeSec: 20, - MaxRoundsWithoutUser: 12, - TaskHistoryRetentionDays: 3, - WaitingResumeDebounceSec: 5, - IdleRoundBudgetReleaseSec: 1800, - AllowedTaskKeywords: []string{}, - EKGConsecutiveErrorThreshold: 3, - }, ContextCompaction: ContextCompactionConfig{ Enabled: true, Mode: "summary", @@ -384,12 +336,6 @@ func DefaultConfig() *Config { }, RuntimeControl: RuntimeControlConfig{ IntentMaxInputChars: 1200, - AutonomyTickIntervalSec: 20, - AutonomyMinRunIntervalSec: 20, - AutonomyIdleThresholdSec: 20, - AutonomyMaxRoundsWithoutUser: 120, - AutonomyMaxPendingDurationSec: 180, - AutonomyMaxConsecutiveStalls: 3, AutoLearnMaxRoundsWithoutUser: 200, RunStateTTLSeconds: 1800, RunStateMax: 500, diff --git a/pkg/config/validate.go b/pkg/config/validate.go index f0b2bda..04a6ebd 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -21,24 +21,6 @@ func Validate(cfg *Config) []error { if rc.IntentMaxInputChars < 200 { errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.intent_max_input_chars must be >= 200")) } - if rc.AutonomyTickIntervalSec < 5 { - errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autonomy_tick_interval_sec must be >= 5")) - } - if rc.AutonomyMinRunIntervalSec < 5 { - errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autonomy_min_run_interval_sec must be >= 5")) - } - if rc.AutonomyIdleThresholdSec < 5 { - errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autonomy_idle_threshold_sec must be >= 5")) - } - if rc.AutonomyMaxRoundsWithoutUser < 0 { - errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autonomy_max_rounds_without_user must be >= 0")) - } - if rc.AutonomyMaxPendingDurationSec < 10 { - errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autonomy_max_pending_duration_sec must be >= 10")) - } - if rc.AutonomyMaxConsecutiveStalls <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autonomy_max_consecutive_stalls must be > 0")) - } if rc.AutoLearnMaxRoundsWithoutUser <= 0 { errs = append(errs, fmt.Errorf("agents.defaults.runtime_control.autolearn_max_rounds_without_user must be > 0")) } @@ -82,51 +64,6 @@ func Validate(cfg *Config) []error { errs = append(errs, fmt.Errorf("agents.defaults.heartbeat.ack_max_chars must be > 0 when enabled=true")) } } - aut := cfg.Agents.Defaults.Autonomy - if aut.Enabled { - if aut.TickIntervalSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.tick_interval_sec must be > 0 when enabled=true")) - } - if aut.MinRunIntervalSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.min_run_interval_sec must be > 0 when enabled=true")) - } - if aut.MaxPendingDurationSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.max_pending_duration_sec must be > 0 when enabled=true")) - } - if aut.MaxConsecutiveStalls <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.max_consecutive_stalls must be > 0 when enabled=true")) - } - if aut.MaxDispatchPerTick < 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.max_dispatch_per_tick must be >= 0 when enabled=true")) - } - if aut.NotifyCooldownSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.notify_cooldown_sec must be > 0 when enabled=true")) - } - if aut.NotifySameReasonCooldownSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.notify_same_reason_cooldown_sec must be > 0 when enabled=true")) - } - if qh := strings.TrimSpace(aut.QuietHours); qh != "" { - parts := strings.Split(qh, "-") - if len(parts) != 2 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.quiet_hours must be HH:MM-HH:MM")) - } - } - if aut.UserIdleResumeSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.user_idle_resume_sec must be > 0 when enabled=true")) - } - if aut.WaitingResumeDebounceSec <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.waiting_resume_debounce_sec must be > 0 when enabled=true")) - } - if aut.IdleRoundBudgetReleaseSec < 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.idle_round_budget_release_sec must be >= 0 when enabled=true")) - } - if aut.TaskHistoryRetentionDays <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.task_history_retention_days must be > 0 when enabled=true")) - } - if aut.EKGConsecutiveErrorThreshold <= 0 { - errs = append(errs, fmt.Errorf("agents.defaults.autonomy.ekg_consecutive_error_threshold must be > 0 when enabled=true")) - } - } if cfg.Agents.Defaults.ContextCompaction.Enabled { cc := cfg.Agents.Defaults.ContextCompaction if cc.Mode != "" { diff --git a/pkg/tools/command_tick.go b/pkg/tools/command_tick.go index 13a8183..09032d2 100644 --- a/pkg/tools/command_tick.go +++ b/pkg/tools/command_tick.go @@ -26,6 +26,7 @@ const ( ) var ErrCommandNoProgress = errors.New("command no progress across tick rounds") +var ErrCommandTickTimeout = errors.New("command tick timeout exceeded") type commandRuntimePolicy struct { BaseTick time.Duration @@ -594,6 +595,75 @@ func runCommandWithDynamicTick(ctx context.Context, cmd *exec.Cmd, source, label } } +type stringTaskResult struct { + output string + err error +} + +// runStringTaskWithCommandTickTimeout executes a string-returning task with a +// command-tick-based timeout loop so timeout behavior stays consistent with the +// command watchdog pacing policy. +func runStringTaskWithCommandTickTimeout( + ctx context.Context, + timeoutSec int, + baseTick time.Duration, + run func(context.Context) (string, error), +) (string, error) { + if run == nil { + return "", fmt.Errorf("run function is nil") + } + if timeoutSec <= 0 { + return run(ctx) + } + if ctx == nil { + ctx = context.Background() + } + + timeout := time.Duration(timeoutSec) * time.Second + started := time.Now() + tick := normalizeCommandTick(baseTick) + if tick <= 0 { + tick = 2 * time.Second + } + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + done := make(chan stringTaskResult, 1) + go func() { + out, err := run(runCtx) + done <- stringTaskResult{output: out, err: err} + }() + + timer := time.NewTimer(tick) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + cancel() + return "", ctx.Err() + case res := <-done: + return res.output, res.err + case <-timer.C: + elapsed := time.Since(started) + if elapsed >= timeout { + cancel() + select { + case res := <-done: + if res.err != nil { + return "", fmt.Errorf("%w: %v", ErrCommandTickTimeout, res.err) + } + case <-time.After(2 * time.Second): + } + return "", fmt.Errorf("%w: %ds", ErrCommandTickTimeout, timeoutSec) + } + next := nextCommandTick(tick, elapsed) + timer.Reset(next) + } + } +} + func (wd *commandWatchdog) buildQueueSnapshotLocked() map[string]interface{} { if wd == nil { return nil diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index 63a1413..363dd5c 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -49,6 +49,26 @@ func (t *SpawnTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional logical agent ID. If omitted, role will be used as fallback.", }, + "max_retries": map[string]interface{}{ + "type": "integer", + "description": "Optional retry limit for this task.", + }, + "retry_backoff_ms": map[string]interface{}{ + "type": "integer", + "description": "Optional retry backoff in milliseconds.", + }, + "timeout_sec": map[string]interface{}{ + "type": "integer", + "description": "Optional per-attempt timeout in seconds.", + }, + "max_task_chars": map[string]interface{}{ + "type": "integer", + "description": "Optional task size quota in characters.", + }, + "max_result_chars": map[string]interface{}{ + "type": "integer", + "description": "Optional result size quota in characters.", + }, "pipeline_id": map[string]interface{}{ "type": "string", "description": "Optional pipeline ID for orchestrated multi-agent workflow", @@ -86,6 +106,11 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s label, _ := args["label"].(string) role, _ := args["role"].(string) agentID, _ := args["agent_id"].(string) + maxRetries := intArg(args, "max_retries") + retryBackoff := intArg(args, "retry_backoff_ms") + timeoutSec := intArg(args, "timeout_sec") + maxTaskChars := intArg(args, "max_task_chars") + maxResultChars := intArg(args, "max_result_chars") pipelineID, _ := args["pipeline_id"].(string) taskID, _ := args["task_id"].(string) if label == "" && role != "" { @@ -114,14 +139,19 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s } result, err := t.manager.Spawn(ctx, SubagentSpawnOptions{ - Task: task, - Label: label, - Role: role, - AgentID: agentID, - OriginChannel: originChannel, - OriginChatID: originChatID, - PipelineID: pipelineID, - PipelineTask: taskID, + Task: task, + Label: label, + Role: role, + AgentID: agentID, + MaxRetries: maxRetries, + RetryBackoff: retryBackoff, + TimeoutSec: timeoutSec, + MaxTaskChars: maxTaskChars, + MaxResultChars: maxResultChars, + OriginChannel: originChannel, + OriginChatID: originChatID, + PipelineID: pipelineID, + PipelineTask: taskID, }) if err != nil { return "", fmt.Errorf("failed to spawn subagent: %w", err) @@ -129,3 +159,19 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s return result, nil } + +func intArg(args map[string]interface{}, key string) int { + if args == nil { + return 0 + } + if v, ok := args[key].(float64); ok { + return int(v) + } + if v, ok := args[key].(int); ok { + return v + } + if v, ok := args[key].(int64); ok { + return int(v) + } + return 0 +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 9801005..7afc09e 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -14,25 +14,31 @@ import ( ) type SubagentTask struct { - ID string - Task string - Label string - Role string - AgentID string - SessionKey string - MemoryNS string - SystemPrompt string - ToolAllowlist []string - PipelineID string - PipelineTask string - SharedState map[string]interface{} - OriginChannel string - OriginChatID string - Status string - Result string - Steering []string - Created int64 - Updated int64 + ID string `json:"id"` + Task string `json:"task"` + Label string `json:"label"` + Role string `json:"role"` + AgentID string `json:"agent_id"` + SessionKey string `json:"session_key"` + MemoryNS string `json:"memory_ns"` + SystemPrompt string `json:"system_prompt,omitempty"` + ToolAllowlist []string `json:"tool_allowlist,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + RetryBackoff int `json:"retry_backoff,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + MaxTaskChars int `json:"max_task_chars,omitempty"` + MaxResultChars int `json:"max_result_chars,omitempty"` + RetryCount int `json:"retry_count,omitempty"` + PipelineID string `json:"pipeline_id,omitempty"` + PipelineTask string `json:"pipeline_task,omitempty"` + SharedState map[string]interface{} `json:"shared_state,omitempty"` + OriginChannel string `json:"origin_channel,omitempty"` + OriginChatID string `json:"origin_chat_id,omitempty"` + Status string `json:"status"` + Result string `json:"result,omitempty"` + Steering []string `json:"steering,omitempty"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` } type SubagentManager struct { @@ -50,14 +56,19 @@ type SubagentManager struct { } type SubagentSpawnOptions struct { - Task string - Label string - Role string - AgentID string - OriginChannel string - OriginChatID string - PipelineID string - PipelineTask string + Task string + Label string + Role string + AgentID string + MaxRetries int + RetryBackoff int + TimeoutSec int + MaxTaskChars int + MaxResultChars int + OriginChannel string + OriginChatID string + PipelineID string + PipelineTask string } func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus, orc *Orchestrator) *SubagentManager { @@ -110,6 +121,11 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) memoryNS := agentID systemPrompt := "" toolAllowlist := []string(nil) + maxRetries := 0 + retryBackoff := 1000 + timeoutSec := 0 + maxTaskChars := 0 + maxResultChars := 0 if profile == nil && sm.profileStore != nil { if p, ok, err := sm.profileStore.Get(agentID); err != nil { return "", err @@ -132,7 +148,35 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) } systemPrompt = strings.TrimSpace(profile.SystemPrompt) toolAllowlist = append([]string(nil), profile.ToolAllowlist...) + maxRetries = profile.MaxRetries + retryBackoff = profile.RetryBackoff + timeoutSec = profile.TimeoutSec + maxTaskChars = profile.MaxTaskChars + maxResultChars = profile.MaxResultChars } + if opts.MaxRetries > 0 { + maxRetries = opts.MaxRetries + } + if opts.RetryBackoff > 0 { + retryBackoff = opts.RetryBackoff + } + if opts.TimeoutSec > 0 { + timeoutSec = opts.TimeoutSec + } + if opts.MaxTaskChars > 0 { + maxTaskChars = opts.MaxTaskChars + } + if opts.MaxResultChars > 0 { + maxResultChars = opts.MaxResultChars + } + if maxTaskChars > 0 && len(task) > maxTaskChars { + return "", fmt.Errorf("task exceeds max_task_chars quota (%d > %d)", len(task), maxTaskChars) + } + maxRetries = normalizePositiveBound(maxRetries, 0, 8) + retryBackoff = normalizePositiveBound(retryBackoff, 500, 120000) + timeoutSec = normalizePositiveBound(timeoutSec, 0, 3600) + maxTaskChars = normalizePositiveBound(maxTaskChars, 0, 400000) + maxResultChars = normalizePositiveBound(maxResultChars, 0, 400000) if role == "" { role = originalRole } @@ -150,22 +194,28 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) now := time.Now().UnixMilli() subagentTask := &SubagentTask{ - ID: taskID, - Task: task, - Label: label, - Role: role, - AgentID: agentID, - SessionKey: sessionKey, - MemoryNS: memoryNS, - SystemPrompt: systemPrompt, - ToolAllowlist: toolAllowlist, - PipelineID: pipelineID, - PipelineTask: pipelineTask, - OriginChannel: originChannel, - OriginChatID: originChatID, - Status: "running", - Created: now, - Updated: now, + ID: taskID, + Task: task, + Label: label, + Role: role, + AgentID: agentID, + SessionKey: sessionKey, + MemoryNS: memoryNS, + SystemPrompt: systemPrompt, + ToolAllowlist: toolAllowlist, + MaxRetries: maxRetries, + RetryBackoff: retryBackoff, + TimeoutSec: timeoutSec, + MaxTaskChars: maxTaskChars, + MaxResultChars: maxResultChars, + RetryCount: 0, + PipelineID: pipelineID, + PipelineTask: pipelineTask, + OriginChannel: originChannel, + OriginChatID: originChatID, + Status: "running", + Created: now, + Updated: now, } taskCtx, cancel := context.WithCancel(ctx) sm.tasks[taskID] = subagentTask @@ -202,90 +252,25 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { _ = sm.orc.MarkTaskRunning(task.PipelineID, task.PipelineTask) } - - // 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. - - // Fall back to one-shot chat when RunFunc is not injected. - if sm.runFunc != nil { - result, err := sm.runFunc(ctx, task) - sm.mu.Lock() - if err != nil { - task.Status = "failed" - task.Result = fmt.Sprintf("Error: %v", err) - task.Updated = time.Now().UnixMilli() - 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 - task.Updated = time.Now().UnixMilli() - if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { - _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, nil) - } + result, runErr := sm.runWithRetry(ctx, task) + sm.mu.Lock() + if runErr != nil { + task.Status = "failed" + task.Result = fmt.Sprintf("Error: %v", runErr) + task.Result = applySubagentResultQuota(task.Result, task.MaxResultChars) + task.Updated = time.Now().UnixMilli() + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, runErr) } - sm.mu.Unlock() } else { - // Original one-shot logic - if sm.provider == nil { - sm.mu.Lock() - task.Status = "failed" - task.Result = "Error: no llm provider configured for subagent execution" - task.Updated = time.Now().UnixMilli() - if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { - _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, fmt.Errorf("no llm provider configured for subagent execution")) - } - sm.mu.Unlock() - return + task.Status = "completed" + task.Result = applySubagentResultQuota(result, task.MaxResultChars) + task.Updated = time.Now().UnixMilli() + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, nil) } - systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently." - rolePrompt := strings.TrimSpace(task.SystemPrompt) - if ws := strings.TrimSpace(sm.workspace); ws != "" { - if data, err := os.ReadFile(filepath.Join(ws, "AGENTS.md")); err == nil { - txt := strings.TrimSpace(string(data)) - if txt != "" { - systemPrompt = "Workspace policy (AGENTS.md):\n" + txt + "\n\nComplete the given task independently and report the result." - } - } - } - if rolePrompt != "" { - systemPrompt += "\n\nRole-specific profile prompt:\n" + rolePrompt - } - messages := []providers.Message{ - { - Role: "system", - Content: systemPrompt, - }, - { - Role: "user", - Content: task.Task, - }, - } - - response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ - "max_tokens": 4096, - }) - - sm.mu.Lock() - if err != nil { - task.Status = "failed" - task.Result = fmt.Sprintf("Error: %v", err) - task.Updated = time.Now().UnixMilli() - 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 - task.Updated = time.Now().UnixMilli() - if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { - _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, nil) - } - } - sm.mu.Unlock() } + sm.mu.Unlock() // 2. Result broadcast (keep existing behavior) if sm.bus != nil { @@ -310,6 +295,8 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { "role": task.Role, "session_key": task.SessionKey, "memory_ns": task.MemoryNS, + "retry_count": fmt.Sprintf("%d", task.RetryCount), + "timeout_sec": fmt.Sprintf("%d", task.TimeoutSec), "pipeline_id": task.PipelineID, "pipeline_task": task.PipelineTask, }, @@ -317,6 +304,92 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { } } +func (sm *SubagentManager) runWithRetry(ctx context.Context, task *SubagentTask) (string, error) { + maxRetries := normalizePositiveBound(task.MaxRetries, 0, 8) + backoffMs := normalizePositiveBound(task.RetryBackoff, 500, 120000) + timeoutSec := normalizePositiveBound(task.TimeoutSec, 0, 3600) + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + result, err := runStringTaskWithCommandTickTimeout( + ctx, + timeoutSec, + 2*time.Second, + func(runCtx context.Context) (string, error) { + return sm.executeTaskOnce(runCtx, task) + }, + ) + if err == nil { + sm.mu.Lock() + task.RetryCount = attempt + task.Updated = time.Now().UnixMilli() + sm.mu.Unlock() + return result, nil + } + lastErr = err + sm.mu.Lock() + task.RetryCount = attempt + task.Updated = time.Now().UnixMilli() + sm.mu.Unlock() + if attempt >= maxRetries { + break + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(backoffMs) * time.Millisecond): + } + } + if lastErr == nil { + lastErr = fmt.Errorf("subagent task failed with unknown error") + } + return "", lastErr +} + +func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTask) (string, error) { + if task == nil { + return "", fmt.Errorf("subagent task is nil") + } + if sm.runFunc != nil { + return sm.runFunc(ctx, task) + } + if sm.provider == nil { + return "", fmt.Errorf("no llm provider configured for subagent execution") + } + + systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently." + rolePrompt := strings.TrimSpace(task.SystemPrompt) + if ws := strings.TrimSpace(sm.workspace); ws != "" { + if data, err := os.ReadFile(filepath.Join(ws, "AGENTS.md")); err == nil { + txt := strings.TrimSpace(string(data)) + if txt != "" { + systemPrompt = "Workspace policy (AGENTS.md):\n" + txt + "\n\nComplete the given task independently and report the result." + } + } + } + if rolePrompt != "" { + systemPrompt += "\n\nRole-specific profile prompt:\n" + rolePrompt + } + messages := []providers.Message{ + { + Role: "system", + Content: systemPrompt, + }, + { + Role: "user", + Content: task.Task, + }, + } + + response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ + "max_tokens": 4096, + }) + if err != nil { + return "", err + } + return response.Content, nil +} + type SubagentRunFunc func(ctx context.Context, task *SubagentTask) (string, error) func (sm *SubagentManager) SetRunFunc(f SubagentRunFunc) { @@ -402,14 +475,19 @@ func (sm *SubagentManager) ResumeTask(ctx context.Context, taskID string) (strin label = label + "-resumed" } _, err := sm.Spawn(ctx, SubagentSpawnOptions{ - Task: t.Task, - Label: label, - Role: t.Role, - AgentID: t.AgentID, - OriginChannel: t.OriginChannel, - OriginChatID: t.OriginChatID, - PipelineID: t.PipelineID, - PipelineTask: t.PipelineTask, + Task: t.Task, + Label: label, + Role: t.Role, + AgentID: t.AgentID, + MaxRetries: t.MaxRetries, + RetryBackoff: t.RetryBackoff, + TimeoutSec: t.TimeoutSec, + MaxTaskChars: t.MaxTaskChars, + MaxResultChars: t.MaxResultChars, + OriginChannel: t.OriginChannel, + OriginChatID: t.OriginChatID, + PipelineID: t.PipelineID, + PipelineTask: t.PipelineTask, }) if err != nil { return "", false @@ -433,6 +511,31 @@ func (sm *SubagentManager) pruneArchivedLocked() { } } +func normalizePositiveBound(v, min, max int) int { + if v < min { + return min + } + if max > 0 && v > max { + return max + } + return v +} + +func applySubagentResultQuota(result string, maxChars int) string { + if maxChars <= 0 { + return result + } + if len(result) <= maxChars { + return result + } + suffix := "\n\n[TRUNCATED: result exceeds max_result_chars quota]" + trimmed := result[:maxChars] + if len(trimmed)+len(suffix) > maxChars && maxChars > len(suffix) { + trimmed = trimmed[:maxChars-len(suffix)] + } + return strings.TrimSpace(trimmed) + suffix +} + func normalizeSubagentIdentifier(in string) string { in = strings.TrimSpace(strings.ToLower(in)) if in == "" { diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/subagent_profile.go index 99f3083..509e019 100644 --- a/pkg/tools/subagent_profile.go +++ b/pkg/tools/subagent_profile.go @@ -19,6 +19,11 @@ type SubagentProfile struct { SystemPrompt string `json:"system_prompt,omitempty"` ToolAllowlist []string `json:"tool_allowlist,omitempty"` MemoryNamespace string `json:"memory_namespace,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + RetryBackoff int `json:"retry_backoff_ms,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + MaxTaskChars int `json:"max_task_chars,omitempty"` + MaxResultChars int `json:"max_result_chars,omitempty"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` @@ -188,6 +193,11 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { } p.Status = normalizeProfileStatus(p.Status) p.ToolAllowlist = normalizeToolAllowlist(p.ToolAllowlist) + p.MaxRetries = clampInt(p.MaxRetries, 0, 8) + p.RetryBackoff = clampInt(p.RetryBackoff, 500, 120000) + p.TimeoutSec = clampInt(p.TimeoutSec, 0, 3600) + p.MaxTaskChars = clampInt(p.MaxTaskChars, 0, 400000) + p.MaxResultChars = clampInt(p.MaxResultChars, 0, 400000) return p } @@ -223,18 +233,25 @@ func normalizeStringList(in []string) []string { } func normalizeToolAllowlist(in []string) []string { - items := normalizeStringList(in) + items := ExpandToolAllowlistEntries(normalizeStringList(in)) if len(items) == 0 { return nil } - for i := range items { - items[i] = strings.ToLower(strings.TrimSpace(items[i])) - } items = normalizeStringList(items) sort.Strings(items) return items } +func clampInt(v, min, max int) int { + if v < min { + return min + } + if max > 0 && v > max { + return max + } + return v +} + func parseStringList(raw interface{}) []string { items, ok := raw.([]interface{}) if !ok { @@ -281,9 +298,15 @@ func (t *SubagentProfileTool) Parameters() map[string]interface{} { "memory_namespace": map[string]interface{}{"type": "string"}, "status": map[string]interface{}{"type": "string", "description": "active|disabled"}, "tool_allowlist": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, + "type": "array", + "description": "Tool allowlist entries. Supports tool names, '*'/'all', and grouped tokens like 'group:files_read' or '@pipeline'.", + "items": map[string]interface{}{"type": "string"}, }, + "max_retries": map[string]interface{}{"type": "integer", "description": "Retry limit for subagent task execution."}, + "retry_backoff_ms": map[string]interface{}{"type": "integer", "description": "Backoff between retries in milliseconds."}, + "timeout_sec": map[string]interface{}{"type": "integer", "description": "Per-attempt timeout in seconds."}, + "max_task_chars": map[string]interface{}{"type": "integer", "description": "Task input size quota (characters)."}, + "max_result_chars": map[string]interface{}{"type": "integer", "description": "Result output size quota (characters)."}, }, "required": []string{"action"}, } @@ -344,6 +367,11 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter MemoryNamespace: stringArg(args, "memory_namespace"), Status: stringArg(args, "status"), ToolAllowlist: parseStringList(args["tool_allowlist"]), + MaxRetries: profileIntArg(args, "max_retries"), + RetryBackoff: profileIntArg(args, "retry_backoff_ms"), + TimeoutSec: profileIntArg(args, "timeout_sec"), + MaxTaskChars: profileIntArg(args, "max_task_chars"), + MaxResultChars: profileIntArg(args, "max_result_chars"), } saved, err := t.store.Upsert(p) if err != nil { @@ -380,6 +408,21 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter if _, ok := args["tool_allowlist"]; ok { next.ToolAllowlist = parseStringList(args["tool_allowlist"]) } + if _, ok := args["max_retries"]; ok { + next.MaxRetries = profileIntArg(args, "max_retries") + } + if _, ok := args["retry_backoff_ms"]; ok { + next.RetryBackoff = profileIntArg(args, "retry_backoff_ms") + } + if _, ok := args["timeout_sec"]; ok { + next.TimeoutSec = profileIntArg(args, "timeout_sec") + } + if _, ok := args["max_task_chars"]; ok { + next.MaxTaskChars = profileIntArg(args, "max_task_chars") + } + if _, ok := args["max_result_chars"]; ok { + next.MaxResultChars = profileIntArg(args, "max_result_chars") + } saved, err := t.store.Upsert(next) if err != nil { return "", err @@ -423,3 +466,19 @@ func stringArg(args map[string]interface{}, key string) string { v, _ := args[key].(string) return strings.TrimSpace(v) } + +func profileIntArg(args map[string]interface{}, key string) int { + if args == nil { + return 0 + } + switch v := args[key].(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + default: + return 0 + } +} diff --git a/pkg/tools/subagent_runtime_control_test.go b/pkg/tools/subagent_runtime_control_test.go new file mode 100644 index 0000000..9d64a02 --- /dev/null +++ b/pkg/tools/subagent_runtime_control_test.go @@ -0,0 +1,123 @@ +package tools + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSubagentSpawnEnforcesTaskQuota(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + return "ok", nil + }) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store") + } + if _, err := store.Upsert(SubagentProfile{ + AgentID: "coder", + MaxTaskChars: 8, + }); err != nil { + t.Fatalf("failed to create profile: %v", err) + } + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "this task is too long", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err == nil { + t.Fatalf("expected max_task_chars quota to reject spawn") + } +} + +func TestSubagentRunWithRetryEventuallySucceeds(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + attempts := 0 + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + attempts++ + if attempts == 1 { + return "", errors.New("temporary failure") + } + return "retry success", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "retry task", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + MaxRetries: 1, + RetryBackoff: 1, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitSubagentDone(t, manager, 4*time.Second) + if task.Status != "completed" { + t.Fatalf("expected completed task, got %s (%s)", task.Status, task.Result) + } + if task.RetryCount != 1 { + t.Fatalf("expected retry_count=1, got %d", task.RetryCount) + } + if attempts < 2 { + t.Fatalf("expected at least 2 attempts, got %d", attempts) + } +} + +func TestSubagentRunWithTimeoutFails(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + return "unexpected", nil + } + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "timeout task", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + TimeoutSec: 1, + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitSubagentDone(t, manager, 4*time.Second) + if task.Status != "failed" { + t.Fatalf("expected failed task on timeout, got %s", task.Status) + } + if task.RetryCount != 0 { + t.Fatalf("expected retry_count=0, got %d", task.RetryCount) + } +} + +func waitSubagentDone(t *testing.T, manager *SubagentManager, timeout time.Duration) *SubagentTask { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + tasks := manager.ListTasks() + if len(tasks) > 0 { + task := tasks[0] + if task.Status != "running" { + return task + } + } + time.Sleep(30 * time.Millisecond) + } + t.Fatalf("timeout waiting for subagent completion") + return nil +} diff --git a/pkg/tools/subagents_tool.go b/pkg/tools/subagents_tool.go index e5d1018..564ae7a 100644 --- a/pkg/tools/subagents_tool.go +++ b/pkg/tools/subagents_tool.go @@ -62,8 +62,8 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} sb.WriteString("Subagents:\n") sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) for i, task := range tasks { - sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s session=%s allowlist=%d\n", - i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, len(task.ToolAllowlist))) + sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s session=%s allowlist=%d retry=%d timeout=%ds\n", + i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec)) } return strings.TrimSpace(sb.String()), nil case "info": @@ -76,8 +76,8 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} var sb strings.Builder sb.WriteString("Subagents Summary:\n") for i, task := range tasks { - sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s steering=%d allowlist=%d\n", - i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, len(task.Steering), len(task.ToolAllowlist))) + sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s steering=%d allowlist=%d retry=%d timeout=%ds\n", + i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, len(task.Steering), len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec)) } return strings.TrimSpace(sb.String()), nil } @@ -89,9 +89,10 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} if !ok { return "subagent not found", nil } - return fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nMemory Namespace: %s\nTool Allowlist: %v\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s", + return fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nMemory Namespace: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\nMax Task Chars: %d\nMax Result Chars: %d\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s", task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, task.MemoryNS, - task.ToolAllowlist, task.Created, task.Updated, len(task.Steering), task.Task, task.Result), nil + task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec, task.MaxTaskChars, task.MaxResultChars, + task.Created, task.Updated, len(task.Steering), task.Task, task.Result), nil case "kill": if strings.EqualFold(strings.TrimSpace(id), "all") { tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) @@ -138,7 +139,8 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} var sb strings.Builder sb.WriteString(fmt.Sprintf("Subagent %s Log\n", task.ID)) sb.WriteString(fmt.Sprintf("Status: %s\n", task.Status)) - sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nTool Allowlist: %v\n", task.AgentID, task.Role, task.SessionKey, task.ToolAllowlist)) + sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\n", + task.AgentID, task.Role, task.SessionKey, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec)) if len(task.Steering) > 0 { sb.WriteString("Steering Messages:\n") for _, m := range task.Steering { diff --git a/pkg/tools/tool_allowlist_groups.go b/pkg/tools/tool_allowlist_groups.go new file mode 100644 index 0000000..16850b5 --- /dev/null +++ b/pkg/tools/tool_allowlist_groups.go @@ -0,0 +1,181 @@ +package tools + +import ( + "sort" + "strings" +) + +type ToolAllowlistGroup struct { + Name string `json:"name"` + Description string `json:"description"` + Aliases []string `json:"aliases,omitempty"` + Tools []string `json:"tools"` +} + +var defaultToolAllowlistGroups = []ToolAllowlistGroup{ + { + Name: "files_read", + Description: "Read-only workspace file tools", + Aliases: []string{"file_read", "readonly_files"}, + Tools: []string{"read_file", "list_dir", "repo_map", "read"}, + }, + { + Name: "files_write", + Description: "Workspace file modification tools", + Aliases: []string{"file_write"}, + Tools: []string{"write_file", "edit_file", "write", "edit"}, + }, + { + Name: "memory_read", + Description: "Read-only memory tools", + Aliases: []string{"mem_read"}, + Tools: []string{"memory_search", "memory_get"}, + }, + { + Name: "memory_write", + Description: "Memory write tools", + Aliases: []string{"mem_write"}, + Tools: []string{"memory_write"}, + }, + { + Name: "memory_all", + Description: "All memory tools", + Aliases: []string{"memory"}, + Tools: []string{"memory_search", "memory_get", "memory_write"}, + }, + { + Name: "pipeline", + Description: "Pipeline orchestration tools", + Aliases: []string{"pipelines"}, + Tools: []string{"pipeline_create", "pipeline_status", "pipeline_state_set", "pipeline_dispatch"}, + }, + { + Name: "subagents", + Description: "Subagent management tools", + Aliases: []string{"subagent", "agent_runtime"}, + Tools: []string{"spawn", "subagents", "subagent_profile"}, + }, +} + +func ToolAllowlistGroups() []ToolAllowlistGroup { + out := make([]ToolAllowlistGroup, 0, len(defaultToolAllowlistGroups)) + for _, g := range defaultToolAllowlistGroups { + item := ToolAllowlistGroup{ + Name: strings.ToLower(strings.TrimSpace(g.Name)), + Description: strings.TrimSpace(g.Description), + Aliases: normalizeAllowlistTokenList(g.Aliases), + Tools: normalizeAllowlistTokenList(g.Tools), + } + if item.Name == "" { + continue + } + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func ExpandToolAllowlistEntries(entries []string) []string { + if len(entries) == 0 { + return nil + } + groups := ToolAllowlistGroups() + resolved := make(map[string][]string, len(groups)) + for _, g := range groups { + if g.Name != "" { + resolved[g.Name] = g.Tools + } + for _, alias := range g.Aliases { + resolved[alias] = g.Tools + } + } + + out := map[string]struct{}{} + for _, raw := range entries { + token := normalizeAllowlistToken(raw) + if token == "" { + continue + } + if token == "*" || token == "all" { + out[token] = struct{}{} + continue + } + + if groupName, isGroupToken := parseAllowlistGroupToken(token); isGroupToken { + if members, ok := resolved[groupName]; ok { + for _, name := range members { + out[name] = struct{}{} + } + continue + } + // Keep unknown group token as-is to preserve user intent and avoid silent mutation. + out[token] = struct{}{} + continue + } + + if members, ok := resolved[token]; ok { + for _, name := range members { + out[name] = struct{}{} + } + continue + } + out[token] = struct{}{} + } + + if len(out) == 0 { + return nil + } + result := make([]string, 0, len(out)) + for name := range out { + result = append(result, name) + } + sort.Strings(result) + return result +} + +func parseAllowlistGroupToken(token string) (string, bool) { + token = normalizeAllowlistToken(token) + if token == "" { + return "", false + } + if strings.HasPrefix(token, "group:") { + v := normalizeAllowlistToken(strings.TrimPrefix(token, "group:")) + if v != "" { + return v, true + } + return "", false + } + if strings.HasPrefix(token, "@") { + v := normalizeAllowlistToken(strings.TrimPrefix(token, "@")) + if v != "" { + return v, true + } + return "", false + } + return "", false +} + +func normalizeAllowlistTokenList(in []string) []string { + if len(in) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, item := range in { + v := normalizeAllowlistToken(item) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +func normalizeAllowlistToken(in string) string { + return strings.ToLower(strings.TrimSpace(in)) +} diff --git a/pkg/tools/tool_allowlist_groups_test.go b/pkg/tools/tool_allowlist_groups_test.go new file mode 100644 index 0000000..f66a686 --- /dev/null +++ b/pkg/tools/tool_allowlist_groups_test.go @@ -0,0 +1,31 @@ +package tools + +import "testing" + +func TestExpandToolAllowlistEntries_GroupPrefix(t *testing.T) { + got := ExpandToolAllowlistEntries([]string{"group:files_read"}) + contains := map[string]bool{} + for _, item := range got { + contains[item] = true + } + if !contains["read_file"] || !contains["list_dir"] { + t.Fatalf("files_read group expansion missing expected tools: %v", got) + } + if contains["write_file"] { + t.Fatalf("files_read group should not include write_file: %v", got) + } +} + +func TestExpandToolAllowlistEntries_BareGroupAndAlias(t *testing.T) { + got := ExpandToolAllowlistEntries([]string{"memory_all", "@pipeline"}) + contains := map[string]bool{} + for _, item := range got { + contains[item] = true + } + if !contains["memory_search"] || !contains["memory_write"] { + t.Fatalf("memory_all expansion missing memory tools: %v", got) + } + if !contains["pipeline_dispatch"] || !contains["pipeline_status"] { + t.Fatalf("pipeline alias expansion missing pipeline tools: %v", got) + } +} diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 9fe47d2..a8d2b42 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -13,9 +13,10 @@ import Skills from './pages/Skills'; import Memory from './pages/Memory'; import TaskAudit from './pages/TaskAudit'; import EKG from './pages/EKG'; -import Tasks from './pages/Tasks'; import LogCodes from './pages/LogCodes'; import SubagentProfiles from './pages/SubagentProfiles'; +import Subagents from './pages/Subagents'; +import Pipelines from './pages/Pipelines'; export default function App() { return ( @@ -35,8 +36,9 @@ export default function App() { } /> } /> } /> - } /> } /> + } /> + } /> diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index b0b4e35..7801fe9 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo, BrainCircuit, Hash, Bot } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Workflow, Boxes } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import NavItem from './NavItem'; @@ -27,13 +27,14 @@ const Sidebar: React.FC = () => { { icon: , label: t('nodes'), to: '/nodes' }, { icon: , label: t('memory'), to: '/memory' }, { icon: , label: t('subagentProfiles'), to: '/subagent-profiles' }, + { icon: , label: t('subagentsRuntime'), to: '/subagents' }, + { icon: , label: t('pipelines'), to: '/pipelines' }, ], }, { title: t('sidebarOps'), items: [ { icon: , label: t('taskAudit'), to: '/task-audit' }, - { icon: , label: t('tasks'), to: '/tasks' }, ], }, { diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index a903e2c..0ce1007 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -17,7 +17,18 @@ const resources = { taskAudit: 'Task Audit', tasks: 'Tasks', subagentProfiles: 'Subagent Profiles', + subagentsRuntime: 'Subagents Runtime', + subagentDetail: 'Subagent Detail', + spawnSubagent: 'Spawn Subagent', + steerMessage: 'Steering message', + pipelines: 'Pipelines', + pipelineDetail: 'Pipeline Detail', + createPipeline: 'Create Pipeline', newProfile: 'New Profile', + spawn: 'Spawn', + kill: 'Kill', + send: 'Send', + dispatch: 'Dispatch', toolAllowlist: 'Tool Allowlist', memoryNamespace: 'Memory Namespace', subagentDeleteConfirmTitle: 'Delete Subagent Profile', @@ -44,18 +55,7 @@ const resources = { lastPauseAt: 'Last Pause Time', allSources: 'All Sources', allStatus: 'All Status', - pauseTask: 'Pause', - retryTask: 'Retry', - completeTask: 'Complete', - ignoreTask: 'Ignore', - taskCrud: 'Task CRUD', - createTask: 'Create', - newTask: 'New Task', - updateTask: 'Update', - deleteTask: 'Delete', export: 'Export', - dailySummary: 'Daily Summary', - noDailySummary: 'No autonomy daily summary yet.', error: 'Error', noTaskAudit: 'No task audit records', selectTask: 'Select a task from the left list', @@ -214,7 +214,6 @@ const resources = { configNoGroups: 'No config groups found.', configDiffPreviewCount: 'Diff Preview ({{count}} items)', saveConfigFailed: 'Failed to save config', - sourceAutonomy: 'autonomy', sourceDirect: 'direct', sourceMemoryTodo: 'memory_todo', statusRunning: 'running', @@ -239,12 +238,6 @@ const resources = { logsClearConfirmMessage: 'Clear current log list from this page?', configDeleteProviderConfirmTitle: 'Delete Provider', configDeleteProviderConfirmMessage: 'Delete provider "{{name}}" from current config?', - taskPauseConfirmTitle: 'Pause Task', - taskPauseConfirmMessage: 'Pause task "{{id}}" now?', - taskCompleteConfirmTitle: 'Complete Task', - taskCompleteConfirmMessage: 'Mark task "{{id}}" as completed?', - taskIgnoreConfirmTitle: 'Ignore Task', - taskIgnoreConfirmMessage: 'Ignore task "{{id}}"? This may hide follow-up processing.', cronExpressionPlaceholder: '*/5 * * * *', recipientId: 'recipient id', languageZh: '中文', @@ -347,22 +340,6 @@ const resources = { every_sec: 'Interval (Seconds)', ack_max_chars: 'Ack Max Chars', prompt_template: 'Prompt Template', - autonomy: 'Autonomy', - tick_interval_sec: 'Tick Interval (Seconds)', - min_run_interval_sec: 'Min Run Interval (Seconds)', - max_pending_duration_sec: 'Max Pending Duration (Seconds)', - max_consecutive_stalls: 'Max Consecutive Stalls', - max_dispatch_per_tick: 'Max Dispatch Per Tick', - notify_cooldown_sec: 'Notify Cooldown (Seconds)', - notify_same_reason_cooldown_sec: 'Same-reason Notify Cooldown (Seconds)', - quiet_hours: 'Quiet Hours', - user_idle_resume_sec: 'User Idle Resume (Seconds)', - max_rounds_without_user: 'Max Rounds Without User', - task_history_retention_days: 'Task History Retention (Days)', - waiting_resume_debounce_sec: 'Waiting Resume Debounce (Seconds)', - idle_round_budget_release_sec: 'Idle Round Budget Release (Seconds)', - allowed_task_keywords: 'Allowed Task Keywords', - ekg_consecutive_error_threshold: 'EKG Consecutive Error Threshold', context_compaction: 'Context Compaction', mode: 'Mode', trigger_messages: 'Trigger Messages', @@ -371,12 +348,6 @@ const resources = { max_transcript_chars: 'Max Transcript Chars', runtime_control: 'Runtime Control', intent_max_input_chars: 'Intent Max Input Chars', - autonomy_tick_interval_sec: 'Autonomy Tick Interval (Seconds)', - autonomy_min_run_interval_sec: 'Autonomy Min Run Interval (Seconds)', - autonomy_idle_threshold_sec: 'Autonomy Idle Threshold (Seconds)', - autonomy_max_rounds_without_user: 'Autonomy Max Rounds Without User', - autonomy_max_pending_duration_sec: 'Autonomy Max Pending Duration (Seconds)', - autonomy_max_consecutive_stalls: 'Autonomy Max Consecutive Stalls', autolearn_max_rounds_without_user: 'Autolearn Max Rounds Without User', run_state_ttl_seconds: 'Run State TTL (Seconds)', run_state_max: 'Run State Max', @@ -447,7 +418,18 @@ const resources = { taskAudit: '任务审计', tasks: '任务管理', subagentProfiles: '子代理档案', + subagentsRuntime: '子代理运行态', + subagentDetail: '子代理详情', + spawnSubagent: '创建子代理任务', + steerMessage: '引导消息', + pipelines: '流水线', + pipelineDetail: '流水线详情', + createPipeline: '创建流水线', newProfile: '新建档案', + spawn: '创建', + kill: '终止', + send: '发送', + dispatch: '派发', toolAllowlist: '工具白名单', memoryNamespace: '记忆命名空间', subagentDeleteConfirmTitle: '删除子代理档案', @@ -474,18 +456,7 @@ const resources = { lastPauseAt: '最近暂停时间', allSources: '全部来源', allStatus: '全部状态', - pauseTask: '暂停', - retryTask: '重试', - completeTask: '完成', - ignoreTask: '忽略', - taskCrud: '任务 CRUD', - createTask: '新建', - newTask: '新任务', - updateTask: '更新', - deleteTask: '删除', export: '导出', - dailySummary: '日报摘要', - noDailySummary: '暂无自治日报。', error: '错误', noTaskAudit: '暂无任务审计记录', selectTask: '请从左侧选择任务', @@ -644,7 +615,6 @@ const resources = { configNoGroups: '未找到配置分组。', configDiffPreviewCount: '配置差异预览({{count}}项)', saveConfigFailed: '保存配置失败', - sourceAutonomy: 'autonomy', sourceDirect: 'direct', sourceMemoryTodo: 'memory_todo', statusRunning: 'running', @@ -669,12 +639,6 @@ const resources = { logsClearConfirmMessage: '确认清空当前页面中的日志列表吗?', configDeleteProviderConfirmTitle: '删除 Provider', configDeleteProviderConfirmMessage: '确认从当前配置中删除 provider “{{name}}”吗?', - taskPauseConfirmTitle: '暂停任务', - taskPauseConfirmMessage: '确认暂停任务“{{id}}”吗?', - taskCompleteConfirmTitle: '完成任务', - taskCompleteConfirmMessage: '确认将任务“{{id}}”标记为完成吗?', - taskIgnoreConfirmTitle: '忽略任务', - taskIgnoreConfirmMessage: '确认忽略任务“{{id}}”吗?这可能会跳过后续处理。', cronExpressionPlaceholder: '*/5 * * * *', recipientId: '接收者 ID', languageZh: '中文', @@ -777,22 +741,6 @@ const resources = { every_sec: '间隔(秒)', ack_max_chars: '确认最大字符数', prompt_template: '提示模板', - autonomy: '自治', - tick_interval_sec: '轮询间隔(秒)', - min_run_interval_sec: '最小运行间隔(秒)', - max_pending_duration_sec: '最大挂起时长(秒)', - max_consecutive_stalls: '最大连续停滞次数', - max_dispatch_per_tick: '每次轮询最大派发数', - notify_cooldown_sec: '通知冷却(秒)', - notify_same_reason_cooldown_sec: '同原因通知冷却(秒)', - quiet_hours: '静默时段', - user_idle_resume_sec: '用户空闲恢复(秒)', - max_rounds_without_user: '无用户最大轮数', - task_history_retention_days: '任务历史保留天数', - waiting_resume_debounce_sec: '等待恢复防抖(秒)', - idle_round_budget_release_sec: '空闲轮次预算释放(秒)', - allowed_task_keywords: '允许任务关键词', - ekg_consecutive_error_threshold: 'EKG 连续错误阈值', context_compaction: '上下文压缩', mode: '模式', trigger_messages: '触发消息数', @@ -801,12 +749,6 @@ const resources = { max_transcript_chars: '转录最大字符数', runtime_control: '运行时控制', intent_max_input_chars: '意图输入最大字符数', - autonomy_tick_interval_sec: '自治轮询间隔(秒)', - autonomy_min_run_interval_sec: '自治最小运行间隔(秒)', - autonomy_idle_threshold_sec: '自治空闲阈值(秒)', - autonomy_max_rounds_without_user: '自治无用户最大轮数', - autonomy_max_pending_duration_sec: '自治最大挂起时长(秒)', - autonomy_max_consecutive_stalls: '自治最大连续停滞次数', autolearn_max_rounds_without_user: '自学习无用户最大轮数', run_state_ttl_seconds: '运行状态 TTL(秒)', run_state_max: '运行状态上限', diff --git a/webui/src/pages/Pipelines.tsx b/webui/src/pages/Pipelines.tsx new file mode 100644 index 0000000..43e22f0 --- /dev/null +++ b/webui/src/pages/Pipelines.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; + +type PipelineTask = { + id: string; + role?: string; + goal?: string; + status?: string; + depends_on?: string[]; + result?: string; + error?: string; +}; + +type Pipeline = { + id: string; + label?: string; + objective?: string; + status?: string; + tasks?: Record; +}; + +const Pipelines: React.FC = () => { + const { t } = useTranslation(); + const { q } = useAppContext(); + const ui = useUI(); + + const [items, setItems] = useState([]); + const [selectedID, setSelectedID] = useState(''); + const [detail, setDetail] = useState(null); + const [maxDispatch, setMaxDispatch] = useState(3); + const [createLabel, setCreateLabel] = useState(''); + const [createObjective, setCreateObjective] = useState(''); + const [tasksJSON, setTasksJSON] = useState('[\n {"id":"coding","role":"coding","goal":"Implement feature"},\n {"id":"docs","role":"docs","goal":"Write docs","depends_on":["coding"]}\n]'); + + const apiPath = '/webui/api/pipelines'; + const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`; + + const callAction = async (payload: Record) => { + const r = await fetch(`${apiPath}${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!r.ok) { + await ui.notify({ title: t('requestFailed'), message: await r.text() }); + return null; + } + return r.json(); + }; + + const loadList = async () => { + const r = await fetch(withAction('list')); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + const arr = Array.isArray(j?.result?.items) ? j.result.items : []; + setItems(arr); + if (arr.length === 0) { + setSelectedID(''); + setDetail(null); + return; + } + const id = selectedID && arr.find((x: Pipeline) => x.id === selectedID) ? selectedID : arr[0].id; + setSelectedID(id); + }; + + const loadDetail = async (pipelineID: string) => { + if (!pipelineID) { + setDetail(null); + return; + } + const u = `${withAction('get')}&pipeline_id=${encodeURIComponent(pipelineID)}`; + const r = await fetch(u); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + setDetail(j?.result?.pipeline || null); + }; + + useEffect(() => { + loadList().catch(() => {}); + }, [q]); + + useEffect(() => { + if (!selectedID) { + setDetail(null); + return; + } + loadDetail(selectedID).catch(() => {}); + }, [selectedID, q]); + + const sortedTasks = useMemo(() => { + const entries = Object.values(detail?.tasks || {}); + entries.sort((a, b) => (a.id || '').localeCompare(b.id || '')); + return entries; + }, [detail]); + + const dispatch = async () => { + if (!detail?.id) return; + const data = await callAction({ action: 'dispatch', pipeline_id: detail.id, max_dispatch: maxDispatch }); + if (!data) return; + await loadList(); + await loadDetail(detail.id); + }; + + const createPipeline = async () => { + if (!createObjective.trim()) { + await ui.notify({ title: t('requestFailed'), message: 'objective is required' }); + return; + } + let tasks: any[] = []; + try { + const parsed = JSON.parse(tasksJSON); + tasks = Array.isArray(parsed) ? parsed : []; + } catch { + await ui.notify({ title: t('requestFailed'), message: 'tasks JSON parse failed' }); + return; + } + const data = await callAction({ + action: 'create', + label: createLabel, + objective: createObjective, + tasks, + }); + if (!data) return; + setCreateLabel(''); + setCreateObjective(''); + await loadList(); + }; + + return ( +
+
+

{t('pipelines')}

+ +
+ +
+
+
{t('pipelines')}
+
+ {items.map((it) => ( + + ))} + {items.length === 0 &&
No pipelines.
} +
+
+ +
+
+
{t('pipelineDetail')}
+ {!detail &&
{t('selectTask')}
} + {detail && ( + <> +
+
ID: {detail.id}
+
Status: {detail.status}
+
Label: {detail.label || '-'}
+
+
Objective
+
{detail.objective || '-'}
+ +
+ setMaxDispatch(Math.max(1, Number(e.target.value) || 1))} + className="w-24 px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" + /> + +
+ +
Tasks
+
+ {sortedTasks.map((task) => ( +
+
{task.id} · {task.status}
+
{task.role || '-'} · deps: {(task.depends_on || []).join(', ') || '-'}
+
{task.goal || '-'}
+ {task.error &&
{task.error}
} +
+ ))} +
+ + )} +
+ +
+
{t('createPipeline')}
+ setCreateLabel(e.target.value)} placeholder="label" className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" /> + setCreateObjective(e.target.value)} placeholder="objective" className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" /> +