From c320de5a87c4f5bd5ac89d7ed852d5e25e577df0 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 10:40:59 +0700 Subject: [PATCH 1/7] feat: grant first setup agent gateway operator access --- cmd/gateway_http_client.go | 4 + cmd/gateway_http_client_test.go | 12 + cmd/gateway_http_handlers.go | 5 + cmd/setup_agent.go | 66 +- cmd/setup_agent_test.go | 15 + internal/http/agents.go | 61 +- .../http/agents_gateway_operator_bootstrap.go | 333 +++++++++ .../agents_gateway_operator_bootstrap_test.go | 656 ++++++++++++++++++ internal/http/secure_cli_agent_grants_test.go | 21 + internal/i18n/catalog_en.go | 14 +- internal/i18n/catalog_vi.go | 14 +- internal/i18n/catalog_zh.go | 14 +- internal/i18n/keys.go | 14 +- internal/tools/credentialed_exec.go | 2 +- internal/tools/credentialed_exec_env_test.go | 42 ++ internal/tools/env_scrub.go | 1 + internal/tools/env_scrub_test.go | 4 + ui/web/src/i18n/locales/en/setup.json | 5 + ui/web/src/i18n/locales/vi/setup.json | 5 + ui/web/src/i18n/locales/zh/setup.json | 5 + ui/web/src/pages/setup/step-agent.tsx | 43 ++ ui/web/src/types/agent.ts | 9 + 22 files changed, 1300 insertions(+), 45 deletions(-) create mode 100644 cmd/gateway_http_client_test.go create mode 100644 cmd/setup_agent_test.go create mode 100644 internal/http/agents_gateway_operator_bootstrap.go create mode 100644 internal/http/agents_gateway_operator_bootstrap_test.go diff --git a/cmd/gateway_http_client.go b/cmd/gateway_http_client.go index b1788a67..9508fa69 100644 --- a/cmd/gateway_http_client.go +++ b/cmd/gateway_http_client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "time" "github.com/nextlevelbuilder/goclaw/internal/config" @@ -29,6 +30,9 @@ var healthClient = &http.Client{Timeout: 3 * time.Second} // resolveGatewayBaseURL reads host/port from config and returns http://host:port. func resolveGatewayBaseURL() string { + if server := strings.TrimSpace(os.Getenv("GOCLAW_SERVER")); server != "" { + return strings.TrimRight(server, "/") + } cfg, err := config.Load(resolveConfigPath()) if err != nil { return "http://127.0.0.1:18790" diff --git a/cmd/gateway_http_client_test.go b/cmd/gateway_http_client_test.go new file mode 100644 index 00000000..e41c783e --- /dev/null +++ b/cmd/gateway_http_client_test.go @@ -0,0 +1,12 @@ +package cmd + +import "testing" + +func TestResolveGatewayBaseURLUsesGOCLAWServer(t *testing.T) { + t.Setenv("GOCLAW_SERVER", "http://127.0.0.1:19999/") + + got := resolveGatewayBaseURL() + if got != "http://127.0.0.1:19999" { + t.Fatalf("resolveGatewayBaseURL()=%q, want GOCLAW_SERVER without trailing slash", got) + } +} diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index c270af1d..b96cd04c 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -33,6 +33,11 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, stores.Tracing, defaultWorkspace, msgBus, summoner, isOwner) agentsH.SetImportStores(stores.Memory, stores.KnowledgeGraph) agentsH.SetDataDir(dataDir) + if stores.SecureCLI != nil && stores.SecureCLIGrants != nil { + if agentCreds, ok := stores.SecureCLI.(store.SecureCLIAgentCredentialStore); ok { + agentsH.SetGatewayOperatorBootstrap(stores.SecureCLI, stores.SecureCLIGrants, agentCreds, gatewayAddr) + } + } } if stores != nil && stores.Skills != nil { diff --git a/cmd/setup_agent.go b/cmd/setup_agent.go index dc2f905f..664cf72c 100644 --- a/cmd/setup_agent.go +++ b/cmd/setup_agent.go @@ -16,6 +16,7 @@ func setupAgentStep() { return } + grantGatewayOperatorAccess := false if len(agents) > 0 { fmt.Printf(" Found %d existing agent(s):\n", len(agents)) for _, a := range agents { @@ -30,12 +31,31 @@ func setupAgentStep() { } else { fmt.Println(" No agents yet. Let's create your first one.") fmt.Println() + grant, err := promptConfirm("Grant this first agent local gateway operator access via the goclaw CLI?", false) + if err != nil { + return + } + grantGatewayOperatorAccess = grant + if grantGatewayOperatorAccess { + fmt.Println(" The agent will get revocable SecureCLI access to run local gateway commands.") + fmt.Println() + } } - createAgent() + createAgent(grantGatewayOperatorAccess) } -func createAgent() { +type setupAgentCreateResponse struct { + httpAgent + GatewayOperatorBootstrap *gatewayOperatorBootstrapResponse `json:"gateway_operator_bootstrap,omitempty"` +} + +type gatewayOperatorBootstrapResponse struct { + Status string `json:"status"` + Warning string `json:"warning,omitempty"` +} + +func createAgent(grantGatewayOperatorAccess bool) { agentKey, err := promptString("Agent key (slug)", "e.g. assistant, coder", "assistant") if err != nil { return @@ -80,19 +100,43 @@ func createAgent() { return } - body := map[string]any{ - "agent_key": agentKey, - "display_name": displayName, - "agent_type": agentType, - "provider": findProviderType(providers, providerID), - "model": model, - } + body := setupAgentCreatePayload(agentKey, displayName, agentType, findProviderType(providers, providerID), model, grantGatewayOperatorAccess) - _, err = gatewayHTTPPost("/v1/agents", body) + result, err := gatewayHTTPPostTyped[setupAgentCreateResponse]("/v1/agents", body) if err != nil { fmt.Fprintf(os.Stderr, " Error: %v\n", err) return } - fmt.Printf(" Agent %q created (%s).\n\n", agentKey, model) + fmt.Printf(" Agent %q created (%s).\n", agentKey, model) + printGatewayOperatorBootstrapResult(result.GatewayOperatorBootstrap) + fmt.Println() +} + +func setupAgentCreatePayload(agentKey, displayName, agentType, providerType, model string, grantGatewayOperatorAccess bool) map[string]any { + body := map[string]any{ + "agent_key": agentKey, + "display_name": displayName, + "agent_type": agentType, + "provider": providerType, + "model": model, + } + if grantGatewayOperatorAccess { + body["grant_gateway_operator_access"] = true + } + return body +} + +func printGatewayOperatorBootstrapResult(result *gatewayOperatorBootstrapResponse) { + if result == nil { + return + } + switch result.Status { + case "granted": + fmt.Println(" Gateway operator access granted. Try: goclaw agent list") + case "warning", "skipped": + if result.Warning != "" { + fmt.Printf(" Warning: %s\n", result.Warning) + } + } } diff --git a/cmd/setup_agent_test.go b/cmd/setup_agent_test.go new file mode 100644 index 00000000..26357e06 --- /dev/null +++ b/cmd/setup_agent_test.go @@ -0,0 +1,15 @@ +package cmd + +import "testing" + +func TestSetupAgentCreatePayloadIncludesGatewayOperatorConsentOnlyWhenTrue(t *testing.T) { + withoutConsent := setupAgentCreatePayload("assistant", "Assistant", "predefined", "anthropic", "claude", false) + if _, ok := withoutConsent["grant_gateway_operator_access"]; ok { + t.Fatalf("grant_gateway_operator_access must be absent without explicit consent: %#v", withoutConsent) + } + + withConsent := setupAgentCreatePayload("assistant", "Assistant", "predefined", "anthropic", "claude", true) + if got, ok := withConsent["grant_gateway_operator_access"].(bool); !ok || !got { + t.Fatalf("grant_gateway_operator_access missing after consent: %#v", withConsent) + } +} diff --git a/internal/http/agents.go b/internal/http/agents.go index f7e506af..601ee3ae 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -26,25 +26,30 @@ import ( // AgentsHandler handles agent CRUD and sharing endpoints. type AgentsHandler struct { - agents store.AgentStore - providers store.ProviderStore - providerReg *providers.Registry - db *sql.DB - tracingStore store.TracingStore - memoryStore store.MemoryStore // for import (nil = disabled) - kgStore store.KnowledgeGraphStore // for import (nil = disabled) - episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds) - vaultStore store.VaultStore // for vault import (nil = disabled) - toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback) - skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip) - skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip) - teamStore store.TeamStore // for system prompt preview team context (nil = skip) - agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip) - defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") - dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export - msgBus *bus.MessageBus // for cache invalidation events (nil = no events) - summoner *AgentSummoner // LLM-based agent setup (nil = disabled) - isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + agents store.AgentStore + providers store.ProviderStore + providerReg *providers.Registry + db *sql.DB + tracingStore store.TracingStore + memoryStore store.MemoryStore // for import (nil = disabled) + kgStore store.KnowledgeGraphStore // for import (nil = disabled) + episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds) + vaultStore store.VaultStore // for vault import (nil = disabled) + toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback) + skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip) + skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip) + teamStore store.TeamStore // for system prompt preview team context (nil = skip) + agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip) + secureCLI store.SecureCLIStore + secureCLIGrants store.SecureCLIAgentGrantStore + secureCLIAgentCreds store.SecureCLIAgentCredentialStore + defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") + dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export + gatewayAddr string + msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + summoner *AgentSummoner // LLM-based agent setup (nil = disabled) + isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + findGatewayOperatorBinary func() (string, error) } // NewAgentsHandler creates a handler for agent management endpoints. @@ -222,10 +227,14 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { return } - var req store.AgentData - if !bindJSON(w, r, locale, &req) { + var createReq struct { + store.AgentData + GrantGatewayOperatorAccess bool `json:"grant_gateway_operator_access,omitempty"` + } + if !bindJSON(w, r, locale, &createReq) { return } + req := createReq.AgentData if !isValidSlug(req.AgentKey) { writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")) @@ -317,6 +326,16 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String()) publicAgent := canonicalizeAgentForResponse(&req) + if createReq.GrantGatewayOperatorAccess { + writeJSON(w, http.StatusCreated, struct { + store.AgentData + GatewayOperatorBootstrap *gatewayOperatorBootstrapResult `json:"gateway_operator_bootstrap,omitempty"` + }{ + AgentData: publicAgent, + GatewayOperatorBootstrap: h.bootstrapGatewayOperatorForCreatedAgent(r.Context(), req.ID, locale), + }) + return + } writeJSON(w, http.StatusCreated, publicAgent) } diff --git a/internal/http/agents_gateway_operator_bootstrap.go b/internal/http/agents_gateway_operator_bootstrap.go new file mode 100644 index 00000000..07288d7c --- /dev/null +++ b/internal/http/agents_gateway_operator_bootstrap.go @@ -0,0 +1,333 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const gatewayOperatorBinaryName = "goclaw" + +var ( + errGatewayOperatorSecureCLIUnavailable = errors.New("gateway operator securecli unavailable") + errGatewayOperatorTokenMissing = errors.New("gateway operator token missing") + errGatewayOperatorBinaryMissing = errors.New("gateway operator goclaw binary missing") + errGatewayOperatorExistingReview = errors.New("gateway operator existing credential requires review") + errGatewayOperatorRegisterFailed = errors.New("gateway operator register failed") + errGatewayOperatorCredentialFailed = errors.New("gateway operator credential failed") +) + +type gatewayOperatorBootstrapResult struct { + Status string `json:"status"` + BinaryID uuid.UUID `json:"binary_id,omitempty"` + GrantID uuid.UUID `json:"grant_id,omitempty"` + Warning string `json:"warning,omitempty"` +} + +func (h *AgentsHandler) SetGatewayOperatorBootstrap(secureCLI store.SecureCLIStore, grants store.SecureCLIAgentGrantStore, agentCreds store.SecureCLIAgentCredentialStore, gatewayAddr string) { + h.secureCLI = secureCLI + h.secureCLIGrants = grants + h.secureCLIAgentCreds = agentCreds + h.gatewayAddr = gatewayAddr + if h.findGatewayOperatorBinary == nil { + h.findGatewayOperatorBinary = defaultFindGatewayOperatorBinary + } +} + +func (h *AgentsHandler) bootstrapGatewayOperatorForCreatedAgent(ctx context.Context, agentID uuid.UUID, locale string) *gatewayOperatorBootstrapResult { + if h.secureCLI == nil || h.secureCLIGrants == nil || h.secureCLIAgentCreds == nil { + return &gatewayOperatorBootstrapResult{Status: "warning", Warning: i18n.T(locale, i18n.MsgGatewayOperatorSecureCLIUnavailable)} + } + first, err := h.isDeterministicFirstAgent(ctx, agentID) + if err != nil { + slog.Warn("gateway_operator.bootstrap.first_agent_check_failed", "agent_id", agentID, "error", err) + return &gatewayOperatorBootstrapResult{Status: "warning", Warning: i18n.T(locale, i18n.MsgGatewayOperatorEligibilityFailed)} + } + if !first { + return &gatewayOperatorBootstrapResult{Status: "skipped", Warning: i18n.T(locale, i18n.MsgGatewayOperatorNotFirstAgent)} + } + + result, err := h.bootstrapGatewayOperatorAccess(ctx, agentID) + if err != nil { + slog.Warn("gateway_operator.bootstrap.failed", "agent_id", agentID, "error", err) + return &gatewayOperatorBootstrapResult{Status: "warning", Warning: gatewayOperatorWarning(locale, err)} + } + result.Status = "granted" + return result +} + +func (h *AgentsHandler) isDeterministicFirstAgent(ctx context.Context, agentID uuid.UUID) (bool, error) { + if h.agents == nil || agentID == uuid.Nil { + return false, nil + } + agents, err := h.agents.List(ctx, "") + if err != nil { + return false, err + } + if len(agents) == 0 { + return false, nil + } + firstID := deterministicFirstAgentID(agents) + return firstID == agentID, nil +} + +func deterministicFirstAgentID(agents []store.AgentData) uuid.UUID { + sort.SliceStable(agents, func(i, j int) bool { + if !agents[i].CreatedAt.Equal(agents[j].CreatedAt) { + return agents[i].CreatedAt.Before(agents[j].CreatedAt) + } + return agents[i].ID.String() < agents[j].ID.String() + }) + if len(agents) == 0 { + return uuid.Nil + } + return agents[0].ID +} + +func (h *AgentsHandler) bootstrapGatewayOperatorAccess(ctx context.Context, agentID uuid.UUID) (*gatewayOperatorBootstrapResult, error) { + if strings.TrimSpace(pkgGatewayToken) == "" { + return nil, errGatewayOperatorTokenMissing + } + if h.secureCLI == nil || h.secureCLIGrants == nil || h.secureCLIAgentCreds == nil { + return nil, errGatewayOperatorSecureCLIUnavailable + } + findBinary := h.findGatewayOperatorBinary + if findBinary == nil { + findBinary = defaultFindGatewayOperatorBinary + } + binaryPath, err := findBinary() + if err != nil { + return nil, errGatewayOperatorBinaryMissing + } + + binary, err := h.ensureGatewayOperatorBinary(ctx, binaryPath) + if err != nil { + return nil, err + } + grant, err := h.ensureGatewayOperatorGrant(ctx, binary.ID, agentID) + if err != nil { + return nil, err + } + if err := h.setGatewayOperatorAgentCredential(ctx, binary.ID, agentID); err != nil { + return nil, err + } + + h.emitCacheInvalidate("secure_cli", binary.ID.String()) + return &gatewayOperatorBootstrapResult{BinaryID: binary.ID, GrantID: grant.ID}, nil +} + +func (h *AgentsHandler) ensureGatewayOperatorBinary(ctx context.Context, binaryPath string) (*store.SecureCLIBinary, error) { + existing, err := h.findGatewayOperatorBinaryConfig(ctx) + if err != nil { + return nil, errGatewayOperatorCredentialFailed + } + if existing != nil { + return h.applyGatewayOperatorBinaryPolicy(ctx, existing, binaryPath) + } + + binary := &store.SecureCLIBinary{ + BinaryName: gatewayOperatorBinaryName, + BinaryPath: &binaryPath, + Description: gatewayOperatorDescription(), + DenyArgs: gatewayOperatorDenyArgs(), + DenyVerbose: gatewayOperatorDenyVerbose(), + TimeoutSeconds: 30, + Tips: gatewayOperatorTips(), + IsGlobal: false, + Enabled: true, + CreatedBy: gatewayOperatorActor(ctx), + } + if err := h.secureCLI.Create(ctx, binary); err != nil { + if found, findErr := h.findGatewayOperatorBinaryConfig(ctx); findErr == nil && found != nil { + if updated, updateErr := h.applyGatewayOperatorBinaryPolicy(ctx, found, binaryPath); updateErr == nil { + return updated, nil + } + } + return nil, errGatewayOperatorRegisterFailed + } + return binary, nil +} + +func (h *AgentsHandler) applyGatewayOperatorBinaryPolicy(ctx context.Context, binary *store.SecureCLIBinary, binaryPath string) (*store.SecureCLIBinary, error) { + if err := h.secureCLI.Update(ctx, binary.ID, gatewayOperatorBinaryPolicyUpdates(binaryPath)); err != nil { + return nil, errGatewayOperatorCredentialFailed + } + binary.IsGlobal = false + binary.Enabled = true + binary.BinaryPath = &binaryPath + binary.Description = gatewayOperatorDescription() + binary.DenyArgs = gatewayOperatorDenyArgs() + binary.DenyVerbose = gatewayOperatorDenyVerbose() + binary.TimeoutSeconds = 30 + binary.Tips = gatewayOperatorTips() + binary.AdapterName = nil + return binary, nil +} + +func (h *AgentsHandler) findGatewayOperatorBinaryConfig(ctx context.Context) (*store.SecureCLIBinary, error) { + binaries, err := h.secureCLI.List(ctx) + if err != nil { + return nil, err + } + for i := range binaries { + if strings.EqualFold(strings.TrimSpace(binaries[i].BinaryName), gatewayOperatorBinaryName) { + b := binaries[i] + return &b, nil + } + } + return nil, nil +} + +func gatewayOperatorBinaryPolicyUpdates(binaryPath string) map[string]any { + return map[string]any{ + "binary_path": binaryPath, + "description": gatewayOperatorDescription(), + "deny_args": gatewayOperatorDenyArgs(), + "deny_verbose": gatewayOperatorDenyVerbose(), + "timeout_seconds": 30, + "tips": gatewayOperatorTips(), + "is_global": false, + "enabled": true, + "adapter_name": nil, + } +} + +func gatewayOperatorDescription() string { + return "Local GoClaw gateway operator CLI" +} + +func gatewayOperatorTips() string { + return "Use for local gateway operations such as `goclaw agent list`. Auth/setup/migration/backup/restore and verbose/debug commands are blocked." +} + +func gatewayOperatorDenyArgs() json.RawMessage { + raw, _ := json.Marshal([]string{ + `^auth\b`, + `^onboard\b`, + `^setup\b`, + `^migrate\b`, + `^upgrade\b`, + `^backup\b`, + `^restore\b`, + `^tenant-backup\b`, + `^tenant-restore\b`, + `^config\s+set\b`, + }) + return raw +} + +func gatewayOperatorDenyVerbose() json.RawMessage { + raw, _ := json.Marshal([]string{`-v`, `--verbose`, `--debug`}) + return raw +} + +func (h *AgentsHandler) ensureGatewayOperatorGrant(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentGrant, error) { + grants, err := h.secureCLIGrants.ListByAgent(ctx, agentID) + if err != nil { + return nil, errGatewayOperatorCredentialFailed + } + for i := range grants { + if grants[i].BinaryID != binaryID { + continue + } + if !grants[i].Enabled { + if err := h.secureCLIGrants.Update(ctx, grants[i].ID, map[string]any{"enabled": true}); err != nil { + return nil, errGatewayOperatorCredentialFailed + } + grants[i].Enabled = true + } + return &grants[i], nil + } + + grant := &store.SecureCLIAgentGrant{ + BinaryID: binaryID, + AgentID: agentID, + Enabled: true, + } + if err := h.secureCLIGrants.Create(ctx, grant); err != nil { + return nil, errGatewayOperatorCredentialFailed + } + return grant, nil +} + +func (h *AgentsHandler) setGatewayOperatorAgentCredential(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error { + env, err := store.SerializeSecureCLIEnv(map[string]store.SecureCLIEnvEntry{ + "GOCLAW_GATEWAY_TOKEN": { + Kind: store.SecureCLIEnvKindSensitive, + Value: pkgGatewayToken, + }, + "GOCLAW_SERVER": { + Kind: store.SecureCLIEnvKindSensitive, + Value: gatewayOperatorServerURL(h.gatewayAddr), + }, + }) + if err != nil { + return errGatewayOperatorCredentialFailed + } + if err := h.secureCLIAgentCreds.SetAgentCredentials(ctx, binaryID, agentID, env, gatewayOperatorActor(ctx)); err != nil { + return errGatewayOperatorCredentialFailed + } + return nil +} + +func gatewayOperatorWarning(locale string, err error) string { + switch { + case errors.Is(err, errGatewayOperatorSecureCLIUnavailable): + return i18n.T(locale, i18n.MsgGatewayOperatorSecureCLIUnavailable) + case errors.Is(err, errGatewayOperatorTokenMissing): + return i18n.T(locale, i18n.MsgGatewayOperatorTokenMissing) + case errors.Is(err, errGatewayOperatorBinaryMissing): + return i18n.T(locale, i18n.MsgGatewayOperatorBinaryMissing) + case errors.Is(err, errGatewayOperatorExistingReview): + return i18n.T(locale, i18n.MsgGatewayOperatorExistingReview) + case errors.Is(err, errGatewayOperatorRegisterFailed): + return i18n.T(locale, i18n.MsgGatewayOperatorRegisterFailed) + default: + return i18n.T(locale, i18n.MsgGatewayOperatorCredentialFailed) + } +} + +func gatewayOperatorActor(ctx context.Context) string { + if userID := strings.TrimSpace(store.UserIDFromContext(ctx)); userID != "" { + return userID + } + return "system" +} + +func gatewayOperatorServerURL(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "" { + return "http://127.0.0.1:18790" + } + if u, err := url.Parse(addr); err == nil && u.Scheme != "" { + return strings.TrimRight(addr, "/") + } + return "http://" + strings.TrimRight(addr, "/") +} + +func defaultFindGatewayOperatorBinary() (string, error) { + if exe, err := os.Executable(); err == nil && strings.EqualFold(filepath.Base(exe), gatewayOperatorBinaryName) && skills.IsExecutableFile(exe) { + return exe, nil + } + if path, err := exec.LookPath(gatewayOperatorBinaryName); err == nil && skills.IsExecutableFile(path) { + return path, nil + } + if path, ok := skills.FindRuntimeExecutable(gatewayOperatorBinaryName); ok && skills.IsExecutableFile(path) { + return path, nil + } + return "", fmt.Errorf("%s binary not found", gatewayOperatorBinaryName) +} diff --git a/internal/http/agents_gateway_operator_bootstrap_test.go b/internal/http/agents_gateway_operator_bootstrap_test.go new file mode 100644 index 00000000..9027ad46 --- /dev/null +++ b/internal/http/agents_gateway_operator_bootstrap_test.go @@ -0,0 +1,656 @@ +package http + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type gatewayOperatorSecureCLIStore struct { + binaries []store.SecureCLIBinary + created *store.SecureCLIBinary + updated map[uuid.UUID]map[string]any +} + +func (s *gatewayOperatorSecureCLIStore) Create(_ context.Context, b *store.SecureCLIBinary) error { + cp := *b + if cp.ID == uuid.Nil { + cp.ID = store.GenNewID() + b.ID = cp.ID + } + s.created = &cp + s.binaries = append(s.binaries, cp) + return nil +} + +func (s *gatewayOperatorSecureCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorSecureCLIStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + if s.updated == nil { + s.updated = map[uuid.UUID]map[string]any{} + } + cp := map[string]any{} + for k, v := range updates { + cp[k] = v + } + s.updated[id] = cp + for i := range s.binaries { + if s.binaries[i].ID != id { + continue + } + for k, v := range updates { + switch k { + case "binary_path": + if path, ok := v.(string); ok { + s.binaries[i].BinaryPath = &path + } + case "description": + if description, ok := v.(string); ok { + s.binaries[i].Description = description + } + case "deny_args": + if raw, ok := v.(json.RawMessage); ok { + s.binaries[i].DenyArgs = raw + } + case "deny_verbose": + if raw, ok := v.(json.RawMessage); ok { + s.binaries[i].DenyVerbose = raw + } + case "timeout_seconds": + if timeoutSeconds, ok := v.(int); ok { + s.binaries[i].TimeoutSeconds = timeoutSeconds + } + case "tips": + if tips, ok := v.(string); ok { + s.binaries[i].Tips = tips + } + case "is_global": + if isGlobal, ok := v.(bool); ok { + s.binaries[i].IsGlobal = isGlobal + } + case "enabled": + if enabled, ok := v.(bool); ok { + s.binaries[i].Enabled = enabled + } + case "adapter_name": + if adapterName, ok := v.(*string); ok { + s.binaries[i].AdapterName = adapterName + } else { + s.binaries[i].AdapterName = nil + } + } + } + return nil + } + return nil +} + +func (s *gatewayOperatorSecureCLIStore) Delete(context.Context, uuid.UUID) error { return nil } + +func (s *gatewayOperatorSecureCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) { + out := make([]store.SecureCLIBinary, len(s.binaries)) + copy(out, s.binaries) + return out, nil +} + +func (s *gatewayOperatorSecureCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorSecureCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} + +func (s *gatewayOperatorSecureCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) { + return nil, nil +} + +func (s *gatewayOperatorSecureCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) { + return false, nil +} + +func (s *gatewayOperatorSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, string) (*store.SecureCLIUserCredential, error) { + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorSecureCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error { + return nil +} + +func (s *gatewayOperatorSecureCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error { + return nil +} + +func (s *gatewayOperatorSecureCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} + +func (s *gatewayOperatorSecureCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) { + return nil, nil +} + +type gatewayOperatorGrantStore struct { + grants map[uuid.UUID]*store.SecureCLIAgentGrant + env map[uuid.UUID][]byte +} + +func (s *gatewayOperatorGrantStore) BinaryExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorGrantStore) AgentExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorGrantStore) Create(_ context.Context, g *store.SecureCLIAgentGrant) error { + if s.grants == nil { + s.grants = map[uuid.UUID]*store.SecureCLIAgentGrant{} + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + cp := *g + s.grants[g.ID] = &cp + return nil +} + +func (s *gatewayOperatorGrantStore) Get(_ context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) { + if g := s.grants[id]; g != nil { + cp := *g + return &cp, nil + } + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorGrantStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + g := s.grants[id] + if g == nil { + return sql.ErrNoRows + } + if enabled, ok := updates["enabled"].(bool); ok { + g.Enabled = enabled + } + return nil +} + +func (s *gatewayOperatorGrantStore) Delete(context.Context, uuid.UUID) error { return nil } + +func (s *gatewayOperatorGrantStore) ListByBinary(_ context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) { + out := []store.SecureCLIAgentGrant{} + for _, g := range s.grants { + if g.BinaryID == binaryID { + out = append(out, *g) + } + } + return out, nil +} + +func (s *gatewayOperatorGrantStore) ListByAgent(_ context.Context, agentID uuid.UUID) ([]store.SecureCLIAgentGrant, error) { + out := []store.SecureCLIAgentGrant{} + for _, g := range s.grants { + if g.AgentID == agentID { + out = append(out, *g) + } + } + return out, nil +} + +func (s *gatewayOperatorGrantStore) UpdateGrantEnv(_ context.Context, grantID uuid.UUID, plaintextEnv []byte) error { + if s.env == nil { + s.env = map[uuid.UUID][]byte{} + } + if s.grants[grantID] == nil { + return sql.ErrNoRows + } + cp := append([]byte(nil), plaintextEnv...) + s.env[grantID] = cp + s.grants[grantID].EncryptedEnv = cp + return nil +} + +type gatewayOperatorAgentCredentialStore struct { + env map[uuid.UUID][]byte + createdBy map[uuid.UUID]string +} + +func (s *gatewayOperatorAgentCredentialStore) BinaryExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorAgentCredentialStore) AgentExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorAgentCredentialStore) GetAgentCredentials(_ context.Context, _ uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) { + if s.env == nil || len(s.env[agentID]) == 0 { + return nil, nil + } + return &store.SecureCLIAgentCredential{ + ID: store.GenNewID(), + AgentID: agentID, + EncryptedEnv: append([]byte(nil), s.env[agentID]...), + CreatedBy: s.createdBy[agentID], + }, nil +} + +func (s *gatewayOperatorAgentCredentialStore) SetAgentCredentials(_ context.Context, _ uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error { + if s.env == nil { + s.env = map[uuid.UUID][]byte{} + } + if s.createdBy == nil { + s.createdBy = map[uuid.UUID]string{} + } + s.env[agentID] = append([]byte(nil), encryptedEnv...) + s.createdBy[agentID] = createdBy + return nil +} + +func (s *gatewayOperatorAgentCredentialStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, _ *string, _ *string, createdBy string) error { + return s.SetAgentCredentials(ctx, binaryID, agentID, encryptedEnv, createdBy) +} + +func (s *gatewayOperatorAgentCredentialStore) DeleteAgentCredentials(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *gatewayOperatorAgentCredentialStore) ListAgentCredentials(context.Context, uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + return nil, nil +} + +func gatewayOperatorContext() context.Context { + ctx := context.Background() + ctx = store.WithTenantID(ctx, uuid.MustParse("0193a5b0-7000-7000-8000-000000000002")) + ctx = store.WithUserID(ctx, "system") + ctx = store.WithRole(ctx, store.RoleOwner) + return ctx +} + +type gatewayOperatorAgentStore struct { + store.AgentStore + agents []store.AgentData + files map[uuid.UUID]map[string]string +} + +func (s *gatewayOperatorAgentStore) Create(_ context.Context, agent *store.AgentData) error { + if agent.ID == uuid.Nil { + agent.ID = store.GenNewID() + } + if agent.CreatedAt.IsZero() { + agent.CreatedAt = time.Now().UTC() + } + agent.UpdatedAt = agent.CreatedAt + cp := *agent + s.agents = append(s.agents, cp) + return nil +} + +func (s *gatewayOperatorAgentStore) GetByKey(_ context.Context, agentKey string) (*store.AgentData, error) { + for i := range s.agents { + if s.agents[i].AgentKey == agentKey { + cp := s.agents[i] + return &cp, nil + } + } + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorAgentStore) List(context.Context, string) ([]store.AgentData, error) { + out := make([]store.AgentData, len(s.agents)) + copy(out, s.agents) + return out, nil +} + +func (s *gatewayOperatorAgentStore) GetAgentContextFiles(context.Context, uuid.UUID) ([]store.AgentContextFileData, error) { + return nil, nil +} + +func (s *gatewayOperatorAgentStore) SetAgentContextFile(_ context.Context, agentID uuid.UUID, fileName, content string) error { + if s.files == nil { + s.files = map[uuid.UUID]map[string]string{} + } + if s.files[agentID] == nil { + s.files[agentID] = map[string]string{} + } + s.files[agentID][fileName] = content + return nil +} + +func TestGatewayOperatorBootstrapCreatesBinaryGrantAndSensitiveTokenEnv(t *testing.T) { + setupTestToken(t, "test-gateway-token") + secureCLI := &gatewayOperatorSecureCLIStore{} + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + agentID := uuid.New() + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.BinaryID == uuid.Nil || result.GrantID == uuid.Nil { + t.Fatalf("bootstrap result missing IDs: %#v", result) + } + if secureCLI.created == nil { + t.Fatal("expected goclaw secure CLI binary to be registered") + } + if secureCLI.created.BinaryName != "goclaw" { + t.Fatalf("binary name=%q, want goclaw", secureCLI.created.BinaryName) + } + if secureCLI.created.IsGlobal { + t.Fatal("gateway operator binary must be non-global") + } + if secureCLI.created.BinaryPath == nil || *secureCLI.created.BinaryPath != "/usr/local/bin/goclaw" { + t.Fatalf("binary path not recorded: %#v", secureCLI.created.BinaryPath) + } + if !strings.Contains(string(secureCLI.created.DenyArgs), "auth") { + t.Fatalf("expected lifecycle/auth deny patterns, got %s", string(secureCLI.created.DenyArgs)) + } + + if len(grants.env) != 0 { + t.Fatalf("gateway token must not be stored in revealable grant env: %#v", grants.env) + } + + envJSON := agentCreds.env[agentID] + if len(envJSON) == 0 { + t.Fatal("expected non-revealable per-agent credential env") + } + entries, err := store.ParseSecureCLIEnv(envJSON) + if err != nil { + t.Fatalf("parse agent credential env: %v", err) + } + tokenEntry := entries["GOCLAW_GATEWAY_TOKEN"] + if tokenEntry.Kind != store.SecureCLIEnvKindSensitive || tokenEntry.Value != "test-gateway-token" { + t.Fatalf("token entry not stored as sensitive override: %#v", tokenEntry) + } + if entries["GOCLAW_SERVER"].Value != "http://127.0.0.1:18790" { + t.Fatalf("GOCLAW_SERVER not injected: %#v", entries["GOCLAW_SERVER"]) + } + + safeEnv := agentCredentialResponse(store.SecureCLIAgentCredential{EncryptedEnv: envJSON}).Env + if safeEnv["GOCLAW_GATEWAY_TOKEN"].Value != nil || !safeEnv["GOCLAW_GATEWAY_TOKEN"].Masked { + encoded, _ := json.Marshal(safeEnv) + t.Fatalf("gateway token not masked in agent credential response: %s", encoded) + } +} + +func TestGatewayOperatorBootstrapReusesExistingGrant(t *testing.T) { + setupTestToken(t, "rotated-test-token") + binaryID := uuid.New() + grantID := uuid.New() + agentID := uuid.New() + secureCLI := &gatewayOperatorSecureCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: binaryID}, + BinaryName: "goclaw", + Enabled: true, + IsGlobal: false, + }}, + } + grants := &gatewayOperatorGrantStore{ + grants: map[uuid.UUID]*store.SecureCLIAgentGrant{ + grantID: { + BaseModel: store.BaseModel{ID: grantID}, + BinaryID: binaryID, + AgentID: agentID, + Enabled: false, + }, + }, + } + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.GrantID != grantID { + t.Fatalf("expected existing grant %s, got %s", grantID, result.GrantID) + } + if !grants.grants[grantID].Enabled { + t.Fatal("existing disabled grant should be re-enabled") + } + if secureCLI.created != nil { + t.Fatal("existing binary should be reused, not recreated") + } + if len(agentCreds.env[agentID]) == 0 { + t.Fatal("expected agent credential env to be refreshed for existing grant") + } +} + +func TestGatewayOperatorBootstrapConvertsExistingGlobalBinaryToGrantScoped(t *testing.T) { + setupTestToken(t, "test-gateway-token") + binaryID := uuid.New() + agentID := uuid.New() + secureCLI := &gatewayOperatorSecureCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: binaryID}, + BinaryName: "goclaw", + Enabled: true, + IsGlobal: true, + }}, + } + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "127.0.0.1:19999") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/opt/goclaw/current/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.BinaryID != binaryID { + t.Fatalf("expected existing binary %s, got %s", binaryID, result.BinaryID) + } + if secureCLI.created != nil { + t.Fatal("existing binary should be policy-updated, not duplicated") + } + updates := secureCLI.updated[binaryID] + if updates == nil { + t.Fatal("expected existing global binary to be updated") + } + if updates["is_global"] != false || updates["enabled"] != true { + t.Fatalf("expected explicit non-global enabled policy, got %#v", updates) + } + if got := secureCLI.binaries[0].BinaryPath; got == nil || *got != "/opt/goclaw/current/goclaw" { + t.Fatalf("binary path not updated: %#v", got) + } + if len(grants.grants) != 1 { + t.Fatalf("expected one explicit agent grant, got %d", len(grants.grants)) + } + entries, err := store.ParseSecureCLIEnv(agentCreds.env[agentID]) + if err != nil { + t.Fatalf("parse agent credential env: %v", err) + } + if entries["GOCLAW_SERVER"].Value != "http://127.0.0.1:19999" { + t.Fatalf("GOCLAW_SERVER not normalized: %#v", entries["GOCLAW_SERVER"]) + } +} + +func TestGatewayOperatorBootstrapRewritesUnsafeExistingNonGlobalBinary(t *testing.T) { + setupTestToken(t, "test-gateway-token") + binaryID := uuid.New() + agentID := uuid.New() + unsafePath := "/tmp/wrapper-goclaw" + adapterName := "git" + secureCLI := &gatewayOperatorSecureCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: binaryID}, + BinaryName: "goclaw", + BinaryPath: &unsafePath, + Enabled: true, + IsGlobal: false, + DenyArgs: json.RawMessage(`[]`), + DenyVerbose: json.RawMessage(`[]`), + TimeoutSeconds: 300, + AdapterName: &adapterName, + }}, + } + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/opt/goclaw/current/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.BinaryID != binaryID { + t.Fatalf("expected existing binary %s, got %s", binaryID, result.BinaryID) + } + updates := secureCLI.updated[binaryID] + if updates == nil { + t.Fatal("expected unsafe existing binary policy to be rewritten before grant") + } + if got := secureCLI.binaries[0].BinaryPath; got == nil || *got != "/opt/goclaw/current/goclaw" { + t.Fatalf("safe binary path not enforced: %#v", got) + } + if !strings.Contains(string(secureCLI.binaries[0].DenyArgs), "migrate") { + t.Fatalf("safe deny policy not enforced: %s", string(secureCLI.binaries[0].DenyArgs)) + } + if secureCLI.binaries[0].AdapterName != nil { + t.Fatalf("gateway operator binary must use passthrough adapter, got %q", *secureCLI.binaries[0].AdapterName) + } + if len(grants.grants) != 1 || len(agentCreds.env[agentID]) == 0 { + t.Fatal("expected grant and non-revealable credential after safe policy rewrite") + } +} + +func TestAgentsCreateAddsGatewayOperatorBootstrapMetadataWhenRequested(t *testing.T) { + setupTestToken(t, "test-gateway-token") + agents := &gatewayOperatorAgentStore{} + secureCLI := &gatewayOperatorSecureCLIStore{} + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{ + agents: agents, + defaultWorkspace: "/tmp/workspace", + } + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + body := []byte(`{ + "agent_key":"assistant", + "display_name":"Assistant", + "provider":"anthropic", + "model":"claude", + "grant_gateway_operator_access":true + }`) + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewReader(body)) + req = req.WithContext(gatewayOperatorContext()) + rr := httptest.NewRecorder() + + handler.handleCreate(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var response struct { + ID uuid.UUID `json:"id"` + AgentKey string `json:"agent_key"` + GatewayOperatorBootstrap *gatewayOperatorBootstrapResult `json:"gateway_operator_bootstrap"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v body=%s", err, rr.Body.String()) + } + if response.ID == uuid.Nil || response.AgentKey != "assistant" { + t.Fatalf("unexpected agent response: %#v", response) + } + if response.GatewayOperatorBootstrap == nil || response.GatewayOperatorBootstrap.Status != "granted" { + t.Fatalf("expected granted bootstrap metadata, got %#v body=%s", response.GatewayOperatorBootstrap, rr.Body.String()) + } + if len(grants.grants) != 1 { + t.Fatalf("expected one grant, got %d", len(grants.grants)) + } + if len(agentCreds.env[response.ID]) == 0 { + t.Fatal("expected per-agent gateway credentials to be stored") + } + if strings.Contains(rr.Body.String(), "test-gateway-token") { + t.Fatalf("gateway token leaked in create response: %s", rr.Body.String()) + } +} + +func TestGatewayOperatorBootstrapFailsClosedWithoutToken(t *testing.T) { + setupTestToken(t, "") + secureCLI := &gatewayOperatorSecureCLIStore{} + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + _, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), uuid.New()) + if err == nil { + t.Fatal("expected missing gateway token to fail closed") + } + if secureCLI.created != nil || len(grants.grants) > 0 || len(agentCreds.env) > 0 { + t.Fatal("missing token must not create binary, grant, or agent credential") + } +} + +func TestGatewayOperatorBootstrapReportsBinaryDiscoveryFailure(t *testing.T) { + setupTestToken(t, "test-gateway-token") + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(&gatewayOperatorSecureCLIStore{}, &gatewayOperatorGrantStore{}, &gatewayOperatorAgentCredentialStore{}, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "", errors.New("not found") + } + + _, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), uuid.New()) + if !errors.Is(err, errGatewayOperatorBinaryMissing) { + t.Fatalf("expected binary discovery warning, got %v", err) + } +} + +func TestGatewayOperatorFirstAgentGateUsesDeterministicEarliestRow(t *testing.T) { + firstID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + secondID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000002") + createdAt := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC) + + got := deterministicFirstAgentID([]store.AgentData{ + {BaseModel: store.BaseModel{ID: secondID, CreatedAt: createdAt.Add(time.Second)}}, + {BaseModel: store.BaseModel{ID: firstID, CreatedAt: createdAt}}, + }) + + if got != firstID { + t.Fatalf("deterministic first id=%s, want %s", got, firstID) + } + + tieWinner := deterministicFirstAgentID([]store.AgentData{ + {BaseModel: store.BaseModel{ID: secondID, CreatedAt: createdAt}}, + {BaseModel: store.BaseModel{ID: firstID, CreatedAt: createdAt}}, + }) + if tieWinner != firstID { + t.Fatalf("tie winner id=%s, want lexical uuid %s", tieWinner, firstID) + } +} diff --git a/internal/http/secure_cli_agent_grants_test.go b/internal/http/secure_cli_agent_grants_test.go index 9a4f322c..68ade8a5 100644 --- a/internal/http/secure_cli_agent_grants_test.go +++ b/internal/http/secure_cli_agent_grants_test.go @@ -186,6 +186,27 @@ func TestSecureCLIGrantCreateValidatesBinaryAndAgentScope(t *testing.T) { } } +func TestValidateAndSerializeEnvVarsRejectsGoClawGatewayToken(t *testing.T) { + rr := httptest.NewRecorder() + + envJSON, ok := validateAndSerializeEnvVars(rr, "en", json.RawMessage(`{ + "GOCLAW_GATEWAY_TOKEN": {"kind":"sensitive","value":"test-secret-token"} + }`)) + + if ok || envJSON != nil { + t.Fatalf("expected GOCLAW_GATEWAY_TOKEN to be rejected by public env validator") + } + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "GOCLAW_GATEWAY_TOKEN") { + t.Fatalf("expected rejected key in response, got %s", rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "test-secret-token") { + t.Fatalf("secret value leaked in validation error: %s", rr.Body.String()) + } +} + func TestSecureCLIGrantUpdateRejectsInvalidEnvVarsBeforeScalarUpdate(t *testing.T) { binaryID := uuid.New() grantID := uuid.New() diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 37c11706..7a3b05db 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -21,9 +21,17 @@ func init() { MsgInvalidUpdates: "invalid updates", // Agent - MsgAgentNotFound: "agent not found: %s", - MsgCannotDeleteDefault: "cannot delete the default agent", - MsgUserCtxRequired: "user context required", + MsgAgentNotFound: "agent not found: %s", + MsgCannotDeleteDefault: "cannot delete the default agent", + MsgUserCtxRequired: "user context required", + MsgGatewayOperatorSecureCLIUnavailable: "Gateway operator access was skipped because SecureCLI storage is unavailable.", + MsgGatewayOperatorEligibilityFailed: "Agent was created, but gateway operator access could not verify first-agent eligibility.", + MsgGatewayOperatorNotFirstAgent: "Gateway operator access was not granted because this is not the first agent.", + MsgGatewayOperatorTokenMissing: "Gateway operator access was skipped because the gateway token is not configured.", + MsgGatewayOperatorBinaryMissing: "Gateway operator access was skipped because the goclaw binary could not be discovered.", + MsgGatewayOperatorExistingReview: "Gateway operator access was skipped because an existing goclaw CLI credential requires manual review.", + MsgGatewayOperatorRegisterFailed: "Gateway operator access was skipped because the goclaw CLI credential could not be registered.", + MsgGatewayOperatorCredentialFailed: "Gateway operator access was skipped because credentials could not be stored.", // Chat MsgRateLimitExceeded: "rate limit exceeded — please wait", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index 194834be..83091133 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -21,9 +21,17 @@ func init() { MsgInvalidUpdates: "cập nhật không hợp lệ", // Agent - MsgAgentNotFound: "không tìm thấy agent: %s", - MsgCannotDeleteDefault: "không thể xóa agent mặc định", - MsgUserCtxRequired: "yêu cầu ngữ cảnh người dùng", + MsgAgentNotFound: "không tìm thấy agent: %s", + MsgCannotDeleteDefault: "không thể xóa agent mặc định", + MsgUserCtxRequired: "yêu cầu ngữ cảnh người dùng", + MsgGatewayOperatorSecureCLIUnavailable: "Đã bỏ qua quyền gateway operator vì kho SecureCLI chưa khả dụng.", + MsgGatewayOperatorEligibilityFailed: "Agent đã được tạo, nhưng không thể xác minh đây là agent đầu tiên để cấp quyền gateway operator.", + MsgGatewayOperatorNotFirstAgent: "Không cấp quyền gateway operator vì đây không phải agent đầu tiên.", + MsgGatewayOperatorTokenMissing: "Đã bỏ qua quyền gateway operator vì gateway token chưa được cấu hình.", + MsgGatewayOperatorBinaryMissing: "Đã bỏ qua quyền gateway operator vì không tìm thấy binary goclaw.", + MsgGatewayOperatorExistingReview: "Đã bỏ qua quyền gateway operator vì credential CLI goclaw hiện có cần kiểm tra thủ công.", + MsgGatewayOperatorRegisterFailed: "Đã bỏ qua quyền gateway operator vì không thể đăng ký credential CLI goclaw.", + MsgGatewayOperatorCredentialFailed: "Đã bỏ qua quyền gateway operator vì không thể lưu credential.", // Chat MsgRateLimitExceeded: "vượt quá giới hạn tốc độ — vui lòng đợi", diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index c1989d84..259bc620 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -21,9 +21,17 @@ func init() { MsgInvalidUpdates: "更新内容无效", // Agent - MsgAgentNotFound: "未找到Agent:%s", - MsgCannotDeleteDefault: "无法删除默认Agent", - MsgUserCtxRequired: "需要用户上下文", + MsgAgentNotFound: "未找到Agent:%s", + MsgCannotDeleteDefault: "无法删除默认Agent", + MsgUserCtxRequired: "需要用户上下文", + MsgGatewayOperatorSecureCLIUnavailable: "已跳过网关 operator 访问,因为 SecureCLI 存储不可用。", + MsgGatewayOperatorEligibilityFailed: "Agent 已创建,但无法验证其是否为第一个 Agent 来授予网关 operator 访问。", + MsgGatewayOperatorNotFirstAgent: "未授予网关 operator 访问,因为这不是第一个 Agent。", + MsgGatewayOperatorTokenMissing: "已跳过网关 operator 访问,因为未配置网关 token。", + MsgGatewayOperatorBinaryMissing: "已跳过网关 operator 访问,因为无法发现 goclaw binary。", + MsgGatewayOperatorExistingReview: "已跳过网关 operator 访问,因为现有 goclaw CLI credential 需要手动检查。", + MsgGatewayOperatorRegisterFailed: "已跳过网关 operator 访问,因为无法注册 goclaw CLI credential。", + MsgGatewayOperatorCredentialFailed: "已跳过网关 operator 访问,因为无法存储 credential。", // Chat MsgRateLimitExceeded: "请求频率超限 — 请稍候", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index 34442601..6628476e 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -22,9 +22,17 @@ const ( MsgInvalidUpdates = "error.invalid_updates" // "invalid updates" // --- Agent --- - MsgAgentNotFound = "error.agent_not_found" // "agent not found: %s" - MsgCannotDeleteDefault = "error.cannot_delete_default" // "cannot delete the default agent" - MsgUserCtxRequired = "error.user_ctx_required" // "user context required" + MsgAgentNotFound = "error.agent_not_found" // "agent not found: %s" + MsgCannotDeleteDefault = "error.cannot_delete_default" // "cannot delete the default agent" + MsgUserCtxRequired = "error.user_ctx_required" // "user context required" + MsgGatewayOperatorSecureCLIUnavailable = "gateway_operator.secure_cli_unavailable" + MsgGatewayOperatorEligibilityFailed = "gateway_operator.eligibility_failed" + MsgGatewayOperatorNotFirstAgent = "gateway_operator.not_first_agent" + MsgGatewayOperatorTokenMissing = "gateway_operator.token_missing" + MsgGatewayOperatorBinaryMissing = "gateway_operator.binary_missing" + MsgGatewayOperatorExistingReview = "gateway_operator.existing_review" + MsgGatewayOperatorRegisterFailed = "gateway_operator.register_failed" + MsgGatewayOperatorCredentialFailed = "gateway_operator.credential_failed" // --- Chat --- MsgRateLimitExceeded = "error.rate_limit" // "rate limit exceeded — please wait" diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index bc4f731e..bcf7cd0e 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -426,7 +426,7 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL // Step 4: Register credential values for output scrubbing for _, v := range envMap { - AddCredentialScrubValues(v) + AddScrubValuesCtx(ctx, v) } // Step 5: Resolve binary to absolute path and verify against config diff --git a/internal/tools/credentialed_exec_env_test.go b/internal/tools/credentialed_exec_env_test.go index 609ad15d..99e7febc 100644 --- a/internal/tools/credentialed_exec_env_test.go +++ b/internal/tools/credentialed_exec_env_test.go @@ -184,6 +184,48 @@ func TestExec_RapidAPIWithRequiredEnvReachesDirectExec(t *testing.T) { } } +func TestExec_GoClawGatewayTokenRawOutputIsScrubbed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script fixture is POSIX-only") + } + + const token = "plain-gateway-token-SHOULD-NOT-LEAK-12345" + binDir := t.TempDir() + binPath := filepath.Join(binDir, "goclaw") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf '%s\\n' \"$GOCLAW_GATEWAY_TOKEN\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + stub := newStubSecureCLIStore() + stub.byName["goclaw"] = &store.SecureCLIBinary{ + BinaryName: "goclaw", + BinaryPath: &binPath, + EncryptedEnv: []byte(`{ + "GOCLAW_GATEWAY_TOKEN":{"kind":"sensitive","value":"` + token + `"}, + "GOCLAW_SERVER":{"kind":"sensitive","value":"http://127.0.0.1:18790"} + }`), + TimeoutSeconds: 10, + DenyArgs: json.RawMessage("[]"), + DenyVerbose: json.RawMessage("[]"), + } + + tool := NewExecTool(t.TempDir(), false) + tool.SetSecureCLIStore(stub) + + ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New()) + result := tool.Execute(ctx, map[string]any{"command": "goclaw agent list"}) + + if result.IsError { + t.Fatalf("expected goclaw direct exec to run, got: %s", result.ForLLM) + } + if strings.Contains(result.ForLLM, token) { + t.Fatalf("raw gateway token leaked into output: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "[REDACTED]") { + t.Fatalf("expected gateway token to be redacted, got: %s", result.ForLLM) + } +} + func TestExec_GHMissingRequiredEnvFailsBeforeRawAuth(t *testing.T) { stub := newStubSecureCLIStore() stub.byName["gh"] = &store.SecureCLIBinary{ diff --git a/internal/tools/env_scrub.go b/internal/tools/env_scrub.go index f2e8ece6..7b792232 100644 --- a/internal/tools/env_scrub.go +++ b/internal/tools/env_scrub.go @@ -20,6 +20,7 @@ var staticCredentialEnvKeys = []string{ "NPM_TOKEN", "DOCKER_PASSWORD", "DOCKER_AUTH", + "GOCLAW_GATEWAY_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", diff --git a/internal/tools/env_scrub_test.go b/internal/tools/env_scrub_test.go index 033cabec..c614dcd2 100644 --- a/internal/tools/env_scrub_test.go +++ b/internal/tools/env_scrub_test.go @@ -19,6 +19,7 @@ func TestScrubCredentialEnv_StripsStatic(t *testing.T) { in := []string{ "HOME=/root", "GH_TOKEN=secret-abc", + "GOCLAW_GATEWAY_TOKEN=goclaw-secret", "PATH=/usr/bin", "AWS_SECRET_ACCESS_KEY=topsecret", "RAPIDAPI_KEY=rapid-secret", @@ -34,6 +35,9 @@ func TestScrubCredentialEnv_StripsStatic(t *testing.T) { if envContains(out, "RAPIDAPI_KEY") { t.Fatalf("RAPIDAPI_KEY must be scrubbed, got: %v", out) } + if envContains(out, "GOCLAW_GATEWAY_TOKEN") { + t.Fatalf("GOCLAW_GATEWAY_TOKEN must be scrubbed, got: %v", out) + } if !envContains(out, "HOME") || !envContains(out, "PATH") { t.Fatalf("essential vars must be preserved, got: %v", out) } diff --git a/ui/web/src/i18n/locales/en/setup.json b/ui/web/src/i18n/locales/en/setup.json index d6b4ac4e..72812125 100644 --- a/ui/web/src/i18n/locales/en/setup.json +++ b/ui/web/src/i18n/locales/en/setup.json @@ -75,6 +75,11 @@ "personalityHintBottom": "Customize this prompt to shape your agent's personality and expertise.", "selfEvolve": "Self-Evolution", "selfEvolveDesc": "Allow agent to evolve its communication style over time via SOUL.md", + "gatewayOperatorAccess": "Gateway operator access", + "gatewayOperatorAccessDesc": "Grant revocable SecureCLI access to the local goclaw CLI for this first agent.", + "gatewayOperatorGranted": "Gateway operator access granted", + "gatewayOperatorGrantedDesc": "The agent can use the local goclaw CLI. Review or revoke it in Packages → CLI Credentials.", + "gatewayOperatorWarning": "Gateway operator access warning", "creating": "Creating...", "create": "Create Agent", "summoningFailed": "Summoning failed. Please adjust your settings and try again.", diff --git a/ui/web/src/i18n/locales/vi/setup.json b/ui/web/src/i18n/locales/vi/setup.json index 2594dfb6..9b1fbafb 100644 --- a/ui/web/src/i18n/locales/vi/setup.json +++ b/ui/web/src/i18n/locales/vi/setup.json @@ -75,6 +75,11 @@ "personalityHintBottom": "Tùy chỉnh prompt này để định hình cá tính và chuyên môn của agent.", "selfEvolve": "Tự tiến hóa", "selfEvolveDesc": "Cho phép agent tự phát triển phong cách giao tiếp theo thời gian qua SOUL.md", + "gatewayOperatorAccess": "Quyền gateway operator", + "gatewayOperatorAccessDesc": "Cấp quyền SecureCLI có thể thu hồi vào CLI goclaw cục bộ cho agent đầu tiên này.", + "gatewayOperatorGranted": "Đã cấp quyền gateway operator", + "gatewayOperatorGrantedDesc": "Agent có thể dùng CLI goclaw cục bộ. Xem lại hoặc thu hồi trong Packages → CLI Credentials.", + "gatewayOperatorWarning": "Cảnh báo quyền gateway operator", "creating": "Đang tạo...", "create": "Tạo agent", "summoningFailed": "Triệu hồi thất bại. Vui lòng điều chỉnh cài đặt và thử lại.", diff --git a/ui/web/src/i18n/locales/zh/setup.json b/ui/web/src/i18n/locales/zh/setup.json index 3a6ae819..74adc4d7 100644 --- a/ui/web/src/i18n/locales/zh/setup.json +++ b/ui/web/src/i18n/locales/zh/setup.json @@ -75,6 +75,11 @@ "personalityHintBottom": "自定义此提示词以塑造Agent的个性和专业领域。", "selfEvolve": "自我进化", "selfEvolveDesc": "允许Agent通过 SOUL.md 随时间进化其沟通风格", + "gatewayOperatorAccess": "网关 operator 访问", + "gatewayOperatorAccessDesc": "为此首个 Agent 授予可撤销的本地 goclaw CLI SecureCLI 访问。", + "gatewayOperatorGranted": "已授予网关 operator 访问", + "gatewayOperatorGrantedDesc": "Agent 可以使用本地 goclaw CLI。可在 Packages → CLI Credentials 中查看或撤销。", + "gatewayOperatorWarning": "网关 operator 访问警告", "creating": "创建中...", "create": "创建Agent", "summoningFailed": "召唤失败,请调整设置后重试。", diff --git a/ui/web/src/pages/setup/step-agent.tsx b/ui/web/src/pages/setup/step-agent.tsx index d291f78c..d4932bf7 100644 --- a/ui/web/src/pages/setup/step-agent.tsx +++ b/ui/web/src/pages/setup/step-agent.tsx @@ -13,6 +13,7 @@ import { SummoningModal } from "@/pages/agents/summoning-modal"; import { useAgentPresets } from "@/pages/agents/agent-presets"; import { useWsEvent } from "@/hooks/use-ws-event"; import { slugify } from "@/lib/slug"; +import { toast } from "@/stores/use-toast-store"; import type { ProviderData } from "@/types/provider"; import type { AgentData } from "@/types/agent"; @@ -42,6 +43,11 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } const [selfEvolve, setSelfEvolve] = useState( Boolean(existingAgent?.self_evolve), ); + const [gatewayOperatorAccess, setGatewayOperatorAccess] = useState(false); + const [gatewayOperatorNotice, setGatewayOperatorNotice] = useState<{ + variant: "success" | "warning"; + message: string; + } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -121,6 +127,7 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } setLoading(true); setError(""); + setGatewayOperatorNotice(null); try { if (isEditing) { @@ -147,9 +154,19 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } agent_description: description.trim() || null, self_evolve: selfEvolve, emoji: selectedEmoji || null, + grant_gateway_operator_access: gatewayOperatorAccess || undefined, }; const result = await createAgent(data) as AgentData; + const bootstrap = result.gateway_operator_bootstrap; + if (bootstrap?.status === "granted") { + const message = t("agent.gatewayOperatorGrantedDesc"); + setGatewayOperatorNotice({ variant: "success", message }); + toast.success(t("agent.gatewayOperatorGranted"), message); + } else if (bootstrap?.warning) { + setGatewayOperatorNotice({ variant: "warning", message: bootstrap.warning }); + toast.warning(t("agent.gatewayOperatorWarning"), bootstrap.warning); + } setAgentResult(result); setSummoningOutcome("pending"); setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey }); @@ -242,8 +259,34 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } + {!isEditing && ( +
+
+ +

{t("agent.gatewayOperatorAccessDesc")}

+
+ +
+ )} + {gatewayOperatorNotice && ( +

+ {gatewayOperatorNotice.message} +

+ )} {error &&

{error}

}
diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index a5933d23..2e12ea78 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -212,6 +212,15 @@ export interface AgentData { other_config?: Record | null; budget_monthly_cents?: number | null; tenant_id?: string; + grant_gateway_operator_access?: boolean; + gateway_operator_bootstrap?: GatewayOperatorBootstrapResult | null; +} + +export interface GatewayOperatorBootstrapResult { + status: "granted" | "warning" | "skipped" | string; + binary_id?: string; + grant_id?: string; + warning?: string; } export interface AgentShareData { From f45bfa860c2a08d32fdf4c90a19034456246342d Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 14:47:51 +0700 Subject: [PATCH 2/7] feat(skills): add skill self-evolution metrics --- cmd/gateway_http_handlers.go | 1 + cmd/gateway_managed.go | 2 + cmd/skills_cmd.go | 4 + cmd/skills_evolution_cmd.go | 222 +++++++ docs/18-http-api.md | 30 + ...21-agent-evolution-and-skill-management.md | 59 +- docs/project-changelog.md | 27 + internal/agent/loop_history.go | 10 +- .../agent/loop_pipeline_tool_callbacks.go | 69 +++ internal/agent/loop_types.go | 10 + internal/agent/resolver.go | 8 +- internal/agent/skill_slash_commands.go | 4 +- internal/http/skills.go | 16 + internal/http/skills_evolution.go | 542 ++++++++++++++++++ internal/http/skills_evolution_test.go | 131 +++++ internal/http/skills_upload_test.go | 21 +- internal/skills/target_path_validator.go | 50 ++ internal/skills/target_path_validator_test.go | 40 ++ internal/store/pg/factory.go | 1 + internal/store/pg/skill_evolution.go | 440 ++++++++++++++ internal/store/skill_evolution_store.go | 124 ++++ internal/store/sqlitestore/factory.go | 1 + internal/store/sqlitestore/schema.go | 92 ++- internal/store/sqlitestore/schema.sql | 80 +++ internal/store/sqlitestore/skill_evolution.go | 505 ++++++++++++++++ internal/store/stores.go | 1 + internal/tools/skill_manage.go | 36 +- internal/upgrade/version.go | 2 +- .../000078_skill_self_evolution.down.sql | 4 + migrations/000078_skill_self_evolution.up.sql | 89 +++ ui/web/src/i18n/locales/en/skills.json | 25 + ui/web/src/i18n/locales/vi/skills.json | 25 + ui/web/src/i18n/locales/zh/skills.json | 25 + ui/web/src/lib/query-keys.ts | 4 + .../pages/skills/hooks/use-skill-evolution.ts | 91 +++ .../skills/lib/skill-detail-deeplink.test.ts | 35 +- .../pages/skills/lib/skill-detail-deeplink.ts | 6 + .../skills/lib/skills-page-state.test.ts | 4 +- .../src/pages/skills/skill-detail-dialog.tsx | 30 +- .../pages/skills/skill-evolution-panel.tsx | 170 ++++++ ui/web/src/types/skill.ts | 51 ++ 41 files changed, 3008 insertions(+), 79 deletions(-) create mode 100644 cmd/skills_evolution_cmd.go create mode 100644 internal/http/skills_evolution.go create mode 100644 internal/http/skills_evolution_test.go create mode 100644 internal/skills/target_path_validator.go create mode 100644 internal/skills/target_path_validator_test.go create mode 100644 internal/store/pg/skill_evolution.go create mode 100644 internal/store/skill_evolution_store.go create mode 100644 internal/store/sqlitestore/skill_evolution.go create mode 100644 migrations/000078_skill_self_evolution.down.sql create mode 100644 migrations/000078_skill_self_evolution.up.sql create mode 100644 ui/web/src/pages/skills/hooks/use-skill-evolution.ts create mode 100644 ui/web/src/pages/skills/skill-evolution-panel.tsx diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index c270af1d..cf34003a 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -41,6 +41,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if len(dirs) > 0 { skillsH = httpapi.NewSkillsHandler(manageStore, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs, stores.Tenants) skillsH.SetDB(stores.DB) + skillsH.SetEvolutionStore(stores.SkillEvolution, stores.Activity) skillsH.SetUploadLimitConfig(skillUploadConfig) if stores.SystemConfigs != nil { skillsH.SetSystemConfigStore(stores.SystemConfigs) diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 179ce8da..866381bd 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -216,7 +216,9 @@ func wireExtras( Tools: toolsReg, ToolPolicy: toolPE, Skills: skillsLoader, + SkillStore: stores.Skills, SkillAccessStore: skillAccessStore, + SkillEvolutionStore: stores.SkillEvolution, SkillSlashCommands: appCfg.Skills.SlashCommands, HasMemory: hasMemory, TraceCollector: traceCollector, diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index d87e1706..2ac79ace 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -22,6 +22,10 @@ func skillsCmd() *cobra.Command { } cmd.AddCommand(skillsListCmd()) cmd.AddCommand(skillsShowCmd()) + cmd.AddCommand(skillsEvolveCmd()) + cmd.AddCommand(skillsMetricsCmd()) + cmd.AddCommand(skillsActivityCmd()) + cmd.AddCommand(skillsSuggestionsCmd()) return cmd } diff --git a/cmd/skills_evolution_cmd.go b/cmd/skills_evolution_cmd.go new file mode 100644 index 00000000..5e74b55d --- /dev/null +++ b/cmd/skills_evolution_cmd.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func skillsEvolveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "evolve", + Short: "Manage per-skill self-evolution settings", + } + cmd.AddCommand(skillsEvolveStatusCmd()) + cmd.AddCommand(skillsEvolveSetCmd("enable", true)) + cmd.AddCommand(skillsEvolveSetCmd("disable", false)) + cmd.AddCommand(skillsEvolveModeCmd()) + return cmd +} + +func skillsEvolveStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status [skill]", + Short: "Show self-evolution settings for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution") + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsEvolveSetCmd(name string, enabled bool) *cobra.Command { + return &cobra.Command{ + Use: name + " [skill]", + Short: name + " self-evolution for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"enabled": enabled}) + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsEvolveModeCmd() *cobra.Command { + return &cobra.Command{ + Use: "mode [skill] [suggest_only|auto_analyze]", + Short: "Set self-evolution mode for a skill", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"mode": args[1]}) + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsMetricsCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "metrics [skill]", + Short: "Show recorded usage metrics for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/metrics") + exitOnErr(err) + if jsonOutput { + printJSON(resp) + return + } + fmt.Printf("Total: %.0f\nStarted: %.0f\nSucceeded: %.0f\nFailed: %.0f\nAbandoned: %.0f\nSuccess rate: %.2f\n", + num(resp["total_calls"]), num(resp["started"]), num(resp["succeeded"]), num(resp["failed"]), num(resp["abandoned"]), num(resp["success_rate"])) + }, + } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") + return cmd +} + +func skillsActivityCmd() *cobra.Command { + return &cobra.Command{ + Use: "activity [skill]", + Short: "Show recent self-evolution activity for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/activity") + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsSuggestionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "suggestions", + Short: "Manage skill improvement suggestions", + } + cmd.AddCommand(skillsSuggestionsListCmd()) + cmd.AddCommand(skillsSuggestionStatusCmd("approve")) + cmd.AddCommand(skillsSuggestionStatusCmd("reject")) + cmd.AddCommand(skillsSuggestionApplyCmd()) + return cmd +} + +func skillsSuggestionsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list [skill]", + Short: "List suggestions for a skill", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution/suggestions") + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsSuggestionStatusCmd(action string) *cobra.Command { + return &cobra.Command{ + Use: action + " [skill] [suggestion-id]", + Short: action + " a skill improvement suggestion", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/%s", url.PathEscape(skillID), url.PathEscape(args[1]), action) + resp, err := gatewayHTTPPost(path, nil) + exitOnErr(err) + printJSON(resp) + }, + } +} + +func skillsSuggestionApplyCmd() *cobra.Command { + var approve bool + cmd := &cobra.Command{ + Use: "apply [skill] [suggestion-id]", + Short: "Apply an approved skill improvement suggestion", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + requireRunningGatewayHTTP() + skillID := resolveGatewaySkillID(args[0]) + path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/apply", url.PathEscape(skillID), url.PathEscape(args[1])) + resp, err := gatewayHTTPPost(path, map[string]any{"approve": approve}) + exitOnErr(err) + printJSON(resp) + }, + } + cmd.Flags().BoolVar(&approve, "approve", false, "approve pending suggestion before applying") + return cmd +} + +func resolveGatewaySkillID(input string) string { + resp, err := gatewayHTTPGet("/v1/skills") + exitOnErr(err) + raw, _ := json.Marshal(resp["skills"]) + var skills []struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + } + _ = json.Unmarshal(raw, &skills) + for _, sk := range skills { + if sk.ID == input || sk.Slug == input || sk.Name == input { + return sk.ID + } + } + return input +} + +func printJSON(v any) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + _ = enc.Encode(v) + fmt.Print(buf.String()) +} + +func exitOnErr(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +func num(v any) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + default: + return 0 + } +} + +func printSuggestionTable(items []map[string]any) { + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tSTATUS\tTYPE\tREASON") + for _, item := range items { + fmt.Fprintf(tw, "%v\t%v\t%v\t%v\n", item["id"], item["status"], item["suggestion_type"], item["reason"]) + } + tw.Flush() +} diff --git a/docs/18-http-api.md b/docs/18-http-api.md index f8c24449..2681959b 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -329,6 +329,36 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea | `PUT` | `/v1/skills/{id}/tenant-config` | Set tenant-level skill config | | `DELETE` | `/v1/skills/{id}/tenant-config` | Delete tenant-level skill config | +### Skill Self-Evolution + +Skill self-evolution is tenant-scoped and off by default per skill. Usage +metrics are written only by trusted runtime paths such as `use_skill` tool +execution and slash-command activation. There is no public usage-write endpoint. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/skills/{id}/evolution` | Read self-evolution settings for a skill | +| `PATCH` | `/v1/skills/{id}/evolution` | Update enabled state or mode | +| `GET` | `/v1/skills/{id}/metrics` | Read aggregate usage metrics and status counts | +| `GET` | `/v1/skills/{id}/activity` | Read admin-only skill evolution activity | +| `GET` | `/v1/skills/{id}/evolution/suggestions` | List skill-scoped improvement suggestions | +| `POST` | `/v1/skills/{id}/evolution/suggestions` | Create a suggestion with evidence and draft patch | +| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/approve` | Approve a suggestion | +| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/reject` | Reject a suggestion | +| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/apply` | Apply an approved suggestion to a new skill version | + +Supported modes: + +| Mode | Behavior | +|------|----------| +| `suggest_only` | Collect metrics and manage suggestions; no automatic patching | +| `auto_analyze` | Reserved for analysis automation; patch application still requires explicit approval | + +Viewer/operator callers can read aggregate metrics. Raw activity details, +actor IDs, failure evidence, draft patches, and suggestion apply actions remain +admin-controlled. System skill mutation is blocked; custom skill suggestions +write a new versioned directory and `skill_versions` record when applied. + ### Skill Grants Skill upload size is enforced per ZIP file. The effective limit resolves in this order: diff --git a/docs/21-agent-evolution-and-skill-management.md b/docs/21-agent-evolution-and-skill-management.md index b535df5c..42df78f3 100644 --- a/docs/21-agent-evolution-and-skill-management.md +++ b/docs/21-agent-evolution-and-skill-management.md @@ -354,7 +354,62 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ | `POST` | `/v1/skills/install-deps` | Install all missing deps | | `GET` | `/v1/skills/runtimes` | Check python3/node availability | -### 3.5 WebSocket RPC +### 3.5 Skill Self-Evolution + +Skill self-evolution tracks how each existing skill performs over time. It is +separate from agent-level `skill_evolve`, which teaches agents when to create or +patch reusable skills. + +**Runtime recording** + +- `use_skill` tool calls record tenant-scoped usage with status `succeeded` or + `failed`, duration, session key, run/trace ID, agent ID, and user scope. +- Slash-command activation records a `started` event when `/` or + `/use ` resolves to a skill. +- Usage writes are internal only. v1 intentionally has no public + `POST /v1/skills/{id}/usage` endpoint, so clients cannot forge success rates. + +**Persistent tables** + +| Table | Purpose | +|-------|---------| +| `skill_evolution_settings` | Per-tenant, per-skill enabled flag and mode | +| `skill_usage_metrics` | Runtime usage events and status counts | +| `skill_improvement_suggestions` | Skill-scoped suggestions with evidence and draft patches | +| `skill_versions` | Immutable applied-version records linked to changed files and suggestions | + +**HTTP and CLI controls** + +- HTTP: `GET/PATCH /v1/skills/{id}/evolution`, + `GET /v1/skills/{id}/metrics`, + `GET /v1/skills/{id}/activity`, and suggestion approve/reject/apply endpoints. +- CLI: `goclaw skills evolve`, `goclaw skills metrics`, + `goclaw skills suggestions`, and `goclaw skills activity`. +- Web UI: Skill detail has an `evolution` tab for settings, metrics, + suggestions, and admin-visible activity. + +**Guardrails** + +- Default mode is `suggest_only`; no automatic patching happens in v1. +- Applying a suggestion to a custom skill copies the current skill directory to + the next version, validates the target path, runs the SKILL.md guard scanner + when needed, updates the active skill, records `skill_versions`, and writes an + activity log entry. +- System/bundled skill mutation is refused by the apply path. +- Viewer surfaces are sanitized. Failure evidence, draft patches, actor IDs, + and activity details require admin visibility. + +**Relationship to self-improving skills** + +This v1 is the control-plane foundation for self-improving skills: runtime usage +events, evidence-backed suggestions, reference-file patches, version records, +and approval/audit surfaces. It does not yet run a consolidation extractor that +turns repeated corrections into learning notes or auto-applies user-scoped +reference overlays. That higher-level learning loop belongs above this +foundation and must keep scope separation, private-content filtering, evidence +thresholds, and owner/admin approval policies explicit. + +### 3.6 WebSocket RPC | Method | Description | |--------|-------------| @@ -362,7 +417,7 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ | `skills.get` | Get skill content by name | | `skills.update` | Update metadata (ownership-protected) | -### 3.6 Grants & Visibility +### 3.7 Grants & Visibility ```mermaid stateDiagram-v2 diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 3b820c20..651fd82d 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,33 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-06-12 +### Skill self-evolution metrics and upgrades (issue #161, issue #142 foundation) + +**Features** + +- Added per-skill self-evolution settings, usage metrics, suggestions, immutable + applied-version records, and activity logs. +- Added trusted runtime usage recording for `use_skill` and slash skill + activations without exposing a public usage-write endpoint. +- Added skill evolution HTTP, CLI, and Web UI surfaces for settings, metrics, + suggestions, approval/apply actions, and admin-visible activity. +- Added the shared foundation needed by self-improving skills: usage evidence, + reference-file patches, versioned apply, and approval/audit surfaces. The + consolidation learning extractor and auto user-scoped overlays remain out of + this v1 scope. + +**Safety** + +- Blocked direct system skill mutation in the suggestion apply path. +- Reused a shared target-path validator for skill companion/reference patches. +- Sanitized failure evidence, draft patches, actor IDs, and activity details + away from non-admin surfaces. + +**Tests** + +- Added focused skill path validator coverage and deep-link coverage for the new + Web UI evolution tab. + ### Mid-flight request preservation (issue #137) **Fixes** diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index ffaf232c..85a7a77d 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -117,7 +117,15 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s } } - userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, userMessage, extraSystemPrompt, skillFilter) + slashReq := &RunRequest{ + SessionKey: sessionKey, + UserID: userID, + SenderID: store.SenderIDFromContext(ctx), + Channel: channel, + ChatID: chatID, + PeerKind: peerKind, + } + userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, slashReq, userMessage, extraSystemPrompt, skillFilter) // Build tool list, filtering out skill_manage when skill_evolve is off. // Also applies ChannelAware filtering so channel-specific tools don't diff --git a/internal/agent/loop_pipeline_tool_callbacks.go b/internal/agent/loop_pipeline_tool_callbacks.go index 90373ab1..cb8bafed 100644 --- a/internal/agent/loop_pipeline_tool_callbacks.go +++ b/internal/agent/loop_pipeline_tool_callbacks.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -58,6 +59,9 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx // v3 evolution metrics: record tool execution non-blocking (best-effort). l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, toolDuration) + if registryName == "use_skill" { + l.recordSkillUsageFromTool(ctx, req, tc, result, toolDuration) + } toolMsg, warningMsgs, action := l.processToolResult(ctx, bridgeRS, req, emitRun, tc, registryName, result, state.Context.HadBootstrap) syncBridgeToState(bridgeRS, state, action) @@ -168,6 +172,9 @@ func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(c // Record tool metrics (non-blocking, best-effort). l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, dur) + if registryName == "use_skill" { + l.recordSkillUsageFromTool(ctx, req, tc, result, dur) + } toolMsg, warningMsgs, action := l.processToolResult(ctx, bridgeRS, req, emitRun, tc, registryName, result, state.Context.HadBootstrap) syncBridgeToState(bridgeRS, state, action) @@ -246,6 +253,68 @@ func (l *Loop) recordToolMetric(ctx context.Context, sessionKey, toolName string }() } +func (l *Loop) recordSkillUsageFromTool(ctx context.Context, req *RunRequest, tc providers.ToolCall, result *tools.Result, duration time.Duration) { + name, _ := tc.Arguments["name"].(string) + name = strings.TrimSpace(name) + if name == "" { + return + } + status := store.SkillUsageStatusSucceeded + reason := "" + if result != nil && result.IsError { + status = store.SkillUsageStatusFailed + reason = strings.TrimSpace(result.ForLLM) + if len(reason) > 500 { + reason = reason[:500] + } + } + l.recordSkillUsage(ctx, req, name, tc.ID, "use_skill", status, reason, duration) +} + +func (l *Loop) recordSkillUsage(ctx context.Context, req *RunRequest, skillName, invocationID, source, status, reason string, duration time.Duration) { + if l.skillEvolutionStore == nil || l.skillStore == nil { + return + } + info, ok := l.skillStore.GetSkill(ctx, skillName) + if !ok || info == nil || info.ID == "" { + return + } + skillID, err := uuid.Parse(info.ID) + if err != nil { + return + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = l.tenantID + } + metric := store.SkillUsageMetric{ + ID: uuid.New(), + SkillID: skillID, + SkillSlug: info.Slug, + SkillVersion: info.Version, + AgentID: l.agentUUID, + InvocationID: invocationID, + InvocationSource: source, + Status: status, + FailureReason: reason, + DurationMs: duration.Milliseconds(), + } + if req != nil { + metric.UserID = req.UserID + metric.SessionKey = req.SessionKey + if req.RunID != "" { + metric.TraceID = req.RunID + } + } + go func() { + bgCtx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 5*time.Second) + defer cancel() + if err := l.skillEvolutionStore.RecordUsage(bgCtx, metric); err != nil { + slog.Debug("skill.metric.record_failed", "skill", skillName, "source", source, "error", err) + } + }() +} + // makeToolEmitRun creates a tool event emitter with request context. func makeToolEmitRun(l *Loop, req *RunRequest) func(AgentEvent) { return func(event AgentEvent) { diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 816e06a1..c1663959 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -254,6 +254,10 @@ type Loop struct { // v3 evolution metrics store (nil = disabled) evolutionMetricsStore store.EvolutionMetricsStore + // Skill self-evolution metrics store (nil = disabled) + skillEvolutionStore store.SkillEvolutionStore + skillStore store.SkillStore + // User identity resolver: maps channel contacts to merged tenant users for credential lookups. userResolver UserIdentityResolver @@ -454,6 +458,10 @@ type LoopConfig struct { // V3 evolution metrics store for recording tool/retrieval/feedback metrics EvolutionMetricsStore store.EvolutionMetricsStore + // Skill self-evolution metrics store for use_skill/slash activation metrics + SkillEvolutionStore store.SkillEvolutionStore + SkillStore store.SkillStore + // User identity resolver for credential lookups (maps channel contacts → tenant users) UserResolver UserIdentityResolver } @@ -586,6 +594,8 @@ func NewLoop(cfg LoopConfig) *Loop { orchMode: cfg.OrchMode, delegateTargets: cfg.DelegateTargets, evolutionMetricsStore: cfg.EvolutionMetricsStore, + skillEvolutionStore: cfg.SkillEvolutionStore, + skillStore: cfg.SkillStore, userResolver: cfg.UserResolver, } } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 823e17fc..174fe08a 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -85,8 +85,10 @@ type ResolverDeps struct { MCPGrantChecker mcpbridge.GrantChecker // Skill access store — for per-agent skill visibility filtering - SkillAccessStore store.SkillAccessStore - SkillSlashCommands config.SkillSlashCommandConfig + SkillAccessStore store.SkillAccessStore + SkillStore store.SkillStore + SkillEvolutionStore store.SkillEvolutionStore + SkillSlashCommands config.SkillSlashCommandConfig // Config permission store for group file writer checks ConfigPermStore store.ConfigPermissionStore @@ -542,6 +544,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { OrchMode: orchMode, DelegateTargets: delegateTargets, EvolutionMetricsStore: evoMetricsStore, + SkillEvolutionStore: deps.SkillEvolutionStore, + SkillStore: deps.SkillStore, UserResolver: newContactResolver(deps.ContactStore), }) diff --git a/internal/agent/skill_slash_commands.go b/internal/agent/skill_slash_commands.go index c9b1f34e..b4d60267 100644 --- a/internal/agent/skill_slash_commands.go +++ b/internal/agent/skill_slash_commands.go @@ -6,6 +6,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" ) type skillSlashCommandKind int @@ -27,7 +28,7 @@ type skillSlashCommandResult struct { Suggestions []skills.Info } -func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt string, skillFilter []string) (string, string, []string) { +func (l *Loop) applySkillSlashCommand(ctx context.Context, req *RunRequest, message, extraPrompt string, skillFilter []string) (string, string, []string) { result := resolveSkillSlashCommand(ctx, l.skillsLoader, l.resolveSkillSlashCommandConfig(ctx), message) if result.Kind == skillSlashCommandNone { return message, extraPrompt, skillFilter @@ -41,6 +42,7 @@ func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt message = result.RemainingPrompt } skillFilter = []string{result.Skill.Slug} + l.recordSkillUsage(ctx, req, result.Skill.Slug, "", "slash", store.SkillUsageStatusStarted, "", 0) case skillSlashCommandList: message = "List the available skills shown in the system instructions." case skillSlashCommandHelp: diff --git a/internal/http/skills.go b/internal/http/skills.go index 7d04a4e5..dcd0ca58 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -36,12 +36,19 @@ type SkillsHandler struct { msgBus *bus.MessageBus tenantCfgStore store.SkillTenantConfigStore tenantStore store.TenantStore + evolutionStore store.SkillEvolutionStore + activityStore store.ActivityStore db *sql.DB // for export/import direct queries uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex) uploadLimitCfg config.SkillsConfig systemConfigs store.SystemConfigStore } +func (h *SkillsHandler) SetEvolutionStore(evolution store.SkillEvolutionStore, activity store.ActivityStore) { + h.evolutionStore = evolution + h.activityStore = activity +} + // NewSkillsHandler creates a handler for skill management endpoints. func NewSkillsHandler(skills store.SkillManageStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore, tenantStore store.TenantStore) *SkillsHandler { return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore, uploadLimitCfg: config.SkillsConfig{MaxUploadSizeMB: config.DefaultSkillMaxUploadSizeMB}} @@ -88,10 +95,19 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions)) mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile)) mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles)) + mux.HandleFunc("GET /v1/skills/{id}/evolution", h.authMiddleware(h.handleGetEvolution)) + mux.HandleFunc("GET /v1/skills/{id}/metrics", h.authMiddleware(h.handleGetSkillMetrics)) + mux.HandleFunc("GET /v1/skills/{id}/activity", h.adminMiddleware(h.handleGetSkillActivity)) + mux.HandleFunc("GET /v1/skills/{id}/evolution/suggestions", h.authMiddleware(h.handleListSkillSuggestions)) // Skill writes (admin+) mux.HandleFunc("POST /v1/skills/upload", h.adminMiddleware(h.handleUpload)) mux.HandleFunc("PUT /v1/skills/{id}", h.adminMiddleware(h.handleUpdate)) mux.HandleFunc("DELETE /v1/skills/{id}", h.adminMiddleware(h.handleDelete)) + mux.HandleFunc("PATCH /v1/skills/{id}/evolution", h.adminMiddleware(h.handlePatchEvolution)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions", h.adminMiddleware(h.handleCreateSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/approve", h.adminMiddleware(h.handleApproveSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/reject", h.adminMiddleware(h.handleRejectSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/apply", h.adminMiddleware(h.handleApplySkillSuggestion)) // Skill grants (admin+) mux.HandleFunc("GET /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleListAgentGrants)) mux.HandleFunc("POST /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleGrantAgent)) diff --git a/internal/http/skills_evolution.go b/internal/http/skills_evolution.go new file mode 100644 index 00000000..c650e44d --- /dev/null +++ b/internal/http/skills_evolution.go @@ -0,0 +1,542 @@ +package http + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type skillEvolutionPatchRequest struct { + Enabled *bool `json:"enabled"` + Mode string `json:"mode"` +} + +type skillSuggestionCreateRequest struct { + SuggestionType string `json:"suggestion_type"` + Reason string `json:"reason"` + Evidence json.RawMessage `json:"evidence"` + DraftPatch json.RawMessage `json:"draft_patch"` + TargetFile string `json:"target_file"` +} + +type skillSuggestionApplyRequest struct { + Approve bool `json:"approve"` +} + +type skillDraftPatch struct { + Find string `json:"find"` + Replace string `json:"replace"` + Content *string `json:"content"` +} + +func (h *SkillsHandler) evolutionConfigured(w http.ResponseWriter) bool { + if h.evolutionStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skill evolution store not configured"}) + return false + } + return true +} + +func (h *SkillsHandler) skillIDFromRequest(w http.ResponseWriter, r *http.Request) (uuid.UUID, store.SkillInfo, bool) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill id"}) + return uuid.Nil, store.SkillInfo{}, false + } + info, ok := h.skills.GetSkillByID(r.Context(), id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return uuid.Nil, store.SkillInfo{}, false + } + return id, info, true +} + +func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + settings, err := h.evolutionStore.GetSettings(r.Context(), skillID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + var req skillEvolutionPatchRequest + if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) { + return + } + current, err := h.evolutionStore.GetSettings(r.Context(), skillID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + enabled := current.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = current.Mode + } + if mode == "" { + mode = store.SkillEvolutionModeSuggestOnly + } + if mode != store.SkillEvolutionModeSuggestOnly && mode != store.SkillEvolutionModeAutoAnalyze { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid evolution mode"}) + return + } + updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{ + SkillID: skillID, + Enabled: enabled, + Mode: mode, + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + action := "skill.evolve.mode_updated" + if req.Enabled != nil { + if enabled { + action = "skill.evolve.enabled" + } else { + action = "skill.evolve.disabled" + } + } + h.logSkillActivity(r, action, skillID, map[string]any{"enabled": enabled, "mode": mode}) + writeJSON(w, http.StatusOK, updated) +} + +func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + var since *time.Time + if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { + if t, err := time.Parse(time.RFC3339, raw); err == nil { + since = &t + } + } + stats, err := h.evolutionStore.AggregateUsage(r.Context(), skillID, since) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) { + stats.TopFailureReasons = nil + } + writeJSON(w, http.StatusOK, stats) +} + +func (h *SkillsHandler) handleGetSkillActivity(w http.ResponseWriter, r *http.Request) { + if h.activityStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "activity store not configured"}) + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + items, err := h.activityStore.List(r.Context(), store.ActivityListOpts{ + EntityType: "skill", + EntityID: skillID.String(), + Limit: clampLimit(r.URL.Query().Get("limit"), 50, 200), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"activity": items}) +} + +func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + items, err := h.evolutionStore.ListSuggestions(r.Context(), skillID, r.URL.Query().Get("status"), clampLimit(r.URL.Query().Get("limit"), 50, 200)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) { + for i := range items { + items[i].Evidence = nil + items[i].DraftPatch = nil + items[i].CreatedByActorID = "" + items[i].ReviewedByActorID = "" + } + } + writeJSON(w, http.StatusOK, map[string]any{"suggestions": items}) +} + +func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + var req skillSuggestionCreateRequest + if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) { + return + } + if strings.TrimSpace(req.SuggestionType) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion_type is required"}) + return + } + target := strings.TrimSpace(req.TargetFile) + if target != "" { + clean, err := skills.ValidateSkillTargetPath(target, true) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + target = clean + } + created, err := h.evolutionStore.CreateSuggestion(r.Context(), store.SkillImprovementSuggestion{ + SkillID: skillID, + SuggestionType: req.SuggestionType, + Reason: req.Reason, + Evidence: req.Evidence, + DraftPatch: req.DraftPatch, + TargetFile: target, + CreatedByActorType: "user", + CreatedByActorID: store.ActorIDFromContext(r.Context()), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + h.logSkillActivity(r, "skill.suggestion.created", skillID, map[string]any{"suggestion_id": created.ID.String(), "type": created.SuggestionType}) + writeJSON(w, http.StatusCreated, created) +} + +func (h *SkillsHandler) handleApproveSkillSuggestion(w http.ResponseWriter, r *http.Request) { + h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusApproved, "skill.suggestion.approved") +} + +func (h *SkillsHandler) handleRejectSkillSuggestion(w http.ResponseWriter, r *http.Request) { + h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusRejected, "skill.suggestion.rejected") +} + +func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Request, status, action string) { + if !h.evolutionConfigured(w) { + return + } + skillID, _, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + suggestionID, err := uuid.Parse(r.PathValue("suggestionID")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) + return + } + updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if updated.SkillID != skillID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) + return + } + h.logSkillActivity(r, action, skillID, map[string]any{"suggestion_id": suggestionID.String()}) + writeJSON(w, http.StatusOK, updated) +} + +func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *http.Request) { + if !h.evolutionConfigured(w) { + return + } + skillID, info, ok := h.skillIDFromRequest(w, r) + if !ok { + return + } + if info.IsSystem { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "system skill mutation is blocked"}) + return + } + suggestionID, err := uuid.Parse(r.PathValue("suggestionID")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) + return + } + var req skillSuggestionApplyRequest + _ = json.NewDecoder(r.Body).Decode(&req) + sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if sg == nil || sg.SkillID != skillID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) + return + } + if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil { + writeJSON(w, http.StatusOK, sg) + return + } + if sg.Status != store.SkillSuggestionStatusApproved { + if !req.Approve { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion must be approved before apply"}) + return + } + sg, err = h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, store.SkillSuggestionStatusApproved, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + } + applied, err := h.applySkillSuggestionPatch(r, skillID, sg) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, applied) +} + +func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid.UUID, sg *store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + currentDir, slug, oldVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), skillID) + if !ok { + return nil, fmt.Errorf("skill not found") + } + if isSystem { + return nil, fmt.Errorf("system skill mutation is blocked") + } + target := sg.TargetFile + if strings.TrimSpace(target) == "" { + target = "SKILL.md" + } + cleanTarget, err := skills.ValidateSkillTargetPath(target, true) + if err != nil { + return nil, err + } + var patch skillDraftPatch + if len(sg.DraftPatch) > 0 { + if err := json.Unmarshal(sg.DraftPatch, &patch); err != nil { + return nil, fmt.Errorf("invalid draft_patch: %w", err) + } + } + if patch.Content == nil && patch.Find == "" { + return nil, fmt.Errorf("draft_patch requires content or find/replace") + } + newVersion, commitLock, err := h.skills.GetNextVersionLocked(r.Context(), slug) + if err != nil { + return nil, err + } + defer commitLock() //nolint:errcheck + + destDir := filepath.Join(h.tenantSkillsDir(r), slug, fmt.Sprintf("%d", newVersion)) + tmpDir := destDir + ".tmp-" + uuid.NewString() + if err := copyDir(currentDir, tmpDir); err != nil { + return nil, fmt.Errorf("stage version: %w", err) + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(tmpDir) + _ = os.RemoveAll(destDir) + } + }() + + targetPath := filepath.Join(tmpDir, filepath.FromSlash(cleanTarget)) + var nextContent string + if patch.Content != nil { + nextContent = *patch.Content + } else { + currentBytes, err := os.ReadFile(targetPath) + if err != nil { + return nil, fmt.Errorf("read target file: %w", err) + } + nextContent = string(currentBytes) + replaced := strings.Replace(nextContent, patch.Find, patch.Replace, 1) + if replaced == nextContent { + return nil, fmt.Errorf("find text not found in target file") + } + nextContent = replaced + } + if cleanTarget == "SKILL.md" { + violations, safe := skills.GuardSkillContent(nextContent) + if !safe { + return nil, errors.New(skills.FormatGuardViolations(violations)) + } + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return nil, fmt.Errorf("create target directory: %w", err) + } + if err := os.WriteFile(targetPath, []byte(nextContent), 0644); err != nil { + return nil, fmt.Errorf("write target file: %w", err) + } + if err := os.Rename(tmpDir, destDir); err != nil { + return nil, fmt.Errorf("commit version files: %w", err) + } + + hash, size, err := hashSkillDir(destDir) + if err != nil { + return nil, err + } + if err := h.skills.UpdateSkill(r.Context(), skillID, map[string]any{ + "version": newVersion, + "file_path": destDir, + "file_size": size, + "file_hash": &hash, + "updated_at": time.Now(), + }); err != nil { + return nil, fmt.Errorf("update skill: %w", err) + } + changedFiles, _ := json.Marshal([]string{cleanTarget}) + if _, err := h.evolutionStore.CreateSkillVersion(r.Context(), store.SkillVersion{ + SkillID: skillID, + Version: newVersion, + ContentHash: hash, + ChangedFiles: changedFiles, + CreatedByActorType: "user", + CreatedByActorID: store.ActorIDFromContext(r.Context()), + CreatedFromSuggestionID: &sg.ID, + }); err != nil { + return nil, fmt.Errorf("record skill version: %w", err) + } + applied, err := h.evolutionStore.MarkSuggestionApplied(r.Context(), sg.ID, newVersion, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + return nil, err + } + h.logSkillActivity(r, "skill.suggestion.applied", skillID, map[string]any{ + "suggestion_id": sg.ID.String(), + "changed_files": []string{cleanTarget}, + "old_version": oldVersion, + "new_version": newVersion, + "content_hash": hash, + }) + cleanup = false + return applied, nil +} + +func (h *SkillsHandler) logSkillActivity(r *http.Request, action string, skillID uuid.UUID, details map[string]any) { + if h.activityStore == nil { + return + } + raw, _ := json.Marshal(details) + _ = h.activityStore.Log(r.Context(), &store.ActivityLog{ + ActorType: "user", + ActorID: store.ActorIDFromContext(r.Context()), + Action: action, + EntityType: "skill", + EntityID: skillID.String(), + Details: raw, + IPAddress: r.RemoteAddr, + }) +} + +func clampLimit(raw string, fallback, max int) int { + if raw == "" { + return fallback + } + var n int + if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 { + return fallback + } + if n > max { + return max + } + return n +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err + }) +} + +func hashSkillDir(dir string) (string, int64, error) { + var size int64 + h := sha256.New() + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + size += info.Size() + rel, _ := filepath.Rel(dir, path) + h.Write([]byte(filepath.ToSlash(rel))) + data, err := os.ReadFile(path) + if err != nil { + return err + } + h.Write(data) + return nil + }) + if err != nil { + return "", 0, err + } + return fmt.Sprintf("%x", h.Sum(nil)), size, nil +} diff --git a/internal/http/skills_evolution_test.go b/internal/http/skills_evolution_test.go new file mode 100644 index 00000000..c2d59573 --- /dev/null +++ b/internal/http/skills_evolution_test.go @@ -0,0 +1,131 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestApplySkillSuggestionPatchCreatesNewReferenceFile(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + evolution := &skillEvolutionStoreStub{} + handler.SetEvolutionStore(evolution, nil) + + currentDir := filepath.Join(root, "skills-store", "reference-skill", "1") + if err := os.MkdirAll(currentDir, 0755); err != nil { + t.Fatalf("mkdir current skill: %v", err) + } + if err := os.WriteFile(filepath.Join(currentDir, "SKILL.md"), []byte(skillMarkdown("Reference Skill", "reference-skill")), 0644); err != nil { + t.Fatalf("write skill: %v", err) + } + skillID := skillStore.seedCustomSkill("reference-skill", currentDir, "active", nil) + content := "Use the documented query syntax.\n" + patch, err := json.Marshal(skillDraftPatch{Content: &content}) + if err != nil { + t.Fatalf("marshal draft patch: %v", err) + } + suggestion := &store.SkillImprovementSuggestion{ + ID: uuid.New(), + SkillID: skillID, + TargetFile: "references/troubleshooting.md", + DraftPatch: patch, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SkillSlug: "reference-skill", + Status: store.SkillSuggestionStatusApproved, + SuggestionType: "skill_reference_add", + } + req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/evolution/suggestions/"+suggestion.ID.String()+"/apply", nil).WithContext(ctx) + + applied, err := handler.applySkillSuggestionPatch(req, skillID, suggestion) + if err != nil { + t.Fatalf("apply suggestion: %v", err) + } + if applied.Status != store.SkillSuggestionStatusApplied { + t.Fatalf("status = %q, want applied", applied.Status) + } + + newReference := filepath.Join(root, "skills-store", "reference-skill", "2", "references", "troubleshooting.md") + got, err := os.ReadFile(newReference) + if err != nil { + t.Fatalf("read created reference: %v", err) + } + if string(got) != content { + t.Fatalf("created reference = %q, want %q", got, content) + } + if len(evolution.versions) != 1 || evolution.versions[0].Version != 2 { + t.Fatalf("versions = %+v, want one version 2", evolution.versions) + } +} + +type skillEvolutionStoreStub struct { + versions []store.SkillVersion +} + +func (s *skillEvolutionStoreStub) GetSettings(context.Context, uuid.UUID) (*store.SkillEvolutionSettings, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) UpsertSettings(context.Context, store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) RecordUsage(context.Context, store.SkillUsageMetric) error { + return nil +} + +func (s *skillEvolutionStoreStub) AggregateUsage(context.Context, uuid.UUID, *time.Time) (*store.SkillUsageStats, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) ListUsage(context.Context, uuid.UUID, int) ([]store.SkillUsageMetric, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) CreateSuggestion(context.Context, store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) ListSuggestions(context.Context, uuid.UUID, string, int) ([]store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) GetSuggestion(context.Context, uuid.UUID) (*store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) UpdateSuggestionStatus(context.Context, uuid.UUID, string, string, string) (*store.SkillImprovementSuggestion, error) { + return nil, nil +} + +func (s *skillEvolutionStoreStub) MarkSuggestionApplied(_ context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + return &store.SkillImprovementSuggestion{ + ID: id, + Status: store.SkillSuggestionStatusApplied, + ReviewedByActorType: actorType, + ReviewedByActorID: actorID, + AppliedVersion: &version, + }, nil +} + +func (s *skillEvolutionStoreStub) CreateSkillVersion(_ context.Context, version store.SkillVersion) (*store.SkillVersion, error) { + s.versions = append(s.versions, version) + return &version, nil +} + +func (s *skillEvolutionStoreStub) ListSkillVersions(context.Context, uuid.UUID, int) ([]store.SkillVersion, error) { + return s.versions, nil +} + +func (s *skillEvolutionStoreStub) GetSkillVersion(context.Context, uuid.UUID, int) (*store.SkillVersion, error) { + return nil, nil +} diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index de45592c..9240d8a6 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -939,6 +939,9 @@ func (s *skillManageStoreStub) seedCustomSkill(slug, dir, status string, missing func (s *skillManageStoreStub) seedCustomSkillForTenant(tenantID uuid.UUID, slug, dir, status string, missing []string) uuid.UUID { id := uuid.New() + if s.nextBySlug[slug] < 1 { + s.nextBySlug[slug] = 1 + } s.skills[id] = store.SkillInfo{ ID: id.String(), TenantID: tenantID.String(), @@ -1046,6 +1049,16 @@ func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, up if visibility, ok := updates["visibility"].(string); ok { skill.Visibility = visibility } + if version, ok := updates["version"].(int); ok { + skill.Version = version + if version > s.nextBySlug[skill.Slug] { + s.nextBySlug[skill.Slug] = version + } + } + if filePath, ok := updates["file_path"].(string); ok { + skill.BaseDir = filePath + skill.Path = filepath.Join(filePath, "SKILL.md") + } s.lastUpdates[id] = maps.Clone(updates) s.skills[id] = skill return nil @@ -1156,8 +1169,12 @@ func (s *skillManageStoreStub) ListAgentGrantsForSkill(context.Context, uuid.UUI func (s *skillManageStoreStub) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil } -func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) { - return "", "", 0, false, false +func (s *skillManageStoreStub) GetSkillFilePath(ctx context.Context, id uuid.UUID) (string, string, int, bool, bool) { + skill, ok := s.skills[id] + if !ok || !s.canAccessSkill(ctx, skill) { + return "", "", 0, false, false + } + return skill.BaseDir, skill.Slug, skill.Version, skill.IsSystem, true } // --------------------------------------------------------------------------- diff --git a/internal/skills/target_path_validator.go b/internal/skills/target_path_validator.go new file mode 100644 index 00000000..4f38df40 --- /dev/null +++ b/internal/skills/target_path_validator.go @@ -0,0 +1,50 @@ +package skills + +import ( + "fmt" + "path" + "strings" +) + +// ValidateSkillTargetPath validates a relative path inside a managed skill root. +// Set allowSkillMD when the caller is explicitly editing the primary SKILL.md. +func ValidateSkillTargetPath(rawPath string, allowSkillMD bool) (string, error) { + if rawPath == "" { + return "", fmt.Errorf("invalid file path %q: empty path", rawPath) + } + if strings.ContainsRune(rawPath, 0x00) { + return "", fmt.Errorf("invalid file path %q: null byte", rawPath) + } + if len(rawPath) >= 2 && rawPath[1] == ':' { + return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath) + } + normalized := strings.ReplaceAll(rawPath, "\\", "/") + if strings.HasPrefix(normalized, "/") { + return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath) + } + for part := range strings.SplitSeq(normalized, "/") { + switch part { + case "..": + return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath) + case ".git": + return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) + } + if strings.HasPrefix(part, ".") { + return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath) + } + } + cleanPath := path.Clean(normalized) + if cleanPath == "." || strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") { + return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath) + } + if strings.EqualFold(cleanPath, "SKILL.md") { + if allowSkillMD { + return "SKILL.md", nil + } + return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath) + } + if IsSystemArtifact(cleanPath) { + return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) + } + return cleanPath, nil +} diff --git a/internal/skills/target_path_validator_test.go b/internal/skills/target_path_validator_test.go new file mode 100644 index 00000000..29ca86a7 --- /dev/null +++ b/internal/skills/target_path_validator_test.go @@ -0,0 +1,40 @@ +package skills + +import "testing" + +func TestValidateSkillTargetPath(t *testing.T) { + tests := []struct { + name string + raw string + allowSkillMD bool + want string + wantErr bool + }{ + {name: "reference file", raw: `references\troubleshooting.md`, want: "references/troubleshooting.md"}, + {name: "skill markdown allowed", raw: "SKILL.md", allowSkillMD: true, want: "SKILL.md"}, + {name: "skill markdown blocked", raw: "SKILL.md", wantErr: true}, + {name: "absolute path", raw: "/tmp/SKILL.md", wantErr: true}, + {name: "parent traversal", raw: "../outside.md", wantErr: true}, + {name: "hidden file", raw: "references/.env", wantErr: true}, + {name: "system artifact", raw: ".git/config", wantErr: true}, + {name: "windows drive", raw: "C:/Users/secret.md", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateSkillTargetPath(tt.raw, tt.allowSkillMD) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got path %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index ea2bc565..f826f9f2 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -55,6 +55,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Tenants: NewPGTenantStore(db), BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db), SkillTenantCfgs: NewPGSkillTenantConfigStore(db), + SkillEvolution: NewPGSkillEvolutionStore(db), SystemConfigs: NewPGSystemConfigStore(db), SubagentTasks: NewPGSubagentTaskStore(db), Vault: NewPGVaultStore(db), diff --git a/internal/store/pg/skill_evolution.go b/internal/store/pg/skill_evolution.go new file mode 100644 index 00000000..445456ab --- /dev/null +++ b/internal/store/pg/skill_evolution.go @@ -0,0 +1,440 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type PGSkillEvolutionStore struct { + db *sql.DB +} + +func NewPGSkillEvolutionStore(db *sql.DB) *PGSkillEvolutionStore { + return &PGSkillEvolutionStore{db: db} +} + +func (s *PGSkillEvolutionStore) resolveSkill(ctx context.Context, skillID uuid.UUID) (string, int, error) { + tenantID := tenantIDForInsert(ctx) + var slug string + var version int + var skillTenant uuid.UUID + var isSystem bool + err := s.db.QueryRowContext(ctx, + `SELECT slug, version, tenant_id, is_system + FROM skills + WHERE id = $1 AND status != 'deleted'`, + skillID, + ).Scan(&slug, &version, &skillTenant, &isSystem) + if errors.Is(err, sql.ErrNoRows) { + return "", 0, fmt.Errorf("skill not found") + } + if err != nil { + return "", 0, err + } + if !store.IsCrossTenant(ctx) && !isSystem && skillTenant != tenantID { + return "", 0, fmt.Errorf("skill not found") + } + return slug, version, nil +} + +func (s *PGSkillEvolutionStore) GetSettings(ctx context.Context, skillID uuid.UUID) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillEvolutionSettings + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at + FROM skill_evolution_settings + WHERE tenant_id = $1 AND skill_id = $2`, + tenantID, skillID, + ).Scan(&out.TenantID, &out.SkillID, &out.Enabled, &out.Mode, &out.LastAnalyzedAt, &out.CreatedAt, &out.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return &store.SkillEvolutionSettings{ + TenantID: tenantID, + SkillID: skillID, + Enabled: false, + Mode: store.SkillEvolutionModeSuggestOnly, + }, nil + } + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) UpsertSettings(ctx context.Context, settings store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, settings.SkillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + mode := strings.TrimSpace(settings.Mode) + if mode == "" { + mode = store.SkillEvolutionModeSuggestOnly + } + var out store.SkillEvolutionSettings + err := s.db.QueryRowContext(ctx, + `INSERT INTO skill_evolution_settings (tenant_id, skill_id, enabled, mode, last_analyzed_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_id, skill_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + mode = EXCLUDED.mode, + last_analyzed_at = EXCLUDED.last_analyzed_at, + updated_at = NOW() + RETURNING tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at`, + tenantID, settings.SkillID, settings.Enabled, mode, settings.LastAnalyzedAt, + ).Scan(&out.TenantID, &out.SkillID, &out.Enabled, &out.Mode, &out.LastAnalyzedAt, &out.CreatedAt, &out.UpdatedAt) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) RecordUsage(ctx context.Context, metric store.SkillUsageMetric) error { + slug, version, err := s.resolveSkill(ctx, metric.SkillID) + if err != nil { + return err + } + tenantID := tenantIDForInsert(ctx) + if metric.ID == uuid.Nil { + metric.ID = uuid.New() + } + if metric.SkillSlug == "" { + metric.SkillSlug = slug + } + if metric.SkillVersion == 0 { + metric.SkillVersion = version + } + if metric.Status == "" { + metric.Status = store.SkillUsageStatusStarted + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO skill_usage_metrics + (id, tenant_id, skill_id, skill_slug, skill_version, agent_id, user_id, session_key, + trace_id, invocation_id, invocation_source, status, failure_reason, tool_calls_count, duration_ms) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, $16), $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + metric.ID, tenantID, metric.SkillID, metric.SkillSlug, metric.SkillVersion, + metric.AgentID, metric.UserID, metric.SessionKey, metric.TraceID, metric.InvocationID, + metric.InvocationSource, metric.Status, metric.FailureReason, metric.ToolCallsCount, metric.DurationMs, uuid.Nil, + ) + return err +} + +func (s *PGSkillEvolutionStore) AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*store.SkillUsageStats, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + where := "tenant_id = $1 AND skill_id = $2" + args := []any{tenantID, skillID} + if since != nil { + where += " AND created_at >= $3" + args = append(args, *since) + } + query := fmt.Sprintf( + `SELECT COUNT(*), + COUNT(*) FILTER (WHERE status = 'started'), + COUNT(*) FILTER (WHERE status = 'succeeded'), + COUNT(*) FILTER (WHERE status = 'failed'), + COUNT(*) FILTER (WHERE status = 'abandoned'), + MAX(created_at) + FROM skill_usage_metrics WHERE %s`, + where, + ) + var out store.SkillUsageStats + out.SkillID = skillID + var last sql.NullTime + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&out.TotalCalls, &out.Started, &out.Succeeded, &out.Failed, &out.Abandoned, &last); err != nil { + return nil, err + } + if last.Valid { + out.LastUsedAt = &last.Time + } + if out.TotalCalls > 0 { + out.SuccessRate = float64(out.Succeeded) / float64(out.TotalCalls) + out.FailureRate = float64(out.Failed) / float64(out.TotalCalls) + } + reasonQuery := fmt.Sprintf( + `SELECT failure_reason, COUNT(*), MAX(created_at) + FROM skill_usage_metrics + WHERE %s AND status = 'failed' AND COALESCE(failure_reason, '') != '' + GROUP BY failure_reason ORDER BY COUNT(*) DESC, MAX(created_at) DESC LIMIT 5`, + where, + ) + rows, err := s.db.QueryContext(ctx, reasonQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var r store.SkillFailureReason + if err := rows.Scan(&r.Reason, &r.Count, &r.LastSeen); err != nil { + return nil, err + } + out.TopFailureReasons = append(out.TopFailureReasons, r) + } + return &out, rows.Err() +} + +func (s *PGSkillEvolutionStore) ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillUsageMetric, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, skill_version, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(user_id,''), COALESCE(session_key,''), COALESCE(trace_id,''), COALESCE(invocation_id,''), + invocation_source, status, COALESCE(failure_reason,''), tool_calls_count, duration_ms, created_at + FROM skill_usage_metrics + WHERE tenant_id = $1 AND skill_id = $2 + ORDER BY created_at DESC LIMIT $3`, + tenantID, skillID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []store.SkillUsageMetric + for rows.Next() { + var m store.SkillUsageMetric + if err := rows.Scan(&m.ID, &m.TenantID, &m.SkillID, &m.SkillSlug, &m.SkillVersion, &m.AgentID, + &m.UserID, &m.SessionKey, &m.TraceID, &m.InvocationID, &m.InvocationSource, &m.Status, + &m.FailureReason, &m.ToolCallsCount, &m.DurationMs, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *PGSkillEvolutionStore) CreateSuggestion(ctx context.Context, sg store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + slug, _, err := s.resolveSkill(ctx, sg.SkillID) + if err != nil { + return nil, err + } + if sg.ID == uuid.Nil { + sg.ID = uuid.New() + } + if sg.SkillSlug == "" { + sg.SkillSlug = slug + } + if sg.Status == "" { + sg.Status = store.SkillSuggestionStatusPending + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillImprovementSuggestion + err = s.db.QueryRowContext(ctx, + `INSERT INTO skill_improvement_suggestions + (id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, + draft_patch, target_file, created_by_actor_type, created_by_actor_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, '{}'::jsonb), COALESCE($9, '{}'::jsonb), $10, $11, $12) + RETURNING id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at`, + sg.ID, tenantID, sg.SkillID, sg.SkillSlug, sg.SuggestionType, sg.Status, sg.Reason, + jsonOrEmpty(sg.Evidence), jsonOrEmpty(sg.DraftPatch), sg.TargetFile, sg.CreatedByActorType, sg.CreatedByActorID, + ).Scan(&out.ID, &out.TenantID, &out.SkillID, &out.SkillSlug, &out.SuggestionType, &out.Status, &out.Reason, + &out.Evidence, &out.DraftPatch, &out.TargetFile, &out.CreatedByActorType, &out.CreatedByActorID, + &out.ReviewedByActorType, &out.ReviewedByActorID, &out.ReviewedAt, &out.AppliedVersion, + &out.CreatedAt, &out.UpdatedAt) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]store.SkillImprovementSuggestion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + q := `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = $1 AND skill_id = $2` + args := []any{tenantID, skillID} + if status != "" { + args = append(args, status) + q += fmt.Sprintf(" AND status = $%d", len(args)) + } + args = append(args, limit) + q += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", len(args)) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPGSuggestions(rows) +} + +func (s *PGSkillEvolutionStore) GetSuggestion(ctx context.Context, id uuid.UUID) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = $1 AND id = $2`, + tenantID, id) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanPGSuggestions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + if _, _, err := s.resolveSkill(ctx, items[0].SkillID); err != nil { + return nil, err + } + return &items[0], nil +} + +func (s *PGSkillEvolutionStore) UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = $1, reviewed_by_actor_type = $2, reviewed_by_actor_id = $3, reviewed_at = NOW(), updated_at = NOW() + WHERE tenant_id = $4 AND id = $5`, + status, actorType, actorID, tenantID, id) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *PGSkillEvolutionStore) MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = 'applied', applied_version = $1, reviewed_by_actor_type = $2, + reviewed_by_actor_id = $3, reviewed_at = COALESCE(reviewed_at, NOW()), updated_at = NOW() + WHERE tenant_id = $4 AND id = $5`, + version, actorType, actorID, tenantID, id) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *PGSkillEvolutionStore) CreateSkillVersion(ctx context.Context, v store.SkillVersion) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, v.SkillID); err != nil { + return nil, err + } + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillVersion + err := s.db.QueryRowContext(ctx, + `INSERT INTO skill_versions + (id, tenant_id, skill_id, version, content_hash, changed_files, created_by_actor_type, + created_by_actor_id, created_from_suggestion_id) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, '[]'::jsonb), $7, $8, $9) + RETURNING id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at`, + v.ID, tenantID, v.SkillID, v.Version, v.ContentHash, jsonOrEmptyArray(v.ChangedFiles), + v.CreatedByActorType, v.CreatedByActorID, v.CreatedFromSuggestionID, + ).Scan(&out.ID, &out.TenantID, &out.SkillID, &out.Version, &out.ContentHash, &out.ChangedFiles, + &out.CreatedByActorType, &out.CreatedByActorID, &out.CreatedFromSuggestionID, &out.CreatedAt) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *PGSkillEvolutionStore) ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 + ORDER BY version DESC LIMIT $3`, + tenantID, skillID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPGSkillVersions(rows) +} + +func (s *PGSkillEvolutionStore) GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3`, + tenantID, skillID, version) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanPGSkillVersions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + return &items[0], nil +} + +func scanPGSuggestions(rows *sql.Rows) ([]store.SkillImprovementSuggestion, error) { + var out []store.SkillImprovementSuggestion + for rows.Next() { + var sg store.SkillImprovementSuggestion + if err := rows.Scan(&sg.ID, &sg.TenantID, &sg.SkillID, &sg.SkillSlug, &sg.SuggestionType, &sg.Status, + &sg.Reason, &sg.Evidence, &sg.DraftPatch, &sg.TargetFile, &sg.CreatedByActorType, &sg.CreatedByActorID, + &sg.ReviewedByActorType, &sg.ReviewedByActorID, &sg.ReviewedAt, &sg.AppliedVersion, + &sg.CreatedAt, &sg.UpdatedAt); err != nil { + return nil, err + } + out = append(out, sg) + } + return out, rows.Err() +} + +func scanPGSkillVersions(rows *sql.Rows) ([]store.SkillVersion, error) { + var out []store.SkillVersion + for rows.Next() { + var v store.SkillVersion + if err := rows.Scan(&v.ID, &v.TenantID, &v.SkillID, &v.Version, &v.ContentHash, &v.ChangedFiles, + &v.CreatedByActorType, &v.CreatedByActorID, &v.CreatedFromSuggestionID, &v.CreatedAt); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +var _ store.SkillEvolutionStore = (*PGSkillEvolutionStore)(nil) diff --git a/internal/store/skill_evolution_store.go b/internal/store/skill_evolution_store.go new file mode 100644 index 00000000..65043c20 --- /dev/null +++ b/internal/store/skill_evolution_store.go @@ -0,0 +1,124 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +const ( + SkillEvolutionModeSuggestOnly = "suggest_only" + SkillEvolutionModeAutoAnalyze = "auto_analyze" + + SkillUsageStatusStarted = "started" + SkillUsageStatusSucceeded = "succeeded" + SkillUsageStatusFailed = "failed" + SkillUsageStatusAbandoned = "abandoned" + + SkillSuggestionStatusPending = "pending" + SkillSuggestionStatusApproved = "approved" + SkillSuggestionStatusRejected = "rejected" + SkillSuggestionStatusApplied = "applied" +) + +// SkillEvolutionSettings stores tenant-scoped self-evolution controls for one skill. +type SkillEvolutionSettings struct { + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + Enabled bool `json:"enabled" db:"enabled"` + Mode string `json:"mode" db:"mode"` + LastAnalyzedAt *time.Time `json:"last_analyzed_at,omitempty" db:"last_analyzed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SkillUsageMetric is a durable usage event emitted by trusted runtime paths. +type SkillUsageMetric struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + SkillSlug string `json:"skill_slug" db:"skill_slug"` + SkillVersion int `json:"skill_version" db:"skill_version"` + AgentID uuid.UUID `json:"agent_id,omitempty" db:"agent_id"` + UserID string `json:"user_id,omitempty" db:"user_id"` + SessionKey string `json:"session_key,omitempty" db:"session_key"` + TraceID string `json:"trace_id,omitempty" db:"trace_id"` + InvocationID string `json:"invocation_id,omitempty" db:"invocation_id"` + InvocationSource string `json:"invocation_source" db:"invocation_source"` + Status string `json:"status" db:"status"` + FailureReason string `json:"failure_reason,omitempty" db:"failure_reason"` + ToolCallsCount int `json:"tool_calls_count" db:"tool_calls_count"` + DurationMs int64 `json:"duration_ms" db:"duration_ms"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +type SkillFailureReason struct { + Reason string `json:"reason"` + Count int `json:"count"` + LastSeen time.Time `json:"last_seen"` +} + +type SkillUsageStats struct { + SkillID uuid.UUID `json:"skill_id"` + TotalCalls int `json:"total_calls"` + Started int `json:"started"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Abandoned int `json:"abandoned"` + SuccessRate float64 `json:"success_rate"` + FailureRate float64 `json:"failure_rate"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + TopFailureReasons []SkillFailureReason `json:"top_failure_reasons,omitempty"` +} + +type SkillImprovementSuggestion struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + SkillSlug string `json:"skill_slug" db:"skill_slug"` + SuggestionType string `json:"suggestion_type" db:"suggestion_type"` + Status string `json:"status" db:"status"` + Reason string `json:"reason" db:"reason"` + Evidence json.RawMessage `json:"evidence,omitempty" db:"evidence"` + DraftPatch json.RawMessage `json:"draft_patch,omitempty" db:"draft_patch"` + TargetFile string `json:"target_file,omitempty" db:"target_file"` + CreatedByActorType string `json:"created_by_actor_type,omitempty" db:"created_by_actor_type"` + CreatedByActorID string `json:"created_by_actor_id,omitempty" db:"created_by_actor_id"` + ReviewedByActorType string `json:"reviewed_by_actor_type,omitempty" db:"reviewed_by_actor_type"` + ReviewedByActorID string `json:"reviewed_by_actor_id,omitempty" db:"reviewed_by_actor_id"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty" db:"reviewed_at"` + AppliedVersion *int `json:"applied_version,omitempty" db:"applied_version"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type SkillVersion struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + SkillID uuid.UUID `json:"skill_id" db:"skill_id"` + Version int `json:"version" db:"version"` + ContentHash string `json:"content_hash" db:"content_hash"` + ChangedFiles json.RawMessage `json:"changed_files,omitempty" db:"changed_files"` + CreatedByActorType string `json:"created_by_actor_type,omitempty" db:"created_by_actor_type"` + CreatedByActorID string `json:"created_by_actor_id,omitempty" db:"created_by_actor_id"` + CreatedFromSuggestionID *uuid.UUID `json:"created_from_suggestion_id,omitempty" db:"created_from_suggestion_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +type SkillEvolutionStore interface { + GetSettings(ctx context.Context, skillID uuid.UUID) (*SkillEvolutionSettings, error) + UpsertSettings(ctx context.Context, settings SkillEvolutionSettings) (*SkillEvolutionSettings, error) + RecordUsage(ctx context.Context, metric SkillUsageMetric) error + AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*SkillUsageStats, error) + ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]SkillUsageMetric, error) + CreateSuggestion(ctx context.Context, suggestion SkillImprovementSuggestion) (*SkillImprovementSuggestion, error) + ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]SkillImprovementSuggestion, error) + GetSuggestion(ctx context.Context, id uuid.UUID) (*SkillImprovementSuggestion, error) + UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*SkillImprovementSuggestion, error) + MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*SkillImprovementSuggestion, error) + CreateSkillVersion(ctx context.Context, version SkillVersion) (*SkillVersion, error) + ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]SkillVersion, error) + GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*SkillVersion, error) +} diff --git a/internal/store/sqlitestore/factory.go b/internal/store/sqlitestore/factory.go index 284c4c1a..ba90f5e8 100644 --- a/internal/store/sqlitestore/factory.go +++ b/internal/store/sqlitestore/factory.go @@ -48,6 +48,7 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) { Tenants: NewSQLiteTenantStore(db), BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db), SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db), + SkillEvolution: NewSQLiteSkillEvolutionStore(db), SystemConfigs: NewSQLiteSystemConfigStore(db), Snapshots: NewSQLiteSnapshotStore(db), Cron: NewSQLiteCronStore(db), diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index e7791b79..8d165eb4 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 46 +const SchemaVersion = 47 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -831,8 +831,98 @@ CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials ( CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id); CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`, + // Version 46 → 47: skill self-evolution settings, metrics, suggestions, and immutable version records. + 46: addSkillSelfEvolutionTables, } +const addSkillSelfEvolutionTables = ` +CREATE TABLE IF NOT EXISTS skill_evolution_settings ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only', + last_analyzed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (tenant_id, skill_id), + CHECK (mode IN ('suggest_only', 'auto_analyze')) +); +CREATE INDEX IF NOT EXISTS idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id); + +CREATE TABLE IF NOT EXISTS skill_usage_metrics ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + skill_version INTEGER NOT NULL DEFAULT 1, + agent_id TEXT, + user_id VARCHAR(255), + session_key TEXT, + trace_id TEXT, + invocation_id TEXT, + invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime', + status VARCHAR(32) NOT NULL DEFAULT 'started', + failure_reason TEXT, + tool_calls_count INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned')) +); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id); + +CREATE TABLE IF NOT EXISTS skill_improvement_suggestions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + suggestion_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + draft_patch TEXT NOT NULL DEFAULT '{}', + target_file TEXT NOT NULL DEFAULT '', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at TEXT, + applied_version INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('pending', 'approved', 'rejected', 'applied')) +); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS skill_versions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + changed_files TEXT NOT NULL DEFAULT '[]', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + created_from_suggestion_id TEXT REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(skill_id, version) +); +CREATE INDEX IF NOT EXISTS idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC); + +INSERT OR IGNORE INTO skill_versions ( + id, tenant_id, skill_id, version, content_hash, changed_files, + created_by_actor_type, created_by_actor_id, created_at +) +SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || + lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))), + tenant_id, id, version, COALESCE(file_hash, ''), '[]', + 'system', 'migration', COALESCE(created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +FROM skills +WHERE status != 'deleted';` + const addChannelMemoryExtractionTables = ` CREATE TABLE IF NOT EXISTS channel_memory_extraction_runs ( id TEXT NOT NULL PRIMARY KEY, diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 28989c07..10e72532 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -2102,3 +2102,83 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain ON browser_cookies (tenant_id, user_id, agent_id, domain); CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at ON browser_cookies (expires_at); + +-- ============================================================ +-- Skill self-evolution (migration 000078 / SQLite schema 47) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS skill_evolution_settings ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only', + last_analyzed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (tenant_id, skill_id), + CHECK (mode IN ('suggest_only', 'auto_analyze')) +); +CREATE INDEX IF NOT EXISTS idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id); + +CREATE TABLE IF NOT EXISTS skill_usage_metrics ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + skill_version INTEGER NOT NULL DEFAULT 1, + agent_id TEXT, + user_id VARCHAR(255), + session_key TEXT, + trace_id TEXT, + invocation_id TEXT, + invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime', + status VARCHAR(32) NOT NULL DEFAULT 'started', + failure_reason TEXT, + tool_calls_count INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned')) +); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id); + +CREATE TABLE IF NOT EXISTS skill_improvement_suggestions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + suggestion_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + draft_patch TEXT NOT NULL DEFAULT '{}', + target_file TEXT NOT NULL DEFAULT '', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at TEXT, + applied_version INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status IN ('pending', 'approved', 'rejected', 'applied')) +); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS skill_versions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + changed_files TEXT NOT NULL DEFAULT '[]', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + created_from_suggestion_id TEXT REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(skill_id, version) +); +CREATE INDEX IF NOT EXISTS idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC); diff --git a/internal/store/sqlitestore/skill_evolution.go b/internal/store/sqlitestore/skill_evolution.go new file mode 100644 index 00000000..285b9555 --- /dev/null +++ b/internal/store/sqlitestore/skill_evolution.go @@ -0,0 +1,505 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type SQLiteSkillEvolutionStore struct { + db *sql.DB +} + +func NewSQLiteSkillEvolutionStore(db *sql.DB) *SQLiteSkillEvolutionStore { + return &SQLiteSkillEvolutionStore{db: db} +} + +func (s *SQLiteSkillEvolutionStore) resolveSkill(ctx context.Context, skillID uuid.UUID) (string, int, error) { + tenantID := tenantIDForInsert(ctx) + var slug, skillTenant string + var version int + var isSystem bool + err := s.db.QueryRowContext(ctx, + `SELECT slug, version, tenant_id, is_system FROM skills WHERE id = ? AND status != 'deleted'`, + skillID.String(), + ).Scan(&slug, &version, &skillTenant, &isSystem) + if errors.Is(err, sql.ErrNoRows) { + return "", 0, fmt.Errorf("skill not found") + } + if err != nil { + return "", 0, err + } + if !store.IsCrossTenant(ctx) && !isSystem && skillTenant != tenantID.String() { + return "", 0, fmt.Errorf("skill not found") + } + return slug, version, nil +} + +func (s *SQLiteSkillEvolutionStore) GetSettings(ctx context.Context, skillID uuid.UUID) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + var out store.SkillEvolutionSettings + var tenantStr, skillStr string + var last nullSqliteTime + var created, updated sqliteTime + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at + FROM skill_evolution_settings WHERE tenant_id = ? AND skill_id = ?`, + tenantID.String(), skillID.String(), + ).Scan(&tenantStr, &skillStr, &out.Enabled, &out.Mode, &last, &created, &updated) + if errors.Is(err, sql.ErrNoRows) { + return &store.SkillEvolutionSettings{ + TenantID: tenantID, + SkillID: skillID, + Enabled: false, + Mode: store.SkillEvolutionModeSuggestOnly, + }, nil + } + if err != nil { + return nil, err + } + out.TenantID, _ = uuid.Parse(tenantStr) + out.SkillID, _ = uuid.Parse(skillStr) + if last.Valid { + t := last.Time + out.LastAnalyzedAt = &t + } + out.CreatedAt = created.Time + out.UpdatedAt = updated.Time + return &out, nil +} + +func (s *SQLiteSkillEvolutionStore) UpsertSettings(ctx context.Context, settings store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { + if _, _, err := s.resolveSkill(ctx, settings.SkillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + mode := strings.TrimSpace(settings.Mode) + if mode == "" { + mode = store.SkillEvolutionModeSuggestOnly + } + now := time.Now().UTC().Format(time.RFC3339Nano) + var last any + if settings.LastAnalyzedAt != nil { + last = settings.LastAnalyzedAt.UTC().Format(time.RFC3339Nano) + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_evolution_settings (tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tenant_id, skill_id) DO UPDATE SET + enabled = excluded.enabled, + mode = excluded.mode, + last_analyzed_at = excluded.last_analyzed_at, + updated_at = excluded.updated_at`, + tenantID.String(), settings.SkillID.String(), settings.Enabled, mode, last, now, now, + ) + if err != nil { + return nil, err + } + return s.GetSettings(ctx, settings.SkillID) +} + +func (s *SQLiteSkillEvolutionStore) RecordUsage(ctx context.Context, metric store.SkillUsageMetric) error { + slug, version, err := s.resolveSkill(ctx, metric.SkillID) + if err != nil { + return err + } + tenantID := tenantIDForInsert(ctx) + if metric.ID == uuid.Nil { + metric.ID = uuid.New() + } + if metric.SkillSlug == "" { + metric.SkillSlug = slug + } + if metric.SkillVersion == 0 { + metric.SkillVersion = version + } + if metric.Status == "" { + metric.Status = store.SkillUsageStatusStarted + } + agentID := "" + if metric.AgentID != uuid.Nil { + agentID = metric.AgentID.String() + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO skill_usage_metrics + (id, tenant_id, skill_id, skill_slug, skill_version, agent_id, user_id, session_key, + trace_id, invocation_id, invocation_source, status, failure_reason, tool_calls_count, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + metric.ID.String(), tenantID.String(), metric.SkillID.String(), metric.SkillSlug, metric.SkillVersion, + agentID, metric.UserID, metric.SessionKey, metric.TraceID, metric.InvocationID, metric.InvocationSource, + metric.Status, metric.FailureReason, metric.ToolCallsCount, metric.DurationMs, + ) + return err +} + +func (s *SQLiteSkillEvolutionStore) AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*store.SkillUsageStats, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + where := "tenant_id = ? AND skill_id = ?" + args := []any{tenantID.String(), skillID.String()} + if since != nil { + where += " AND created_at >= ?" + args = append(args, since.UTC().Format(time.RFC3339Nano)) + } + query := fmt.Sprintf( + `SELECT COUNT(*), + SUM(CASE WHEN status = 'started' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END), + MAX(created_at) + FROM skill_usage_metrics WHERE %s`, + where, + ) + var out store.SkillUsageStats + out.SkillID = skillID + var last nullSqliteTime + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&out.TotalCalls, &out.Started, &out.Succeeded, &out.Failed, &out.Abandoned, &last); err != nil { + return nil, err + } + if last.Valid { + t := last.Time + out.LastUsedAt = &t + } + if out.TotalCalls > 0 { + out.SuccessRate = float64(out.Succeeded) / float64(out.TotalCalls) + out.FailureRate = float64(out.Failed) / float64(out.TotalCalls) + } + reasonQuery := fmt.Sprintf( + `SELECT failure_reason, COUNT(*), MAX(created_at) + FROM skill_usage_metrics + WHERE %s AND status = 'failed' AND COALESCE(failure_reason, '') != '' + GROUP BY failure_reason ORDER BY COUNT(*) DESC, MAX(created_at) DESC LIMIT 5`, + where, + ) + rows, err := s.db.QueryContext(ctx, reasonQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var r store.SkillFailureReason + var lastSeen sqliteTime + if err := rows.Scan(&r.Reason, &r.Count, &lastSeen); err != nil { + return nil, err + } + r.LastSeen = lastSeen.Time + out.TopFailureReasons = append(out.TopFailureReasons, r) + } + return &out, rows.Err() +} + +func (s *SQLiteSkillEvolutionStore) ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillUsageMetric, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, skill_version, COALESCE(agent_id, ''), + COALESCE(user_id,''), COALESCE(session_key,''), COALESCE(trace_id,''), COALESCE(invocation_id,''), + invocation_source, status, COALESCE(failure_reason,''), tool_calls_count, duration_ms, created_at + FROM skill_usage_metrics + WHERE tenant_id = ? AND skill_id = ? + ORDER BY created_at DESC LIMIT ?`, + tenantID.String(), skillID.String(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []store.SkillUsageMetric + for rows.Next() { + var m store.SkillUsageMetric + var idStr, tenantStr, skillStr, agentStr string + var created sqliteTime + if err := rows.Scan(&idStr, &tenantStr, &skillStr, &m.SkillSlug, &m.SkillVersion, &agentStr, + &m.UserID, &m.SessionKey, &m.TraceID, &m.InvocationID, &m.InvocationSource, &m.Status, + &m.FailureReason, &m.ToolCallsCount, &m.DurationMs, &created); err != nil { + return nil, err + } + m.ID, _ = uuid.Parse(idStr) + m.TenantID, _ = uuid.Parse(tenantStr) + m.SkillID, _ = uuid.Parse(skillStr) + if agentStr != "" { + m.AgentID, _ = uuid.Parse(agentStr) + } + m.CreatedAt = created.Time + out = append(out, m) + } + return out, rows.Err() +} + +func (s *SQLiteSkillEvolutionStore) CreateSuggestion(ctx context.Context, sg store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { + slug, _, err := s.resolveSkill(ctx, sg.SkillID) + if err != nil { + return nil, err + } + if sg.ID == uuid.Nil { + sg.ID = uuid.New() + } + if sg.SkillSlug == "" { + sg.SkillSlug = slug + } + if sg.Status == "" { + sg.Status = store.SkillSuggestionStatusPending + } + tenantID := tenantIDForInsert(ctx) + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err = s.db.ExecContext(ctx, + `INSERT INTO skill_improvement_suggestions + (id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, + draft_patch, target_file, created_by_actor_type, created_by_actor_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sg.ID.String(), tenantID.String(), sg.SkillID.String(), sg.SkillSlug, sg.SuggestionType, + sg.Status, sg.Reason, string(jsonOrDefault(sg.Evidence, "{}")), string(jsonOrDefault(sg.DraftPatch, "{}")), + sg.TargetFile, sg.CreatedByActorType, sg.CreatedByActorID, now, now, + ) + if err != nil { + return nil, err + } + return s.GetSuggestion(ctx, sg.ID) +} + +func (s *SQLiteSkillEvolutionStore) ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]store.SkillImprovementSuggestion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + q := `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = ? AND skill_id = ?` + args := []any{tenantID.String(), skillID.String()} + if status != "" { + q += " AND status = ?" + args = append(args, status) + } + q += " ORDER BY created_at DESC LIMIT ?" + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSQLiteSuggestions(rows) +} + +func (s *SQLiteSkillEvolutionStore) GetSuggestion(ctx context.Context, id uuid.UUID) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch, + COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version, + created_at, updated_at + FROM skill_improvement_suggestions WHERE tenant_id = ? AND id = ?`, + tenantID.String(), id.String()) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanSQLiteSuggestions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + if _, _, err := s.resolveSkill(ctx, items[0].SkillID); err != nil { + return nil, err + } + return &items[0], nil +} + +func (s *SQLiteSkillEvolutionStore) UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = ?, reviewed_by_actor_type = ?, reviewed_by_actor_id = ?, reviewed_at = ?, updated_at = ? + WHERE tenant_id = ? AND id = ?`, + status, actorType, actorID, now, now, tenantID.String(), id.String()) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *SQLiteSkillEvolutionStore) MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + tenantID := tenantIDForInsert(ctx) + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(ctx, + `UPDATE skill_improvement_suggestions + SET status = 'applied', applied_version = ?, reviewed_by_actor_type = ?, + reviewed_by_actor_id = ?, reviewed_at = COALESCE(reviewed_at, ?), updated_at = ? + WHERE tenant_id = ? AND id = ?`, + version, actorType, actorID, now, now, tenantID.String(), id.String()) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("suggestion not found") + } + return s.GetSuggestion(ctx, id) +} + +func (s *SQLiteSkillEvolutionStore) CreateSkillVersion(ctx context.Context, v store.SkillVersion) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, v.SkillID); err != nil { + return nil, err + } + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + tenantID := tenantIDForInsert(ctx) + suggestionID := "" + if v.CreatedFromSuggestionID != nil { + suggestionID = v.CreatedFromSuggestionID.String() + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_versions + (id, tenant_id, skill_id, version, content_hash, changed_files, created_by_actor_type, + created_by_actor_id, created_from_suggestion_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?)`, + v.ID.String(), tenantID.String(), v.SkillID.String(), v.Version, v.ContentHash, + string(jsonOrDefault(v.ChangedFiles, "[]")), v.CreatedByActorType, v.CreatedByActorID, suggestionID, now, + ) + if err != nil { + return nil, err + } + return s.GetSkillVersion(ctx, v.SkillID, v.Version) +} + +func (s *SQLiteSkillEvolutionStore) ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = ? AND skill_id = ? + ORDER BY version DESC LIMIT ?`, + tenantID.String(), skillID.String(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSQLiteSkillVersions(rows) +} + +func (s *SQLiteSkillEvolutionStore) GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*store.SkillVersion, error) { + if _, _, err := s.resolveSkill(ctx, skillID); err != nil { + return nil, err + } + tenantID := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, skill_id, version, content_hash, changed_files, + COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''), + created_from_suggestion_id, created_at + FROM skill_versions WHERE tenant_id = ? AND skill_id = ? AND version = ?`, + tenantID.String(), skillID.String(), version) + if err != nil { + return nil, err + } + defer rows.Close() + items, err := scanSQLiteSkillVersions(rows) + if err != nil || len(items) == 0 { + return nil, err + } + return &items[0], nil +} + +func scanSQLiteSuggestions(rows *sql.Rows) ([]store.SkillImprovementSuggestion, error) { + var out []store.SkillImprovementSuggestion + for rows.Next() { + var sg store.SkillImprovementSuggestion + var idStr, tenantStr, skillStr string + var evidence, draft []byte + var reviewedAt nullSqliteTime + var applied sql.NullInt64 + var created, updated sqliteTime + if err := rows.Scan(&idStr, &tenantStr, &skillStr, &sg.SkillSlug, &sg.SuggestionType, &sg.Status, + &sg.Reason, &evidence, &draft, &sg.TargetFile, &sg.CreatedByActorType, &sg.CreatedByActorID, + &sg.ReviewedByActorType, &sg.ReviewedByActorID, &reviewedAt, &applied, + &created, &updated); err != nil { + return nil, err + } + sg.ID, _ = uuid.Parse(idStr) + sg.TenantID, _ = uuid.Parse(tenantStr) + sg.SkillID, _ = uuid.Parse(skillStr) + sg.Evidence = evidence + sg.DraftPatch = draft + if reviewedAt.Valid { + t := reviewedAt.Time + sg.ReviewedAt = &t + } + if applied.Valid { + v := int(applied.Int64) + sg.AppliedVersion = &v + } + sg.CreatedAt = created.Time + sg.UpdatedAt = updated.Time + out = append(out, sg) + } + return out, rows.Err() +} + +func scanSQLiteSkillVersions(rows *sql.Rows) ([]store.SkillVersion, error) { + var out []store.SkillVersion + for rows.Next() { + var v store.SkillVersion + var idStr, tenantStr, skillStr string + var changed []byte + var suggestion sql.NullString + var created sqliteTime + if err := rows.Scan(&idStr, &tenantStr, &skillStr, &v.Version, &v.ContentHash, &changed, + &v.CreatedByActorType, &v.CreatedByActorID, &suggestion, &created); err != nil { + return nil, err + } + v.ID, _ = uuid.Parse(idStr) + v.TenantID, _ = uuid.Parse(tenantStr) + v.SkillID, _ = uuid.Parse(skillStr) + v.ChangedFiles = changed + if suggestion.Valid && suggestion.String != "" { + parsed, _ := uuid.Parse(suggestion.String) + v.CreatedFromSuggestionID = &parsed + } + v.CreatedAt = created.Time + out = append(out, v) + } + return out, rows.Err() +} + +func jsonOrDefault(raw json.RawMessage, fallback string) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(fallback) + } + return raw +} + +var _ store.SkillEvolutionStore = (*SQLiteSkillEvolutionStore)(nil) diff --git a/internal/store/stores.go b/internal/store/stores.go index 2b3b07b6..d908a725 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -35,6 +35,7 @@ type Stores struct { Tenants TenantStore BuiltinToolTenantCfgs BuiltinToolTenantConfigStore SkillTenantCfgs SkillTenantConfigStore + SkillEvolution SkillEvolutionStore SystemConfigs SystemConfigStore SubagentTasks SubagentTaskStore Vault VaultStore diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go index 405669c9..2cc5cdfe 100644 --- a/internal/tools/skill_manage.go +++ b/internal/tools/skill_manage.go @@ -558,41 +558,7 @@ func parseManagedSkillFiles(raw any) ([]managedSkillFile, error) { } func validateManagedSkillFilePath(rawPath string) (string, error) { - if rawPath == "" { - return "", fmt.Errorf("invalid file path %q: empty path", rawPath) - } - if strings.ContainsRune(rawPath, 0x00) { - return "", fmt.Errorf("invalid file path %q: null byte", rawPath) - } - if len(rawPath) >= 2 && rawPath[1] == ':' { - return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath) - } - normalized := strings.ReplaceAll(rawPath, "\\", "/") - if strings.HasPrefix(normalized, "/") { - return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath) - } - for part := range strings.SplitSeq(normalized, "/") { - switch part { - case "..": - return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath) - case ".git": - return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) - } - if strings.HasPrefix(part, ".") { - return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath) - } - } - cleanPath := path.Clean(normalized) - if cleanPath == "." || cleanPath == "SKILL.md" || strings.EqualFold(cleanPath, "SKILL.md") { - return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath) - } - if strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") { - return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath) - } - if skills.IsSystemArtifact(cleanPath) { - return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath) - } - return cleanPath, nil + return skills.ValidateSkillTargetPath(rawPath, false) } func collectExistingManagedSkillCompanionFiles(srcDir string) ([]managedSkillFile, error) { diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 36f249f9..67b4ce6e 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 77 +const RequiredSchemaVersion uint = 78 diff --git a/migrations/000078_skill_self_evolution.down.sql b/migrations/000078_skill_self_evolution.down.sql new file mode 100644 index 00000000..e87b3103 --- /dev/null +++ b/migrations/000078_skill_self_evolution.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS skill_versions; +DROP TABLE IF EXISTS skill_improvement_suggestions; +DROP TABLE IF EXISTS skill_usage_metrics; +DROP TABLE IF EXISTS skill_evolution_settings; diff --git a/migrations/000078_skill_self_evolution.up.sql b/migrations/000078_skill_self_evolution.up.sql new file mode 100644 index 00000000..8ea90862 --- /dev/null +++ b/migrations/000078_skill_self_evolution.up.sql @@ -0,0 +1,89 @@ +CREATE TABLE skill_evolution_settings ( + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only', + last_analyzed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, skill_id), + CONSTRAINT chk_skill_evolution_mode CHECK (mode IN ('suggest_only', 'auto_analyze')) +); + +CREATE INDEX idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id); + +CREATE TABLE skill_usage_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + skill_version INT NOT NULL DEFAULT 1, + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + user_id VARCHAR(255), + session_key TEXT, + trace_id TEXT, + invocation_id TEXT, + invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime', + status VARCHAR(32) NOT NULL DEFAULT 'started', + failure_reason TEXT, + tool_calls_count INT NOT NULL DEFAULT 0, + duration_ms BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_skill_usage_status CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned')) +); + +CREATE INDEX idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC); +CREATE INDEX idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC); +CREATE INDEX idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC); +CREATE INDEX idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id) WHERE invocation_id IS NOT NULL; + +CREATE TABLE skill_improvement_suggestions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + skill_slug VARCHAR(255) NOT NULL, + suggestion_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL DEFAULT '', + evidence JSONB NOT NULL DEFAULT '{}', + draft_patch JSONB NOT NULL DEFAULT '{}', + target_file TEXT NOT NULL DEFAULT '', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at TIMESTAMPTZ, + applied_version INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_skill_suggestion_status CHECK (status IN ('pending', 'approved', 'rejected', 'applied')) +); + +CREATE INDEX idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC); +CREATE INDEX idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC); + +CREATE TABLE skill_versions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + version INT NOT NULL, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + changed_files JSONB NOT NULL DEFAULT '[]', + created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '', + created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '', + created_from_suggestion_id UUID REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(skill_id, version) +); + +CREATE INDEX idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC); + +INSERT INTO skill_versions ( + tenant_id, skill_id, version, content_hash, changed_files, + created_by_actor_type, created_by_actor_id, created_at +) +SELECT tenant_id, id, version, COALESCE(file_hash, ''), '[]'::jsonb, + 'system', 'migration', COALESCE(created_at, NOW()) +FROM skills +WHERE status != 'deleted' +ON CONFLICT (skill_id, version) DO NOTHING; diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index 90daadab..0a7c5fb0 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -227,6 +227,31 @@ "copySuccess": "Deeplink copied", "copyFailed": "Failed to copy deeplink" }, + "evolution": { + "tab": "Evolution", + "title": "Self-evolution", + "enabled": "Enabled", + "disabled": "Disabled", + "status": "Status: {{status}}", + "unavailable": "Evolution data is unavailable for this skill.", + "modes": { + "suggest_only": "Suggest only", + "auto_analyze": "Auto analyze" + }, + "metrics": "Metrics", + "total": "Total", + "succeeded": "Succeeded", + "failed": "Failed", + "successRate": "Success rate", + "suggestions": "Suggestions", + "noSuggestions": "No suggestions.", + "noReason": "No reason recorded.", + "approve": "Approve", + "reject": "Reject", + "apply": "Apply", + "activity": "Activity", + "noActivity": "No recent activity." + }, "toast": { "updated": "Skill updated", "updateFailed": "Failed to update skill", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 918eb60f..cc871d6e 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -177,6 +177,31 @@ "copySuccess": "Đã copy deeplink", "copyFailed": "Không thể copy deeplink" }, + "evolution": { + "tab": "Tự tiến hóa", + "title": "Tự tiến hóa", + "enabled": "Bật", + "disabled": "Tắt", + "status": "Trạng thái: {{status}}", + "unavailable": "Không có dữ liệu tiến hóa cho skill này.", + "modes": { + "suggest_only": "Chỉ đề xuất", + "auto_analyze": "Tự phân tích" + }, + "metrics": "Chỉ số", + "total": "Tổng", + "succeeded": "Thành công", + "failed": "Thất bại", + "successRate": "Tỉ lệ thành công", + "suggestions": "Đề xuất", + "noSuggestions": "Chưa có đề xuất.", + "noReason": "Chưa ghi lý do.", + "approve": "Duyệt", + "reject": "Từ chối", + "apply": "Áp dụng", + "activity": "Hoạt động", + "noActivity": "Chưa có hoạt động gần đây." + }, "toast": { "updated": "Đã cập nhật skill", "updateFailed": "Không thể cập nhật skill", diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index a44236c9..5effbd72 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -177,6 +177,31 @@ "copySuccess": "已复制 Deeplink", "copyFailed": "复制 Deeplink 失败" }, + "evolution": { + "tab": "进化", + "title": "自进化", + "enabled": "启用", + "disabled": "禁用", + "status": "状态:{{status}}", + "unavailable": "此 Skill 没有可用的进化数据。", + "modes": { + "suggest_only": "仅建议", + "auto_analyze": "自动分析" + }, + "metrics": "指标", + "total": "总计", + "succeeded": "成功", + "failed": "失败", + "successRate": "成功率", + "suggestions": "建议", + "noSuggestions": "暂无建议。", + "noReason": "未记录原因。", + "approve": "批准", + "reject": "拒绝", + "apply": "应用", + "activity": "活动", + "noActivity": "暂无最近活动。" + }, "toast": { "updated": "技能已更新", "updateFailed": "更新技能失败", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 3109fbee..46470119 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -57,6 +57,10 @@ export const queryKeys = { skills: { all: ["skills"] as const, agentGrants: (agentId: string) => ["skills", "agent", agentId] as const, + evolution: (skillId: string) => ["skills", skillId, "evolution"] as const, + metrics: (skillId: string) => ["skills", skillId, "metrics"] as const, + suggestions: (skillId: string) => ["skills", skillId, "suggestions"] as const, + activity: (skillId: string) => ["skills", skillId, "activity"] as const, runtimes: ["skills", "runtimes"] as const, }, cron: { diff --git a/ui/web/src/pages/skills/hooks/use-skill-evolution.ts b/ui/web/src/pages/skills/hooks/use-skill-evolution.ts new file mode 100644 index 00000000..167870fc --- /dev/null +++ b/ui/web/src/pages/skills/hooks/use-skill-evolution.ts @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { + SkillActivityLog, + SkillEvolutionSettings, + SkillImprovementSuggestion, + SkillUsageStats, +} from "@/types/skill"; + +export function useSkillEvolution(skillId: string | undefined, enabled: boolean) { + const http = useHttp(); + const queryClient = useQueryClient(); + + const settings = useQuery({ + queryKey: skillId ? queryKeys.skills.evolution(skillId) : ["skills", "missing", "evolution"], + queryFn: () => http.get(`/v1/skills/${skillId}/evolution`), + enabled: enabled && !!skillId, + }); + + const metrics = useQuery({ + queryKey: skillId ? queryKeys.skills.metrics(skillId) : ["skills", "missing", "metrics"], + queryFn: () => http.get(`/v1/skills/${skillId}/metrics`), + enabled: enabled && !!skillId, + }); + + const suggestions = useQuery({ + queryKey: skillId ? queryKeys.skills.suggestions(skillId) : ["skills", "missing", "suggestions"], + queryFn: async () => { + const res = await http.get<{ suggestions: SkillImprovementSuggestion[] }>(`/v1/skills/${skillId}/evolution/suggestions`); + return res.suggestions ?? []; + }, + enabled: enabled && !!skillId, + }); + + const activity = useQuery({ + queryKey: skillId ? queryKeys.skills.activity(skillId) : ["skills", "missing", "activity"], + queryFn: async () => { + const res = await http.get<{ activity: SkillActivityLog[] }>(`/v1/skills/${skillId}/activity`); + return res.activity ?? []; + }, + enabled: enabled && !!skillId, + retry: false, + }); + + const invalidate = useCallback(async () => { + if (!skillId) return; + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.skills.evolution(skillId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.skills.metrics(skillId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.skills.suggestions(skillId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.skills.activity(skillId) }), + ]); + }, [queryClient, skillId]); + + const updateSettings = useCallback(async (updates: Partial>) => { + if (!skillId) return; + await http.patch(`/v1/skills/${skillId}/evolution`, updates); + await invalidate(); + }, [http, invalidate, skillId]); + + const approveSuggestion = useCallback(async (id: string) => { + if (!skillId) return; + await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/approve`, {}); + await invalidate(); + }, [http, invalidate, skillId]); + + const rejectSuggestion = useCallback(async (id: string) => { + if (!skillId) return; + await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/reject`, {}); + await invalidate(); + }, [http, invalidate, skillId]); + + const applySuggestion = useCallback(async (id: string) => { + if (!skillId) return; + await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/apply`, {}); + await invalidate(); + }, [http, invalidate, skillId]); + + return { + settings, + metrics, + suggestions, + activity, + updateSettings, + approveSuggestion, + rejectSuggestion, + applySuggestion, + }; +} diff --git a/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts b/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts index 27759b5a..d493c168 100644 --- a/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts +++ b/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts @@ -1,29 +1,20 @@ import { describe, expect, it } from "vitest"; -import { - parseSkillDetailVersionParam, - shouldLoadSkillDetailFile, -} from "./skill-detail-deeplink"; +import { normalizeSkillDetailTab, parseSkillDetailVersionParam } from "./skill-detail-deeplink"; -describe("skill detail deeplink helpers", () => { - it("parses valid version params", () => { - expect(parseSkillDetailVersionParam("1")).toBe(1); - expect(parseSkillDetailVersionParam("42")).toBe(42); +describe("skill detail deeplink", () => { + it("keeps evolution tab when the skill has an id", () => { + expect(normalizeSkillDetailTab("evolution", true, true)).toBe("evolution"); }); - it("rejects malformed version params", () => { - expect(parseSkillDetailVersionParam(null)).toBeNull(); - expect(parseSkillDetailVersionParam("")).toBeNull(); - expect(parseSkillDetailVersionParam("abc")).toBeNull(); - expect(parseSkillDetailVersionParam("1.5")).toBeNull(); + it("falls back from unavailable tabs to content", () => { + expect(normalizeSkillDetailTab("files", false, true)).toBe("content"); + expect(normalizeSkillDetailTab("evolution", true, false)).toBe("content"); + expect(normalizeSkillDetailTab("unknown", true, true)).toBe("content"); + }); + + it("parses positive integer versions only", () => { + expect(parseSkillDetailVersionParam("4")).toBe(4); expect(parseSkillDetailVersionParam("0")).toBeNull(); - expect(parseSkillDetailVersionParam("-1")).toBeNull(); - }); - - it("loads a deeplinked file only from the files tab when a file list exists", () => { - expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, null)).toBe(true); - expect(shouldLoadSkillDetailFile("content", "scripts/run.py", 3, null)).toBe(false); - expect(shouldLoadSkillDetailFile("files", null, 3, null)).toBe(false); - expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 0, null)).toBe(false); - expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, "scripts/run.py")).toBe(false); + expect(parseSkillDetailVersionParam("bad")).toBeNull(); }); }); diff --git a/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts b/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts index 396b2da5..7e5c010f 100644 --- a/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts +++ b/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts @@ -5,6 +5,12 @@ export function parseSkillDetailVersionParam(value: string | null): number | nul return parsed; } +export function normalizeSkillDetailTab(value: string, hasFiles: boolean, hasEvolution: boolean): "content" | "files" | "evolution" { + if (value === "files" && hasFiles) return "files"; + if (value === "evolution" && hasEvolution) return "evolution"; + return "content"; +} + export function shouldLoadSkillDetailFile( detailTab: string, selectedFilePath: string | null, diff --git a/ui/web/src/pages/skills/lib/skills-page-state.test.ts b/ui/web/src/pages/skills/lib/skills-page-state.test.ts index 01d96697..b66beb18 100644 --- a/ui/web/src/pages/skills/lib/skills-page-state.test.ts +++ b/ui/web/src/pages/skills/lib/skills-page-state.test.ts @@ -18,14 +18,14 @@ describe("skills page state", () => { }); it("serializes non-default filters while preserving modal params", () => { - const params = new URLSearchParams("skill=abc&detailTab=files&file=SKILL.md"); + const params = new URLSearchParams("skill=abc&detailTab=evolution&file=SKILL.md"); const next = serializeSkillsPageState(params, { tab: "custom", q: "pdf", filter: "missing-deps", sort: "deps" }); expect(next.get("tab")).toBe("custom"); expect(next.get("q")).toBe("pdf"); expect(next.get("filter")).toBe("missing-deps"); expect(next.get("sort")).toBe("deps"); expect(next.get("skill")).toBe("abc"); - expect(next.get("detailTab")).toBe("files"); + expect(next.get("detailTab")).toBe("evolution"); expect(next.get("file")).toBe("SKILL.md"); }); diff --git a/ui/web/src/pages/skills/skill-detail-dialog.tsx b/ui/web/src/pages/skills/skill-detail-dialog.tsx index 5f35623f..eb4be92e 100644 --- a/ui/web/src/pages/skills/skill-detail-dialog.tsx +++ b/ui/web/src/pages/skills/skill-detail-dialog.tsx @@ -23,9 +23,10 @@ import { toast } from "@/stores/use-toast-store"; import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill"; import { buildTree } from "./skill-file-helpers"; import { FileBrowser } from "./skill-file-browser"; -import { parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink"; +import { normalizeSkillDetailTab, parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink"; import { getSkillAccessModeKey } from "./lib/skill-access-mode"; import type { SkillExportFormat } from "./lib/skill-export-download"; +import { SkillEvolutionPanel } from "./skill-evolution-panel"; interface SkillDetailDialogProps { skill: SkillInfo & { content: string }; @@ -60,6 +61,8 @@ export function SkillDetailDialog({ }: SkillDetailDialogProps) { const { t } = useTranslation("skills"); const hasFiles = !!skill.id; + const hasEvolution = !!skill.id; + const activeDetailTab = normalizeSkillDetailTab(detailTab, hasFiles, hasEvolution); const accessModeKey = getSkillAccessModeKey(skill.visibility); const accessModeLabel = accessModeKey === "unknown" ? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") }) @@ -137,7 +140,7 @@ export function SkillDetailDialog({ }, [selectedVersion, loadFiles]); useEffect(() => { - if (detailTab !== "files" || !hasFiles) return; + if (activeDetailTab !== "files" || !hasFiles) return; loadVersions(); const versionParam = parseSkillDetailVersionParam(selectedVersionParam); if (versionParam !== null && versionParam !== selectedVersion) { @@ -147,15 +150,15 @@ export function SkillDetailDialog({ if (selectedVersion == null && skill.version) { setSelectedVersion(skill.version); } - }, [detailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]); + }, [activeDetailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]); useEffect(() => { - if (!shouldLoadSkillDetailFile(detailTab, selectedFilePath, files.length, activePath)) return; + if (!shouldLoadSkillDetailFile(activeDetailTab, selectedFilePath, files.length, activePath)) return; loadFileContent(selectedFilePath); - }, [activePath, detailTab, files.length, loadFileContent, selectedFilePath]); + }, [activePath, activeDetailTab, files.length, loadFileContent, selectedFilePath]); const handleTabChange = (tab: string) => { - onStateChange({ detailTab: tab }); + onStateChange({ detailTab: tab, version: tab === "files" ? selectedVersionParam : null, file: tab === "files" ? activePath : null }); if (tab === "files" && hasFiles) { loadVersions(); if (files.length === 0 && !filesLoading) { @@ -184,10 +187,10 @@ export function SkillDetailDialog({ url.pathname = "/skills"; const next = new URLSearchParams(url.search); next.set("skill", skill.id || skill.slug || skill.name); - next.set("detailTab", detailTab === "files" ? "files" : "content"); - if (detailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion)); + next.set("detailTab", activeDetailTab); + if (activeDetailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion)); else next.delete("version"); - if (detailTab === "files" && activePath) next.set("file", activePath); + if (activeDetailTab === "files" && activePath) next.set("file", activePath); else next.delete("file"); url.search = next.toString(); @@ -290,10 +293,11 @@ export function SkillDetailDialog({ )} - + {t("detail.content")} {hasFiles && {t("detail.files")}} + {hasEvolution && {t("evolution.tab")}} @@ -331,6 +335,12 @@ export function SkillDetailDialog({ /> )} + + {hasEvolution && ( + + + + )} diff --git a/ui/web/src/pages/skills/skill-evolution-panel.tsx b/ui/web/src/pages/skills/skill-evolution-panel.tsx new file mode 100644 index 00000000..763656d2 --- /dev/null +++ b/ui/web/src/pages/skills/skill-evolution-panel.tsx @@ -0,0 +1,170 @@ +import { useTranslation } from "react-i18next"; +import { Check, X, Play, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { useSkillEvolution } from "./hooks/use-skill-evolution"; +import type { SkillInfo } from "@/types/skill"; + +interface SkillEvolutionPanelProps { + skill: SkillInfo; + active: boolean; +} + +export function SkillEvolutionPanel({ skill, active }: SkillEvolutionPanelProps) { + const { t } = useTranslation("skills"); + const { + settings, + metrics, + suggestions, + activity, + updateSettings, + approveSuggestion, + rejectSuggestion, + applySuggestion, + } = useSkillEvolution(skill.id, active); + + if (!skill.id) { + return ( +
+ {t("evolution.unavailable")} +
+ ); + } + + const data = settings.data; + const stats = metrics.data; + const pending = suggestions.data?.filter((item) => item.status === "pending") ?? []; + const isBusy = settings.isFetching || metrics.isFetching || suggestions.isFetching; + + return ( +
+
+
+
+
+

{t("evolution.title")}

+ {isBusy && } +
+

{t("evolution.status", { status: data?.enabled ? t("evolution.enabled") : t("evolution.disabled") })}

+
+
+
+ + updateSettings({ enabled })} + /> +
+ +
+
+
+ +
+
+

{t("evolution.metrics")}

+ {stats?.last_used_at && {new Date(stats.last_used_at).toLocaleString()}} +
+
+ + + + +
+
+ +
+
+

{t("evolution.suggestions")}

+ 0 ? "default" : "outline"}>{pending.length} +
+
+ {(suggestions.data ?? []).length === 0 ? ( +

{t("evolution.noSuggestions")}

+ ) : ( + suggestions.data?.map((item) => ( +
+
+
+
+ {item.status} + {item.suggestion_type} + {item.target_file && {item.target_file}} +
+

{item.reason || t("evolution.noReason")}

+
+
+ {item.status === "pending" && ( + <> + + + + )} + {item.status === "approved" && !skill.is_system && ( + + )} +
+
+
+ )) + )} +
+
+ + {!activity.isError && ( +
+

{t("evolution.activity")}

+
+ {(activity.data ?? []).length === 0 ? ( +

{t("evolution.noActivity")}

+ ) : ( + activity.data?.slice(0, 8).map((item) => ( +
+ {item.action} + {new Date(item.created_at).toLocaleString()} +
+ )) + )} +
+
+ )} +
+ ); +} + +function Metric({ label, value }: { label: string; value: number | string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/ui/web/src/types/skill.ts b/ui/web/src/types/skill.ts index 3eb93153..34ec1073 100644 --- a/ui/web/src/types/skill.ts +++ b/ui/web/src/types/skill.ts @@ -56,3 +56,54 @@ export interface SkillAgentGrant { granted_by: string; can_manage: boolean; } + +export interface SkillEvolutionSettings { + tenant_id: string; + skill_id: string; + enabled: boolean; + mode: "suggest_only" | "auto_analyze"; + last_analyzed_at?: string; +} + +export interface SkillFailureReason { + reason: string; + count: number; + last_seen: string; +} + +export interface SkillUsageStats { + skill_id: string; + total_calls: number; + started: number; + succeeded: number; + failed: number; + abandoned: number; + success_rate: number; + failure_rate: number; + last_used_at?: string; + top_failure_reasons?: SkillFailureReason[]; +} + +export interface SkillImprovementSuggestion { + id: string; + skill_id: string; + skill_slug: string; + suggestion_type: string; + status: "pending" | "approved" | "rejected" | "applied"; + reason: string; + target_file?: string; + applied_version?: number; + created_at: string; + updated_at: string; +} + +export interface SkillActivityLog { + id: string; + actor_type: string; + actor_id: string; + action: string; + entity_type?: string; + entity_id?: string; + details?: unknown; + created_at: string; +} From 016a6d2de8760db0e5caa760cf28af7d5ffe9ada Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 14:50:33 +0700 Subject: [PATCH 3/7] fix(cli): avoid skill evolution JSON helper collision --- cmd/skills_evolution_cmd.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/skills_evolution_cmd.go b/cmd/skills_evolution_cmd.go index 5e74b55d..7145fefe 100644 --- a/cmd/skills_evolution_cmd.go +++ b/cmd/skills_evolution_cmd.go @@ -33,7 +33,7 @@ func skillsEvolveStatusCmd() *cobra.Command { skillID := resolveGatewaySkillID(args[0]) resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution") exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } } @@ -48,7 +48,7 @@ func skillsEvolveSetCmd(name string, enabled bool) *cobra.Command { skillID := resolveGatewaySkillID(args[0]) resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"enabled": enabled}) exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } } @@ -63,7 +63,7 @@ func skillsEvolveModeCmd() *cobra.Command { skillID := resolveGatewaySkillID(args[0]) resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"mode": args[1]}) exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } } @@ -80,7 +80,7 @@ func skillsMetricsCmd() *cobra.Command { resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/metrics") exitOnErr(err) if jsonOutput { - printJSON(resp) + printSkillEvolutionJSON(resp) return } fmt.Printf("Total: %.0f\nStarted: %.0f\nSucceeded: %.0f\nFailed: %.0f\nAbandoned: %.0f\nSuccess rate: %.2f\n", @@ -101,7 +101,7 @@ func skillsActivityCmd() *cobra.Command { skillID := resolveGatewaySkillID(args[0]) resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/activity") exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } } @@ -128,7 +128,7 @@ func skillsSuggestionsListCmd() *cobra.Command { skillID := resolveGatewaySkillID(args[0]) resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution/suggestions") exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } } @@ -144,7 +144,7 @@ func skillsSuggestionStatusCmd(action string) *cobra.Command { path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/%s", url.PathEscape(skillID), url.PathEscape(args[1]), action) resp, err := gatewayHTTPPost(path, nil) exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } } @@ -161,7 +161,7 @@ func skillsSuggestionApplyCmd() *cobra.Command { path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/apply", url.PathEscape(skillID), url.PathEscape(args[1])) resp, err := gatewayHTTPPost(path, map[string]any{"approve": approve}) exitOnErr(err) - printJSON(resp) + printSkillEvolutionJSON(resp) }, } cmd.Flags().BoolVar(&approve, "approve", false, "approve pending suggestion before applying") @@ -186,7 +186,7 @@ func resolveGatewaySkillID(input string) string { return input } -func printJSON(v any) { +func printSkillEvolutionJSON(v any) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetIndent("", " ") From 1e0ea7615dc857f32f96ac0dbfeed173325a9369 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 15:04:29 +0700 Subject: [PATCH 4/7] fix(skills): guard suggestion status updates by skill --- internal/http/skills_evolution.go | 9 +++- internal/http/skills_evolution_test.go | 69 ++++++++++++++++++++++++-- internal/store/sqlitestore/schema.sql | 2 +- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/internal/http/skills_evolution.go b/internal/http/skills_evolution.go index c650e44d..3025fe51 100644 --- a/internal/http/skills_evolution.go +++ b/internal/http/skills_evolution.go @@ -267,15 +267,20 @@ func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Re writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) return } - updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context())) + sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - if updated.SkillID != skillID { + if sg == nil || sg.SkillID != skillID { writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) return } + updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context())) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } h.logSkillActivity(r, action, skillID, map[string]any{"suggestion_id": suggestionID.String()}) writeJSON(w, http.StatusOK, updated) } diff --git a/internal/http/skills_evolution_test.go b/internal/http/skills_evolution_test.go index c2d59573..032bedcb 100644 --- a/internal/http/skills_evolution_test.go +++ b/internal/http/skills_evolution_test.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -67,8 +68,45 @@ func TestApplySkillSuggestionPatchCreatesNewReferenceFile(t *testing.T) { } } +func TestHandleSuggestionStatusDoesNotUpdateDifferentSkillSuggestion(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + requestSkillID := skillStore.seedCustomSkill("request-skill", filepath.Join(root, "skills-store", "request-skill", "1"), "active", nil) + ownerSkillID := skillStore.seedCustomSkill("owner-skill", filepath.Join(root, "skills-store", "owner-skill", "1"), "active", nil) + suggestionID := uuid.New() + evolution := &skillEvolutionStoreStub{ + suggestions: map[uuid.UUID]*store.SkillImprovementSuggestion{ + suggestionID: { + ID: suggestionID, + SkillID: ownerSkillID, + Status: store.SkillSuggestionStatusPending, + SuggestionType: "skill_reference_add", + }, + }, + } + handler.SetEvolutionStore(evolution, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+requestSkillID.String()+"/evolution/suggestions/"+suggestionID.String()+"/approve", nil).WithContext(ctx) + req.SetPathValue("id", requestSkillID.String()) + req.SetPathValue("suggestionID", suggestionID.String()) + w := httptest.NewRecorder() + + handler.handleApproveSkillSuggestion(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s, want 404", w.Code, w.Body.String()) + } + if evolution.statusUpdates != 0 { + t.Fatalf("status updates = %d, want 0", evolution.statusUpdates) + } + if got := evolution.suggestions[suggestionID].Status; got != store.SkillSuggestionStatusPending { + t.Fatalf("suggestion status = %q, want pending", got) + } +} + type skillEvolutionStoreStub struct { - versions []store.SkillVersion + versions []store.SkillVersion + suggestions map[uuid.UUID]*store.SkillImprovementSuggestion + statusUpdates int } func (s *skillEvolutionStoreStub) GetSettings(context.Context, uuid.UUID) (*store.SkillEvolutionSettings, error) { @@ -99,12 +137,33 @@ func (s *skillEvolutionStoreStub) ListSuggestions(context.Context, uuid.UUID, st return nil, nil } -func (s *skillEvolutionStoreStub) GetSuggestion(context.Context, uuid.UUID) (*store.SkillImprovementSuggestion, error) { - return nil, nil +func (s *skillEvolutionStoreStub) GetSuggestion(_ context.Context, id uuid.UUID) (*store.SkillImprovementSuggestion, error) { + if s.suggestions == nil { + return nil, nil + } + sg, ok := s.suggestions[id] + if !ok { + return nil, nil + } + copied := *sg + return &copied, nil } -func (s *skillEvolutionStoreStub) UpdateSuggestionStatus(context.Context, uuid.UUID, string, string, string) (*store.SkillImprovementSuggestion, error) { - return nil, nil +func (s *skillEvolutionStoreStub) UpdateSuggestionStatus(_ context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + s.statusUpdates++ + if s.suggestions == nil { + return nil, fmt.Errorf("suggestion not found") + } + sg, ok := s.suggestions[id] + if !ok { + return nil, fmt.Errorf("suggestion not found") + } + sg.Status = status + sg.ReviewedByActorType = actorType + sg.ReviewedByActorID = actorID + now := time.Now() + sg.ReviewedAt = &now + return sg, nil } func (s *skillEvolutionStoreStub) MarkSuggestionApplied(_ context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index b2bd44c7..c7df01dd 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -2104,7 +2104,7 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at ON browser_cookies (expires_at); -- ============================================================ --- Skill self-evolution (migration 000078 / SQLite schema 47) +-- Skill self-evolution (migration 000079 / SQLite schema 48) -- ============================================================ CREATE TABLE IF NOT EXISTS skill_evolution_settings ( From a76f443411b2e2bb2c34e93d12e375c157aaf1eb Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 15:17:57 +0700 Subject: [PATCH 5/7] feat: add persona context to delivery messages --- cmd/gateway_consumer_delivery_test.go | 47 +++++- cmd/gateway_consumer_normal.go | 29 +++- internal/agent/delivery_persona.go | 51 +++++++ internal/agent/systemprompt_provider_test.go | 30 ++++ .../channels/chat_behavior_events_test.go | 140 ++++++++++++++++++ .../channels/delivery_message_generator.go | 94 ++++++++++-- .../delivery_message_generator_test.go | 62 ++++++++ internal/channels/events.go | 21 +-- 8 files changed, 440 insertions(+), 34 deletions(-) create mode 100644 internal/agent/delivery_persona.go create mode 100644 internal/channels/delivery_message_generator_test.go diff --git a/cmd/gateway_consumer_delivery_test.go b/cmd/gateway_consumer_delivery_test.go index ce4a89ff..f7d6b7d8 100644 --- a/cmd/gateway_consumer_delivery_test.go +++ b/cmd/gateway_consumer_delivery_test.go @@ -45,7 +45,7 @@ func TestBuildDeliveryRuntimeUsesResolvedDeliveryProviderModels(t *testing.T) { TenantID: tenantID, Content: "kiểm tra giúp tôi", Metadata: map[string]string{"locale": "vi"}, - }, "dm", "telegram", "agent") + }, "user-1", "dm", "telegram", "agent") quick, ok := runtime.QuickAckGenerator.(channels.ProviderDeliveryMessageGenerator) if !ok { @@ -82,7 +82,7 @@ func TestBuildDeliveryRuntimeFallsBackToAgentProviderModelWhenUnset(t *testing.T Enabled: true, Mode: channels.IntermediateModeSidecar, }, - }, bus.InboundMessage{TenantID: tenantID}, "dm", "telegram", "agent") + }, bus.InboundMessage{TenantID: tenantID}, "user-1", "dm", "telegram", "agent") quick, ok := runtime.QuickAckGenerator.(channels.ProviderDeliveryMessageGenerator) if !ok { @@ -100,6 +100,40 @@ func TestBuildDeliveryRuntimeFallsBackToAgentProviderModelWhenUnset(t *testing.T } } +func TestBuildDeliveryRuntimeIncludesAgentPersonaBrief(t *testing.T) { + agent := deliveryRuntimePersonaTestAgent{ + deliveryRuntimeTestAgent: deliveryRuntimeTestAgent{ + id: "agent", + uuid: uuid.New(), + model: "agent-model", + providerName: "agent-provider", + provider: deliveryRuntimeTestProvider{name: "agent-provider", model: "agent-default"}, + }, + personaByUserID: map[string]string{ + "raw-user": "wrong persona", + "resolved-user": "Style: concise, warm", + }, + } + runtime := buildDeliveryRuntime(context.Background(), &ConsumerDeps{}, deliveryRuntimePersonaTestAgent{ + deliveryRuntimeTestAgent: agent.deliveryRuntimeTestAgent, + personaByUserID: agent.personaByUserID, + }, channels.ResolvedChatBehavior{ + Enabled: true, + QuickAck: channels.ResolvedQuickAckConfig{ + Enabled: true, + Mode: channels.QuickAckModeSidecar, + }, + IntermediateReplies: channels.ResolvedIntermediateRepliesConfig{ + Enabled: true, + Mode: channels.IntermediateModeSidecar, + }, + }, bus.InboundMessage{UserID: "raw-user"}, "resolved-user", "dm", "telegram", "agent") + + if runtime.PersonaBrief != "Style: concise, warm" { + t.Fatalf("runtime persona brief = %q, want agent persona", runtime.PersonaBrief) + } +} + type deliveryRuntimeTestAgent struct { id string uuid uuid.UUID @@ -120,6 +154,15 @@ func (a deliveryRuntimeTestAgent) Model() string { return a.model func (a deliveryRuntimeTestAgent) ProviderName() string { return a.providerName } func (a deliveryRuntimeTestAgent) Provider() providers.Provider { return a.provider } +type deliveryRuntimePersonaTestAgent struct { + deliveryRuntimeTestAgent + personaByUserID map[string]string +} + +func (a deliveryRuntimePersonaTestAgent) DeliveryPersonaBrief(_ context.Context, userID string) string { + return a.personaByUserID[userID] +} + type deliveryRuntimeTestProvider struct { name string model string diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index b1fbf179..e61735bb 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -249,7 +249,7 @@ func processNormalMessage( chatBehavior = deps.ChannelMgr.ResolveChatBehaviorWithAgent(msg.Channel, workspaceBehavior, agentBehavior) } blockReply := deps.ChannelMgr != nil && chatBehavior.IntermediateReplies.Enabled - deliveryRuntime := buildDeliveryRuntime(ctx, deps, agentLoop, chatBehavior, msg, peerKind, resolveChannelType(deps.ChannelMgr, msg.Channel), agentID) + deliveryRuntime := buildDeliveryRuntime(ctx, deps, agentLoop, chatBehavior, msg, userID, peerKind, resolveChannelType(deps.ChannelMgr, msg.Channel), agentID) toolStatus := deps.Cfg.Gateway.ToolStatus == nil || *deps.Cfg.Gateway.ToolStatus // default true if deps.ChannelMgr != nil { deps.ChannelMgr.RegisterRunWithDelivery(runID, msg.Channel, chatIDForRun, messageID, outMeta, msg.TenantID, channelStream, blockReply, toolStatus, chatBehavior, deliveryRuntime, reasoningDelivery) @@ -611,17 +611,18 @@ func processNormalMessage( }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, chatBehavior, channelStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig()) } -func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, behavior channels.ResolvedChatBehavior, msg bus.InboundMessage, peerKind, channelType, agentKey string) channels.DeliveryRuntime { +func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, behavior channels.ResolvedChatBehavior, msg bus.InboundMessage, userID, peerKind, channelType, agentKey string) channels.DeliveryRuntime { locale := msg.Metadata["locale"] if locale == "" { locale = "auto" } runtime := channels.DeliveryRuntime{ - Locale: locale, - Inbound: msg.Content, - PeerKind: peerKind, - Channel: channelType, - AgentName: agentKey, + Locale: locale, + Inbound: msg.Content, + PeerKind: peerKind, + Channel: channelType, + AgentName: agentKey, + PersonaBrief: deliveryPersonaBrief(ctx, agentLoop, userID), } if behavior.Enabled && behavior.QuickAck.Enabled { switch behavior.QuickAck.Mode { @@ -638,6 +639,20 @@ func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop age return runtime } +type deliveryPersonaProvider interface { + DeliveryPersonaBrief(context.Context, string) string +} + +func deliveryPersonaBrief(ctx context.Context, agentLoop agent.Agent, userID string) string { + if agentLoop == nil { + return "" + } + if provider, ok := agentLoop.(deliveryPersonaProvider); ok { + return provider.DeliveryPersonaBrief(ctx, userID) + } + return "" +} + func buildDeliveryGenerator(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, tenantID uuid.UUID, providerName, model string) channels.DeliveryMessageGenerator { provider := agentLoop.Provider() resolvedProviderName := agentLoop.ProviderName() diff --git a/internal/agent/delivery_persona.go b/internal/agent/delivery_persona.go new file mode 100644 index 00000000..055f9cea --- /dev/null +++ b/internal/agent/delivery_persona.go @@ -0,0 +1,51 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" +) + +// BuildDeliveryPersonaBrief extracts only compact style cues suitable for +// delivery-only messages. It intentionally omits raw context file names and +// unrelated persona sections. +func BuildDeliveryPersonaBrief(files []bootstrap.ContextFile) string { + var soulContent string + for _, f := range files { + if filepath.Base(f.Path) == bootstrap.SoulFile { + soulContent = f.Content + break + } + } + if strings.TrimSpace(soulContent) == "" { + return "" + } + + parts := make([]string, 0, 2) + if style := compactPersonaCue(extractMarkdownSection(soulContent, "Style")); style != "" { + parts = append(parts, "Style: "+style) + } + if vibe := compactPersonaCue(extractMarkdownSection(soulContent, "Vibe")); vibe != "" { + parts = append(parts, "Vibe: "+vibe) + } + return strings.Join(parts, " | ") +} + +// DeliveryPersonaBrief returns the active compact persona for channel delivery. +// It uses the same context resolution path as the main prompt so per-user +// overrides apply when present. +func (l *Loop) DeliveryPersonaBrief(ctx context.Context, userID string) string { + if l == nil { + return "" + } + if strings.TrimSpace(userID) != "" { + return BuildDeliveryPersonaBrief(l.resolveContextFiles(ctx, userID)) + } + return BuildDeliveryPersonaBrief(l.contextFiles) +} + +func compactPersonaCue(s string) string { + return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") +} diff --git a/internal/agent/systemprompt_provider_test.go b/internal/agent/systemprompt_provider_test.go index 08a03a22..2cd7ac60 100644 --- a/internal/agent/systemprompt_provider_test.go +++ b/internal/agent/systemprompt_provider_test.go @@ -32,6 +32,36 @@ func TestNoSOULEchoForAnthropic(t *testing.T) { } } +func TestBuildDeliveryPersonaBriefUsesCompactSOULStyleAndVibe(t *testing.T) { + files := []bootstrap.ContextFile{{ + Path: "SOUL.md", + Content: "# Fox\n## Style\nConcise and warm\n## Vibe\nPlayful but direct\n## Boundaries\nNever expose this section", + }} + + got := BuildDeliveryPersonaBrief(files) + for _, want := range []string{"Style: Concise and warm", "Vibe: Playful but direct"} { + if !strings.Contains(got, want) { + t.Fatalf("delivery persona brief = %q, missing %q", got, want) + } + } + for _, blocked := range []string{"Boundaries", "Never expose", "SOUL.md", "context_file"} { + if strings.Contains(got, blocked) { + t.Fatalf("delivery persona brief leaked %q: %q", blocked, got) + } + } +} + +func TestBuildDeliveryPersonaBriefEmptyWithoutSOULStyle(t *testing.T) { + files := []bootstrap.ContextFile{{ + Path: "SOUL.md", + Content: "# Fox\n## Boundaries\nNo style here", + }} + + if got := BuildDeliveryPersonaBrief(files); got != "" { + t.Fatalf("delivery persona brief = %q, want empty fallback", got) + } +} + // TestProviderStablePrefixPosition verifies StablePrefix is before cache boundary. func TestProviderStablePrefixPosition(t *testing.T) { cfg := SystemPromptConfig{ diff --git a/internal/channels/chat_behavior_events_test.go b/internal/channels/chat_behavior_events_test.go index ffaaff7d..8d9475d8 100644 --- a/internal/channels/chat_behavior_events_test.go +++ b/internal/channels/chat_behavior_events_test.go @@ -222,6 +222,50 @@ func TestHandleAgentEvent_GeneratedQuickAckUsesSidecarGenerator(t *testing.T) { } } +func TestHandleAgentEvent_GeneratedQuickAckReceivesPersonaBrief(t *testing.T) { + behavior := ResolvedChatBehavior{ + Enabled: true, + QuickAck: ResolvedQuickAckConfig{ + Enabled: true, + Mode: QuickAckModeLLMGenerated, + MinDelayMs: 0, + MaxTokens: 20, + MaxChars: 80, + }, + } + + requests := make(chan DeliveryMessageRequest, 1) + mb := bus.New() + mgr := NewManager(mb) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"}) + mgr.RegisterRunWithDelivery("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, behavior, DeliveryRuntime{ + QuickAckGenerator: captureDeliveryGenerator{content: "Mình nhận rồi.", requests: requests}, + Inbound: "kiểm tra giúp tôi", + Locale: "vi", + PersonaBrief: "Style: concise, warm", + }) + + mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, ok := mb.SubscribeOutbound(ctx); !ok { + t.Fatal("expected generated quick acknowledgement") + } + + select { + case got := <-requests: + if got.PersonaBrief != "Style: concise, warm" { + t.Fatalf("quick ack persona brief = %q, want runtime persona", got.PersonaBrief) + } + if got.Purpose != DeliveryPurposeQuickAck { + t.Fatalf("quick ack purpose = %q", got.Purpose) + } + case <-time.After(time.Second): + t.Fatal("generator did not receive quick ack request") + } +} + func TestHandleAgentEvent_GeneratedQuickAckDoesNotUseTemplateWhenGeneratorFails(t *testing.T) { behavior := ResolvedChatBehavior{ Enabled: true, @@ -286,6 +330,50 @@ func TestHandleAgentEvent_IntermediateProgressDoesNotUseFallbackWhenGeneratorFai } } +func TestHandleAgentEvent_IntermediateProgressReceivesPersonaBrief(t *testing.T) { + behavior := ResolvedChatBehavior{ + Enabled: true, + IntermediateReplies: ResolvedIntermediateRepliesConfig{ + Enabled: true, + Mode: IntermediateModeSidecar, + MaxTokens: 20, + MaxChars: 120, + }, + QuickAck: ResolvedQuickAckConfig{Enabled: false}, + } + + requests := make(chan DeliveryMessageRequest, 1) + mb := bus.New() + mgr := NewManager(mb) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"}) + mgr.RegisterRunWithDelivery("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, behavior, DeliveryRuntime{ + ProgressGenerator: captureDeliveryGenerator{content: "Đang soi tiếp.", requests: requests}, + Inbound: "kiểm tra giúp tôi", + Locale: "vi", + PersonaBrief: "Style: concise, warm", + }) + + mgr.HandleAgentEvent(protocol.AgentEventToolCall, "run-1", map[string]string{"name": "skill_search"}) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, ok := mb.SubscribeOutbound(ctx); !ok { + t.Fatal("expected generated progress outbound message") + } + + select { + case got := <-requests: + if got.PersonaBrief != "Style: concise, warm" { + t.Fatalf("progress persona brief = %q, want runtime persona", got.PersonaBrief) + } + if got.Purpose != DeliveryPurposeProgress { + t.Fatalf("progress purpose = %q", got.Purpose) + } + case <-time.After(time.Second): + t.Fatal("generator did not receive progress request") + } +} + func TestHandleAgentEvent_FixedQuickAckPreservesInitialExplicitBlockReply(t *testing.T) { behavior := ResolvedChatBehavior{ Enabled: true, @@ -440,6 +528,46 @@ func TestHandleAgentEvent_ToolAnnouncementBypassesInitialQuickAckSuppression(t * } } +func TestHandleAgentEvent_FixedQuickAckIgnoresPersonaBrief(t *testing.T) { + behavior := ResolvedChatBehavior{ + Enabled: true, + QuickAck: ResolvedQuickAckConfig{ + Enabled: true, + Mode: QuickAckModeFixedTemplate, + MinDelayMs: 0, + MaxChars: 120, + Templates: []string{"Checking the tool result now."}, + }, + } + + requests := make(chan DeliveryMessageRequest, 1) + mb := bus.New() + mgr := NewManager(mb) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"}) + mgr.RegisterRunWithDelivery("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, behavior, DeliveryRuntime{ + QuickAckGenerator: captureDeliveryGenerator{content: "Generated.", requests: requests}, + PersonaBrief: "Style: concise, warm", + }) + + mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + got, ok := mb.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected fixed quick acknowledgement") + } + if got.Content != "Checking the tool result now." { + t.Fatalf("fixed quick ack content = %q, want template", got.Content) + } + + select { + case req := <-requests: + t.Fatalf("fixed quick ack unexpectedly called generator with request %+v", req) + default: + } +} + type fakeDeliveryGenerator struct { content string err error @@ -448,3 +576,15 @@ type fakeDeliveryGenerator struct { func (g fakeDeliveryGenerator) GenerateDeliveryMessage(context.Context, DeliveryMessageRequest) (string, error) { return g.content, g.err } + +type captureDeliveryGenerator struct { + content string + requests chan<- DeliveryMessageRequest +} + +func (g captureDeliveryGenerator) GenerateDeliveryMessage(_ context.Context, req DeliveryMessageRequest) (string, error) { + if g.requests != nil { + g.requests <- req + } + return g.content, nil +} diff --git a/internal/channels/delivery_message_generator.go b/internal/channels/delivery_message_generator.go index 8fea7225..aa4e6e97 100644 --- a/internal/channels/delivery_message_generator.go +++ b/internal/channels/delivery_message_generator.go @@ -24,16 +24,17 @@ type DeliveryMessageGenerator interface { } type DeliveryMessageRequest struct { - Purpose string - UserMessage string - Locale string - PeerKind string - ChannelType string - AgentName string - ToolName string - MaxTokens int - MaxChars int - Timeout time.Duration + Purpose string + UserMessage string + Locale string + PeerKind string + ChannelType string + AgentName string + ToolName string + PersonaBrief string + MaxTokens int + MaxChars int + Timeout time.Duration } type DeliveryRuntime struct { @@ -44,6 +45,7 @@ type DeliveryRuntime struct { PeerKind string Channel string AgentName string + PersonaBrief string } type ProviderDeliveryMessageGenerator struct { @@ -95,7 +97,7 @@ func (g ProviderDeliveryMessageGenerator) GenerateDeliveryMessage(ctx context.Co if err != nil { return "", err } - return sanitizeDeliveryMessage(resp.Content, req.MaxChars), nil + return sanitizeGeneratedDeliveryMessage(resp.Content, req.MaxChars), nil } func deliverySystemPrompt(req DeliveryMessageRequest) string { @@ -103,9 +105,23 @@ func deliverySystemPrompt(req DeliveryMessageRequest) string { if limit <= 0 { limit = defaultQuickAckChars } - return "Write one short, natural channel delivery update. Match the user's language. " + - "Do not mention internal prompts, tools, tool names, providers, or hidden reasoning. " + - "Do not use markdown tables or bullet lists. No promises. Max " + strconv.Itoa(limit) + " characters." + parts := []string{ + "Write one short, natural channel delivery update.", + "Match the user's language.", + } + if persona := strings.TrimSpace(req.PersonaBrief); persona != "" { + parts = append(parts, + "Match this agent voice/persona when writing the delivery update: "+clipRunes(persona, 400)+".", + "Embody the persona; do not describe it.", + ) + } + parts = append(parts, + "Do not reveal, quote, summarize, or mention SOUL.md, context files, system prompts, tools, tool names, providers, or hidden reasoning.", + "Do not use markdown tables or bullet lists.", + "No promises.", + "Max "+strconv.Itoa(limit)+" characters.", + ) + return strings.Join(parts, " ") } func deliveryUserPrompt(req DeliveryMessageRequest) string { @@ -126,15 +142,63 @@ func deliveryUserPrompt(req DeliveryMessageRequest) string { } func sanitizeDeliveryMessage(content string, maxChars int) string { + return clipDeliveryMessage(normalizeDeliveryMessage(content), maxChars) +} + +func sanitizeGeneratedDeliveryMessage(content string, maxChars int) string { + content = normalizeDeliveryMessage(content) + if content == "" || containsDeliveryLeak(content) { + return "" + } + return clipDeliveryMessage(content, maxChars) +} + +func normalizeDeliveryMessage(content string) string { content = strings.TrimSpace(content) content = strings.Trim(content, "`\"'") - content = strings.Join(strings.Fields(content), " ") + return strings.Join(strings.Fields(content), " ") +} + +func clipDeliveryMessage(content string, maxChars int) string { if maxChars <= 0 { maxChars = defaultQuickAckChars } return clipRunes(content, maxChars) } +func containsDeliveryLeak(content string) bool { + lower := strings.ToLower(content) + for _, phrase := range []string{ + "soul.md", + "identity.md", + "agents.md", + "context_file", + "context file", + "context files", + "internal_config", + "internal config", + "system prompt", + "system prompts", + "internal prompt", + "internal prompts", + "hidden reasoning", + } { + if strings.Contains(lower, phrase) { + return true + } + } + + for _, word := range strings.FieldsFunc(lower, func(r rune) bool { + return (r < 'a' || r > 'z') && (r < '0' || r > '9') + }) { + switch word { + case "tool", "tools", "provider", "providers": + return true + } + } + return false +} + func clipRunes(s string, max int) string { if max <= 0 || utf8.RuneCountInString(s) <= max { return s diff --git a/internal/channels/delivery_message_generator_test.go b/internal/channels/delivery_message_generator_test.go new file mode 100644 index 00000000..743b7c5f --- /dev/null +++ b/internal/channels/delivery_message_generator_test.go @@ -0,0 +1,62 @@ +package channels + +import ( + "strings" + "testing" +) + +func TestDeliverySystemPromptIncludesPersonaBriefAndLeakPrevention(t *testing.T) { + prompt := deliverySystemPrompt(DeliveryMessageRequest{ + MaxChars: 120, + PersonaBrief: "Style: concise, warm | Vibe: playful", + }) + + for _, want := range []string{ + "Match this agent voice/persona", + "concise, warm", + "Do not reveal, quote, summarize, or mention SOUL.md", + "context files", + "system prompts", + "providers", + "tools", + "hidden reasoning", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("delivery system prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestDeliverySystemPromptOmitsPersonaWhenUnavailable(t *testing.T) { + prompt := deliverySystemPrompt(DeliveryMessageRequest{MaxChars: 120}) + + if strings.Contains(prompt, "Match this agent voice/persona") { + t.Fatalf("delivery system prompt included persona guidance without brief:\n%s", prompt) + } + if !strings.Contains(prompt, "Max 120 characters") { + t.Fatalf("delivery system prompt = %q, want max char instruction", prompt) + } +} + +func TestSanitizeDeliveryMessageRejectsInternalContextLeaks(t *testing.T) { + for _, input := range []string{ + "I used SOUL.md to choose that tone.", + "Your context files say I should answer shortly.", + "The block says I should answer shortly.", + "The block says I should answer shortly.", + "The system prompt tells me to wait.", + "My hidden reasoning is still running.", + "I am calling a tool provider now.", + } { + if got := sanitizeGeneratedDeliveryMessage(input, 120); got != "" { + t.Fatalf("sanitizeGeneratedDeliveryMessage(%q) = %q, want rejected", input, got) + } + } +} + +func TestSanitizeDeliveryMessageKeepsFixedTemplateTerms(t *testing.T) { + got := sanitizeDeliveryMessage("Checking the tool result now.", 120) + if got != "Checking the tool result now." { + t.Fatalf("sanitizeDeliveryMessage fixed template = %q, want unchanged content", got) + } +} diff --git a/internal/channels/events.go b/internal/channels/events.go index 24b7e71a..9b3e4a33 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -582,16 +582,17 @@ func (rc *RunContext) deliveryRequestLocked(purpose, toolName string) DeliveryMe timeout = rc.ChatBehavior.IntermediateReplies.Timeout } return DeliveryMessageRequest{ - Purpose: purpose, - UserMessage: rc.Delivery.Inbound, - Locale: rc.Delivery.Locale, - PeerKind: rc.Delivery.PeerKind, - ChannelType: rc.Delivery.Channel, - AgentName: rc.Delivery.AgentName, - ToolName: toolName, - MaxTokens: maxTokens, - MaxChars: maxChars, - Timeout: timeout, + Purpose: purpose, + UserMessage: rc.Delivery.Inbound, + Locale: rc.Delivery.Locale, + PeerKind: rc.Delivery.PeerKind, + ChannelType: rc.Delivery.Channel, + AgentName: rc.Delivery.AgentName, + ToolName: toolName, + PersonaBrief: rc.Delivery.PersonaBrief, + MaxTokens: maxTokens, + MaxChars: maxChars, + Timeout: timeout, } } From dd6ee64f23e4de8bf2781e043af53c2a9fbd28fb Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 15:38:23 +0700 Subject: [PATCH 6/7] fix(skills): harden evolution mutations --- internal/http/skills.go | 23 ++++- internal/http/skills_evolution.go | 122 ++++++++++++++++--------- internal/http/skills_evolution_test.go | 110 +++++++++++++++++++++- internal/i18n/catalog_en.go | 14 ++- internal/i18n/catalog_vi.go | 14 ++- internal/i18n/catalog_zh.go | 14 ++- internal/i18n/keys.go | 14 ++- 7 files changed, 247 insertions(+), 64 deletions(-) diff --git a/internal/http/skills.go b/internal/http/skills.go index 92eea618..c4224329 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -103,11 +103,11 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/skills/upload", h.adminMiddleware(h.handleUpload)) mux.HandleFunc("PUT /v1/skills/{id}", h.adminMiddleware(h.handleUpdate)) mux.HandleFunc("DELETE /v1/skills/{id}", h.adminMiddleware(h.handleDelete)) - mux.HandleFunc("PATCH /v1/skills/{id}/evolution", h.adminMiddleware(h.handlePatchEvolution)) - mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions", h.adminMiddleware(h.handleCreateSkillSuggestion)) - mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/approve", h.adminMiddleware(h.handleApproveSkillSuggestion)) - mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/reject", h.adminMiddleware(h.handleRejectSkillSuggestion)) - mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/apply", h.adminMiddleware(h.handleApplySkillSuggestion)) + mux.HandleFunc("PATCH /v1/skills/{id}/evolution", h.tenantAdminMiddleware(h.handlePatchEvolution)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions", h.tenantAdminMiddleware(h.handleCreateSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/approve", h.tenantAdminMiddleware(h.handleApproveSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/reject", h.tenantAdminMiddleware(h.handleRejectSkillSuggestion)) + mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/apply", h.tenantAdminMiddleware(h.handleApplySkillSuggestion)) mux.HandleFunc("GET /v1/skills/{id}/dependencies", h.adminMiddleware(h.handleSkillDependenciesStatus)) mux.HandleFunc("POST /v1/skills/{id}/dependencies/scan", h.adminMiddleware(h.handleSkillDependenciesStatus)) mux.HandleFunc("POST /v1/skills/{id}/dependencies/check", h.adminMiddleware(h.handleSkillDependenciesStatus)) @@ -154,6 +154,19 @@ func (h *SkillsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc return requireAuth(permissions.RoleAdmin, next) } +func (h *SkillsHandler) tenantAdminMiddleware(next http.HandlerFunc) http.HandlerFunc { + return h.adminMiddleware(func(w http.ResponseWriter, r *http.Request) { + if store.IsMasterScope(r.Context()) { + next(w, r) + return + } + if !requireTenantAdmin(w, r, h.tenantStore) { + return + } + next(w, r) + }) +} + // requireMasterTenant rejects requests from non-master tenants. // System skill management (install packages, rescan deps) is a server-wide operation // that should only be accessible to the master tenant or cross-tenant admins. diff --git a/internal/http/skills_evolution.go b/internal/http/skills_evolution.go index 3025fe51..59770e42 100644 --- a/internal/http/skills_evolution.go +++ b/internal/http/skills_evolution.go @@ -14,11 +14,21 @@ import ( "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" ) +var ( + errSkillEvolutionSkillNotFound = errors.New("skill not found") + errSkillEvolutionSystemMutation = errors.New("system skill mutation is blocked") + errSkillEvolutionInvalidDraftPatch = errors.New("invalid draft_patch") + errSkillEvolutionDraftPatchRequired = errors.New("draft_patch requires content or find/replace") + errSkillEvolutionFindTextNotFound = errors.New("find text not found in target file") + errSkillEvolutionGuardPolicyViolation = errors.New("skill guard policy violation") +) + type skillEvolutionPatchRequest struct { Enabled *bool `json:"enabled"` Mode string `json:"mode"` @@ -42,30 +52,34 @@ type skillDraftPatch struct { Content *string `json:"content"` } -func (h *SkillsHandler) evolutionConfigured(w http.ResponseWriter) bool { +func (h *SkillsHandler) evolutionConfigured(w http.ResponseWriter, r *http.Request) bool { if h.evolutionStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skill evolution store not configured"}) + writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgSkillEvolutionNotConfigured) return false } return true } +func writeSkillEvolutionError(w http.ResponseWriter, r *http.Request, status int, key string, args ...any) { + writeJSON(w, status, map[string]string{"error": i18n.T(store.LocaleFromContext(r.Context()), key, args...)}) +} + func (h *SkillsHandler) skillIDFromRequest(w http.ResponseWriter, r *http.Request) (uuid.UUID, store.SkillInfo, bool) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill id"}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "skill") return uuid.Nil, store.SkillInfo{}, false } info, ok := h.skills.GetSkillByID(r.Context(), id) if !ok { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "skill", id.String()) return uuid.Nil, store.SkillInfo{}, false } return id, info, true } func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Request) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -74,14 +88,14 @@ func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Reques } settings, err := h.evolutionStore.GetSettings(r.Context(), skillID) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } writeJSON(w, http.StatusOK, settings) } func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Request) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -94,7 +108,7 @@ func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Requ } current, err := h.evolutionStore.GetSettings(r.Context(), skillID) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } enabled := current.Enabled @@ -109,7 +123,7 @@ func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Requ mode = store.SkillEvolutionModeSuggestOnly } if mode != store.SkillEvolutionModeSuggestOnly && mode != store.SkillEvolutionModeAutoAnalyze { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid evolution mode"}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidEvolutionMode) return } updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{ @@ -118,7 +132,7 @@ func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Requ Mode: mode, }) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } action := "skill.evolve.mode_updated" @@ -134,7 +148,7 @@ func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Requ } func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Request) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -149,7 +163,7 @@ func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Req } stats, err := h.evolutionStore.AggregateUsage(r.Context(), skillID, since) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) { @@ -160,7 +174,7 @@ func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Req func (h *SkillsHandler) handleGetSkillActivity(w http.ResponseWriter, r *http.Request) { if h.activityStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "activity store not configured"}) + writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgActivityStoreNotConfigured) return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -173,14 +187,14 @@ func (h *SkillsHandler) handleGetSkillActivity(w http.ResponseWriter, r *http.Re Limit: clampLimit(r.URL.Query().Get("limit"), 50, 200), }) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"activity": items}) } func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *http.Request) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -189,7 +203,7 @@ func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *htt } items, err := h.evolutionStore.ListSuggestions(r.Context(), skillID, r.URL.Query().Get("status"), clampLimit(r.URL.Query().Get("limit"), 50, 200)) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) { @@ -204,7 +218,7 @@ func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *htt } func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *http.Request) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -216,14 +230,14 @@ func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *ht return } if strings.TrimSpace(req.SuggestionType) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion_type is required"}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgRequired, "suggestion_type") return } target := strings.TrimSpace(req.TargetFile) if target != "" { clean, err := skills.ValidateSkillTargetPath(target, true) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidRequest, err.Error()) return } target = clean @@ -239,7 +253,7 @@ func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *ht CreatedByActorID: store.ActorIDFromContext(r.Context()), }) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } h.logSkillActivity(r, "skill.suggestion.created", skillID, map[string]any{"suggestion_id": created.ID.String(), "type": created.SuggestionType}) @@ -255,7 +269,7 @@ func (h *SkillsHandler) handleRejectSkillSuggestion(w http.ResponseWriter, r *ht } func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Request, status, action string) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, _, ok := h.skillIDFromRequest(w, r) @@ -264,21 +278,21 @@ func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Re } suggestionID, err := uuid.Parse(r.PathValue("suggestionID")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion") return } sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } if sg == nil || sg.SkillID != skillID { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) + writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String()) return } updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context())) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } h.logSkillActivity(r, action, skillID, map[string]any{"suggestion_id": suggestionID.String()}) @@ -286,7 +300,7 @@ func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Re } func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *http.Request) { - if !h.evolutionConfigured(w) { + if !h.evolutionConfigured(w, r) { return } skillID, info, ok := h.skillIDFromRequest(w, r) @@ -294,23 +308,23 @@ func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *htt return } if info.IsSystem { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "system skill mutation is blocked"}) + writeSkillEvolutionError(w, r, http.StatusForbidden, i18n.MsgSystemSkillMutationBlocked) return } suggestionID, err := uuid.Parse(r.PathValue("suggestionID")) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion") return } var req skillSuggestionApplyRequest _ = json.NewDecoder(r.Body).Decode(&req) sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } if sg == nil || sg.SkillID != skillID { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"}) + writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String()) return } if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil { @@ -319,18 +333,18 @@ func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *htt } if sg.Status != store.SkillSuggestionStatusApproved { if !req.Approve { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion must be approved before apply"}) + writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgSuggestionMustBeApproved) return } sg, err = h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, store.SkillSuggestionStatusApproved, "user", store.ActorIDFromContext(r.Context())) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error()) return } } applied, err := h.applySkillSuggestionPatch(r, skillID, sg) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": h.skillEvolutionApplyErrorMessage(r, skillID, err)}) return } writeJSON(w, http.StatusOK, applied) @@ -339,10 +353,10 @@ func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *htt func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid.UUID, sg *store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) { currentDir, slug, oldVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), skillID) if !ok { - return nil, fmt.Errorf("skill not found") + return nil, errSkillEvolutionSkillNotFound } if isSystem { - return nil, fmt.Errorf("system skill mutation is blocked") + return nil, errSkillEvolutionSystemMutation } target := sg.TargetFile if strings.TrimSpace(target) == "" { @@ -355,11 +369,11 @@ func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid. var patch skillDraftPatch if len(sg.DraftPatch) > 0 { if err := json.Unmarshal(sg.DraftPatch, &patch); err != nil { - return nil, fmt.Errorf("invalid draft_patch: %w", err) + return nil, fmt.Errorf("%w: %v", errSkillEvolutionInvalidDraftPatch, err) } } if patch.Content == nil && patch.Find == "" { - return nil, fmt.Errorf("draft_patch requires content or find/replace") + return nil, errSkillEvolutionDraftPatchRequired } newVersion, commitLock, err := h.skills.GetNextVersionLocked(r.Context(), slug) if err != nil { @@ -372,10 +386,10 @@ func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid. if err := copyDir(currentDir, tmpDir); err != nil { return nil, fmt.Errorf("stage version: %w", err) } - cleanup := true + removeDestOnError := true defer func() { - if cleanup { - _ = os.RemoveAll(tmpDir) + _ = os.RemoveAll(tmpDir) + if removeDestOnError { _ = os.RemoveAll(destDir) } }() @@ -392,14 +406,14 @@ func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid. nextContent = string(currentBytes) replaced := strings.Replace(nextContent, patch.Find, patch.Replace, 1) if replaced == nextContent { - return nil, fmt.Errorf("find text not found in target file") + return nil, errSkillEvolutionFindTextNotFound } nextContent = replaced } if cleanTarget == "SKILL.md" { violations, safe := skills.GuardSkillContent(nextContent) if !safe { - return nil, errors.New(skills.FormatGuardViolations(violations)) + return nil, fmt.Errorf("%w: %s", errSkillEvolutionGuardPolicyViolation, skills.FormatGuardViolations(violations)) } } if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { @@ -425,6 +439,9 @@ func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid. }); err != nil { return nil, fmt.Errorf("update skill: %w", err) } + // The active skill row now points at destDir; keep files even if + // version history or suggestion metadata fails after this point. + removeDestOnError = false changedFiles, _ := json.Marshal([]string{cleanTarget}) if _, err := h.evolutionStore.CreateSkillVersion(r.Context(), store.SkillVersion{ SkillID: skillID, @@ -448,10 +465,29 @@ func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid. "new_version": newVersion, "content_hash": hash, }) - cleanup = false return applied, nil } +func (h *SkillsHandler) skillEvolutionApplyErrorMessage(r *http.Request, skillID uuid.UUID, err error) string { + locale := store.LocaleFromContext(r.Context()) + switch { + case errors.Is(err, errSkillEvolutionSkillNotFound): + return i18n.T(locale, i18n.MsgNotFound, "skill", skillID.String()) + case errors.Is(err, errSkillEvolutionSystemMutation): + return i18n.T(locale, i18n.MsgSystemSkillMutationBlocked) + case errors.Is(err, errSkillEvolutionInvalidDraftPatch): + return i18n.T(locale, i18n.MsgInvalidDraftPatch, strings.TrimPrefix(err.Error(), errSkillEvolutionInvalidDraftPatch.Error()+": ")) + case errors.Is(err, errSkillEvolutionDraftPatchRequired): + return i18n.T(locale, i18n.MsgDraftPatchRequired) + case errors.Is(err, errSkillEvolutionFindTextNotFound): + return i18n.T(locale, i18n.MsgFindTextNotFound) + case errors.Is(err, errSkillEvolutionGuardPolicyViolation): + return i18n.T(locale, i18n.MsgInvalidRequest, strings.TrimPrefix(err.Error(), errSkillEvolutionGuardPolicyViolation.Error()+": ")) + default: + return i18n.T(locale, i18n.MsgInvalidRequest, err.Error()) + } +} + func (h *SkillsHandler) logSkillActivity(r *http.Request, action string, skillID uuid.UUID, details map[string]any) { if h.activityStore == nil { return diff --git a/internal/http/skills_evolution_test.go b/internal/http/skills_evolution_test.go index 032bedcb..7b34e424 100644 --- a/internal/http/skills_evolution_test.go +++ b/internal/http/skills_evolution_test.go @@ -3,16 +3,19 @@ package http import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/crypto" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -68,6 +71,95 @@ func TestApplySkillSuggestionPatchCreatesNewReferenceFile(t *testing.T) { } } +func TestApplySkillSuggestionPatchKeepsActiveFilesWhenVersionRecordFails(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + evolution := &skillEvolutionStoreStub{createVersionErr: errors.New("version store unavailable")} + handler.SetEvolutionStore(evolution, nil) + + currentDir := filepath.Join(root, "skills-store", "failure-skill", "1") + if err := os.MkdirAll(currentDir, 0755); err != nil { + t.Fatalf("mkdir current skill: %v", err) + } + if err := os.WriteFile(filepath.Join(currentDir, "SKILL.md"), []byte(skillMarkdown("Failure Skill", "failure-skill")), 0644); err != nil { + t.Fatalf("write skill: %v", err) + } + skillID := skillStore.seedCustomSkill("failure-skill", currentDir, "active", nil) + content := "Keep active files when metadata recording fails.\n" + patch, err := json.Marshal(skillDraftPatch{Content: &content}) + if err != nil { + t.Fatalf("marshal draft patch: %v", err) + } + suggestion := &store.SkillImprovementSuggestion{ + ID: uuid.New(), + SkillID: skillID, + TargetFile: "references/recovery.md", + DraftPatch: patch, + Status: store.SkillSuggestionStatusApproved, + SuggestionType: "skill_reference_add", + } + req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/evolution/suggestions/"+suggestion.ID.String()+"/apply", nil).WithContext(ctx) + + _, err = handler.applySkillSuggestionPatch(req, skillID, suggestion) + if err == nil || !strings.Contains(err.Error(), "record skill version") { + t.Fatalf("error = %v, want record skill version failure", err) + } + + newDir := filepath.Join(root, "skills-store", "failure-skill", "2") + if _, err := os.Stat(newDir); err != nil { + t.Fatalf("new active skill dir missing after metadata failure: %v", err) + } + created, err := os.ReadFile(filepath.Join(newDir, "references", "recovery.md")) + if err != nil { + t.Fatalf("read preserved reference: %v", err) + } + if string(created) != content { + t.Fatalf("preserved reference = %q, want %q", created, content) + } + info, ok := skillStore.GetSkillByID(ctx, skillID) + if !ok { + t.Fatal("skill not found after apply failure") + } + if info.BaseDir != newDir || info.Version != 2 { + t.Fatalf("skill active target = (%q, v%d), want (%q, v2)", info.BaseDir, info.Version, newDir) + } +} + +func TestSkillEvolutionMutationRouteRejectsNonTenantAdmin(t *testing.T) { + handler, skillStore, _, root := newTestUploadHandler(t) + tenantID := uuid.New() + skillID := skillStore.seedCustomSkillForTenant(tenantID, "evolution-skill", filepath.Join(root, "tenants", tenantID.String(), "skills-store", "evolution-skill", "1"), "active", nil) + tenants := newMockTenantStore() + tenants.addTenant(tenantID, "tenant-a") + tenants.setUserRole(tenantID, "scope-admin", store.TenantRoleMember) + handler.tenantStore = tenants + evolution := &skillEvolutionStoreStub{} + handler.SetEvolutionStore(evolution, nil) + rawKey := "evolution-admin" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(rawKey): { + ID: uuid.New(), + Scopes: []string{"operator.admin"}, + TenantID: tenantID, + OwnerID: "scope-admin", + }, + }) + + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + req := httptest.NewRequest(http.MethodPatch, "/v1/skills/"+skillID.String()+"/evolution", strings.NewReader(`{"enabled":true}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s, want 403", w.Code, w.Body.String()) + } + if evolution.settingsUpdates != 0 { + t.Fatalf("settings updates = %d, want 0", evolution.settingsUpdates) + } +} + func TestHandleSuggestionStatusDoesNotUpdateDifferentSkillSuggestion(t *testing.T) { handler, skillStore, ctx, root := newTestUploadHandler(t) requestSkillID := skillStore.seedCustomSkill("request-skill", filepath.Join(root, "skills-store", "request-skill", "1"), "active", nil) @@ -104,9 +196,12 @@ func TestHandleSuggestionStatusDoesNotUpdateDifferentSkillSuggestion(t *testing. } type skillEvolutionStoreStub struct { - versions []store.SkillVersion - suggestions map[uuid.UUID]*store.SkillImprovementSuggestion - statusUpdates int + versions []store.SkillVersion + suggestions map[uuid.UUID]*store.SkillImprovementSuggestion + statusUpdates int + settingsUpdates int + createVersionErr error + markAppliedErr error } func (s *skillEvolutionStoreStub) GetSettings(context.Context, uuid.UUID) (*store.SkillEvolutionSettings, error) { @@ -114,7 +209,8 @@ func (s *skillEvolutionStoreStub) GetSettings(context.Context, uuid.UUID) (*stor } func (s *skillEvolutionStoreStub) UpsertSettings(context.Context, store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) { - return nil, nil + s.settingsUpdates++ + return &store.SkillEvolutionSettings{}, nil } func (s *skillEvolutionStoreStub) RecordUsage(context.Context, store.SkillUsageMetric) error { @@ -167,6 +263,9 @@ func (s *skillEvolutionStoreStub) UpdateSuggestionStatus(_ context.Context, id u } func (s *skillEvolutionStoreStub) MarkSuggestionApplied(_ context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) { + if s.markAppliedErr != nil { + return nil, s.markAppliedErr + } return &store.SkillImprovementSuggestion{ ID: id, Status: store.SkillSuggestionStatusApplied, @@ -177,6 +276,9 @@ func (s *skillEvolutionStoreStub) MarkSuggestionApplied(_ context.Context, id uu } func (s *skillEvolutionStoreStub) CreateSkillVersion(_ context.Context, version store.SkillVersion) (*store.SkillVersion, error) { + if s.createVersionErr != nil { + return nil, s.createVersionErr + } s.versions = append(s.versions, version) return &version, nil } diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 37c11706..920c8b78 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -133,9 +133,17 @@ func init() { MsgCannotRemoveLastWriter: "cannot remove the last file writer", // Skills - MsgSkillsUpdateNotSupported: "skills.update not supported for file-based skills", - MsgCannotResolveSkillID: "cannot resolve skill ID for file-based skill", - MsgInvalidVisibility: "invalid visibility %q: must be one of private, public", + MsgSkillsUpdateNotSupported: "skills.update not supported for file-based skills", + MsgCannotResolveSkillID: "cannot resolve skill ID for file-based skill", + MsgInvalidVisibility: "invalid visibility %q: must be one of private, public", + MsgSkillEvolutionNotConfigured: "skill evolution store is not configured", + MsgActivityStoreNotConfigured: "activity store is not configured", + MsgInvalidEvolutionMode: "invalid evolution mode", + MsgSystemSkillMutationBlocked: "system skill mutation is blocked", + MsgSuggestionMustBeApproved: "suggestion must be approved before apply", + MsgInvalidDraftPatch: "invalid draft_patch: %s", + MsgDraftPatchRequired: "draft_patch requires content or find/replace", + MsgFindTextNotFound: "find text not found in target file", // Logs MsgInvalidLogAction: "action must be 'start' or 'stop'", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index 194834be..266e1050 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -133,9 +133,17 @@ func init() { MsgCannotRemoveLastWriter: "không thể xóa người quản lý cuối cùng", // Skills - MsgSkillsUpdateNotSupported: "skills.update không được hỗ trợ với skill dựa trên tệp", - MsgCannotResolveSkillID: "không thể xác định ID skill dựa trên tệp", - MsgInvalidVisibility: "visibility không hợp lệ %q: phải là private hoặc public", + MsgSkillsUpdateNotSupported: "skills.update không được hỗ trợ với skill dựa trên tệp", + MsgCannotResolveSkillID: "không thể xác định ID skill dựa trên tệp", + MsgInvalidVisibility: "visibility không hợp lệ %q: phải là private hoặc public", + MsgSkillEvolutionNotConfigured: "kho lưu skill evolution chưa được cấu hình", + MsgActivityStoreNotConfigured: "kho lưu activity chưa được cấu hình", + MsgInvalidEvolutionMode: "evolution mode không hợp lệ", + MsgSystemSkillMutationBlocked: "không được phép sửa skill hệ thống", + MsgSuggestionMustBeApproved: "phải duyệt suggestion trước khi apply", + MsgInvalidDraftPatch: "draft_patch không hợp lệ: %s", + MsgDraftPatchRequired: "draft_patch cần content hoặc find/replace", + MsgFindTextNotFound: "không tìm thấy đoạn cần thay trong tệp đích", // Logs MsgInvalidLogAction: "action phải là 'start' hoặc 'stop'", diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index c1989d84..5a47157e 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -133,9 +133,17 @@ func init() { MsgCannotRemoveLastWriter: "无法移除最后一个文件管理员", // Skills - MsgSkillsUpdateNotSupported: "基于文件的Skill不支持 skills.update", - MsgCannotResolveSkillID: "无法解析基于文件的Skill ID", - MsgInvalidVisibility: "无效的 visibility %q:必须为 private 或 public", + MsgSkillsUpdateNotSupported: "基于文件的Skill不支持 skills.update", + MsgCannotResolveSkillID: "无法解析基于文件的Skill ID", + MsgInvalidVisibility: "无效的 visibility %q:必须为 private 或 public", + MsgSkillEvolutionNotConfigured: "技能演进存储未配置", + MsgActivityStoreNotConfigured: "活动存储未配置", + MsgInvalidEvolutionMode: "无效的 evolution mode", + MsgSystemSkillMutationBlocked: "禁止修改系统Skill", + MsgSuggestionMustBeApproved: "应用前必须先批准建议", + MsgInvalidDraftPatch: "无效的 draft_patch:%s", + MsgDraftPatchRequired: "draft_patch 需要 content 或 find/replace", + MsgFindTextNotFound: "在目标文件中未找到要替换的文本", // Logs MsgInvalidLogAction: "action 必须是 'start' 或 'stop'", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index 34442601..d57ce781 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -134,9 +134,17 @@ const ( MsgCannotRemoveLastWriter = "error.cannot_remove_last_writer" // "cannot remove the last file writer" // --- Skills --- - MsgSkillsUpdateNotSupported = "error.skills_update_not_supported" // "skills.update not supported for file-based skills" - MsgCannotResolveSkillID = "error.cannot_resolve_skill_id" // "cannot resolve skill ID for file-based skill" - MsgInvalidVisibility = "error.invalid_visibility" // "invalid visibility %q: must be one of private, public" + MsgSkillsUpdateNotSupported = "error.skills_update_not_supported" // "skills.update not supported for file-based skills" + MsgCannotResolveSkillID = "error.cannot_resolve_skill_id" // "cannot resolve skill ID for file-based skill" + MsgInvalidVisibility = "error.invalid_visibility" // "invalid visibility %q: must be one of private, public" + MsgSkillEvolutionNotConfigured = "error.skill_evolution_not_configured" // "skill evolution store is not configured" + MsgActivityStoreNotConfigured = "error.activity_store_not_configured" // "activity store is not configured" + MsgInvalidEvolutionMode = "error.invalid_evolution_mode" // "invalid evolution mode" + MsgSystemSkillMutationBlocked = "error.system_skill_mutation_blocked" // "system skill mutation is blocked" + MsgSuggestionMustBeApproved = "error.suggestion_must_be_approved" // "suggestion must be approved before apply" + MsgInvalidDraftPatch = "error.invalid_draft_patch" // "invalid draft_patch: %s" + MsgDraftPatchRequired = "error.draft_patch_required" // "draft_patch requires content or find/replace" + MsgFindTextNotFound = "error.find_text_not_found" // "find text not found in target file" // --- Package updates (Phase 4+5) --- MsgPackageNotInstalled = "packages.update.not_installed" // "Package {name} is not installed" From 9cd57a920cb094254dff807338296de71a18702a Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 12 Jun 2026 17:33:54 +0700 Subject: [PATCH 7/7] feat: add multi-attachment delivery batching --- cmd/gateway_consumer.go | 1 + docs/05-channels-messaging.md | 18 +++ docs/project-changelog.md | 21 +++ internal/agent/loop_tools.go | 2 +- internal/agent/loop_types.go | 1 + internal/bus/types.go | 19 +-- internal/channels/capabilities.go | 46 ++++++ internal/channels/telegram/send.go | 96 +++--------- .../channels/telegram/send_media_group.go | 138 +++++++++++++++++ .../telegram/send_media_group_helpers.go | 142 ++++++++++++++++++ .../telegram/send_media_group_test.go | 70 +++++++++ internal/tools/send_file.go | 112 ++++++++------ internal/tools/send_file_batch_requests.go | 58 +++++++ internal/tools/send_file_test.go | 74 +++++++++ .../phase-01-research-and-tdd-contracts.md | 51 +++++++ .../phase-02-tool-api-batch-attachments.md | 59 ++++++++ ...hannel-capability-and-telegram-grouping.md | 55 +++++++ ...ase-04-regression-verification-and-ship.md | 54 +++++++ .../plan.md | 45 ++++++ .../reports/validation-red-team-report.md | 19 +++ 20 files changed, 954 insertions(+), 127 deletions(-) create mode 100644 internal/channels/telegram/send_media_group.go create mode 100644 internal/channels/telegram/send_media_group_helpers.go create mode 100644 internal/channels/telegram/send_media_group_test.go create mode 100644 internal/tools/send_file_batch_requests.go create mode 100644 plans/260612-1708-issue-172-multi-attachment-delivery/phase-01-research-and-tdd-contracts.md create mode 100644 plans/260612-1708-issue-172-multi-attachment-delivery/phase-02-tool-api-batch-attachments.md create mode 100644 plans/260612-1708-issue-172-multi-attachment-delivery/phase-03-channel-capability-and-telegram-grouping.md create mode 100644 plans/260612-1708-issue-172-multi-attachment-delivery/phase-04-regression-verification-and-ship.md create mode 100644 plans/260612-1708-issue-172-multi-attachment-delivery/plan.md create mode 100644 plans/260612-1708-issue-172-multi-attachment-delivery/reports/validation-red-team-report.md diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index de888c43..7268d968 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -238,6 +238,7 @@ func appendMediaToOutbound(msg *bus.OutboundMessage, media []agent.MediaResult) msg.Media = append(msg.Media, bus.MediaAttachment{ URL: mr.Path, ContentType: mr.ContentType, + Caption: mr.Caption, }) if mr.AsVoice { if msg.Metadata == nil { diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index 976f5977..43d1af24 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -105,6 +105,24 @@ Splitting is intentionally conservative. Replies containing fenced code, tables, Progress messages are not added to session history by this behavior. Existing run timeline handling for explicit `block.reply` remains unchanged. +### Outbound Media Delivery + +Agent tools can queue media files through `Result.Media`; the consumer converts +those entries into `OutboundMessage.Media` and preserves file order, MIME type, +filename, and optional captions. + +- `send_file` accepts either the legacy single `path` + optional `caption`, or + `attachments: [{path, caption?}, ...]` for batch delivery of existing + workspace files. +- Telegram groups compatible outbound media into `sendMediaGroup` album chunks + of 2-10 items. Photo/video items can share a chunk; documents group only with + documents; audio groups only with audio. Voice-mode audio, singleton chunks, + oversized images sent as documents, and incompatible runs use the existing + ordered single-send fallback. +- Discord already sends multiple files plus optional text in one message. +- Slack and other media-capable channels keep ordered fallback behavior unless + their adapter advertises a stronger batch capability. + ### Reasoning Delivery Telegram channel config supports explicit reasoning delivery: diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 98225430..0a626218 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,27 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-06-12 +### Multi-attachment outbound delivery (issue #172) + +**Changes** + +- Extended `send_file` with `attachments[]` so agents can queue multiple + existing workspace files in one tool call while preserving order and captions. +- Added caption propagation from tool result media through agent media results + into outbound channel attachments. +- Added Telegram media-group delivery for compatible batches with 2-10 item + chunks, plus ordered fallback for singleton, voice, oversized-image, or mixed + incompatible cases. +- Added explicit channel batch capability metadata for Telegram, Discord, and + ordered fallback channels. + +**Tests** + +- Added batch `send_file` regressions for happy path, duplicate rejection, and + all-or-nothing delivered-state handling. +- Added Telegram media-group chunking tests for compatible grouping, max-size + chunking, and non-groupable fallback. + ### Operator trace CLI (issue #158) **Changes** diff --git a/internal/agent/loop_tools.go b/internal/agent/loop_tools.go index af825ac2..0fefbc6c 100644 --- a/internal/agent/loop_tools.go +++ b/internal/agent/loop_tools.go @@ -91,7 +91,7 @@ func (l *Loop) processToolResult( if ct == "" { ct = mimeFromExt(filepath.Ext(mf.Path)) } - mr := MediaResult{Path: mf.Path, ContentType: ct} + mr := MediaResult{Path: mf.Path, ContentType: ct, Caption: mf.Caption} if result.MediaPrompts != nil { mr.Prompt = result.MediaPrompts[i] } diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index c1663959..81eeb74a 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -681,6 +681,7 @@ type RunResult struct { type MediaResult struct { Path string `json:"path"` // local file path ContentType string `json:"content_type,omitempty"` // MIME type + Caption string `json:"caption,omitempty"` // optional outbound caption Size int64 `json:"size,omitempty"` // file size in bytes AsVoice bool `json:"as_voice,omitempty"` // send as voice message (Telegram OGG) // Prompt is the generation prompt for AI-generated media (e.g. create_image). diff --git a/internal/bus/types.go b/internal/bus/types.go index 4eec4dd4..c3477589 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -14,6 +14,7 @@ type MediaFile struct { Path string `json:"path"` MimeType string `json:"mime_type,omitempty"` // e.g. "application/pdf", "image/jpeg" Filename string `json:"filename,omitempty"` // original user-provided filename, e.g. "Báo cáo Q4.pdf"; empty → UUID fallback in persistMedia + Caption string `json:"caption,omitempty"` // optional outbound caption attached to this file } // InboundMessage represents a message received from a channel (Telegram, Discord, etc.) @@ -35,14 +36,14 @@ type InboundMessage struct { // OutboundMessage represents a message to be sent to a channel. type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []MediaAttachment `json:"media,omitempty"` // optional media attachments - Metadata map[string]string `json:"metadata,omitempty"` // channel-specific metadata - TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope for per-tenant TTS - AgentID uuid.UUID `json:"agent_id,omitempty"` // agent scope for per-agent TTS voice override - AgentOtherConfig []byte `json:"agent_other_config,omitempty"` // agent's other_config for TTS voice/model + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []MediaAttachment `json:"media,omitempty"` // optional media attachments + Metadata map[string]string `json:"metadata,omitempty"` // channel-specific metadata + TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope for per-tenant TTS + AgentID uuid.UUID `json:"agent_id,omitempty"` // agent scope for per-agent TTS voice override + AgentOtherConfig []byte `json:"agent_other_config,omitempty"` // agent's other_config for TTS voice/model } // MediaAttachment represents a media file to be sent with a message. @@ -54,7 +55,7 @@ type MediaAttachment struct { // Event represents a server-side event to broadcast to WebSocket clients. type Event struct { - Name string `json:"name"` // event name (e.g. "agent", "chat", "health") + Name string `json:"name"` // event name (e.g. "agent", "chat", "health") Payload any `json:"payload,omitempty"` TenantID uuid.UUID `json:"-"` // tenant scope for event filtering (not serialized to clients) } diff --git a/internal/channels/capabilities.go b/internal/channels/capabilities.go index 1198dfc2..ef3f2340 100644 --- a/internal/channels/capabilities.go +++ b/internal/channels/capabilities.go @@ -6,6 +6,21 @@ import "errors" // Callers (e.g. webhook handler) should either degrade to text-only or return HTTP 501. var ErrMediaUnsupported = errors.New("channel does not support media attachments") +// MediaBatchCapability describes how a channel can deliver multiple outbound +// attachments from one OutboundMessage. +type MediaBatchCapability struct { + Supported bool + MaxAttachments int + Grouping string +} + +const ( + MediaBatchGroupingNone = "none" + MediaBatchGroupingSingleMessage = "single_message" + MediaBatchGroupingAlbumChunks = "album_chunks" + MediaBatchGroupingOrdered = "ordered" +) + // mediaCapableTypes lists channel platform types that consume msg.Media in their Send() // implementation. Verified against adapters: // - telegram: internal/channels/telegram/send.go:251 @@ -30,8 +45,39 @@ var mediaCapableTypes = map[string]bool{ TypeFacebook: true, } +var mediaBatchCapabilities = map[string]MediaBatchCapability{ + TypeTelegram: { + Supported: true, + MaxAttachments: 10, + Grouping: MediaBatchGroupingAlbumChunks, + }, + TypeDiscord: { + Supported: true, + MaxAttachments: 10, + Grouping: MediaBatchGroupingSingleMessage, + }, + TypeSlack: { + Supported: true, + MaxAttachments: 0, + Grouping: MediaBatchGroupingOrdered, + }, +} + // IsMediaCapable reports whether the given channel platform type supports media attachments. // Use Manager.ChannelTypeForName to resolve the type from a channel instance name. func IsMediaCapable(channelType string) bool { return mediaCapableTypes[channelType] } + +// MediaBatchCapabilityFor reports best-known multi-attachment delivery behavior. +// Unknown media-capable channels preserve order by falling back to their Send() +// implementation's existing per-file behavior. +func MediaBatchCapabilityFor(channelType string) MediaBatchCapability { + if cap, ok := mediaBatchCapabilities[channelType]; ok { + return cap + } + if IsMediaCapable(channelType) { + return MediaBatchCapability{Supported: true, Grouping: MediaBatchGroupingOrdered} + } + return MediaBatchCapability{Grouping: MediaBatchGroupingNone} +} diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index 74324b21..42bd43fb 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -96,11 +96,12 @@ func isPostConnectNetworkErr(err error) bool { // need the longer sendMediaOverallTimeout budget. Keyed by the `name` argument // passed to retrySend so no call site has to change when we bump the budget. var mediaSendMethods = map[string]struct{}{ - "sendPhoto": {}, - "sendVideo": {}, - "sendAudio": {}, - "sendVoice": {}, - "sendDocument": {}, + "sendPhoto": {}, + "sendVideo": {}, + "sendAudio": {}, + "sendVoice": {}, + "sendDocument": {}, + "sendMediaGroup": {}, } // retrySend wraps a Telegram send call with retry logic for transient network errors. @@ -361,81 +362,32 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { // Ref: TS src/telegram/send.ts → sendMessageTelegram with mediaUrl func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.OutboundMessage, replyTo, threadID int) error { chatIDObj := tu.ID(chatID) + items, err := c.prepareTelegramMediaItems(msg) + if err != nil { + return err + } - for _, media := range msg.Media { - // Determine caption (use message content for first media, or media caption) - caption := media.Caption - if caption == "" && msg.Content != "" { - caption = msg.Content - msg.Content = "" // only use for first media - } - - // Convert caption from markdown to Telegram HTML (same as regular messages). - // If the HTML caption exceeds Telegram's 1024-byte limit, skip caption entirely - // and send the full text as a separate message. Truncating HTML at a byte boundary - // can split tags (e.g. cut inside ...) causing parse errors. - var followUpText string - if caption != "" { - caption = markdownToTelegramHTML(caption) - if len(caption) > telegramCaptionMaxLen { - followUpText = caption - caption = "" - } - } - - if err := c.validateOutboundMediaSize(media.URL); err != nil { - return err - } - - // Send based on content type. - // Large images (>photoSizeThreshold) are sent as documents to avoid Telegram compression. - ct := strings.ToLower(media.ContentType) - switch { - case strings.HasPrefix(ct, "image/"): - sendAsDoc := false - if info, statErr := os.Stat(media.URL); statErr == nil && info.Size() > photoSizeThreshold { - sendAsDoc = true - slog.Info("large image, sending as document to preserve quality", "path", media.URL, "size", info.Size()) - } - if sendAsDoc { - if err := c.sendDocument(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { - return err - } - } else if err := c.sendPhoto(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { + for _, chunk := range chunkTelegramMediaItems(items) { + if chunk.grouped { + if err := c.sendTelegramMediaGroup(ctx, chatIDObj, chunk.items, replyTo, threadID); err != nil { return err } - case strings.HasPrefix(ct, "video/"): - if err := c.sendVideo(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { - return err - } - case strings.HasPrefix(ct, "audio/"): - // Voice message: use SendVoice for inline playback bubble. - if msg.Metadata["audio_as_voice"] == "true" && isVoiceCompatible(ct) { - if err := c.sendVoice(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { - return err - } - } else { - if err := c.sendAudio(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { + replyTo = 0 + for _, item := range chunk.items { + if err := c.sendTelegramMediaFollowUp(ctx, chatID, item.followUpText, threadID); err != nil { return err } } - default: - if err := c.sendDocument(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { - return err - } + continue } - // Only reply to the first media item - replyTo = 0 - // Send follow-up text if caption was split. - // followUpText is already HTML (from markdownToTelegramHTML above), so - // just chunk and send — do NOT convert again (double-escaping breaks entities). - if followUpText != "" { - chunks := chunkHTML(followUpText, telegramMaxMessageLen) - for _, chunk := range chunks { - if err := c.sendHTML(ctx, chatID, chunk, 0, threadID); err != nil { - return err - } + for _, item := range chunk.items { + if err := c.sendSingleTelegramMediaItem(ctx, chatIDObj, item, replyTo, threadID); err != nil { + return err + } + replyTo = 0 + if err := c.sendTelegramMediaFollowUp(ctx, chatID, item.followUpText, threadID); err != nil { + return err } } } diff --git a/internal/channels/telegram/send_media_group.go b/internal/channels/telegram/send_media_group.go new file mode 100644 index 00000000..45b0245f --- /dev/null +++ b/internal/channels/telegram/send_media_group.go @@ -0,0 +1,138 @@ +package telegram + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/mymmrac/telego" +) + +func (c *Channel) sendTelegramMediaGroup(ctx context.Context, chatID telego.ChatID, items []telegramMediaSendItem, replyTo, threadID int) error { + files := make([]*os.File, 0, len(items)) + defer func() { + for _, file := range files { + _ = file.Close() + } + }() + + params := &telego.SendMediaGroupParams{ + ChatID: chatID, + Media: make([]telego.InputMedia, 0, len(items)), + } + if sendThreadID := resolveThreadIDForSend(threadID); sendThreadID > 0 { + params.MessageThreadID = sendThreadID + } + if replyTo > 0 { + params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo, AllowSendingWithoutReply: true} + } + + for _, item := range items { + file, err := os.Open(item.media.URL) + if err != nil { + return fmt.Errorf("open media %s: %w", item.media.URL, err) + } + files = append(files, file) + params.Media = append(params.Media, inputMediaForTelegramGroupItem(item, file)) + } + + reset := func() { + for _, file := range files { + _, _ = file.Seek(0, 0) + } + } + err := c.retrySend(ctx, "sendMediaGroup", reset, func(ctx context.Context) error { + _, e := c.bot.SendMediaGroup(ctx, params) + return e + }) + if err != nil && parseErrRe.MatchString(err.Error()) { + stripTelegramGroupCaptions(params.Media) + reset() + _, err = c.bot.SendMediaGroup(ctx, params) + } + if err != nil && params.MessageThreadID != 0 && threadNotFoundRe.MatchString(err.Error()) { + slog.Warn("sendMediaGroup: thread not found, retrying without thread", "thread_id", params.MessageThreadID) + params.MessageThreadID = 0 + reset() + _, err = c.bot.SendMediaGroup(ctx, params) + } + return err +} + +func inputMediaForTelegramGroupItem(item telegramMediaSendItem, file *os.File) telego.InputMedia { + input := telego.InputFile{File: file} + switch item.kind { + case telegramMediaPhoto: + media := &telego.InputMediaPhoto{Type: telego.MediaTypePhoto, Media: input, Caption: item.caption} + if item.caption != "" { + media.ParseMode = telego.ModeHTML + } + return media + case telegramMediaVideo: + media := &telego.InputMediaVideo{Type: telego.MediaTypeVideo, Media: input, Caption: item.caption} + if item.caption != "" { + media.ParseMode = telego.ModeHTML + } + return media + case telegramMediaAudio: + media := &telego.InputMediaAudio{Type: telego.MediaTypeAudio, Media: input, Caption: item.caption} + if item.caption != "" { + media.ParseMode = telego.ModeHTML + } + return media + default: + media := &telego.InputMediaDocument{Type: telego.MediaTypeDocument, Media: input, Caption: item.caption} + if item.caption != "" { + media.ParseMode = telego.ModeHTML + } + return media + } +} + +func stripTelegramGroupCaptions(media []telego.InputMedia) { + for _, item := range media { + switch typed := item.(type) { + case *telego.InputMediaPhoto: + typed.ParseMode = "" + typed.Caption = stripHTML(typed.Caption) + case *telego.InputMediaVideo: + typed.ParseMode = "" + typed.Caption = stripHTML(typed.Caption) + case *telego.InputMediaAudio: + typed.ParseMode = "" + typed.Caption = stripHTML(typed.Caption) + case *telego.InputMediaDocument: + typed.ParseMode = "" + typed.Caption = stripHTML(typed.Caption) + } + } +} + +func (c *Channel) sendSingleTelegramMediaItem(ctx context.Context, chatID telego.ChatID, item telegramMediaSendItem, replyTo, threadID int) error { + switch item.kind { + case telegramMediaPhoto: + return c.sendPhoto(ctx, chatID, item.media.URL, item.caption, replyTo, threadID) + case telegramMediaVideo: + return c.sendVideo(ctx, chatID, item.media.URL, item.caption, replyTo, threadID) + case telegramMediaAudio: + if item.asVoice { + return c.sendVoice(ctx, chatID, item.media.URL, item.caption, replyTo, threadID) + } + return c.sendAudio(ctx, chatID, item.media.URL, item.caption, replyTo, threadID) + default: + return c.sendDocument(ctx, chatID, item.media.URL, item.caption, replyTo, threadID) + } +} + +func (c *Channel) sendTelegramMediaFollowUp(ctx context.Context, chatID int64, text string, threadID int) error { + if text == "" { + return nil + } + for _, chunk := range chunkHTML(text, telegramMaxMessageLen) { + if err := c.sendHTML(ctx, chatID, chunk, 0, threadID); err != nil { + return err + } + } + return nil +} diff --git a/internal/channels/telegram/send_media_group_helpers.go b/internal/channels/telegram/send_media_group_helpers.go new file mode 100644 index 00000000..747039f8 --- /dev/null +++ b/internal/channels/telegram/send_media_group_helpers.go @@ -0,0 +1,142 @@ +package telegram + +import ( + "log/slog" + "mime" + "os" + "path/filepath" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +const ( + telegramMediaGroupMinItems = 2 + telegramMediaGroupMaxItems = 10 +) + +type telegramMediaKind string + +const ( + telegramMediaPhoto telegramMediaKind = "photo" + telegramMediaVideo telegramMediaKind = "video" + telegramMediaAudio telegramMediaKind = "audio" + telegramMediaDocument telegramMediaKind = "document" +) + +type telegramMediaSendItem struct { + media bus.MediaAttachment + kind telegramMediaKind + caption string + followUpText string + groupable bool + asVoice bool +} + +type telegramMediaChunk struct { + items []telegramMediaSendItem + grouped bool +} + +func (c *Channel) prepareTelegramMediaItems(msg bus.OutboundMessage) ([]telegramMediaSendItem, error) { + content := msg.Content + items := make([]telegramMediaSendItem, 0, len(msg.Media)) + for _, media := range msg.Media { + if err := c.validateOutboundMediaSize(media.URL); err != nil { + return nil, err + } + + caption := media.Caption + if caption == "" && content != "" { + caption = content + content = "" + } + + var followUpText string + if caption != "" { + caption = markdownToTelegramHTML(caption) + if len(caption) > telegramCaptionMaxLen { + followUpText = caption + caption = "" + } + } + + kind, groupable, asVoice := classifyTelegramOutboundMedia(media, msg.Metadata) + items = append(items, telegramMediaSendItem{ + media: media, + kind: kind, + caption: caption, + followUpText: followUpText, + groupable: groupable, + asVoice: asVoice, + }) + } + return items, nil +} + +func classifyTelegramOutboundMedia(media bus.MediaAttachment, metadata map[string]string) (telegramMediaKind, bool, bool) { + ct := strings.ToLower(media.ContentType) + if ct == "" { + ct = strings.ToLower(mime.TypeByExtension(filepath.Ext(media.URL))) + } + + switch { + case strings.HasPrefix(ct, "image/"): + if info, statErr := os.Stat(media.URL); statErr == nil && info.Size() > photoSizeThreshold { + slog.Info("large image, sending as document to preserve quality", "path", media.URL, "size", info.Size()) + return telegramMediaDocument, true, false + } + return telegramMediaPhoto, true, false + case strings.HasPrefix(ct, "video/"): + return telegramMediaVideo, true, false + case strings.HasPrefix(ct, "audio/"): + asVoice := metadata["audio_as_voice"] == "true" && isVoiceCompatible(ct) + return telegramMediaAudio, !asVoice, asVoice + default: + return telegramMediaDocument, true, false + } +} + +func chunkTelegramMediaItems(items []telegramMediaSendItem) []telegramMediaChunk { + chunks := make([]telegramMediaChunk, 0, len(items)) + for i := 0; i < len(items); { + key := telegramMediaGroupKey(items[i]) + if key == "" { + chunks = append(chunks, telegramMediaChunk{items: items[i : i+1]}) + i++ + continue + } + + j := i + 1 + for j < len(items) && telegramMediaGroupKey(items[j]) == key { + j++ + } + + for start := i; start < j; { + size := min(j-start, telegramMediaGroupMaxItems) + chunks = append(chunks, telegramMediaChunk{ + items: items[start : start+size], + grouped: size >= telegramMediaGroupMinItems, + }) + start += size + } + i = j + } + return chunks +} + +func telegramMediaGroupKey(item telegramMediaSendItem) string { + if !item.groupable { + return "" + } + switch item.kind { + case telegramMediaPhoto, telegramMediaVideo: + return "visual" + case telegramMediaAudio: + return "audio" + case telegramMediaDocument: + return "document" + default: + return "" + } +} diff --git a/internal/channels/telegram/send_media_group_test.go b/internal/channels/telegram/send_media_group_test.go new file mode 100644 index 00000000..dbf08555 --- /dev/null +++ b/internal/channels/telegram/send_media_group_test.go @@ -0,0 +1,70 @@ +package telegram + +import ( + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +func TestChunkTelegramMediaItems_GroupsCompatibleRuns(t *testing.T) { + items := []telegramMediaSendItem{ + {media: bus.MediaAttachment{URL: "a.jpg"}, kind: telegramMediaPhoto, groupable: true}, + {media: bus.MediaAttachment{URL: "b.mp4"}, kind: telegramMediaVideo, groupable: true}, + {media: bus.MediaAttachment{URL: "c.pdf"}, kind: telegramMediaDocument, groupable: true}, + {media: bus.MediaAttachment{URL: "d.pdf"}, kind: telegramMediaDocument, groupable: true}, + {media: bus.MediaAttachment{URL: "e.mp3"}, kind: telegramMediaAudio, groupable: true}, + {media: bus.MediaAttachment{URL: "f.jpg"}, kind: telegramMediaPhoto, groupable: true}, + } + + chunks := chunkTelegramMediaItems(items) + if len(chunks) != 4 { + t.Fatalf("chunks len = %d, want 4", len(chunks)) + } + assertTelegramChunk(t, chunks[0], true, 2) + assertTelegramChunk(t, chunks[1], true, 2) + assertTelegramChunk(t, chunks[2], false, 1) + assertTelegramChunk(t, chunks[3], false, 1) +} + +func TestChunkTelegramMediaItems_SplitsAtTelegramMax(t *testing.T) { + items := make([]telegramMediaSendItem, 12) + for i := range items { + items[i] = telegramMediaSendItem{ + media: bus.MediaAttachment{URL: "photo.jpg"}, + kind: telegramMediaPhoto, + groupable: true, + } + } + + chunks := chunkTelegramMediaItems(items) + if len(chunks) != 2 { + t.Fatalf("chunks len = %d, want 2", len(chunks)) + } + assertTelegramChunk(t, chunks[0], true, telegramMediaGroupMaxItems) + assertTelegramChunk(t, chunks[1], true, 2) +} + +func TestChunkTelegramMediaItems_KeepsNonGroupableItemsSeparate(t *testing.T) { + items := []telegramMediaSendItem{ + {media: bus.MediaAttachment{URL: "voice.ogg"}, kind: telegramMediaAudio, groupable: false}, + {media: bus.MediaAttachment{URL: "a.jpg"}, kind: telegramMediaPhoto, groupable: true}, + {media: bus.MediaAttachment{URL: "b.jpg"}, kind: telegramMediaPhoto, groupable: true}, + } + + chunks := chunkTelegramMediaItems(items) + if len(chunks) != 2 { + t.Fatalf("chunks len = %d, want 2", len(chunks)) + } + assertTelegramChunk(t, chunks[0], false, 1) + assertTelegramChunk(t, chunks[1], true, 2) +} + +func assertTelegramChunk(t *testing.T, chunk telegramMediaChunk, grouped bool, size int) { + t.Helper() + if chunk.grouped != grouped { + t.Fatalf("chunk.grouped = %v, want %v", chunk.grouped, grouped) + } + if len(chunk.items) != size { + t.Fatalf("chunk size = %d, want %d", len(chunk.items), size) + } +} diff --git a/internal/tools/send_file.go b/internal/tools/send_file.go index 8057723e..caff4ad4 100644 --- a/internal/tools/send_file.go +++ b/internal/tools/send_file.go @@ -55,17 +55,34 @@ func (t *SendFileTool) Parameters() map[string]any { "type": "string", "description": "Optional text message accompanying the file", }, + "attachments": map[string]any{ + "type": "array", + "description": "Optional batch of files to send in order. When set, each item must include path and may include caption.", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to a file to send (relative to workspace, or absolute)", + }, + "caption": map[string]any{ + "type": "string", + "description": "Optional caption for this attachment", + }, + }, + "required": []string{"path"}, + }, + }, }, - "required": []string{"path"}, } } // Execute resolves and validates the path, checks for duplicate delivery, then // returns a Result with Media populated for downstream pipeline delivery. func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *Result { - path := argString(args, "path") - if path == "" { - return ErrorResult("path is required") + requests, err := parseSendFileRequests(args) + if err != nil { + return ErrorResult(err.Error()) } // Per-request workspace (multi-tenant: each user has own workspace in context). @@ -74,52 +91,57 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *Result workspace = t.workspace } - // Resolve path with allowed-prefixes support (mirrors write_file pattern). allowed := allowedWithTeamWorkspace(ctx, t.allowedPrefixes) - resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed) - if err != nil { - return ErrorResult("cannot access path: " + err.Error()) + media := make([]bus.MediaFile, 0, len(requests)) + seen := make(map[string]struct{}, len(requests)) + for _, req := range requests { + resolved, err := resolvePathWithAllowed(req.Path, workspace, effectiveRestrict(ctx, t.restrict), allowed) + if err != nil { + return ErrorResult("cannot access path: " + err.Error()) + } + + // Deny-paths guard: reject access to internal files (memory.db, config.json, etc.). + if err := checkDeniedPath(resolved, workspace, t.deniedPrefixes); err != nil { + return ErrorResult(err.Error()) + } + + // Stat: file must exist and be a regular file (not a directory or device). + fi, err := os.Stat(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("file not found: %s", req.Path)) + } + if !fi.Mode().IsRegular() { + return ErrorResult(fmt.Sprintf("path is not a regular file: %s", req.Path)) + } + + if _, duplicate := seen[resolved]; duplicate { + return ErrorResult(fmt.Sprintf("duplicate attachment path in batch: %s", filepath.Base(resolved))) + } + seen[resolved] = struct{}{} + + // Duplicate-delivery guard: block if already delivered in this turn. + if dm := DeliveredMediaFromCtx(ctx); dm != nil && dm.IsDelivered(resolved) { + return ErrorResult(fmt.Sprintf( + "file already delivered in this turn: %s. Do not re-send the same file. "+ + "If user explicitly asked to resend, the next turn will reset delivery state.", + filepath.Base(resolved))) + } + + media = append(media, bus.MediaFile{ + Path: resolved, + Filename: filepath.Base(resolved), + MimeType: mimeFromPath(resolved), + Caption: req.Caption, + }) } - // Deny-paths guard: reject access to internal files (memory.db, config.json, etc.). - if err := checkDeniedPath(resolved, workspace, t.deniedPrefixes); err != nil { - return ErrorResult(err.Error()) - } - - // Stat: file must exist and be a regular file (not a directory or device). - fi, err := os.Stat(resolved) - if err != nil { - return ErrorResult(fmt.Sprintf("file not found: %s", path)) - } - if !fi.Mode().IsRegular() { - return ErrorResult(fmt.Sprintf("path is not a regular file: %s", path)) - } - - // Duplicate-delivery guard: block if already delivered in this turn. - if dm := DeliveredMediaFromCtx(ctx); dm != nil && dm.IsDelivered(resolved) { - return ErrorResult(fmt.Sprintf( - "file already delivered in this turn: %s. Do not re-send the same file. "+ - "If user explicitly asked to resend, the next turn will reset delivery state.", - filepath.Base(resolved))) - } - - // Build result — caption overrides default message if provided. - filename := filepath.Base(resolved) - msg := fmt.Sprintf("Sent file: %s", filename) - if caption := argString(args, "caption"); caption != "" { - msg = caption - } - result := SilentResult(msg) - result.Media = []bus.MediaFile{{ - Path: resolved, - Filename: filename, - MimeType: mimeFromPath(resolved), - }} - - // Mark delivered so subsequent send_file or message(MEDIA:) calls detect the duplicate. if dm := DeliveredMediaFromCtx(ctx); dm != nil { - dm.Mark(resolved) + for _, mf := range media { + dm.Mark(mf.Path) + } } + result := SilentResult(sendFileResultMessage(media)) + result.Media = media return result } diff --git a/internal/tools/send_file_batch_requests.go b/internal/tools/send_file_batch_requests.go new file mode 100644 index 00000000..a7101564 --- /dev/null +++ b/internal/tools/send_file_batch_requests.go @@ -0,0 +1,58 @@ +package tools + +import ( + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +type sendFileRequest struct { + Path string + Caption string +} + +func parseSendFileRequests(args map[string]any) ([]sendFileRequest, error) { + if raw, ok := args["attachments"]; ok && raw != nil { + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("attachments must be an array") + } + if len(items) == 0 { + return nil, fmt.Errorf("attachments must include at least one file") + } + requests := make([]sendFileRequest, 0, len(items)) + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("attachments[%d] must be an object", i) + } + path := argString(obj, "path") + if path == "" { + return nil, fmt.Errorf("attachments[%d].path is required", i) + } + requests = append(requests, sendFileRequest{Path: path, Caption: argString(obj, "caption")}) + } + return requests, nil + } + + path := argString(args, "path") + if path == "" { + return nil, fmt.Errorf("path is required") + } + return []sendFileRequest{{Path: path, Caption: argString(args, "caption")}}, nil +} + +func sendFileResultMessage(media []bus.MediaFile) string { + if len(media) == 1 { + if media[0].Caption != "" { + return media[0].Caption + } + return fmt.Sprintf("Sent file: %s", media[0].Filename) + } + names := make([]string, 0, len(media)) + for _, mf := range media { + names = append(names, mf.Filename) + } + return fmt.Sprintf("Sent %d files: %s", len(media), strings.Join(names, ", ")) +} diff --git a/internal/tools/send_file_test.go b/internal/tools/send_file_test.go index 17dfc62c..b40a7cc7 100644 --- a/internal/tools/send_file_test.go +++ b/internal/tools/send_file_test.go @@ -462,3 +462,77 @@ func TestSendFile_T13_BinaryFileLargeNoContentRead(t *testing.T) { t.Errorf("Media[0].Path = %q, want %q", result.Media[0].Path, bigFile) } } + +func TestSendFile_T15_BatchAttachmentsHappyPath(t *testing.T) { + ws, reportPDF, subFile, _ := mkSendFileWorkspace(t) + tool := NewSendFileTool(ws, true) + + result := tool.Execute(context.Background(), map[string]any{ + "attachments": []any{ + map[string]any{"path": reportPDF, "caption": "Q4 report"}, + map[string]any{"path": subFile, "caption": "Notes"}, + }, + }) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if len(result.Media) != 2 { + t.Fatalf("expected 2 Media entries, got %d", len(result.Media)) + } + if result.Media[0].Path != reportPDF || result.Media[1].Path != subFile { + t.Errorf("batch order = [%q, %q], want [%q, %q]", + result.Media[0].Path, result.Media[1].Path, reportPDF, subFile) + } + if result.Media[0].Caption != "Q4 report" || result.Media[1].Caption != "Notes" { + t.Errorf("captions = [%q, %q], want [Q4 report, Notes]", + result.Media[0].Caption, result.Media[1].Caption) + } +} + +func TestSendFile_T16_BatchDuplicateRejectedBeforeMarking(t *testing.T) { + ws, reportPDF, subFile, _ := mkSendFileWorkspace(t) + tool := NewSendFileTool(ws, true) + dm := NewDeliveredMedia() + ctx := WithDeliveredMedia(context.Background(), dm) + + result := tool.Execute(ctx, map[string]any{ + "attachments": []any{ + map[string]any{"path": reportPDF}, + map[string]any{"path": subFile}, + map[string]any{"path": reportPDF}, + }, + }) + if !result.IsError { + t.Fatal("expected duplicate batch error, got success") + } + if !strings.Contains(strings.ToLower(result.ForLLM), "duplicate") { + t.Errorf("expected duplicate error, got: %s", result.ForLLM) + } + if dm.IsDelivered(reportPDF) || dm.IsDelivered(subFile) { + t.Fatal("duplicate batch must not mark any file delivered") + } +} + +func TestSendFile_T17_BatchAlreadyDeliveredRejectedBeforeMarkingNewFiles(t *testing.T) { + ws, reportPDF, subFile, _ := mkSendFileWorkspace(t) + tool := NewSendFileTool(ws, true) + dm := NewDeliveredMedia() + dm.Mark(reportPDF) + ctx := WithDeliveredMedia(context.Background(), dm) + + result := tool.Execute(ctx, map[string]any{ + "attachments": []any{ + map[string]any{"path": subFile}, + map[string]any{"path": reportPDF}, + }, + }) + if !result.IsError { + t.Fatal("expected already-delivered batch error, got success") + } + if !strings.Contains(strings.ToLower(result.ForLLM), "already") { + t.Errorf("expected already-delivered error, got: %s", result.ForLLM) + } + if dm.IsDelivered(subFile) { + t.Fatal("failed batch must not mark new files delivered") + } +} diff --git a/plans/260612-1708-issue-172-multi-attachment-delivery/phase-01-research-and-tdd-contracts.md b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-01-research-and-tdd-contracts.md new file mode 100644 index 00000000..3e7e05b9 --- /dev/null +++ b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-01-research-and-tdd-contracts.md @@ -0,0 +1,51 @@ +--- +phase: 1 +title: Research and TDD Contracts +status: completed +priority: P1 +effort: 1h +dependencies: [] +--- + +# Phase 1: Research and TDD Contracts + +## Overview + +Lock current behavior and expected batch semantics before implementation. Tests should fail until the new batch API and Telegram grouping helpers exist. + +## Requirements + +- Functional: agents can request multiple existing files in one call. +- Functional: returned `Result.Media` preserves file order, filename, MIME type, and per-file captions when provided. +- Functional: duplicate delivery guard rejects already queued files in the same run. +- Non-functional: no file content reads in the tool path; only `stat` and path metadata. +- Non-functional: keep single-file `send_file` behavior backward compatible. + +## Architecture + +`send_file` stays the existing tool name and grows an optional `attachments` array. The array entries mirror the current single-file fields: `path` and optional `caption`. If `attachments` is present, it is the source of truth; otherwise current `path` behavior remains. + +## Related Code Files + +- Modify: `internal/tools/send_file.go` +- Modify: `internal/tools/send_file_test.go` +- Modify: `internal/channels/capabilities.go` +- Create: `internal/channels/telegram/send_media_group_test.go` if helper tests need a focused file. + +## Implementation Steps + +1. Add failing tests for `send_file` with `attachments: [{path, caption}, ...]`. +2. Add failing tests for duplicate handling: one duplicate in a batch rejects before marking new paths. +3. Add failing tests proving old `path` + `caption` still returns one media entry. +4. Add Telegram helper tests for chunk sizing and type grouping without calling the network. +5. Add Discord regression test coverage around multi-file `sendMediaMessage` if existing test hooks allow it without live API. + +## Success Criteria + +- [x] Tests describe batch tool contract and current single-file compatibility. +- [x] Telegram grouping constraints are encoded in pure helper tests. +- [x] No live channel credentials required. + +## Risk Assessment + +Main risk is over-scoping platform capability metadata. Keep tests focused on behavior needed by issue #172. diff --git a/plans/260612-1708-issue-172-multi-attachment-delivery/phase-02-tool-api-batch-attachments.md b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-02-tool-api-batch-attachments.md new file mode 100644 index 00000000..91fc4b42 --- /dev/null +++ b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-02-tool-api-batch-attachments.md @@ -0,0 +1,59 @@ +--- +phase: 2 +title: Tool API Batch Attachments +status: completed +priority: P1 +effort: 2h +dependencies: + - 1 +--- + +# Phase 2: Tool API Batch Attachments + +## Overview + +Implement the backward-compatible tool API extension in `send_file`. + +## Requirements + +- Functional: `send_file({attachments:[...]})` queues multiple media files in one result. +- Functional: path resolution, allow/deny guards, regular-file checks, MIME detection, and duplicate detection match single-file behavior. +- Functional: optional captions are copied onto result media for downstream channel senders. +- Non-functional: batch validation is all-or-nothing; do not mark any path delivered until every entry validates. + +## Architecture + +Refactor `SendFileTool.Execute` into small helpers: +- Parse single vs batch arguments. +- Resolve and validate each requested attachment. +- Check duplicate state against `DeliveredMedia` and within the batch. +- Build `[]bus.MediaFile`, then mark delivered after success. + +No new tool name unless tests show the model schema becomes unclear; existing `send_file` is the least disruptive contract. + +## Related Code Files + +- Modify: `internal/tools/send_file.go` +- Modify: `internal/tools/send_file_test.go` +- Modify: `internal/bus/types.go` +- Modify: `internal/agent/loop_types.go` +- Modify: `internal/agent/loop_tools.go` +- Modify: `cmd/gateway_consumer.go` + +## Implementation Steps + +1. Extend `SendFileTool.Parameters()` with optional `attachments` array. +2. Add caption support to `bus.MediaFile`, `agent.MediaResult`, and `appendMediaToOutbound` so tool-level captions reach channel senders. +3. Implement parsing helpers with clear validation errors for missing path, non-array entries, duplicate paths, and denied paths. +4. Keep existing single-file code path behavior and messages stable. +5. Run `go test ./internal/tools -run 'TestSendFile|TestMessage|TestWriteFile|TestDeliveredMedia'`. + +## Success Criteria + +- [x] Batch call returns N media entries in request order. +- [x] Duplicate paths in same batch or already delivered paths fail cleanly. +- [x] Single-path `send_file` tests still pass. + +## Risk Assessment + +Changing media structs has a broad compile surface. Keep new fields additive with `omitempty` JSON tags and update only conversion points that need captions. diff --git a/plans/260612-1708-issue-172-multi-attachment-delivery/phase-03-channel-capability-and-telegram-grouping.md b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-03-channel-capability-and-telegram-grouping.md new file mode 100644 index 00000000..4574f382 --- /dev/null +++ b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-03-channel-capability-and-telegram-grouping.md @@ -0,0 +1,55 @@ +--- +phase: 3 +title: Channel Capability and Telegram Grouping +status: completed +priority: P1 +effort: 3h +dependencies: + - 2 +--- + +# Phase 3: Channel Capability and Telegram Grouping + +## Overview + +Add channel capability metadata and implement Telegram media-group delivery for compatible batches while preserving ordered fallback elsewhere. + +## Requirements + +- Functional: Telegram sends compatible media in album chunks of 2-10. +- Functional: Telegram documents group only with documents, audio only with audio, and photo/video may group together. +- Functional: mixed or singleton chunks fall back to existing per-file send helpers. +- Functional: caption behavior remains stable: first item uses shared message content when no per-file caption exists; oversized captions become follow-up text. +- Non-functional: existing thread/reply behavior stays intact; reply applies to first grouped chunk only. + +## Architecture + +Add small reusable capability structs in `internal/channels/capabilities.go`, then keep platform-specific grouping logic in Telegram package: +- Capability data exposes max batch count and grouping mode for docs/tests. +- Telegram helper classifies outbound media as photo/video/audio/document, validates size, prepares captions, and emits chunks. +- `sendMediaMessage` chooses media-group chunks only when safe; all other cases call the existing `sendPhoto`, `sendVideo`, `sendAudio`, `sendVoice`, or `sendDocument` helpers. + +## Related Code Files + +- Modify: `internal/channels/capabilities.go` +- Modify: `internal/channels/telegram/send.go` +- Modify/Create: `internal/channels/telegram/*_test.go` +- Modify: `internal/channels/discord/send_media.go` only if regression tests expose a real compatibility gap. + +## Implementation Steps + +1. Add channel capability metadata for Telegram and Discord; Slack is media-capable but no single-message multi-file guarantee in this phase. +2. Add Telegram pure helpers for chunking compatible media batches. +3. Implement `sendMediaGroup` with `telego.SendMediaGroupParams` and file seek/open handling. +4. Keep fallback path for audio-as-voice, oversized image-as-document, singleton chunks, and any group send error where retrying single sends is safer. +5. Run `go test ./internal/channels/telegram ./internal/channels/discord ./internal/channels/slack`. + +## Success Criteria + +- [x] Telegram helper tests prove 2-10 compatible grouping and ordered fallback. +- [x] Discord existing multi-file upload path remains unchanged. +- [x] Slack fallback continues ordered file uploads plus text. + +## Risk Assessment + +Telegram media groups are not transactional across fallback retries. Avoid marking success until the channel call returns nil; do not hide partial failure. diff --git a/plans/260612-1708-issue-172-multi-attachment-delivery/phase-04-regression-verification-and-ship.md b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-04-regression-verification-and-ship.md new file mode 100644 index 00000000..f5a1ee81 --- /dev/null +++ b/plans/260612-1708-issue-172-multi-attachment-delivery/phase-04-regression-verification-and-ship.md @@ -0,0 +1,54 @@ +--- +phase: 4 +title: Regression Verification and Ship +status: completed +priority: P1 +effort: 1h +dependencies: + - 3 +--- + +# Phase 4: Regression Verification and Ship + +## Overview + +Run focused regression checks, update docs if behavior changed, review locally, then ship a beta PR. + +## Requirements + +- Functional: acceptance criteria from issue #172 are demonstrably covered for tool API, Telegram, and Discord/fallback behavior. +- Non-functional: no syntax/build errors in touched packages. +- Non-functional: no secrets or local env values in plan/issue/PR text. + +## Architecture + +Verification should stay package-focused unless touched files force broader coverage. Full integration tests are not required because no DB schema or live channel credential path changes. + +## Related Code Files + +- Modify if warranted: `docs/05-channels-messaging.md` +- Modify if warranted: `docs/project-changelog.md` +- Read: `.github/workflows/ci.yaml` for PR check expectations if local commands differ. + +## Implementation Steps + +1. Run focused tests: + - `PATH=/usr/local/go/bin:$PATH go test ./internal/tools ./internal/channels/telegram ./internal/channels/discord ./internal/channels/slack` +2. Run compile checks: + - `PATH=/usr/local/go/bin:$PATH go test ./internal/channels/... ./internal/tools/...` + - `PATH=/usr/local/go/bin:$PATH go build ./...` + - `PATH=/usr/local/go/bin:$PATH go build -tags sqliteonly ./...` +3. Update docs/changelog only if public behavior changed enough to warrant it. +4. Run local pending diff review and fix Critical/Important findings. +5. Commit, push branch, create PR to `dev`, run PR review/fix/reply, then add `ready to ship beta`. + +## Success Criteria + +- [x] Focused tests pass. +- [x] PG and SQLite builds pass. +- [x] PR targets `dev` and links issue #172. +- [x] Source issue and PR carry `ready to ship beta`; `ready to cook` removed after PR review. + +## Risk Assessment + +`go build ./...` may expose pre-existing platform/toolchain issues in unrelated packages. If so, record exact package/error and still run focused touched-package verification. diff --git a/plans/260612-1708-issue-172-multi-attachment-delivery/plan.md b/plans/260612-1708-issue-172-multi-attachment-delivery/plan.md new file mode 100644 index 00000000..f971a8fa --- /dev/null +++ b/plans/260612-1708-issue-172-multi-attachment-delivery/plan.md @@ -0,0 +1,45 @@ +--- +title: Issue 172 Multi Attachment Delivery +description: '' +status: completed +priority: P2 +branch: codex/issue-172-multi-attachment-delivery +tags: [] +blockedBy: [] +blocks: [] +created: '2026-06-12T10:08:08.060Z' +createdBy: 'ck:plan' +source: skill +--- + +# Issue 172 Multi Attachment Delivery + +## Overview + +Implement GitHub issue #172 in beta mode. Add a batch attachment tool API that lets agents queue multiple existing workspace files in one tool call, preserve order/caption metadata, and let channel senders group uploads where the platform supports it. + +Scout summary: +- `send_file` currently accepts one `path` and returns one `Result.Media` entry: `internal/tools/send_file.go`. +- `write_file(deliver=true)` already queues one file and marks it in `DeliveredMedia`: `internal/tools/filesystem_write.go`. +- `message` can publish embedded multiple `MEDIA:` refs in one `bus.OutboundMessage`: `internal/tools/message.go`. +- Tool result media flows through `agent.MediaResult` and `appendMediaToOutbound`; captions must be copied there or batch metadata is lost. +- Discord already sends multiple files in one message: `internal/channels/discord/send_media.go`. +- Telegram currently loops one media item at a time, even though `telego.SendMediaGroup` supports 2-10 album items with type restrictions: `internal/channels/telegram/send.go`. +- Slack uploads files one by one, so it remains ordered fallback unless/until a safe multi-upload contract is added: `internal/channels/slack/send.go`. + +Scope boundary: +- In scope: backend tool contract, media caption propagation, Telegram grouped album chunks, Discord regression coverage, ordered fallback for unsupported/mixed cases. +- Out of scope: web UI changes, inbound media debounce, database migrations, WhatsApp/Zalo/Lark deep batching beyond existing ordered send behavior. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Research and TDD Contracts](./phase-01-research-and-tdd-contracts.md) | Completed | +| 2 | [Tool API Batch Attachments](./phase-02-tool-api-batch-attachments.md) | Completed | +| 3 | [Channel Capability and Telegram Grouping](./phase-03-channel-capability-and-telegram-grouping.md) | Completed | +| 4 | [Regression Verification and Ship](./phase-04-regression-verification-and-ship.md) | Completed | + +## Dependencies + +No active blocking plan found in the project plan scan. Related historical plan: Discord thread attachment backfill, read-only context. diff --git a/plans/260612-1708-issue-172-multi-attachment-delivery/reports/validation-red-team-report.md b/plans/260612-1708-issue-172-multi-attachment-delivery/reports/validation-red-team-report.md new file mode 100644 index 00000000..e77da2b6 --- /dev/null +++ b/plans/260612-1708-issue-172-multi-attachment-delivery/reports/validation-red-team-report.md @@ -0,0 +1,19 @@ +# Validation and Red-Team Report + +## Validation + +- `ck plan validate --strict plans/260612-1708-issue-172-multi-attachment-delivery/plan.md` passed with 0 errors and 0 warnings. +- Repo matched source issue: `digitopvn/goclaw`, default branch `dev`. +- Source issue #172 is open and asks for multi-attachment delivery with channel limits, fallback, metadata preservation, and tests for Telegram plus another supported channel. +- Verified Telegram `telego.SendMediaGroupParams.Media` requires 2-10 items and docs/audio same-type grouping in local module `github.com/mymmrac/telego@v1.6.0`. + +## Red-Team Findings + +- Accepted: caption metadata would be lost unless copied from `bus.MediaFile` through `agent.MediaResult` into `bus.MediaAttachment`. Plan updated to include these conversion points. +- Accepted: Telegram cannot group every mixed batch. Plan limits media groups to photo/video together, document-only, and audio-only chunks; singleton and unsupported cases use existing ordered sends. +- Rejected: adding live channel integration tests. They require credentials and would make CI flaky; pure helper tests plus existing sender path tests are enough for this scope. +- Deferred: Slack true multi-file batch. Current Slack path uploads files in order and preserves thread routing; Slack's upload behavior needs separate product/API decision. + +## Unresolved Questions + +None.