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