mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(skills): add channel commands to list and force installed skills
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user