Files
2026-07-13 13:00:08 +08:00

134 lines
4.4 KiB
Go

package command
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"reasonix/internal/tool"
)
// SlashEntry is one invocable slash command exposed to the model through the
// slash_command tool. It is a uniform view over the two kinds the user can also
// type at the prompt — custom commands and skills — so the tool need not know
// which is which. Render turns positional args into the prompt text the command
// expands to (the same text typing "/name args" would send).
type SlashEntry struct {
Name string // without the leading slash, e.g. "review" or "git:commit"
Description string
ArgHint string // optional argument hint, for the listing
Render func(args []string) string // expands the template/playbook with args
}
// slashCommandTool lets the model invoke a loaded slash command by name. Unlike a
// tool that performs an action and returns a result, a slash command is a *prompt
// template*: the tool returns the expanded prompt text, which the model then reads
// and acts on within the same turn — mirroring what typing "/name" does for a
// human. Calling with no name (or "list") returns the available commands.
type slashCommandTool struct {
entries map[string]SlashEntry
names []string // sorted, for a stable listing
}
// NewSlashCommandTool builds the tool from the invocable entries (custom commands
// + skills, adapted by the caller). A later entry wins on a name clash, matching
// the prompt's command>skill precedence when the caller orders them that way.
func NewSlashCommandTool(entries []SlashEntry) tool.Tool {
m := make(map[string]SlashEntry, len(entries))
for _, e := range entries {
name := strings.TrimPrefix(strings.TrimSpace(e.Name), "/")
if name == "" {
continue
}
e.Name = name
m[name] = e
}
names := make([]string, 0, len(m))
for n := range m {
names = append(names, n)
}
sort.Strings(names)
return &slashCommandTool{entries: m, names: names}
}
func (*slashCommandTool) Name() string { return "slash_command" }
func (*slashCommandTool) ReadOnly() bool { return true }
func (t *slashCommandTool) Description() string {
var b strings.Builder
b.WriteString("Invoke a project slash command (a reusable prompt template or skill) by name. " +
"Returns the command's expanded prompt text for you to act on in this turn — it does not run on its own. " +
"Call with an empty command (or \"list\") to see what's available. ")
if len(t.names) == 0 {
b.WriteString("No slash commands are configured in this project.")
return b.String()
}
b.WriteString("Available: ")
for i, n := range t.names {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(n)
}
b.WriteString(".")
return b.String()
}
func (*slashCommandTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"command": {"type": "string", "description": "Slash command name (with or without a leading slash). Empty or \"list\" returns the available commands."},
"arguments": {"type": "string", "description": "Arguments passed to the command, as you'd type them after the name (space-separated)."}
}
}`)
}
func (t *slashCommandTool) Execute(_ context.Context, raw json.RawMessage) (string, error) {
var p struct {
Command string `json:"command"`
Arguments string `json:"arguments"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
}
name := strings.TrimPrefix(strings.TrimSpace(p.Command), "/")
if name == "" || strings.EqualFold(name, "list") {
return t.list(), nil
}
e, ok := t.entries[name]
if !ok {
return "", fmt.Errorf("no slash command %q; available: %s", name, strings.Join(t.names, ", "))
}
args := strings.Fields(p.Arguments)
expanded := e.Render(args)
// Frame the expansion so the model treats it as an instruction to follow now,
// not as data to echo back.
return fmt.Sprintf("Expanded /%s — follow these instructions now:\n\n%s", name, expanded), nil
}
func (t *slashCommandTool) list() string {
if len(t.names) == 0 {
return "No slash commands are configured in this project."
}
var b strings.Builder
b.WriteString("Available slash commands:\n")
for _, n := range t.names {
e := t.entries[n]
line := "- /" + n
if e.ArgHint != "" {
line += " " + e.ArgHint
}
if e.Description != "" {
line += " — " + e.Description
}
b.WriteString(line + "\n")
}
return strings.TrimRight(b.String(), "\n")
}