feat(skills): add channel commands to list and force installed skills

This commit is contained in:
afjcjsbx
2026-03-22 15:33:25 +01:00
parent 2f6f25dc58
commit d7d2bf69bf
16 changed files with 575 additions and 3 deletions
+68
View File
@@ -497,6 +497,7 @@ func (cb *ContextBuilder) BuildMessages(
currentMessage string,
media []string,
channel, chatID, senderID, senderDisplayName string,
activeSkills ...string,
) []providers.Message {
messages := []providers.Message{}
@@ -530,6 +531,11 @@ func (cb *ContextBuilder) BuildMessages(
{Type: "text", Text: dynamicCtx},
}
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
stringParts = append(stringParts, skillsText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
}
if summary != "" {
summaryText := fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
@@ -737,6 +743,68 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages
}
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
if cb.skillsLoader == nil || len(skillNames) == 0 {
return ""
}
var ordered []string
seen := make(map[string]struct{}, len(skillNames))
for _, name := range skillNames {
canonical, ok := cb.ResolveSkillName(name)
if !ok {
continue
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
ordered = append(ordered, canonical)
}
if len(ordered) == 0 {
return ""
}
content := cb.skillsLoader.LoadSkillsForContext(ordered)
if strings.TrimSpace(content) == "" {
return ""
}
return fmt.Sprintf(`# Active Skills
The following skills are active for this request. Follow them when relevant.
%s`, content)
}
func (cb *ContextBuilder) ListSkillNames() []string {
if cb.skillsLoader == nil {
return nil
}
allSkills := cb.skillsLoader.ListSkills()
names := make([]string, 0, len(allSkills))
for _, skill := range allSkills {
names = append(names, skill.Name)
}
return names
}
func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" || cb.skillsLoader == nil {
return "", false
}
for _, skill := range cb.skillsLoader.ListSkills() {
if strings.EqualFold(skill.Name, name) {
return skill.Name, true
}
}
return "", false
}
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
+160
View File
@@ -48,6 +48,7 @@ type AgentLoop struct {
transcriber voice.Transcriber
cmdRegistry *commands.Registry
mcp mcpRuntime
pendingSkills sync.Map
mu sync.RWMutex
reloadFunc func() error
// Track active requests for safe provider cleanup
@@ -62,6 +63,7 @@ type processOptions struct {
SenderID string // Current sender ID for dynamic context
SenderDisplayName string // Current sender display name for dynamic context
UserMessage string // User message content (may include prefix)
ForcedSkills []string // Skills explicitly requested for this message
Media []string // media:// refs from inbound message
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization
@@ -782,6 +784,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return response, nil
}
if pending := al.takePendingSkills(opts.SessionKey); len(pending) > 0 {
opts.ForcedSkills = append(opts.ForcedSkills, pending...)
logger.InfoCF("agent", "Applying pending skill override",
map[string]any{
"session_key": opts.SessionKey,
"skills": strings.Join(pending, ","),
})
}
return al.runAgentLoop(ctx, agent, opts)
}
@@ -915,6 +926,7 @@ func (al *AgentLoop) runAgentLoop(
opts.ChatID,
opts.SenderID,
opts.SenderDisplayName,
activeSkillNames(agent, opts)...,
)
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
@@ -1226,6 +1238,7 @@ func (al *AgentLoop) runLLMIteration(
messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName,
activeSkillNames(agent, opts)...,
)
continue
}
@@ -1942,6 +1955,10 @@ func (al *AgentLoop) handleCommand(
return "", false
}
if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched {
return reply, handled
}
if al.cmdRegistry == nil {
return "", false
}
@@ -1998,6 +2015,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return nil
},
}
if agent != nil && agent.ContextBuilder != nil {
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
}
rt.ReloadConfig = func() error {
if al.reloadFunc == nil {
return fmt.Errorf("reload not configured")
@@ -2057,6 +2077,146 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return rt
}
func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
var out []string
seen := make(map[string]struct{})
appendNames := func(names []string) {
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
}
if agent != nil {
appendNames(agent.SkillsFilter)
}
appendNames(opts.ForcedSkills)
return out
}
func (al *AgentLoop) applyExplicitSkillCommand(
raw string,
agent *AgentInstance,
opts *processOptions,
) (matched bool, handled bool, reply string) {
commandName, ok := commands.CommandName(raw)
if !ok || commandName != "use" {
return false, false, ""
}
if agent == nil || agent.ContextBuilder == nil {
return true, true, commandsUnavailableSkillMessage()
}
fields := strings.Fields(strings.TrimSpace(raw))
if len(fields) < 2 {
return true, true, buildUseCommandHelp(agent)
}
if strings.EqualFold(fields[1], "clear") || strings.EqualFold(fields[1], "off") {
al.clearPendingSkills(opts.SessionKey)
return true, true, "Cleared pending skill override."
}
canonicalSkill, ok := agent.ContextBuilder.ResolveSkillName(fields[1])
if !ok {
return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", fields[1])
}
if len(fields) == 2 {
al.setPendingSkills(opts.SessionKey, []string{canonicalSkill})
return true, true, fmt.Sprintf(
"Skill %q is armed for your next message.\nSend your next request normally, or use /use clear to cancel.",
canonicalSkill,
)
}
message := strings.TrimSpace(strings.Join(fields[2:], " "))
if message == "" {
return true, true, buildUseCommandHelp(agent)
}
opts.UserMessage = message
opts.ForcedSkills = append(opts.ForcedSkills, canonicalSkill)
return true, false, ""
}
func commandsUnavailableSkillMessage() string {
return "Skill selection is unavailable in the current context."
}
func buildUseCommandHelp(agent *AgentInstance) string {
if agent == nil || agent.ContextBuilder == nil {
return "Usage: /use <skill> [message]"
}
names := agent.ContextBuilder.ListSkillNames()
if len(names) == 0 {
return "Usage: /use <skill> [message]\nNo installed skills found."
}
return fmt.Sprintf(
"Usage: /use <skill> [message]\n\nInstalled Skills:\n- %s\n\nUse /use <skill> to apply a skill to your next message, or /use <skill> <message> to force it immediately.",
strings.Join(names, "\n- "),
)
}
func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" || len(skillNames) == 0 {
return
}
filtered := make([]string, 0, len(skillNames))
for _, name := range skillNames {
name = strings.TrimSpace(name)
if name != "" {
filtered = append(filtered, name)
}
}
if len(filtered) == 0 {
return
}
al.pendingSkills.Store(sessionKey, filtered)
}
func (al *AgentLoop) takePendingSkills(sessionKey string) []string {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return nil
}
value, ok := al.pendingSkills.LoadAndDelete(sessionKey)
if !ok {
return nil
}
skills, ok := value.([]string)
if !ok {
return nil
}
return append([]string(nil), skills...)
}
func (al *AgentLoop) clearPendingSkills(sessionKey string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return
}
al.pendingSkills.Delete(sessionKey)
}
func mapCommandError(result commands.ExecuteResult) string {
if result.Command == "" {
return fmt.Sprintf("Failed to execute command: %v", result.Err)
+157
View File
@@ -132,6 +132,163 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
}
}
func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
tmpDir := t.TempDir()
skillDir := filepath.Join(tmpDir, "skills", "shell")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("mkdir skill dir: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("# shell\n\nPrefer concise shell commands and explain them briefly."),
0o644,
); err != nil {
t.Fatalf("write skill file: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/use shell explain how to list files",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages")
}
systemPrompt := provider.lastMessages[0].Content
if !strings.Contains(systemPrompt, "# Active Skills") {
t.Fatalf("system prompt missing active skills section:\n%s", systemPrompt)
}
if !strings.Contains(systemPrompt, "### Skill: shell") {
t.Fatalf("system prompt missing requested skill content:\n%s", systemPrompt)
}
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" {
t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage)
}
}
func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.GetRegistry().GetDefaultAgent()
opts := processOptions{}
reply, handled := al.handleCommand(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/use missing explain how to list files",
}, agent, &opts)
if !handled {
t.Fatal("expected /use with unknown skill to be handled")
}
if !strings.Contains(reply, "Unknown skill: missing") {
t.Fatalf("reply = %q, want unknown skill error", reply)
}
}
func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
tmpDir := t.TempDir()
skillDir := filepath.Join(tmpDir, "skills", "shell")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("mkdir skill dir: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("# shell\n\nPrefer concise shell commands and explain them briefly."),
0o644,
); err != nil {
t.Fatalf("write skill file: %v", err)
}
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/use shell",
})
if err != nil {
t.Fatalf("processMessage() arm error = %v", err)
}
if !strings.Contains(response, `Skill "shell" is armed for your next message.`) {
t.Fatalf("arm response = %q, want armed confirmation", response)
}
response, err = al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "explain how to list files",
})
if err != nil {
t.Fatalf("processMessage() follow-up error = %v", err)
}
if response != "Mock response" {
t.Fatalf("follow-up response = %q, want %q", response, "Mock response")
}
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages")
}
systemPrompt := provider.lastMessages[0].Content
if !strings.Contains(systemPrompt, "### Skill: shell") {
t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt)
}
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" {
t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage)
}
}
func TestRecordLastChannel(t *testing.T) {
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
defer cleanup()