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
6d3bbdfc95
commit
b93df0f2c8
4 changed files with 112 additions and 2 deletions
|
|
@ -261,6 +261,16 @@ func (cb *ContextBuilder) loadSkills() string {
|
|||
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.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ type processOptions struct {
|
|||
Channel string // Target channel for tool execution
|
||||
ChatID string // Target chat ID for tool execution
|
||||
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
|
||||
EnableSummary bool // Whether to trigger summarization
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
||||
return response, nil
|
||||
|
|
@ -344,6 +352,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
UserMessage: msg.Content,
|
||||
HistoryMessage: skillCompact,
|
||||
DefaultResponse: "I've completed processing but have no response to give.",
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
|
|
@ -438,8 +447,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
opts.ChatID,
|
||||
)
|
||||
|
||||
// 3. Save user message to session
|
||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||
// 3. Save user message to session (use compact form if available)
|
||||
historyMsg := opts.UserMessage
|
||||
if opts.HistoryMessage != "" {
|
||||
historyMsg = opts.HistoryMessage
|
||||
}
|
||||
agent.Sessions.AddMessage(opts.SessionKey, "user", historyMsg)
|
||||
|
||||
// 4. Record user prompt for stats
|
||||
if al.stats != nil {
|
||||
|
|
@ -1201,6 +1214,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
|||
|
||||
case "/session":
|
||||
return al.handleSessionCommand(args), true
|
||||
|
||||
case "/skills":
|
||||
return al.handleSkillsCommand(), true
|
||||
}
|
||||
|
||||
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.
|
||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||
peerKind := msg.Metadata["peer_kind"]
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
return c.handleQuickCommand(ctx, message)
|
||||
}, 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 {
|
||||
return c.handleMessage(ctx, &message)
|
||||
}, th.AnyMessage())
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
|||
/list [models|channels] - List available options
|
||||
/todo - Show your task list
|
||||
/session - Show usage statistics
|
||||
/skills - List available skills
|
||||
/skill <name> [message] - Invoke a skill
|
||||
`
|
||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue