134 lines
4.4 KiB
Go
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")
|
|
}
|