返回 DeepSeek-Reasonix
tool.go
根目录 / internal / history / tool.go
1 package history
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/historycatalog"
10 "reasonix/internal/tool"
11 )
12
13 type historyTool struct {
14 searcher historySearcher
15 }
16
17 type historySearcher interface {
18 Search(context.Context, SearchRequest) ([]Hit, error)
19 Around(context.Context, AroundRequest) ([]MessageContext, error)
20 }
21
22 // NewTool returns a read-only history retrieval tool bound to local sessions.
23 func NewTool(opts Options) tool.Tool {
24 return historyTool{searcher: NewSearcher(opts)}
25 }
26
27 // NewIndexedTool is the production history tool. It never performs a
28 // synchronous directory scan; while the shared projection is building it
29 // returns the already indexed subset and an explicit progress notice.
30 func NewIndexedTool(opts Options) tool.Tool {
31 return historyTool{searcher: NewIndexedSearcher(opts)}
32 }
33
34 func (historyTool) Name() string { return tool.HostHistory }
35
36 func (historyTool) Description() string {
37 return "Search saved local session history with lightweight BM25 retrieval, then read messages around a hit. " +
38 "Use search when past decisions, failed attempts, commands, or tool inputs may help the current task; use around with a returned session_path and message_index to inspect the nearby transcript. " +
39 "By default it searches user text, assistant text, tool inputs, and tool errors; normal tool outputs are excluded unless kind includes tool_output."
40 }
41
42 func (historyTool) Schema() json.RawMessage {
43 return json.RawMessage(`{
44 "type": "object",
45 "properties": {
46 "operation": {"type": "string", "enum": ["search", "around"], "description": "search ranks saved history; around returns nearby messages for a search hit."},
47 "query": {"type": "string", "description": "Search query for operation=search."},
48 "scope": {"type": "string", "enum": ["project", "global"], "description": "project searches the current session directory; global also includes compacted-history archives."},
49 "kind": {"type": "array", "items": {"type": "string", "enum": ["user_text", "assistant_text", "tool_input", "tool_error", "tool_output"]}, "description": "History parts to search. Defaults to user_text, assistant_text, tool_input, and tool_error."},
50 "tool_name": {"type": "string", "description": "Optional tool-name filter for tool_input, tool_error, or tool_output."},
51 "limit": {"type": "integer", "description": "Maximum search hits to return, default 8, max 20."},
52 "session_path": {"type": "string", "description": "Path from a search hit. Required for operation=around."},
53 "message_index": {"type": "integer", "description": "Message index from a search hit. Required for operation=around."},
54 "before": {"type": "integer", "description": "Messages before message_index for operation=around, default 3, max 10."},
55 "after": {"type": "integer", "description": "Messages after message_index for operation=around, default 3, max 10."}
56 },
57 "required": ["operation"]
58 }`)
59 }
60
61 func (t historyTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
62 var in struct {
63 Operation string `json:"operation"`
64 Query string `json:"query"`
65 Scope string `json:"scope"`
66 Kind []Kind `json:"kind"`
67 ToolName string `json:"tool_name"`
68 Limit int `json:"limit"`
69 SessionPath string `json:"session_path"`
70 MessageIndex *int `json:"message_index"`
71 Before int `json:"before"`
72 After int `json:"after"`
73 }
74 if err := json.Unmarshal(args, &in); err != nil {
75 return "", fmt.Errorf("invalid arguments: %w", err)
76 }
77 switch strings.TrimSpace(in.Operation) {
78 case "search":
79 hits, err := t.searcher.Search(ctx, SearchRequest{
80 Query: in.Query,
81 Scope: in.Scope,
82 Kinds: in.Kind,
83 ToolName: in.ToolName,
84 Limit: in.Limit,
85 })
86 if err != nil {
87 return "", err
88 }
89 formatted := formatHits(in.Query, hits)
90 if statusProvider, ok := t.searcher.(interface{ IndexStatus() historycatalog.Status }); ok {
91 status := statusProvider.IndexStatus()
92 if status.State != "ready" || status.Pending > 0 || (status.Total > 0 && status.Indexed < status.Total) {
93 formatted = fmt.Sprintf("History index is still building (%d/%d); results may be incomplete.\n\n%s", status.Indexed, status.Total, formatted)
94 }
95 }
96 return formatted, nil
97 case "around":
98 if in.MessageIndex == nil {
99 return "", fmt.Errorf("message_index is required for operation=around")
100 }
101 msgs, err := t.searcher.Around(ctx, AroundRequest{
102 SessionPath: in.SessionPath,
103 MessageIndex: *in.MessageIndex,
104 Before: in.Before,
105 After: in.After,
106 })
107 if err != nil {
108 return "", err
109 }
110 return formatAround(in.SessionPath, *in.MessageIndex, msgs), nil
111 case "":
112 return "", fmt.Errorf("operation is required")
113 default:
114 return "", fmt.Errorf("unknown operation %q", in.Operation)
115 }
116 }
117
118 func (historyTool) ReadOnly() bool { return true }
119
120 func formatHits(query string, hits []Hit) string {
121 if len(hits) == 0 {
122 return strings.Join([]string{
123 "No saved session history matched " + strconvQuote(query) + ".",
124 "",
125 "0 results does not prove the event never happened. Try:",
126 "1. Retry with fewer, rarer terms such as a function name, command, error phrase, ticket id, or decision keyword.",
127 "2. Widen scope from project to global when cross-project or compacted-history context may matter.",
128 "3. If you need tool output, include kind=[\"tool_output\"] or filter by tool_name for tool input/error/output searches.",
129 }, "\n")
130 }
131 var b strings.Builder
132 fmt.Fprintf(&b, "History search results for %s:\n", strconvQuote(query))
133 for i, hit := range hits {
134 fmt.Fprintf(&b, "\n%d. score=%.3f source=%s session_id=%s message_index=%d kind=%s role=%s",
135 i+1, hit.Score, hit.Source, hit.SessionID, hit.MessageIndex, hit.Kind, hit.Role)
136 if hit.ToolName != "" {
137 fmt.Fprintf(&b, " tool=%s", hit.ToolName)
138 }
139 fmt.Fprintf(&b, "\n session_path: %s\n snippet: %s\n",
140 hit.SessionPath, hit.Snippet)
141 }
142 b.WriteString("\nUse operation=\"around\" with a session_path and message_index to read nearby messages.")
143 return strings.TrimSpace(b.String())
144 }
145
146 func formatAround(path string, idx int, msgs []MessageContext) string {
147 var b strings.Builder
148 fmt.Fprintf(&b, "History around %s message_index=%d:\n", path, idx)
149 for _, msg := range msgs {
150 fmt.Fprintf(&b, "\n%s\n", msg.Text)
151 }
152 return strings.TrimSpace(b.String())
153 }
154
155 func strconvQuote(s string) string {
156 b, _ := json.Marshal(s)
157 return string(b)
158 }
159
159 lines GO