merge: sync dev into usage analytics branch

This commit is contained in:
Goon committed 2026-06-12 17:58:22 +07:00
commit 06877365c4
89 files changed
+5957 -301

No files matched your search

+1
View File
@@ -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 {
+45 -2
View File
@@ -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
+22 -7
View File
@@ -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()
+5
View File
@@ -41,6 +41,11 @@ func TestResolveGatewayClientOverrides(t *testing.T) {
if got := resolveGatewayBaseURL(); got != "http://remote.example.com:18790" {
t.Fatalf("resolveGatewayBaseURL() = %q, want normalized env URL", got)
}
t.Setenv("GOCLAW_SERVER", "http://127.0.0.1:19999/")
if got := resolveGatewayBaseURL(); got != "http://127.0.0.1:19999" {
t.Fatalf("resolveGatewayBaseURL() = %q, want trimmed GOCLAW_SERVER URL", got)
}
}
func TestGatewayHTTPDoRawUsesServerAndTokenOverride(t *testing.T) {
+6
View File
@@ -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 {
@@ -41,6 +46,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)
+2
View File
@@ -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,
+55 -11
View File
@@ -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)
}
}
}
+15
View File
@@ -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)
}
}
+4
View File
@@ -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())
cmd.AddCommand(skillsDepsCmd())
cmd.AddCommand(skillsAccessCmd())
cmd.AddCommand(skillsGrantCmd())
+222
View File
@@ -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)
printSkillEvolutionJSON(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)
printSkillEvolutionJSON(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)
printSkillEvolutionJSON(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 {
printSkillEvolutionJSON(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)
printSkillEvolutionJSON(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)
printSkillEvolutionJSON(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)
printSkillEvolutionJSON(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)
printSkillEvolutionJSON(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 printSkillEvolutionJSON(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()
}
+18
View File
@@ -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:
+30
View File
@@ -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:
@@ -362,7 +362,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 `/<slug>` or
`/use <skill>` 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 |
|--------|-------------|
@@ -370,7 +425,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
+48
View File
@@ -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**
@@ -24,6 +45,33 @@ Significant changes, features, and fixes in reverse chronological order.
- Added command/client regressions for gateway URL and token overrides, trace
query serialization, follow scope validation, and timeline run ID handling.
### 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.
### Skill lifecycle API and CLI (issue #159)
**Changes**
+51
View File
@@ -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)), " ")
}
+9 -1
View File
@@ -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
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -59,6 +60,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)
@@ -186,6 +190,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)
@@ -264,6 +271,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) {
+1 -1
View File
@@ -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]
}
+11
View File
@@ -255,6 +255,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
@@ -456,6 +460,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
}
@@ -589,6 +597,8 @@ func NewLoop(cfg LoopConfig) *Loop {
orchMode: cfg.OrchMode,
delegateTargets: cfg.DelegateTargets,
evolutionMetricsStore: cfg.EvolutionMetricsStore,
skillEvolutionStore: cfg.SkillEvolutionStore,
skillStore: cfg.SkillStore,
userResolver: cfg.UserResolver,
}
}
@@ -674,6 +684,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).
+6 -2
View File
@@ -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
@@ -544,6 +546,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
OrchMode: orchMode,
DelegateTargets: delegateTargets,
EvolutionMetricsStore: evoMetricsStore,
SkillEvolutionStore: deps.SkillEvolutionStore,
SkillStore: deps.SkillStore,
UserResolver: newContactResolver(deps.ContactStore),
})
+3 -1
View File
@@ -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
@@ -42,6 +43,7 @@ func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt
}
skillFilter = []string{result.Skill.Slug}
l.recordSkillSlashUsageEvent(ctx, 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:
@@ -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{
+10 -9
View File
@@ -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)
}
+46
View File
@@ -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}
}
@@ -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
}
+79 -15
View File
@@ -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
@@ -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 <context_file> block says I should answer shortly.",
"The <internal_config> 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)
}
}
+11 -10
View File
@@ -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,
}
}
+24 -72
View File
@@ -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 <code>...</code>) 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
}
}
}
@@ -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
}
@@ -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 ""
}
}
@@ -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)
}
}
+40 -21
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
}
@@ -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()
+29
View File
@@ -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.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))
@@ -138,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.
+583
View File
@@ -0,0 +1,583 @@
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/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"`
}
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, r *http.Request) bool {
if h.evolutionStore == nil {
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 {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "skill")
return uuid.Nil, store.SkillInfo{}, false
}
info, ok := h.skills.GetSkillByID(r.Context(), id)
if !ok {
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, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
settings, err := h.evolutionStore.GetSettings(r.Context(), skillID)
if err != nil {
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, r) {
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 {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, 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 {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidEvolutionMode)
return
}
updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{
SkillID: skillID,
Enabled: enabled,
Mode: mode,
})
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, 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, r) {
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 {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, 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 {
writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgActivityStoreNotConfigured)
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 {
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, r) {
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 {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, 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, r) {
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) == "" {
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 {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidRequest, 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 {
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})
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, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion")
return
}
sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
if sg == nil || sg.SkillID != skillID {
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 {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
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, r) {
return
}
skillID, info, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
if info.IsSystem {
writeSkillEvolutionError(w, r, http.StatusForbidden, i18n.MsgSystemSkillMutationBlocked)
return
}
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
if err != nil {
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 {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
if sg == nil || sg.SkillID != skillID {
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String())
return
}
if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil {
writeJSON(w, http.StatusOK, sg)
return
}
if sg.Status != store.SkillSuggestionStatusApproved {
if !req.Approve {
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 {
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": h.skillEvolutionApplyErrorMessage(r, skillID, err)})
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, errSkillEvolutionSkillNotFound
}
if isSystem {
return nil, errSkillEvolutionSystemMutation
}
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("%w: %v", errSkillEvolutionInvalidDraftPatch, err)
}
}
if patch.Content == nil && patch.Find == "" {
return nil, errSkillEvolutionDraftPatchRequired
}
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)
}
removeDestOnError := true
defer func() {
_ = os.RemoveAll(tmpDir)
if removeDestOnError {
_ = 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, errSkillEvolutionFindTextNotFound
}
nextContent = replaced
}
if cleanTarget == "SKILL.md" {
violations, safe := skills.GuardSkillContent(nextContent)
if !safe {
return nil, fmt.Errorf("%w: %s", errSkillEvolutionGuardPolicyViolation, 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)
}
// 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,
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,
})
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
}
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
}
+292
View File
@@ -0,0 +1,292 @@
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"
)
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)
}
}
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)
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
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) {
return nil, nil
}
func (s *skillEvolutionStoreStub) UpsertSettings(context.Context, store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) {
s.settingsUpdates++
return &store.SkillEvolutionSettings{}, 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, 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, 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) {
if s.markAppliedErr != nil {
return nil, s.markAppliedErr
}
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) {
if s.createVersionErr != nil {
return nil, s.createVersionErr
}
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
}
+19 -2
View File
@@ -951,6 +951,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(),
@@ -1058,6 +1061,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
@@ -1242,8 +1255,12 @@ func (s *skillManageStoreStub) ListUserGrantsForSkill(_ context.Context, skillID
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
}
// ---------------------------------------------------------------------------
+22 -6
View File
@@ -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",
@@ -133,9 +141,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'",
+22 -6
View File
@@ -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",
@@ -133,9 +141,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'",
+22 -6
View File
@@ -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: "请求频率超限 — 请稍候",
@@ -133,9 +141,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'",
+22 -6
View File
@@ -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"
@@ -134,9 +142,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"
+50
View File
@@ -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
}
@@ -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)
}
})
}
}
+1
View File
@@ -56,6 +56,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),
+440
View File
@@ -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)
+124
View File
@@ -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)
}
+1
View File
@@ -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),
UsageEvents: NewSQLiteUsageEventStore(db),
+98 -5
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 48
const SchemaVersion = 49
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -848,8 +848,14 @@ DROP TABLE skill_user_grants;
ALTER TABLE skill_user_grants_new RENAME TO skill_user_grants;
CREATE INDEX IF NOT EXISTS idx_skill_user_grants_user ON skill_user_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_skill_user_grants_tenant ON skill_user_grants(tenant_id);`,
// Version 47 → 48: append-only usage event analytics.
47: `CREATE TABLE IF NOT EXISTS usage_events (
// Version 47 → 48: skill self-evolution settings, metrics, suggestions, and immutable version records.
47: addSkillSelfEvolutionTables,
// Version 48 → 49: append-only usage event analytics.
48: addUsageEventAnalyticsTables,
}
const addUsageEventAnalyticsTables = `
CREATE TABLE IF NOT EXISTS usage_events (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
event_time TEXT NOT NULL,
@@ -932,8 +938,95 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_event_rollups_unique
CREATE INDEX IF NOT EXISTS idx_usage_event_rollups_tenant_hour
ON usage_event_rollups(tenant_id, bucket_hour DESC);
CREATE INDEX IF NOT EXISTS idx_usage_event_rollups_resource_hour
ON usage_event_rollups(tenant_id, resource_type, resource_name, bucket_hour DESC);`,
}
ON usage_event_rollups(tenant_id, resource_type, resource_name, bucket_hour DESC);`
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 (
+80
View File
@@ -2198,3 +2198,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 000079 / SQLite schema 48)
-- ============================================================
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);
@@ -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)
+1
View File
@@ -36,6 +36,7 @@ type Stores struct {
Tenants TenantStore
BuiltinToolTenantCfgs BuiltinToolTenantConfigStore
SkillTenantCfgs SkillTenantConfigStore
SkillEvolution SkillEvolutionStore
SystemConfigs SystemConfigStore
SubagentTasks SubagentTaskStore
Vault VaultStore
+1 -1
View File
@@ -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
@@ -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{
+1
View File
@@ -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",
+4
View File
@@ -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)
}
+67 -45
View File
@@ -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
}
@@ -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, ", "))
}
+74
View File
@@ -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")
}
}
+1 -35
View File
@@ -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) {
+1 -1
View File
@@ -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 = 79
const RequiredSchemaVersion uint = 80
@@ -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;
@@ -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;
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+5
View File
@@ -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.",
+25
View File
@@ -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",
+5
View File
@@ -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.",
+25
View File
@@ -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",
+5
View File
@@ -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": "召唤失败,请调整设置后重试。",
+25
View File
@@ -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": "更新技能失败",
+4
View File
@@ -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: {
+43
View File
@@ -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 }
</div>
<Switch id="setup-self-evolve" checked={selfEvolve} onCheckedChange={setSelfEvolve} />
</div>
{!isEditing && (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="space-y-0.5">
<Label htmlFor="setup-gateway-operator" className="text-sm font-normal">
{t("agent.gatewayOperatorAccess")}
</Label>
<p className="text-xs text-muted-foreground">{t("agent.gatewayOperatorAccessDesc")}</p>
</div>
<Switch
id="setup-gateway-operator"
checked={gatewayOperatorAccess}
onCheckedChange={setGatewayOperatorAccess}
/>
</div>
)}
</div>
{gatewayOperatorNotice && (
<p
className={`rounded-md border px-3 py-2 text-xs ${
gatewayOperatorNotice.variant === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200"
}`}
>
{gatewayOperatorNotice.message}
</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className={`flex ${onBack ? "justify-between" : "justify-end"} gap-2`}>
@@ -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<SkillEvolutionSettings>(`/v1/skills/${skillId}/evolution`),
enabled: enabled && !!skillId,
});
const metrics = useQuery({
queryKey: skillId ? queryKeys.skills.metrics(skillId) : ["skills", "missing", "metrics"],
queryFn: () => http.get<SkillUsageStats>(`/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<Pick<SkillEvolutionSettings, "enabled" | "mode">>) => {
if (!skillId) return;
await http.patch<SkillEvolutionSettings>(`/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,
};
}
@@ -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();
});
});
@@ -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,
@@ -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");
});
+20 -10
View File
@@ -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({
)}
</DialogHeader>
<Tabs value={detailTab === "files" && hasFiles ? "files" : "content"} className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<Tabs value={activeDetailTab} className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="content">{t("detail.content")}</TabsTrigger>
{hasFiles && <TabsTrigger value="files">{t("detail.files")}</TabsTrigger>}
{hasEvolution && <TabsTrigger value="evolution">{t("evolution.tab")}</TabsTrigger>}
</TabsList>
<TabsContent value="content" className="flex-1 overflow-y-auto mt-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
@@ -331,6 +335,12 @@ export function SkillDetailDialog({
/>
</TabsContent>
)}
{hasEvolution && (
<TabsContent value="evolution" className="flex-1 overflow-y-auto mt-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
<SkillEvolutionPanel skill={skill} active={activeDetailTab === "evolution"} />
</TabsContent>
)}
</Tabs>
</DialogContent>
</Dialog>
@@ -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 (
<div className="rounded-md border border-dashed bg-muted/20 p-6 text-sm text-muted-foreground">
{t("evolution.unavailable")}
</div>
);
}
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 (
<div className="space-y-4">
<section className="rounded-md border p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">{t("evolution.title")}</h3>
{isBusy && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</div>
<p className="text-sm text-muted-foreground">{t("evolution.status", { status: data?.enabled ? t("evolution.enabled") : t("evolution.disabled") })}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="skill-evolution-enabled" className="text-sm">{t("evolution.enabled")}</Label>
<Switch
id="skill-evolution-enabled"
checked={!!data?.enabled}
disabled={settings.isLoading}
onCheckedChange={(enabled) => updateSettings({ enabled })}
/>
</div>
<Select
value={data?.mode ?? "suggest_only"}
onValueChange={(mode) => updateSettings({ mode: mode as "suggest_only" | "auto_analyze" })}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="suggest_only">{t("evolution.modes.suggest_only")}</SelectItem>
<SelectItem value="auto_analyze">{t("evolution.modes.auto_analyze")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</section>
<section className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium">{t("evolution.metrics")}</h3>
{stats?.last_used_at && <span className="text-xs text-muted-foreground">{new Date(stats.last_used_at).toLocaleString()}</span>}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label={t("evolution.total")} value={stats?.total_calls ?? 0} />
<Metric label={t("evolution.succeeded")} value={stats?.succeeded ?? 0} />
<Metric label={t("evolution.failed")} value={stats?.failed ?? 0} />
<Metric label={t("evolution.successRate")} value={`${Math.round((stats?.success_rate ?? 0) * 100)}%`} />
</div>
</section>
<section className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium">{t("evolution.suggestions")}</h3>
<Badge variant={pending.length > 0 ? "default" : "outline"}>{pending.length}</Badge>
</div>
<div className="space-y-2">
{(suggestions.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t("evolution.noSuggestions")}</p>
) : (
suggestions.data?.map((item) => (
<div key={item.id} className="rounded-md border bg-muted/20 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.status}</Badge>
<span className="text-sm font-medium">{item.suggestion_type}</span>
{item.target_file && <span className="text-xs text-muted-foreground">{item.target_file}</span>}
</div>
<p className="text-sm text-muted-foreground">{item.reason || t("evolution.noReason")}</p>
</div>
<div className="flex shrink-0 gap-1">
{item.status === "pending" && (
<>
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => approveSuggestion(item.id)} aria-label={t("evolution.approve")}>
<Check className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => rejectSuggestion(item.id)} aria-label={t("evolution.reject")}>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
{item.status === "approved" && !skill.is_system && (
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => applySuggestion(item.id)} aria-label={t("evolution.apply")}>
<Play className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
))
)}
</div>
</section>
{!activity.isError && (
<section className="rounded-md border p-4">
<h3 className="mb-3 text-sm font-medium">{t("evolution.activity")}</h3>
<div className="space-y-2">
{(activity.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t("evolution.noActivity")}</p>
) : (
activity.data?.slice(0, 8).map((item) => (
<div key={item.id} className="flex flex-col gap-1 rounded-md bg-muted/30 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm">{item.action}</span>
<span className="text-xs text-muted-foreground">{new Date(item.created_at).toLocaleString()}</span>
</div>
))
)}
</div>
</section>
)}
</div>
);
}
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-md bg-muted/30 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
);
}
+9
View File
@@ -212,6 +212,15 @@ export interface AgentData {
other_config?: Record<string, unknown> | 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 {
+51
View File
@@ -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;
}