feat: add /skill and /skills commands with history compaction
/skills lists available skills (zero LLM cost). /skill <name> [message] injects SKILL.md into context for reliable skill invocation. After the turn completes, only the skill name tag is kept in session history to avoid wasting the context window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b8e9cae12d
commit
ca337cf9d1
4 changed files with 112 additions and 2 deletions
|
|
@ -261,6 +261,16 @@ func (cb *ContextBuilder) loadSkills() string {
|
||||||
return "# Skill Definitions\n\n" + content
|
return "# Skill Definitions\n\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadSkill loads a skill by name, returning its content (with frontmatter stripped) and whether it was found.
|
||||||
|
func (cb *ContextBuilder) LoadSkill(name string) (string, bool) {
|
||||||
|
return cb.skillsLoader.LoadSkill(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSkills returns all available skills from all tiers.
|
||||||
|
func (cb *ContextBuilder) ListSkills() []skills.SkillInfo {
|
||||||
|
return cb.skillsLoader.ListSkills()
|
||||||
|
}
|
||||||
|
|
||||||
// GetSkillsInfo returns information about loaded skills.
|
// GetSkillsInfo returns information about loaded skills.
|
||||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
allSkills := cb.skillsLoader.ListSkills()
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ type processOptions struct {
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID for tool execution
|
ChatID string // Target chat ID for tool execution
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
|
HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction)
|
||||||
DefaultResponse string // Response when LLM returns empty
|
DefaultResponse string // Response when LLM returns empty
|
||||||
EnableSummary bool // Whether to trigger summarization
|
EnableSummary bool // Whether to trigger summarization
|
||||||
SendResponse bool // Whether to send response via bus
|
SendResponse bool // Whether to send response via bus
|
||||||
|
|
@ -306,6 +307,13 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand /skill command: inject SKILL.md content into message, then continue to LLM
|
||||||
|
var skillCompact string
|
||||||
|
if expanded, compact, ok := al.expandSkillCommand(msg); ok {
|
||||||
|
msg.Content = expanded
|
||||||
|
skillCompact = compact
|
||||||
|
}
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
if response, handled := al.handleCommand(ctx, msg); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|
@ -344,6 +352,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
|
HistoryMessage: skillCompact,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: "I've completed processing but have no response to give.",
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
|
@ -438,8 +447,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. Save user message to session
|
// 3. Save user message to session (use compact form if available)
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
historyMsg := opts.UserMessage
|
||||||
|
if opts.HistoryMessage != "" {
|
||||||
|
historyMsg = opts.HistoryMessage
|
||||||
|
}
|
||||||
|
agent.Sessions.AddMessage(opts.SessionKey, "user", historyMsg)
|
||||||
|
|
||||||
// 4. Record user prompt for stats
|
// 4. Record user prompt for stats
|
||||||
if al.stats != nil {
|
if al.stats != nil {
|
||||||
|
|
@ -1201,6 +1214,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
case "/session":
|
case "/session":
|
||||||
return al.handleSessionCommand(args), true
|
return al.handleSessionCommand(args), true
|
||||||
|
|
||||||
|
case "/skills":
|
||||||
|
return al.handleSkillsCommand(), true
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
|
|
@ -1253,6 +1269,84 @@ func (al *AgentLoop) handleSessionCommand(args []string) string {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandSkillCommand detects "/skill <name> [message]" and returns:
|
||||||
|
// - expanded: full content with SKILL.md injected (for LLM)
|
||||||
|
// - compact: skill name tag + user message only (for history)
|
||||||
|
// - ok: whether expansion happened
|
||||||
|
func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
if !strings.HasPrefix(content, "/skill ") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: /skill <name> [message]
|
||||||
|
rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7
|
||||||
|
parts := strings.SplitN(rest, " ", 2)
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
skillName := parts[0]
|
||||||
|
userMessage := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
userMessage = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
skillContent, found := agent.ContextBuilder.LoadSkill(skillName)
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := fmt.Sprintf("[Skill: %s]", skillName)
|
||||||
|
|
||||||
|
// Build expanded message: skill instructions + user message (for LLM)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(tag)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
sb.WriteString(skillContent)
|
||||||
|
if userMessage != "" {
|
||||||
|
sb.WriteString("\n\n---\n\n")
|
||||||
|
sb.WriteString(userMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build compact form: skill name tag + user message only (for history)
|
||||||
|
compactForm := tag
|
||||||
|
if userMessage != "" {
|
||||||
|
compactForm = tag + "\n" + userMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), compactForm, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSkillsCommand lists all available skills.
|
||||||
|
func (al *AgentLoop) handleSkillsCommand() string {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "No agent configured."
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsList := agent.ContextBuilder.ListSkills()
|
||||||
|
if len(skillsList) == 0 {
|
||||||
|
return "No skills available.\nAdd skills to your workspace/skills/ directory."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Available Skills\n\n")
|
||||||
|
for _, s := range skillsList {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%s)\n", s.Name, s.Source))
|
||||||
|
if s.Description != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s\n", s.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\nUse: /skill <name> [message]"))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
// extractPeer extracts the routing peer from inbound message metadata.
|
// extractPeer extracts the routing peer from inbound message metadata.
|
||||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
peerKind := msg.Metadata["peer_kind"]
|
peerKind := msg.Metadata["peer_kind"]
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,10 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
return c.handleQuickCommand(ctx, message)
|
return c.handleQuickCommand(ctx, message)
|
||||||
}, th.CommandEqual("session"))
|
}, th.CommandEqual("session"))
|
||||||
|
|
||||||
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
|
return c.handleQuickCommand(ctx, message)
|
||||||
|
}, th.CommandEqual("skills"))
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
return c.handleMessage(ctx, &message)
|
return c.handleMessage(ctx, &message)
|
||||||
}, th.AnyMessage())
|
}, th.AnyMessage())
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
||||||
/list [models|channels] - List available options
|
/list [models|channels] - List available options
|
||||||
/todo - Show your task list
|
/todo - Show your task list
|
||||||
/session - Show usage statistics
|
/session - Show usage statistics
|
||||||
|
/skills - List available skills
|
||||||
|
/skill <name> [message] - Invoke a skill
|
||||||
`
|
`
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
ChatID: telego.ChatID{ID: message.Chat.ID},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue