返回 DeepSeek-Reasonix
sessiontool.go
根目录 / internal / tool / sessiontool / sessiontool.go
1 // Package sessiontool provides list_sessions and read_session tools that let
2 // the AI discover and read past conversation sessions, enabling cross-session
3 // AI context sharing. The tools reuse agent.ListSessionOrder, agent.LoadSession,
4 // and agent.IsCleanupPending — the same infrastructure used by the history
5 // tool and session picker — to avoid duplicating session-file logic.
6 package sessiontool
7
8 import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "path/filepath"
13 "strings"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/provider"
17 "reasonix/internal/textutil"
18 )
19
20 // ---- list_sessions tool -----------------------------------------------------
21
22 type listSessionsTool struct {
23 sessionDir string
24 }
25
26 // NewListSessionsTool creates a tool that lists saved sessions.
27 func NewListSessionsTool(sessionDir string) *listSessionsTool {
28 return &listSessionsTool{sessionDir: sessionDir}
29 }
30
31 func (t *listSessionsTool) Name() string { return "list_sessions" }
32 func (t *listSessionsTool) ReadOnly() bool { return true }
33
34 func (t *listSessionsTool) Description() string {
35 return "List saved AI conversation sessions. Returns timestamp, model, turn count, preview, and file for each visible session, newest first. Uses session metadata (branch sidecar timestamps). Use read_session to view a session's conversation."
36 }
37
38 func (t *listSessionsTool) Schema() json.RawMessage {
39 return json.RawMessage(`{"type":"object","properties":{},"required":[]}`)
40 }
41
42 func (t *listSessionsTool) Execute(_ context.Context, _ json.RawMessage) (string, error) {
43 ordered, err := agent.ListSessionOrder(t.sessionDir)
44 if err != nil {
45 return "", fmt.Errorf("list_sessions: %w", err)
46 }
47 if len(ordered) == 0 {
48 return "No sessions found.\n", nil
49 }
50
51 var b strings.Builder
52 fmt.Fprintf(&b, "# Saved Sessions (%d total)\n\n", len(ordered))
53 b.WriteString("| # | Timestamp | Model | Turns | Preview | File\n")
54 b.WriteString("|---|-----------|-------|-------|-----------------|-----\n")
55 for i, s := range ordered {
56 ts := s.LastActivityAt.Format("2006-01-02 15:04")
57 model := modelFromPath(s.Path)
58 preview, turns := agent.SessionPreview(s.Path)
59 fmt.Fprintf(&b, "| %d | %s | %s | %d | %s | `%s`\n",
60 i+1, ts, model, turns, preview, filepath.Base(s.Path))
61 }
62 b.WriteString("\nUse `read_session` with the filename under \"File\" to view the session.\n")
63 return b.String(), nil
64 }
65
66 // ---- read_session tool ------------------------------------------------------
67
68 type readSessionTool struct {
69 sessionDir string
70 }
71
72 // NewReadSessionTool creates a tool that reads saved sessions.
73 func NewReadSessionTool(sessionDir string) *readSessionTool {
74 return &readSessionTool{sessionDir: sessionDir}
75 }
76
77 func (t *readSessionTool) Name() string { return "read_session" }
78 func (t *readSessionTool) ReadOnly() bool { return true }
79
80 func (t *readSessionTool) Description() string {
81 return `Read a saved AI conversation session by file name (e.g. "20260618-231556.000000000-gpt-4.jsonl"). Returns a bounded, privacy-safe view: each message truncated to 2000 runes, no reasoning content, no system prompts, no tool result content (opt-in via show_tool_results). Use list_sessions to discover available sessions.`
82 }
83
84 func (t *readSessionTool) Schema() json.RawMessage {
85 return json.RawMessage(`{
86 "type": "object",
87 "properties": {
88 "session": {
89 "type": "string",
90 "description": "Session file name (e.g. \"20260618-231556.000000000-gpt-4.jsonl\") or full path. Use list_sessions to see available sessions."
91 },
92 "max_turns": {
93 "type": "integer",
94 "description": "Maximum user-assistant turns to return (default 50). 0 = no limit."
95 },
96 "show_tool_results": {
97 "type": "boolean",
98 "description": "When true, include tool result content (default false). Tool results may contain secrets, command output, environment data, or private file contents."
99 }
100 },
101 "required": ["session"]
102 }`)
103 }
104
105 func (t *readSessionTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
106 var params struct {
107 Session string `json:"session"`
108 MaxTurns *int `json:"max_turns"`
109 ShowToolResults bool `json:"show_tool_results"`
110 }
111 if err := json.Unmarshal(args, &params); err != nil {
112 return "", fmt.Errorf("read_session: invalid args: %w", err)
113 }
114 if params.Session == "" {
115 return "", fmt.Errorf("read_session: 'session' argument is required")
116 }
117
118 sessionPath := params.Session
119 // If it's just a filename (no path separator), resolve relative to sessionDir
120 if !strings.Contains(sessionPath, string(filepath.Separator)) && !strings.Contains(sessionPath, "/") {
121 sessionPath = filepath.Join(t.sessionDir, sessionPath)
122 }
123 // Guard against path traversal
124 sessionPath = filepath.Clean(sessionPath)
125 dir := filepath.Clean(t.sessionDir)
126 if !strings.HasPrefix(sessionPath, dir+string(filepath.Separator)) && sessionPath != dir {
127 return "", fmt.Errorf("read_session: path %q is outside the session directory", params.Session)
128 }
129
130 // Reject cleanup-pending sessions (reuses agent.IsCleanupPending directly).
131 if agent.IsCleanupPending(sessionPath) {
132 return "", fmt.Errorf("read_session: session %q is pending cleanup", filepath.Base(sessionPath))
133 }
134
135 // Reuse agent.LoadSession for JSONL decoding.
136 ses, err := agent.LoadSession(sessionPath)
137 if err != nil {
138 return "", fmt.Errorf("read_session: %w", err)
139 }
140 msgs := ses.Snapshot()
141 if len(msgs) == 0 {
142 return "Session is empty.\n", nil
143 }
144
145 // Parse max_turns: default 50, 0 means no limit.
146 maxTurns := 50
147 if params.MaxTurns != nil {
148 if *params.MaxTurns == 0 {
149 maxTurns = 0
150 } else if *params.MaxTurns > 0 {
151 maxTurns = *params.MaxTurns
152 }
153 }
154
155 var b strings.Builder
156 fmt.Fprintf(&b, "# Session: %s\n", filepath.Base(sessionPath))
157
158 turnCount := 0
159 loop:
160 for _, m := range msgs {
161 switch m.Role {
162 case provider.RoleSystem:
163 // System prompts excluded for privacy (matching history tool).
164
165 case provider.RoleUser:
166 turnCount++
167 if maxTurns > 0 && turnCount > maxTurns {
168 b.WriteString("... (truncated, use max_turns to increase limit)\n")
169 break loop
170 }
171 fmt.Fprintf(&b, "## User (turn %d)\n", turnCount)
172 b.WriteString(truncateRunes(m.Content, 2000))
173 b.WriteString("\n\n")
174
175 case provider.RoleAssistant:
176 if m.Content != "" {
177 fmt.Fprintf(&b, "## Assistant (turn %d)\n", max(turnCount, 1))
178 b.WriteString(truncateRunes(m.Content, 2000))
179 b.WriteString("\n\n")
180 }
181 if len(m.ToolCalls) > 0 {
182 b.WriteString("### Tool Calls\n\n")
183 for _, tc := range m.ToolCalls {
184 fmt.Fprintf(&b, "- `%s(%s)`\n", tc.Name, truncateRunes(string(tc.Arguments), 1200))
185 }
186 b.WriteString("\n")
187 }
188
189 case provider.RoleTool:
190 fmt.Fprintf(&b, "### Tool Result: %s\n\n", m.Name)
191 if params.ShowToolResults && m.Content != "" {
192 b.WriteString(truncateRunes(m.Content, 2000))
193 b.WriteString("\n\n")
194 }
195 }
196 }
197
198 return b.String(), nil
199 }
200
201 // ---- helpers ----------------------------------------------------------------
202
203 // truncateRunes preserves the historical name but truncates by grapheme
204 // clusters so previews do not split combined emoji or other visible characters.
205 func truncateRunes(s string, max int) string {
206 s = strings.TrimSpace(s)
207 return textutil.TruncateGraphemes(s, max, "...")
208 }
209
210 // modelFromPath extracts the model name from a session file path.
211 // Filename format: "20060102-150405.000000000-model-name.jsonl"
212 func modelFromPath(path string) string {
213 name := filepath.Base(path)
214 name = strings.TrimSuffix(name, ".jsonl")
215 firstDash := strings.Index(name, "-")
216 if firstDash < 0 {
217 return "(unknown)"
218 }
219 rest := name[firstDash+1:]
220 secondDash := strings.Index(rest, "-")
221 if secondDash < 0 {
222 return rest
223 }
224 return rest[secondDash+1:]
225 }
226
226 lines GO