返回 DeepSeek-Reasonix
slashtool.go
根目录 / internal / command / slashtool.go
1 package command
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "sort"
8 "strings"
9
10 "reasonix/internal/tool"
11 )
12
13 // SlashEntry is one invocable slash command exposed to the model through the
14 // slash_command tool. It is a uniform view over the two kinds the user can also
15 // type at the prompt — custom commands and skills — so the tool need not know
16 // which is which. Render turns positional args into the prompt text the command
17 // expands to (the same text typing "/name args" would send).
18 type SlashEntry struct {
19 Name string // without the leading slash, e.g. "review" or "git:commit"
20 Description string
21 ArgHint string // optional argument hint, for the listing
22 Render func(args []string) string // expands the template/playbook with args
23 }
24
25 // slashCommandTool lets the model invoke a loaded slash command by name. Unlike a
26 // tool that performs an action and returns a result, a slash command is a *prompt
27 // template*: the tool returns the expanded prompt text, which the model then reads
28 // and acts on within the same turn — mirroring what typing "/name" does for a
29 // human. Calling with no name (or "list") returns the available commands.
30 type slashCommandTool struct {
31 entries map[string]SlashEntry
32 names []string // sorted, for a stable listing
33 }
34
35 // NewSlashCommandTool builds the tool from the invocable entries (custom commands
36 // + skills, adapted by the caller). A later entry wins on a name clash, matching
37 // the prompt's command>skill precedence when the caller orders them that way.
38 func NewSlashCommandTool(entries []SlashEntry) tool.Tool {
39 m := make(map[string]SlashEntry, len(entries))
40 for _, e := range entries {
41 name := strings.TrimPrefix(strings.TrimSpace(e.Name), "/")
42 if name == "" {
43 continue
44 }
45 e.Name = name
46 m[name] = e
47 }
48 names := make([]string, 0, len(m))
49 for n := range m {
50 names = append(names, n)
51 }
52 sort.Strings(names)
53 return &slashCommandTool{entries: m, names: names}
54 }
55
56 func (*slashCommandTool) Name() string { return "slash_command" }
57
58 func (*slashCommandTool) ReadOnly() bool { return true }
59
60 func (t *slashCommandTool) Description() string {
61 var b strings.Builder
62 b.WriteString("Invoke a project slash command (a reusable prompt template or skill) by name. " +
63 "Returns the command's expanded prompt text for you to act on in this turn — it does not run on its own. " +
64 "Call with an empty command (or \"list\") to see what's available. ")
65 if len(t.names) == 0 {
66 b.WriteString("No slash commands are configured in this project.")
67 return b.String()
68 }
69 b.WriteString("Available: ")
70 for i, n := range t.names {
71 if i > 0 {
72 b.WriteString(", ")
73 }
74 b.WriteString(n)
75 }
76 b.WriteString(".")
77 return b.String()
78 }
79
80 func (*slashCommandTool) Schema() json.RawMessage {
81 return json.RawMessage(`{
82 "type": "object",
83 "properties": {
84 "command": {"type": "string", "description": "Slash command name (with or without a leading slash). Empty or \"list\" returns the available commands."},
85 "arguments": {"type": "string", "description": "Arguments passed to the command, as you'd type them after the name (space-separated)."}
86 }
87 }`)
88 }
89
90 func (t *slashCommandTool) Execute(_ context.Context, raw json.RawMessage) (string, error) {
91 var p struct {
92 Command string `json:"command"`
93 Arguments string `json:"arguments"`
94 }
95 if len(raw) > 0 {
96 if err := json.Unmarshal(raw, &p); err != nil {
97 return "", fmt.Errorf("invalid args: %w", err)
98 }
99 }
100 name := strings.TrimPrefix(strings.TrimSpace(p.Command), "/")
101 if name == "" || strings.EqualFold(name, "list") {
102 return t.list(), nil
103 }
104 e, ok := t.entries[name]
105 if !ok {
106 return "", fmt.Errorf("no slash command %q; available: %s", name, strings.Join(t.names, ", "))
107 }
108 args := strings.Fields(p.Arguments)
109 expanded := e.Render(args)
110 // Frame the expansion so the model treats it as an instruction to follow now,
111 // not as data to echo back.
112 return fmt.Sprintf("Expanded /%s — follow these instructions now:\n\n%s", name, expanded), nil
113 }
114
115 func (t *slashCommandTool) list() string {
116 if len(t.names) == 0 {
117 return "No slash commands are configured in this project."
118 }
119 var b strings.Builder
120 b.WriteString("Available slash commands:\n")
121 for _, n := range t.names {
122 e := t.entries[n]
123 line := "- /" + n
124 if e.ArgHint != "" {
125 line += " " + e.ArgHint
126 }
127 if e.Description != "" {
128 line += " — " + e.Description
129 }
130 b.WriteString(line + "\n")
131 }
132 return strings.TrimRight(b.String(), "\n")
133 }
134
134 lines GO