mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-27 16:19:50 +00:00
feat(skills): add slash command activation
This commit is contained in:
@@ -209,6 +209,7 @@ func wireExtras(
|
||||
ToolPolicy: toolPE,
|
||||
Skills: skillsLoader,
|
||||
SkillAccessStore: skillAccessStore,
|
||||
SkillSlashCommands: appCfg.Skills.SlashCommands,
|
||||
HasMemory: hasMemory,
|
||||
TraceCollector: traceCollector,
|
||||
EnsureUserProfile: ensureUserProfile,
|
||||
|
||||
@@ -279,6 +279,30 @@ This decision is re-evaluated each time the system prompt is built, so newly hot
|
||||
|
||||
---
|
||||
|
||||
## 9.5. Explicit Slash Skill Commands
|
||||
|
||||
Users can bypass implicit skill matching by starting a prompt with a slash command:
|
||||
|
||||
| Pattern | Behavior |
|
||||
|---------|----------|
|
||||
| `/<slug> prompt` | Activates the skill by slug and treats `prompt` as the skill input |
|
||||
| `/use <slug-or-name> prompt` | Activates the skill by slug or display name |
|
||||
| `/list-skills` | Shows available skills for the current agent context |
|
||||
| `/help <slug-or-name>` | Shows description and usage guidance for one skill |
|
||||
|
||||
Slash detection runs during prompt construction after request context is scoped and before the skills section is built. A matched skill narrows the per-request `SkillFilter` to that skill and injects the full `SKILL.md` instructions into the system prompt for the current turn only. Normal matching remains unchanged for messages that do not start with the configured prefix, path-like strings such as `/home/user/file`, or unresolved commands without suggestions.
|
||||
|
||||
Tenant settings live in `system_configs`:
|
||||
|
||||
| Key | Default | Behavior |
|
||||
|-----|---------|----------|
|
||||
| `skills.slash_commands.enabled` | `true` | Enable slash command detection |
|
||||
| `skills.slash_commands.suggest_not_found` | `true` | Suggest similar skills for unknown commands |
|
||||
| `skills.slash_commands.partial_matching` | `false` | Allow unique prefixes such as `/frontend` |
|
||||
| `skills.slash_commands.prefix` | `/` | Single-character command prefix |
|
||||
|
||||
---
|
||||
|
||||
## 10. Skills -- BM25 Search
|
||||
|
||||
An in-memory BM25 index provides keyword-based skill search. The index is lazily rebuilt whenever the skill version changes.
|
||||
|
||||
@@ -29,6 +29,11 @@ How skills access Python, Node.js, and system tools inside Docker containers and
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Explicit skill activation is handled before runtime execution. When a user starts
|
||||
their prompt with `/<skill-slug>` or `/use <skill name>`, the gateway resolves
|
||||
the skill, injects its `SKILL.md` into the current turn, and then normal runtime
|
||||
rules apply to any scripts or package dependencies that skill uses.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pre-installed Packages (Option A)
|
||||
|
||||
@@ -337,6 +337,12 @@ tenant `system_configs["skills.max_upload_size_mb"]`, then `SKILL.md` frontmatte
|
||||
`GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB`, then the default 20 MB. Values are clamped
|
||||
to 1-500 MB.
|
||||
|
||||
Skill slash-command behavior is configured through tenant `system_configs`:
|
||||
`skills.slash_commands.enabled`, `skills.slash_commands.suggest_not_found`,
|
||||
`skills.slash_commands.partial_matching`, and `skills.slash_commands.prefix`.
|
||||
The default prefix is `/`; supported prompt forms are `/<slug> prompt`,
|
||||
`/use <slug-or-name> prompt`, `/list-skills`, and `/help <slug-or-name>`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent |
|
||||
|
||||
@@ -6,6 +6,17 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
## 2026-05-24
|
||||
|
||||
### Slash skill commands
|
||||
|
||||
**Features**
|
||||
|
||||
- Added explicit slash skill activation for `/<slug>`, `/use <slug-or-name>`, `/list-skills`, and `/help <slug-or-name>`.
|
||||
- Added tenant settings for slash command enablement, similar-skill suggestions, partial matching, and custom prefix.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Added backend coverage for parser false positives, exact/partial skill resolution, suggestions, help/list commands, and config overlays.
|
||||
|
||||
### Configurable skill upload limits
|
||||
|
||||
**Features**
|
||||
|
||||
@@ -117,6 +117,8 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
}
|
||||
}
|
||||
|
||||
userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, 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
|
||||
// appear in ## Tooling when the current channel doesn't support them.
|
||||
|
||||
@@ -122,11 +122,12 @@ type Loop struct {
|
||||
summarizeMu sync.Map // sessionKey → *sync.Mutex
|
||||
|
||||
// Bootstrap/persona context (loaded at startup, injected into system prompt)
|
||||
ownerIDs []string
|
||||
skillsLoader *skills.Loader
|
||||
skillAllowList []string // nil = all, [] = none, ["x","y"] = filter
|
||||
hasMemory bool
|
||||
contextFiles []bootstrap.ContextFile
|
||||
ownerIDs []string
|
||||
skillsLoader *skills.Loader
|
||||
skillAllowList []string // nil = all, [] = none, ["x","y"] = filter
|
||||
skillSlashCommands config.SkillSlashCommandConfig
|
||||
hasMemory bool
|
||||
contextFiles []bootstrap.ContextFile
|
||||
|
||||
// Per-user profile + file seeding + dynamic context loading
|
||||
ensureUserProfile EnsureUserProfileFunc // create/resolve user profile + workspace
|
||||
@@ -185,6 +186,7 @@ type Loop struct {
|
||||
// Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']).
|
||||
// Filesystem tools (read_file, write_file, edit, list_files) check these at execution time.
|
||||
tenantAllowedPaths []string
|
||||
systemConfigs store.SystemConfigStore
|
||||
|
||||
// Per-tenant disabled tools (tool name → true means excluded from LLM)
|
||||
disabledTools map[string]bool
|
||||
@@ -330,11 +332,12 @@ type LoopConfig struct {
|
||||
OnEvent func(AgentEvent)
|
||||
|
||||
// Bootstrap/persona context
|
||||
OwnerIDs []string
|
||||
SkillsLoader *skills.Loader
|
||||
SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter
|
||||
HasMemory bool
|
||||
ContextFiles []bootstrap.ContextFile
|
||||
OwnerIDs []string
|
||||
SkillsLoader *skills.Loader
|
||||
SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter
|
||||
SkillSlashCommands config.SkillSlashCommandConfig
|
||||
HasMemory bool
|
||||
ContextFiles []bootstrap.ContextFile
|
||||
|
||||
// Compaction config
|
||||
CompactionCfg *config.CompactionConfig
|
||||
@@ -383,6 +386,7 @@ type LoopConfig struct {
|
||||
|
||||
// Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']).
|
||||
TenantAllowedPaths []string
|
||||
SystemConfigs store.SystemConfigStore
|
||||
|
||||
// Per-tenant disabled tools (tool name → true means excluded)
|
||||
DisabledTools map[string]bool
|
||||
@@ -530,6 +534,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
ownerIDs: cfg.OwnerIDs,
|
||||
skillsLoader: cfg.SkillsLoader,
|
||||
skillAllowList: cfg.SkillAllowList,
|
||||
skillSlashCommands: cfg.SkillSlashCommands,
|
||||
hasMemory: cfg.HasMemory,
|
||||
contextFiles: cfg.ContextFiles,
|
||||
defaultTimezone: cfg.DefaultTimezone,
|
||||
@@ -553,6 +558,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
builtinToolSettings: cfg.BuiltinToolSettings,
|
||||
tenantToolSettings: cfg.TenantToolSettings,
|
||||
tenantAllowedPaths: cfg.TenantAllowedPaths,
|
||||
systemConfigs: cfg.SystemConfigs,
|
||||
disabledTools: cfg.DisabledTools,
|
||||
reasoningConfig: cfg.ReasoningConfig,
|
||||
promptMode: cfg.PromptMode,
|
||||
|
||||
@@ -85,7 +85,8 @@ type ResolverDeps struct {
|
||||
MCPGrantChecker mcpbridge.GrantChecker
|
||||
|
||||
// Skill access store — for per-agent skill visibility filtering
|
||||
SkillAccessStore store.SkillAccessStore
|
||||
SkillAccessStore store.SkillAccessStore
|
||||
SkillSlashCommands config.SkillSlashCommandConfig
|
||||
|
||||
// Config permission store for group file writer checks
|
||||
ConfigPermStore store.ConfigPermissionStore
|
||||
@@ -491,6 +492,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithWorkspace(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), hasTeam), isTeamLead),
|
||||
SkillsLoader: deps.Skills,
|
||||
SkillAllowList: skillAllowList,
|
||||
SkillSlashCommands: deps.SkillSlashCommands,
|
||||
HasMemory: hasMemory,
|
||||
ContextFiles: contextFiles,
|
||||
EnsureUserProfile: deps.EnsureUserProfile,
|
||||
@@ -511,6 +513,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
BuiltinToolSettings: builtinSettings,
|
||||
TenantToolSettings: tenantToolSettings,
|
||||
TenantAllowedPaths: tenantAllowedPaths,
|
||||
SystemConfigs: deps.SystemConfigs,
|
||||
DisabledTools: disabledTools,
|
||||
ReasoningConfig: store.ResolveEffectiveReasoningConfig(providerReasoningDefaults, ag.ParseReasoningConfig()),
|
||||
PromptMode: PromptMode(ag.ParsePromptMode()),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
)
|
||||
|
||||
func (r skillSlashCommandResult) systemPromptSection() string {
|
||||
switch r.Kind {
|
||||
case skillSlashCommandActivate:
|
||||
return fmt.Sprintf("## Explicit Skill Activation\n\nThe user explicitly requested skill `%s` (%s). Use this skill for the current request and treat the remaining user message as the skill input. Call `use_skill` with name `%s` for observability when available, then follow these instructions:\n\n%s", r.Skill.Slug, r.Skill.Name, r.Skill.Slug, r.SkillContent)
|
||||
case skillSlashCommandList, skillSlashCommandHelp, skillSlashCommandUnknown:
|
||||
return r.Guidance
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildSkillSlashListGuidance(all []skills.Info) string {
|
||||
if len(all) == 0 {
|
||||
return "## Skill Slash Command\n\nNo skills are currently available."
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Slug < all[j].Slug })
|
||||
var lines []string
|
||||
lines = append(lines, "## Skill Slash Command", "", "Available skills:")
|
||||
for _, skill := range all {
|
||||
lines = append(lines, fmt.Sprintf("- `%s` - %s", skill.Slug, skillDisplayDescription(skill)))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildSkillSlashHelpGuidance(skill skills.Info) string {
|
||||
return fmt.Sprintf("## Skill Slash Command\n\nSkill `%s` (%s)\nDescription: %s\nLocation: %s\nExplain this skill and how to invoke it with the configured slash prefix.", skill.Slug, skill.Name, skillDisplayDescription(skill), filepath.ToSlash(skill.Path))
|
||||
}
|
||||
|
||||
func buildSkillSlashUnknownGuidance(target string, suggestions []skills.Info) string {
|
||||
var lines []string
|
||||
lines = append(lines, "## Skill Slash Command", "", fmt.Sprintf("Requested skill `%s` was not found. Suggest these available alternatives:", target))
|
||||
for _, skill := range suggestions {
|
||||
lines = append(lines, fmt.Sprintf("- `%s` - %s", skill.Slug, skillDisplayDescription(skill)))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func skillDisplayDescription(skill skills.Info) string {
|
||||
if strings.TrimSpace(skill.Description) != "" {
|
||||
return strings.TrimSpace(skill.Description)
|
||||
}
|
||||
return skill.Name
|
||||
}
|
||||
|
||||
func appendExtraPrompt(existing, addition string) string {
|
||||
addition = strings.TrimSpace(addition)
|
||||
if addition == "" {
|
||||
return existing
|
||||
}
|
||||
if strings.TrimSpace(existing) == "" {
|
||||
return addition
|
||||
}
|
||||
return existing + "\n\n" + addition
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
)
|
||||
|
||||
type parsedSkillSlashCommand struct {
|
||||
verb string
|
||||
target string
|
||||
rest string
|
||||
}
|
||||
|
||||
func parseSkillSlashCommand(message, prefix string) (parsedSkillSlashCommand, bool) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" || !strings.HasPrefix(message, prefix) {
|
||||
return parsedSkillSlashCommand{}, false
|
||||
}
|
||||
after := strings.TrimSpace(strings.TrimPrefix(message, prefix))
|
||||
if after == "" || looksLikePath(after) {
|
||||
return parsedSkillSlashCommand{}, false
|
||||
}
|
||||
first, rest, _ := strings.Cut(after, " ")
|
||||
first = strings.TrimSpace(first)
|
||||
rest = strings.TrimSpace(rest)
|
||||
switch strings.ToLower(first) {
|
||||
case "list-skills":
|
||||
return parsedSkillSlashCommand{verb: "list-skills"}, true
|
||||
case "help":
|
||||
if rest == "" {
|
||||
return parsedSkillSlashCommand{}, false
|
||||
}
|
||||
return parsedSkillSlashCommand{verb: "help", target: rest}, true
|
||||
case "use", "activate":
|
||||
if rest == "" {
|
||||
return parsedSkillSlashCommand{}, false
|
||||
}
|
||||
return parsedSkillSlashCommand{verb: strings.ToLower(first), rest: rest}, true
|
||||
default:
|
||||
return parsedSkillSlashCommand{verb: "direct", target: first, rest: rest}, true
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikePath(value string) bool {
|
||||
first, _, _ := strings.Cut(value, " ")
|
||||
return strings.Contains(first, "/") || strings.Contains(first, "\\") || strings.Contains(first, ".")
|
||||
}
|
||||
|
||||
func firstWord(value string) (string, string) {
|
||||
first, rest, ok := strings.Cut(strings.TrimSpace(value), " ")
|
||||
if !ok {
|
||||
return strings.TrimSpace(value), ""
|
||||
}
|
||||
return strings.TrimSpace(first), strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
func matchSkillCommandTarget(all []skills.Info, raw string, partial bool) (skills.Info, bool, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return skills.Info{}, false, ""
|
||||
}
|
||||
type candidate struct {
|
||||
info skills.Info
|
||||
matchText string
|
||||
remainder string
|
||||
score int
|
||||
}
|
||||
var matches []candidate
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
partialTarget, partialRemainder := firstWord(raw)
|
||||
lowerPartialTarget := strings.ToLower(partialTarget)
|
||||
for _, skill := range all {
|
||||
for _, value := range []string{skill.Slug, skill.Name} {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
lowerValue := strings.ToLower(value)
|
||||
if lowerRaw == lowerValue {
|
||||
matches = append(matches, candidate{info: skill, matchText: value, score: len([]rune(value))})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(lowerRaw, lowerValue+" ") {
|
||||
remainder := trimMatchedSkillCommandPrefix(raw, value)
|
||||
matches = append(matches, candidate{info: skill, matchText: value, remainder: remainder, score: len([]rune(value))})
|
||||
continue
|
||||
}
|
||||
if partial && lowerPartialTarget != "" && strings.HasPrefix(lowerValue, lowerPartialTarget) {
|
||||
matches = append(matches, candidate{info: skill, matchText: value, remainder: partialRemainder, score: len([]rune(partialTarget))})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return skills.Info{}, false, ""
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].score > matches[j].score
|
||||
})
|
||||
bestBySlug := make([]candidate, 0, len(matches))
|
||||
seenSlug := make(map[string]struct{}, len(matches))
|
||||
for _, match := range matches {
|
||||
if _, ok := seenSlug[match.info.Slug]; ok {
|
||||
continue
|
||||
}
|
||||
seenSlug[match.info.Slug] = struct{}{}
|
||||
bestBySlug = append(bestBySlug, match)
|
||||
}
|
||||
best := bestBySlug[0]
|
||||
if len(bestBySlug) > 1 && bestBySlug[0].score == bestBySlug[1].score {
|
||||
return skills.Info{}, false, ""
|
||||
}
|
||||
return best.info, true, best.remainder
|
||||
}
|
||||
|
||||
func trimMatchedSkillCommandPrefix(raw, matched string) string {
|
||||
rawRunes := []rune(raw)
|
||||
matchedRunes := []rune(matched)
|
||||
if len(rawRunes) < len(matchedRunes) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(rawRunes[len(matchedRunes):]))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
)
|
||||
|
||||
func unknownSkillSlashResult(all []skills.Info, target string, cfg config.SkillSlashCommandConfig) skillSlashCommandResult {
|
||||
if !cfg.EffectiveSuggestNotFound() {
|
||||
return skillSlashCommandResult{Kind: skillSlashCommandNone}
|
||||
}
|
||||
suggestions := similarSkills(all, target, 3)
|
||||
if len(suggestions) == 0 {
|
||||
return skillSlashCommandResult{Kind: skillSlashCommandNone}
|
||||
}
|
||||
return skillSlashCommandResult{
|
||||
Kind: skillSlashCommandUnknown,
|
||||
Guidance: buildSkillSlashUnknownGuidance(target, suggestions),
|
||||
Suggestions: suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
func similarSkills(all []skills.Info, target string, limit int) []skills.Info {
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
type scored struct {
|
||||
info skills.Info
|
||||
score int
|
||||
}
|
||||
var scoredSkills []scored
|
||||
for _, skill := range all {
|
||||
best := scoreSimilarSkill(target, skill)
|
||||
if best <= 3 {
|
||||
scoredSkills = append(scoredSkills, scored{info: skill, score: best})
|
||||
}
|
||||
}
|
||||
sort.Slice(scoredSkills, func(i, j int) bool {
|
||||
if scoredSkills[i].score == scoredSkills[j].score {
|
||||
return scoredSkills[i].info.Slug < scoredSkills[j].info.Slug
|
||||
}
|
||||
return scoredSkills[i].score < scoredSkills[j].score
|
||||
})
|
||||
if len(scoredSkills) > limit {
|
||||
scoredSkills = scoredSkills[:limit]
|
||||
}
|
||||
out := make([]skills.Info, len(scoredSkills))
|
||||
for i, scored := range scoredSkills {
|
||||
out[i] = scored.info
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scoreSimilarSkill(target string, skill skills.Info) int {
|
||||
targetRunes := []rune(target)
|
||||
slug := strings.ToLower(skill.Slug)
|
||||
best := slashSkillDistance(target, slug)
|
||||
if slugRunes := []rune(slug); len(slugRunes) >= len(targetRunes) {
|
||||
best = min(best, slashSkillDistance(target, string(slugRunes[:len(targetRunes)])))
|
||||
}
|
||||
name := strings.ToLower(skill.Name)
|
||||
if name != "" {
|
||||
best = min(best, slashSkillDistance(target, name))
|
||||
if nameRunes := []rune(name); len(nameRunes) >= len(targetRunes) {
|
||||
best = min(best, slashSkillDistance(target, string(nameRunes[:len(targetRunes)])))
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(slug, target) || strings.Contains(name, target) {
|
||||
best = 0
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func slashSkillDistance(a, b string) int {
|
||||
ar := []rune(a)
|
||||
br := []rune(b)
|
||||
if len(ar) == 0 {
|
||||
return len(br)
|
||||
}
|
||||
prev := make([]int, len(br)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i, ca := range ar {
|
||||
cur := make([]int, len(br)+1)
|
||||
cur[0] = i + 1
|
||||
for j, cb := range br {
|
||||
cost := 0
|
||||
if unicode.ToLower(ca) != unicode.ToLower(cb) {
|
||||
cost = 1
|
||||
}
|
||||
cur[j+1] = min(min(cur[j]+1, prev[j+1]+1), prev[j]+cost)
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
return prev[len(br)]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
)
|
||||
|
||||
type skillSlashCommandKind int
|
||||
|
||||
const (
|
||||
skillSlashCommandNone skillSlashCommandKind = iota
|
||||
skillSlashCommandActivate
|
||||
skillSlashCommandList
|
||||
skillSlashCommandHelp
|
||||
skillSlashCommandUnknown
|
||||
)
|
||||
|
||||
type skillSlashCommandResult struct {
|
||||
Kind skillSlashCommandKind
|
||||
Skill skills.Info
|
||||
SkillContent string
|
||||
RemainingPrompt string
|
||||
Guidance string
|
||||
Suggestions []skills.Info
|
||||
}
|
||||
|
||||
func (l *Loop) applySkillSlashCommand(ctx context.Context, 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
|
||||
}
|
||||
extraPrompt = appendExtraPrompt(extraPrompt, result.systemPromptSection())
|
||||
switch result.Kind {
|
||||
case skillSlashCommandActivate:
|
||||
if result.RemainingPrompt == "" {
|
||||
message = "Use the activated skill to help with the user's request."
|
||||
} else {
|
||||
message = result.RemainingPrompt
|
||||
}
|
||||
skillFilter = []string{result.Skill.Slug}
|
||||
case skillSlashCommandList:
|
||||
message = "List the available skills shown in the system instructions."
|
||||
case skillSlashCommandHelp:
|
||||
message = "Explain the requested skill and how it should be used."
|
||||
case skillSlashCommandUnknown:
|
||||
message = "Explain that the requested skill was not found and suggest available alternatives."
|
||||
}
|
||||
return message, extraPrompt, skillFilter
|
||||
}
|
||||
|
||||
func (l *Loop) resolveSkillSlashCommandConfig(ctx context.Context) config.SkillSlashCommandConfig {
|
||||
cfg := l.skillSlashCommands
|
||||
if l.systemConfigs == nil {
|
||||
return cfg
|
||||
}
|
||||
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashCommandsEnabledSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
v := parseSkillSlashBool(raw)
|
||||
cfg.Enabled = &v
|
||||
}
|
||||
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashSuggestNotFoundSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
v := parseSkillSlashBool(raw)
|
||||
cfg.SuggestNotFound = &v
|
||||
}
|
||||
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashPartialMatchingSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
cfg.PartialMatching = parseSkillSlashBool(raw)
|
||||
}
|
||||
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashCommandPrefixSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
cfg.Prefix = raw
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func resolveSkillSlashCommand(ctx context.Context, loader *skills.Loader, cfg config.SkillSlashCommandConfig, message string) skillSlashCommandResult {
|
||||
if loader == nil || !cfg.EffectiveEnabled() {
|
||||
return skillSlashCommandResult{Kind: skillSlashCommandNone}
|
||||
}
|
||||
parsed, ok := parseSkillSlashCommand(message, cfg.EffectivePrefix())
|
||||
if !ok {
|
||||
return skillSlashCommandResult{Kind: skillSlashCommandNone}
|
||||
}
|
||||
all := loader.ListSkills(ctx)
|
||||
switch parsed.verb {
|
||||
case "list-skills":
|
||||
return skillSlashCommandResult{Kind: skillSlashCommandList, Guidance: buildSkillSlashListGuidance(all)}
|
||||
case "help":
|
||||
skill, matched, _ := matchSkillCommandTarget(all, parsed.target, cfg.EffectivePartialMatching())
|
||||
if !matched {
|
||||
return unknownSkillSlashResult(all, parsed.target, cfg)
|
||||
}
|
||||
return skillSlashCommandResult{Kind: skillSlashCommandHelp, Skill: skill, Guidance: buildSkillSlashHelpGuidance(skill)}
|
||||
case "use", "activate":
|
||||
return resolveSkillActivation(ctx, loader, all, parsed.rest, cfg)
|
||||
default:
|
||||
return resolveSkillActivation(ctx, loader, all, parsed.target+" "+parsed.rest, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSkillActivation(ctx context.Context, loader *skills.Loader, all []skills.Info, raw string, cfg config.SkillSlashCommandConfig) skillSlashCommandResult {
|
||||
skill, matched, remainder := matchSkillCommandTarget(all, raw, cfg.EffectivePartialMatching())
|
||||
if !matched {
|
||||
fields := strings.Fields(raw)
|
||||
target := strings.TrimSpace(raw)
|
||||
if len(fields) > 0 {
|
||||
target = fields[0]
|
||||
}
|
||||
return unknownSkillSlashResult(all, target, cfg)
|
||||
}
|
||||
content, ok := loader.LoadSkill(ctx, skill.Slug)
|
||||
if !ok {
|
||||
return unknownSkillSlashResult(all, skill.Slug, cfg)
|
||||
}
|
||||
return skillSlashCommandResult{
|
||||
Kind: skillSlashCommandActivate,
|
||||
Skill: skill,
|
||||
SkillContent: content,
|
||||
RemainingPrompt: strings.TrimSpace(remainder),
|
||||
}
|
||||
}
|
||||
|
||||
func parseSkillSlashBool(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
)
|
||||
|
||||
func TestResolveSkillSlashCommandExactSlug(t *testing.T) {
|
||||
loader := newSlashTestLoader(t)
|
||||
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, "/frontend-design build a landing page")
|
||||
|
||||
if result.Kind != skillSlashCommandActivate {
|
||||
t.Fatalf("kind = %v, want activate", result.Kind)
|
||||
}
|
||||
if result.Skill.Slug != "frontend-design" {
|
||||
t.Fatalf("slug = %q, want frontend-design", result.Skill.Slug)
|
||||
}
|
||||
if result.RemainingPrompt != "build a landing page" {
|
||||
t.Fatalf("remaining = %q", result.RemainingPrompt)
|
||||
}
|
||||
if !strings.Contains(result.SkillContent, "Use responsive components.") {
|
||||
t.Fatal("expected loaded SKILL.md content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSlashCommandExactNameUseSyntax(t *testing.T) {
|
||||
loader := newSlashTestLoader(t)
|
||||
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, "/use Frontend Design build a landing page")
|
||||
|
||||
if result.Kind != skillSlashCommandActivate {
|
||||
t.Fatalf("kind = %v, want activate", result.Kind)
|
||||
}
|
||||
if result.Skill.Slug != "frontend-design" {
|
||||
t.Fatalf("slug = %q, want frontend-design", result.Skill.Slug)
|
||||
}
|
||||
if result.RemainingPrompt != "build a landing page" {
|
||||
t.Fatalf("remaining = %q", result.RemainingPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSlashCommandPartialMatchRequiresUniqueEnabled(t *testing.T) {
|
||||
loader := newSlashTestLoader(t)
|
||||
|
||||
disabled := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, "/front build")
|
||||
if disabled.Kind != skillSlashCommandUnknown {
|
||||
t.Fatalf("disabled partial kind = %v, want unknown", disabled.Kind)
|
||||
}
|
||||
|
||||
enabled := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/", PartialMatching: true}, "/front build")
|
||||
if enabled.Kind != skillSlashCommandActivate {
|
||||
t.Fatalf("enabled partial kind = %v, want activate", enabled.Kind)
|
||||
}
|
||||
if enabled.Skill.Slug != "frontend-design" {
|
||||
t.Fatalf("slug = %q, want frontend-design", enabled.Skill.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSlashCommandFalsePositives(t *testing.T) {
|
||||
loader := newSlashTestLoader(t)
|
||||
for _, msg := range []string{"/home/user/project", "/etc/config.yaml", "https://example.com/path", "regular prompt"} {
|
||||
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, msg)
|
||||
if result.Kind != skillSlashCommandNone {
|
||||
t.Fatalf("%q kind = %v, want none", msg, result.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSlashCommandListAndHelp(t *testing.T) {
|
||||
loader := newSlashTestLoader(t)
|
||||
cfg := config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}
|
||||
|
||||
list := resolveSkillSlashCommand(context.Background(), loader, cfg, "/list-skills")
|
||||
if list.Kind != skillSlashCommandList {
|
||||
t.Fatalf("list kind = %v, want list", list.Kind)
|
||||
}
|
||||
if !strings.Contains(list.Guidance, "frontend-design") || !strings.Contains(list.Guidance, "git-helper") {
|
||||
t.Fatalf("list guidance missing skills: %s", list.Guidance)
|
||||
}
|
||||
|
||||
help := resolveSkillSlashCommand(context.Background(), loader, cfg, "/help frontend-design")
|
||||
if help.Kind != skillSlashCommandHelp {
|
||||
t.Fatalf("help kind = %v, want help", help.Kind)
|
||||
}
|
||||
if help.Skill.Slug != "frontend-design" || !strings.Contains(help.Guidance, "Frontend Design") {
|
||||
t.Fatalf("unexpected help result: %#v", help)
|
||||
}
|
||||
|
||||
helpByName := resolveSkillSlashCommand(context.Background(), loader, cfg, "/help Frontend Design")
|
||||
if helpByName.Kind != skillSlashCommandHelp {
|
||||
t.Fatalf("help by name kind = %v, want help", helpByName.Kind)
|
||||
}
|
||||
if helpByName.Skill.Slug != "frontend-design" {
|
||||
t.Fatalf("help by name slug = %q, want frontend-design", helpByName.Skill.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSlashCommandSuggestsUnknown(t *testing.T) {
|
||||
loader := newSlashTestLoader(t)
|
||||
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/", SuggestNotFound: boolPtr(true)}, "/fronted build")
|
||||
|
||||
if result.Kind != skillSlashCommandUnknown {
|
||||
t.Fatalf("kind = %v, want unknown", result.Kind)
|
||||
}
|
||||
if len(result.Suggestions) == 0 || result.Suggestions[0].Slug != "frontend-design" {
|
||||
t.Fatalf("suggestions = %#v", result.Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func newSlashTestLoader(t *testing.T) *skills.Loader {
|
||||
t.Helper()
|
||||
t.Setenv("GOCLAW_DISABLE_PERSONAL_SKILLS", "1")
|
||||
root := t.TempDir()
|
||||
writeSkill(t, root, "frontend-design", "Frontend Design", "Create polished UI layouts.", "Use responsive components.")
|
||||
writeSkill(t, root, "git-helper", "Git Helper", "Handle git workflows.", "Use clean commits.")
|
||||
return skills.NewLoader("", root, "")
|
||||
}
|
||||
|
||||
func writeSkill(t *testing.T, root, slug, name, description, body string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, slug)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n" + body + "\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -130,8 +131,17 @@ type DatabaseConfig struct {
|
||||
|
||||
// SkillsConfig configures the skills storage system.
|
||||
type SkillsConfig struct {
|
||||
StorageDir string `json:"storage_dir,omitempty"` // directory for skill content (default: dataDir/skills-store/)
|
||||
MaxUploadSizeMB int `json:"max_upload_size_mb,omitempty"` // per-file ZIP upload limit
|
||||
StorageDir string `json:"storage_dir,omitempty"` // directory for skill content (default: dataDir/skills-store/)
|
||||
MaxUploadSizeMB int `json:"max_upload_size_mb,omitempty"` // per-file ZIP upload limit
|
||||
SlashCommands SkillSlashCommandConfig `json:"slash_commands,omitempty"`
|
||||
}
|
||||
|
||||
// SkillSlashCommandConfig controls explicit slash-command skill activation.
|
||||
type SkillSlashCommandConfig struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
SuggestNotFound *bool `json:"suggest_not_found,omitempty"`
|
||||
PartialMatching bool `json:"partial_matching,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -139,7 +149,13 @@ const (
|
||||
MinSkillMaxUploadSizeMB = 1
|
||||
MaxSkillMaxUploadSizeMB = 500
|
||||
|
||||
SkillMaxUploadSizeSystemConfigKey = "skills.max_upload_size_mb"
|
||||
DefaultSkillSlashCommandPrefix = "/"
|
||||
|
||||
SkillMaxUploadSizeSystemConfigKey = "skills.max_upload_size_mb"
|
||||
SkillSlashCommandsEnabledSystemConfigKey = "skills.slash_commands.enabled"
|
||||
SkillSlashSuggestNotFoundSystemConfigKey = "skills.slash_commands.suggest_not_found"
|
||||
SkillSlashPartialMatchingSystemConfigKey = "skills.slash_commands.partial_matching"
|
||||
SkillSlashCommandPrefixSystemConfigKey = "skills.slash_commands.prefix"
|
||||
)
|
||||
|
||||
func ClampSkillMaxUploadSizeMB(value int) int {
|
||||
@@ -163,6 +179,36 @@ func (c SkillsConfig) EffectiveMaxUploadSizeBytes() int64 {
|
||||
return int64(c.EffectiveMaxUploadSizeMB()) << 20
|
||||
}
|
||||
|
||||
func (c SkillSlashCommandConfig) EffectiveEnabled() bool {
|
||||
if c.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *c.Enabled
|
||||
}
|
||||
|
||||
func (c SkillSlashCommandConfig) EffectiveSuggestNotFound() bool {
|
||||
if c.SuggestNotFound == nil {
|
||||
return true
|
||||
}
|
||||
return *c.SuggestNotFound
|
||||
}
|
||||
|
||||
func (c SkillSlashCommandConfig) EffectivePartialMatching() bool {
|
||||
return c.PartialMatching
|
||||
}
|
||||
|
||||
func (c SkillSlashCommandConfig) EffectivePrefix() string {
|
||||
prefix := strings.TrimSpace(c.Prefix)
|
||||
if prefix == "" {
|
||||
return DefaultSkillSlashCommandPrefix
|
||||
}
|
||||
runes := []rune(prefix)
|
||||
if len(runes) != 1 || runes[0] == ' ' || runes[0] == '\t' || runes[0] == '\n' || runes[0] == '\r' {
|
||||
return DefaultSkillSlashCommandPrefix
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// AgentBinding maps a channel/peer pattern to a specific agent.
|
||||
// Matching TS AgentBinding from config/types.agents.ts.
|
||||
type AgentBinding struct {
|
||||
|
||||
@@ -63,6 +63,15 @@ func isLoopbackGatewayHost(host string) bool {
|
||||
return err == nil && addr.IsLoopback()
|
||||
}
|
||||
|
||||
func parseEnvBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Default returns a Config with sensible defaults.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
@@ -242,6 +251,21 @@ func (c *Config) applyEnvOverrides() {
|
||||
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(mb)
|
||||
}
|
||||
}
|
||||
envBoolPtr := func(key string, dst **bool) {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
b := parseEnvBool(v)
|
||||
*dst = &b
|
||||
}
|
||||
}
|
||||
envBool := func(key string, dst *bool) {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
*dst = parseEnvBool(v)
|
||||
}
|
||||
}
|
||||
envBoolPtr("GOCLAW_SKILLS_SLASH_COMMANDS_ENABLED", &c.Skills.SlashCommands.Enabled)
|
||||
envBoolPtr("GOCLAW_SKILLS_SLASH_COMMANDS_SUGGEST_NOT_FOUND", &c.Skills.SlashCommands.SuggestNotFound)
|
||||
envBool("GOCLAW_SKILLS_SLASH_COMMANDS_PARTIAL_MATCHING", &c.Skills.SlashCommands.PartialMatching)
|
||||
envStr("GOCLAW_SKILLS_SLASH_COMMANDS_PREFIX", &c.Skills.SlashCommands.Prefix)
|
||||
|
||||
// Database
|
||||
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
|
||||
|
||||
@@ -27,6 +27,18 @@ func TestDefault_SensibleDefaults(t *testing.T) {
|
||||
if cfg.Skills.EffectiveMaxUploadSizeMB() != DefaultSkillMaxUploadSizeMB {
|
||||
t.Fatalf("default skill upload max: got %d, want %d", cfg.Skills.EffectiveMaxUploadSizeMB(), DefaultSkillMaxUploadSizeMB)
|
||||
}
|
||||
if !cfg.Skills.SlashCommands.EffectiveEnabled() {
|
||||
t.Fatal("slash commands should default enabled")
|
||||
}
|
||||
if !cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
|
||||
t.Fatal("slash command suggestions should default enabled")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectivePartialMatching() {
|
||||
t.Fatal("slash command partial matching should default disabled")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectivePrefix() != "/" {
|
||||
t.Fatalf("slash command prefix = %q, want /", cfg.Skills.SlashCommands.EffectivePrefix())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -129,6 +141,78 @@ func TestLoad_SkillsMaxUploadSizeFromFileAndEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SkillSlashCommandsFromFileEnvAndSystemConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json5")
|
||||
os.WriteFile(cfgPath, []byte(`{
|
||||
"skills": {
|
||||
"slash_commands": {
|
||||
"enabled": false,
|
||||
"suggest_not_found": false,
|
||||
"partial_matching": true,
|
||||
"prefix": "!"
|
||||
}
|
||||
}
|
||||
}`), 0644)
|
||||
|
||||
cfg, err := Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load error: %v", err)
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectiveEnabled() {
|
||||
t.Fatal("file enabled override should be false")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
|
||||
t.Fatal("file suggestion override should be false")
|
||||
}
|
||||
if !cfg.Skills.SlashCommands.EffectivePartialMatching() {
|
||||
t.Fatal("file partial matching override should be true")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectivePrefix() != "!" {
|
||||
t.Fatalf("file prefix = %q, want !", cfg.Skills.SlashCommands.EffectivePrefix())
|
||||
}
|
||||
|
||||
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_ENABLED", "true")
|
||||
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_SUGGEST_NOT_FOUND", "true")
|
||||
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_PARTIAL_MATCHING", "false")
|
||||
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_PREFIX", "#")
|
||||
cfg, err = Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load with env error: %v", err)
|
||||
}
|
||||
if !cfg.Skills.SlashCommands.EffectiveEnabled() {
|
||||
t.Fatal("env enabled override should be true")
|
||||
}
|
||||
if !cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
|
||||
t.Fatal("env suggestion override should be true")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectivePartialMatching() {
|
||||
t.Fatal("env partial matching override should be false")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectivePrefix() != "#" {
|
||||
t.Fatalf("env prefix = %q, want #", cfg.Skills.SlashCommands.EffectivePrefix())
|
||||
}
|
||||
|
||||
cfg.ApplySystemConfigs(map[string]string{
|
||||
"skills.slash_commands.enabled": "false",
|
||||
"skills.slash_commands.suggest_not_found": "false",
|
||||
"skills.slash_commands.partial_matching": "true",
|
||||
"skills.slash_commands.prefix": "%",
|
||||
})
|
||||
if cfg.Skills.SlashCommands.EffectiveEnabled() {
|
||||
t.Fatal("system enabled override should be false")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
|
||||
t.Fatal("system suggestion override should be false")
|
||||
}
|
||||
if !cfg.Skills.SlashCommands.EffectivePartialMatching() {
|
||||
t.Fatal("system partial matching override should be true")
|
||||
}
|
||||
if cfg.Skills.SlashCommands.EffectivePrefix() != "%" {
|
||||
t.Fatalf("system prefix = %q, want %%", cfg.Skills.SlashCommands.EffectivePrefix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsMaxUploadSizeClampAndSystemConfigOverlay(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Skills.MaxUploadSizeMB = 0
|
||||
|
||||
@@ -77,6 +77,10 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
|
||||
// Skills
|
||||
integer(SkillMaxUploadSizeSystemConfigKey, &c.Skills.MaxUploadSizeMB)
|
||||
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(c.Skills.MaxUploadSizeMB)
|
||||
boolean(SkillSlashCommandsEnabledSystemConfigKey, &c.Skills.SlashCommands.Enabled)
|
||||
boolean(SkillSlashSuggestNotFoundSystemConfigKey, &c.Skills.SlashCommands.SuggestNotFound)
|
||||
boolValue(SkillSlashPartialMatchingSystemConfigKey, &c.Skills.SlashCommands.PartialMatching)
|
||||
str(SkillSlashCommandPrefixSystemConfigKey, &c.Skills.SlashCommands.Prefix)
|
||||
|
||||
// TTS
|
||||
str("tts.provider", &c.Tts.Provider)
|
||||
|
||||
@@ -22,7 +22,7 @@ type ConfigMethods struct {
|
||||
cfgPath string
|
||||
secretsStore store.ConfigSecretsStore
|
||||
syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs
|
||||
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
|
||||
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
|
||||
}
|
||||
|
||||
func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
|
||||
@@ -265,27 +265,53 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client,
|
||||
"type": "object",
|
||||
"description": "Gateway server settings (host, port, token)",
|
||||
},
|
||||
"tools": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Tool configuration (browser, exec, web search)",
|
||||
},
|
||||
"skills": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Skill storage and upload settings",
|
||||
"properties": map[string]any{
|
||||
"max_upload_size_mb": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": config.MinSkillMaxUploadSizeMB,
|
||||
"maximum": config.MaxSkillMaxUploadSizeMB,
|
||||
"default": config.DefaultSkillMaxUploadSizeMB,
|
||||
"description": "Maximum skill ZIP upload size in MB",
|
||||
"tools": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Tool configuration (browser, exec, web search)",
|
||||
},
|
||||
"skills": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Skill storage and upload settings",
|
||||
"properties": map[string]any{
|
||||
"max_upload_size_mb": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": config.MinSkillMaxUploadSizeMB,
|
||||
"maximum": config.MaxSkillMaxUploadSizeMB,
|
||||
"default": config.DefaultSkillMaxUploadSizeMB,
|
||||
"description": "Maximum skill ZIP upload size in MB",
|
||||
},
|
||||
"slash_commands": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Explicit slash command skill activation settings",
|
||||
"properties": map[string]any{
|
||||
"enabled": map[string]any{
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable slash command detection in user prompts",
|
||||
},
|
||||
"suggest_not_found": map[string]any{
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Suggest similar skills when a requested skill is not found",
|
||||
},
|
||||
"partial_matching": map[string]any{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Allow unique skill slug/name prefixes",
|
||||
},
|
||||
"prefix": map[string]any{
|
||||
"type": "string",
|
||||
"default": config.DefaultSkillSlashCommandPrefix,
|
||||
"description": "Single-character slash command prefix",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"sessions": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Session storage configuration",
|
||||
},
|
||||
},
|
||||
"sessions": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Session storage configuration",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface InitState {
|
||||
bgProvider: string;
|
||||
bgModel: string;
|
||||
skillUploadMaxSize: string;
|
||||
skillSlashEnabled: boolean;
|
||||
skillSlashSuggest: boolean;
|
||||
skillSlashPartial: boolean;
|
||||
skillSlashPrefix: string;
|
||||
}
|
||||
|
||||
export const DEFAULTS: InitState = {
|
||||
@@ -56,6 +60,10 @@ export const DEFAULTS: InitState = {
|
||||
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
||||
bgProvider: "", bgModel: "",
|
||||
skillUploadMaxSize: "20",
|
||||
skillSlashEnabled: true,
|
||||
skillSlashSuggest: true,
|
||||
skillSlashPartial: false,
|
||||
skillSlashPrefix: "/",
|
||||
};
|
||||
|
||||
export function parseBool(v: string | undefined, fallback: boolean): boolean {
|
||||
|
||||
@@ -62,6 +62,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
const [bgProvider, setBgProvider] = useState("");
|
||||
const [bgModel, setBgModel] = useState("");
|
||||
const [skillUploadMaxSize, setSkillUploadMaxSize] = useState("20");
|
||||
const [skillSlashEnabled, setSkillSlashEnabled] = useState(true);
|
||||
const [skillSlashSuggest, setSkillSlashSuggest] = useState(true);
|
||||
const [skillSlashPartial, setSkillSlashPartial] = useState(false);
|
||||
const [skillSlashPrefix, setSkillSlashPrefix] = useState("/");
|
||||
|
||||
const applyConfigs = useCallback((
|
||||
configs: Record<string, string>,
|
||||
@@ -79,6 +83,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
|
||||
bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "",
|
||||
skillUploadMaxSize: configs["skills.max_upload_size_mb"] ?? "20",
|
||||
skillSlashEnabled: parseBool(configs["skills.slash_commands.enabled"], true),
|
||||
skillSlashSuggest: parseBool(configs["skills.slash_commands.suggest_not_found"], true),
|
||||
skillSlashPartial: parseBool(configs["skills.slash_commands.partial_matching"], false),
|
||||
skillSlashPrefix: configs["skills.slash_commands.prefix"] ?? "/",
|
||||
};
|
||||
setInit(s);
|
||||
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
|
||||
@@ -87,6 +95,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
|
||||
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
|
||||
setSkillUploadMaxSize(s.skillUploadMaxSize);
|
||||
setSkillSlashEnabled(s.skillSlashEnabled);
|
||||
setSkillSlashSuggest(s.skillSlashSuggest);
|
||||
setSkillSlashPartial(s.skillSlashPartial);
|
||||
setSkillSlashPrefix(s.skillSlashPrefix);
|
||||
resetEmb();
|
||||
}, [resetEmb]);
|
||||
|
||||
@@ -131,6 +143,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
if (bgProvider !== init.bgProvider) updates["background.provider"] = bgProvider;
|
||||
if (bgModel !== init.bgModel) updates["background.model"] = bgModel;
|
||||
if (skillUploadMaxSize !== init.skillUploadMaxSize) updates["skills.max_upload_size_mb"] = skillUploadMaxSize;
|
||||
if (skillSlashEnabled !== init.skillSlashEnabled) updates["skills.slash_commands.enabled"] = String(skillSlashEnabled);
|
||||
if (skillSlashSuggest !== init.skillSlashSuggest) updates["skills.slash_commands.suggest_not_found"] = String(skillSlashSuggest);
|
||||
if (skillSlashPartial !== init.skillSlashPartial) updates["skills.slash_commands.partial_matching"] = String(skillSlashPartial);
|
||||
if (skillSlashPrefix !== init.skillSlashPrefix) updates["skills.slash_commands.prefix"] = skillSlashPrefix.trim() || "/";
|
||||
for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value });
|
||||
const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence;
|
||||
if (kgChanged) {
|
||||
@@ -211,6 +227,14 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
<SystemSettingsSkillsCard
|
||||
uploadMaxSize={skillUploadMaxSize}
|
||||
setUploadMaxSize={setSkillUploadMaxSize}
|
||||
slashEnabled={skillSlashEnabled}
|
||||
setSlashEnabled={setSkillSlashEnabled}
|
||||
slashSuggest={skillSlashSuggest}
|
||||
setSlashSuggest={setSkillSlashSuggest}
|
||||
slashPartial={skillSlashPartial}
|
||||
setSlashPartial={setSkillSlashPartial}
|
||||
slashPrefix={skillSlashPrefix}
|
||||
setSlashPrefix={setSkillSlashPrefix}
|
||||
/>
|
||||
|
||||
<SystemSettingsCompactionCard
|
||||
|
||||
@@ -3,15 +3,32 @@ import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface SystemSettingsSkillsCardProps {
|
||||
uploadMaxSize: string;
|
||||
setUploadMaxSize: (value: string) => void;
|
||||
slashEnabled: boolean;
|
||||
setSlashEnabled: (value: boolean) => void;
|
||||
slashSuggest: boolean;
|
||||
setSlashSuggest: (value: boolean) => void;
|
||||
slashPartial: boolean;
|
||||
setSlashPartial: (value: boolean) => void;
|
||||
slashPrefix: string;
|
||||
setSlashPrefix: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SystemSettingsSkillsCard({
|
||||
uploadMaxSize,
|
||||
setUploadMaxSize,
|
||||
slashEnabled,
|
||||
setSlashEnabled,
|
||||
slashSuggest,
|
||||
setSlashSuggest,
|
||||
slashPartial,
|
||||
setSlashPartial,
|
||||
slashPrefix,
|
||||
setSlashPrefix,
|
||||
}: SystemSettingsSkillsCardProps) {
|
||||
const { t } = useTranslation("system-settings");
|
||||
|
||||
@@ -24,7 +41,7 @@ export function SystemSettingsSkillsCard({
|
||||
</CardTitle>
|
||||
<CardDescription>{t("skills.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-0">
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="skillUploadMaxSize" className="text-sm font-medium">
|
||||
@@ -42,7 +59,71 @@ export function SystemSettingsSkillsCard({
|
||||
className="w-24 shrink-0 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<SkillSwitchRow
|
||||
id="skillSlashEnabled"
|
||||
label={t("skills.slashEnabled")}
|
||||
hint={t("skills.slashEnabledHint")}
|
||||
checked={slashEnabled}
|
||||
onCheckedChange={setSlashEnabled}
|
||||
/>
|
||||
<SkillSwitchRow
|
||||
id="skillSlashSuggest"
|
||||
label={t("skills.slashSuggest")}
|
||||
hint={t("skills.slashSuggestHint")}
|
||||
checked={slashSuggest}
|
||||
onCheckedChange={setSlashSuggest}
|
||||
/>
|
||||
<SkillSwitchRow
|
||||
id="skillSlashPartial"
|
||||
label={t("skills.slashPartial")}
|
||||
hint={t("skills.slashPartialHint")}
|
||||
checked={slashPartial}
|
||||
onCheckedChange={setSlashPartial}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="skillSlashPrefix" className="text-sm font-medium">
|
||||
{t("skills.slashPrefix")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("skills.slashPrefixHint")}</p>
|
||||
</div>
|
||||
<Input
|
||||
id="skillSlashPrefix"
|
||||
maxLength={1}
|
||||
value={slashPrefix}
|
||||
onChange={(e) => setSlashPrefix(e.target.value.slice(0, 1))}
|
||||
className="w-16 shrink-0 text-center text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSwitchRow({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,9 +66,17 @@
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
"description": "Tenant skill package upload limits.",
|
||||
"description": "Tenant skill package and slash command settings.",
|
||||
"maxUploadSize": "Max upload size",
|
||||
"maxUploadSizeHint": "Per-file ZIP limit in MB. Allowed range: 1-500."
|
||||
"maxUploadSizeHint": "Per-file ZIP limit in MB. Allowed range: 1-500.",
|
||||
"slashEnabled": "Enable slash commands",
|
||||
"slashEnabledHint": "Detect /skill-name and /use skill-name at the start of user prompts.",
|
||||
"slashSuggest": "Suggest similar skills",
|
||||
"slashSuggestHint": "Show close matches when a requested skill is not found.",
|
||||
"slashPartial": "Allow partial matching",
|
||||
"slashPartialHint": "Let unique prefixes like /frontend activate matching skills.",
|
||||
"slashPrefix": "Command prefix",
|
||||
"slashPrefixHint": "Single character used before skill commands."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Pending Message Compaction",
|
||||
|
||||
@@ -66,9 +66,17 @@
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
"description": "Giới hạn tải lên gói skill theo tenant.",
|
||||
"description": "Cài đặt gói skill và slash command theo tenant.",
|
||||
"maxUploadSize": "Dung lượng tải lên tối đa",
|
||||
"maxUploadSizeHint": "Giới hạn ZIP theo từng tệp, tính bằng MB. Khoảng cho phép: 1-500."
|
||||
"maxUploadSizeHint": "Giới hạn ZIP theo từng tệp, tính bằng MB. Khoảng cho phép: 1-500.",
|
||||
"slashEnabled": "Bật slash command",
|
||||
"slashEnabledHint": "Nhận diện /skill-name và /use skill-name ở đầu prompt.",
|
||||
"slashSuggest": "Gợi ý skill gần đúng",
|
||||
"slashSuggestHint": "Hiển thị các skill gần giống khi không tìm thấy skill được gọi.",
|
||||
"slashPartial": "Cho phép khớp một phần",
|
||||
"slashPartialHint": "Cho phép prefix duy nhất như /frontend kích hoạt skill tương ứng.",
|
||||
"slashPrefix": "Prefix command",
|
||||
"slashPrefixHint": "Một ký tự dùng trước skill command."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Nén tin nhắn chờ",
|
||||
|
||||
@@ -66,9 +66,17 @@
|
||||
},
|
||||
"skills": {
|
||||
"title": "技能",
|
||||
"description": "租户技能包上传限制。",
|
||||
"description": "租户技能包和斜杠命令设置。",
|
||||
"maxUploadSize": "最大上传大小",
|
||||
"maxUploadSizeHint": "单个 ZIP 文件限制,单位 MB。允许范围:1-500。"
|
||||
"maxUploadSizeHint": "单个 ZIP 文件限制,单位 MB。允许范围:1-500。",
|
||||
"slashEnabled": "启用斜杠命令",
|
||||
"slashEnabledHint": "在用户提示开头检测 /skill-name 和 /use skill-name。",
|
||||
"slashSuggest": "建议相似技能",
|
||||
"slashSuggestHint": "找不到请求的技能时显示接近匹配项。",
|
||||
"slashPartial": "允许部分匹配",
|
||||
"slashPartialHint": "允许像 /frontend 这样的唯一前缀激活匹配技能。",
|
||||
"slashPrefix": "命令前缀",
|
||||
"slashPrefixHint": "技能命令前使用的单个字符。"
|
||||
},
|
||||
"compaction": {
|
||||
"title": "待处理消息压缩",
|
||||
|
||||
Reference in New Issue
Block a user