feat(skills): add channel commands to list and force installed skills
This commit is contained in:
parent
2f6f25dc58
commit
d7d2bf69bf
16 changed files with 575 additions and 3 deletions
|
|
@ -33,3 +33,23 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
|||
3. Obtain the HTTP API Token
|
||||
4. Fill in the Token in the configuration file
|
||||
5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`)
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`.
|
||||
|
||||
Skill-related commands:
|
||||
|
||||
- `/list skills` lists the installed skills visible to the current agent.
|
||||
- `/use <skill> <message>` forces a skill for a single request.
|
||||
- `/use <skill>` arms the skill for your next message in the same chat.
|
||||
- `/use clear` clears a pending skill override.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/list skills
|
||||
/use git explain how to squash the last 3 commits
|
||||
/use git
|
||||
explain how to squash the last 3 commits
|
||||
```
|
||||
|
|
|
|||
|
|
@ -33,3 +33,23 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
|
|||
3. 获取 HTTP API Token
|
||||
4. 将 Token 填入配置文件中
|
||||
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
|
||||
|
||||
## 内置命令
|
||||
|
||||
Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。
|
||||
|
||||
与技能相关的命令:
|
||||
|
||||
- `/list skills`:列出当前 Agent 可见的已安装技能。
|
||||
- `/use <skill> <message>`:只在本次请求中强制使用指定技能。
|
||||
- `/use <skill>`:为同一聊天中的下一条消息预先启用该技能。
|
||||
- `/use clear`:清除待应用的技能覆盖。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
/list skills
|
||||
/use git explain how to squash the last 3 commits
|
||||
/use italiapersonalfinance
|
||||
dammi le ultime news
|
||||
```
|
||||
|
|
|
|||
|
|
@ -60,11 +60,18 @@ picoclaw gateway
|
|||
|
||||
**4. Telegram command menu (auto-registered at startup)**
|
||||
|
||||
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync.
|
||||
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||
|
||||
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||
|
||||
You can also manage installed skills directly from Telegram:
|
||||
|
||||
- `/list skills`
|
||||
- `/use <skill> <message>`
|
||||
- `/use <skill>` and then send the actual request in the next message
|
||||
- `/use clear`
|
||||
|
||||
**4. Advanced Formatting**
|
||||
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ For advanced/test setups, you can override the builtin skills root with:
|
|||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### Using Skills From Chat Channels
|
||||
|
||||
Once skills are installed, you can inspect and force them directly from a chat channel:
|
||||
|
||||
- `/list skills` shows the installed skill names available to the current agent.
|
||||
- `/use <skill> <message>` forces a specific skill for a single request.
|
||||
- `/use <skill>` arms that skill for your next message in the same chat session.
|
||||
- `/use clear` cancels a pending skill override created by `/use <skill>`.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/list skills
|
||||
/use git explain how to squash the last 3 commits
|
||||
/use italiapersonalfinance
|
||||
dammi le ultime news
|
||||
```
|
||||
|
||||
### Unified Command Execution Policy
|
||||
|
||||
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||
|
|
|
|||
|
|
@ -63,11 +63,18 @@ picoclaw gateway
|
|||
|
||||
**4. Telegram 命令菜单(启动时自动注册)**
|
||||
|
||||
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||
|
||||
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||
|
||||
你也可以直接在 Telegram 中管理已安装技能:
|
||||
|
||||
- `/list skills`
|
||||
- `/use <skill> <message>`
|
||||
- `/use <skill>`,然后在下一条消息里发送真正的请求
|
||||
- `/use clear`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
|||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### 在聊天频道中使用技能
|
||||
|
||||
技能安装完成后,可以直接在聊天频道里查看并显式启用它们:
|
||||
|
||||
- `/list skills`:显示当前 Agent 可用的已安装技能名称。
|
||||
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
|
||||
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
|
||||
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
/list skills
|
||||
/use git explain how to squash the last 3 commits
|
||||
/use italiapersonalfinance
|
||||
dammi le ultime news
|
||||
```
|
||||
|
||||
### 统一命令执行策略
|
||||
|
||||
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
|
|||
helpCommand(),
|
||||
showCommand(),
|
||||
listCommand(),
|
||||
useCommand(),
|
||||
switchCommand(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
|
|
|
|||
|
|
@ -39,9 +39,14 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
|||
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
||||
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "/list [models|channels|agents]") {
|
||||
if !strings.Contains(reply, "/list [models|channels|agents|skills]") {
|
||||
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "/use <skill> <message>") {
|
||||
if !strings.Contains(reply, "/use <skill> [message]") {
|
||||
t.Fatalf("/help reply missing /use usage, got %q", reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||
|
|
@ -143,3 +148,43 @@ func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
|
|||
t.Fatalf("/list agents reply=%q, want agent IDs", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) {
|
||||
rt := &Runtime{
|
||||
ListSkillNames: func() []string {
|
||||
return []string{"shell", "git"}
|
||||
},
|
||||
}
|
||||
defs := BuiltinDefinitions()
|
||||
ex := NewExecutor(NewRegistry(defs), rt)
|
||||
|
||||
var reply string
|
||||
res := ex.Execute(context.Background(), Request{
|
||||
Text: "/list skills",
|
||||
Reply: func(text string) error {
|
||||
reply = text
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if res.Outcome != OutcomeHandled {
|
||||
t.Fatalf("/list skills: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||
}
|
||||
if !strings.Contains(reply, "shell") || !strings.Contains(reply, "git") {
|
||||
t.Fatalf("/list skills reply=%q, want installed skill names", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) {
|
||||
defs := BuiltinDefinitions()
|
||||
ex := NewExecutor(NewRegistry(defs), nil)
|
||||
|
||||
res := ex.Execute(context.Background(), Request{
|
||||
Text: "/use shell run ls",
|
||||
})
|
||||
if res.Outcome != OutcomePassthrough {
|
||||
t.Fatalf("/use outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||
}
|
||||
if res.Command != "use" {
|
||||
t.Fatalf("/use command=%q, want=%q", res.Command, "use")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,23 @@ func listCommand() Definition {
|
|||
Description: "Registered agents",
|
||||
Handler: agentsHandler(),
|
||||
},
|
||||
{
|
||||
Name: "skills",
|
||||
Description: "Installed skills",
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil || rt.ListSkillNames == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
names := rt.ListSkillNames()
|
||||
if len(names) == 0 {
|
||||
return req.Reply("No installed skills")
|
||||
}
|
||||
return req.Reply(fmt.Sprintf(
|
||||
"Installed Skills:\n- %s\n\nUse /use <skill> <message> to force one for a single request, or /use <skill> to apply it to your next message.",
|
||||
strings.Join(names, "\n- "),
|
||||
))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
pkg/commands/cmd_use.go
Normal file
9
pkg/commands/cmd_use.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package commands
|
||||
|
||||
func useCommand() Definition {
|
||||
return Definition{
|
||||
Name: "use",
|
||||
Description: "Force a specific installed skill for one request",
|
||||
Usage: "/use <skill> [message]",
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) {
|
|||
return name, true
|
||||
}
|
||||
|
||||
// CommandName returns the normalized command name for an input if present.
|
||||
func CommandName(input string) (string, bool) {
|
||||
return parseCommandName(input)
|
||||
}
|
||||
|
||||
func trimCommandPrefix(token string) (string, bool) {
|
||||
for _, prefix := range commandPrefixes {
|
||||
if strings.HasPrefix(token, prefix) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type Runtime struct {
|
|||
GetModelInfo func() (name, provider string)
|
||||
ListAgentIDs func() []string
|
||||
ListDefinitions func() []Definition
|
||||
ListSkillNames func() []string
|
||||
GetEnabledChannels func() []string
|
||||
SwitchModel func(value string) (oldModel string, err error)
|
||||
SwitchChannel func(value string) error
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
|
|||
GetEnabledChannels: func() []string {
|
||||
return []string{"telegram"}
|
||||
},
|
||||
ListSkillNames: func() []string {
|
||||
return []string{"shell"}
|
||||
},
|
||||
}
|
||||
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||
|
||||
|
|
@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
|
|||
if !strings.Contains(reply, "telegram") {
|
||||
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
|
||||
}
|
||||
|
||||
reply = ""
|
||||
res = ex.Execute(context.Background(), Request{
|
||||
Channel: "whatsapp",
|
||||
Text: "/list skills",
|
||||
Reply: func(text string) error {
|
||||
reply = text
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if res.Outcome != OutcomeHandled {
|
||||
t.Fatalf("whatsapp /list skills outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||
}
|
||||
if !strings.Contains(reply, "shell") {
|
||||
t.Fatalf("whatsapp /list skills reply=%q, expected installed skills content", reply)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue