1package server
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 "time"
10
11 "shelley.exe.dev/claudetool"
12 "shelley.exe.dev/db/generated"
13 "shelley.exe.dev/llm"
14)
15
16// SubagentRunner implements claudetool.SubagentRunner.
17type SubagentRunner struct {
18 server *Server
19}
20
21// NewSubagentRunner creates a new SubagentRunner.
22func NewSubagentRunner(s *Server) *SubagentRunner {
23 return &SubagentRunner{server: s}
24}
25
26// RunSubagent implements claudetool.SubagentRunner.
27func (r *SubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) {
28 s := r.server
29
30 // Get or create conversation manager for the subagent
31 manager, err := s.getOrCreateConversationManager(ctx, conversationID)
32 if err != nil {
33 return "", fmt.Errorf("failed to get conversation manager: %w", err)
34 }
35
36 // Get the model ID from the server's default
37 // In predictable-only mode, use "predictable" as the model
38 modelID := s.defaultModel
39 if modelID == "" && s.predictableOnly {
40 modelID = "predictable"
41 }
42
43 // Get LLM service
44 llmService, err := s.llmManager.GetService(modelID)
45 if err != nil {
46 return "", fmt.Errorf("failed to get LLM service: %w", err)
47 }
48
49 // If the subagent is currently working, stop it first before sending new message
50 if manager.IsAgentWorking() {
51 s.logger.Info("Subagent is working, stopping before sending new message", "conversationID", conversationID)
52 if err := manager.CancelConversation(ctx); err != nil {
53 s.logger.Error("Failed to cancel subagent conversation", "error", err)
54 // Continue anyway - we still want to send the new message
55 }
56 // Re-hydrate the manager after cancellation
57 if err := manager.Hydrate(ctx); err != nil {
58 return "", fmt.Errorf("failed to hydrate after cancellation: %w", err)
59 }
60 }
61
62 // Create user message
63 userMessage := llm.Message{
64 Role: llm.MessageRoleUser,
65 Content: []llm.Content{{Type: llm.ContentTypeText, Text: prompt}},
66 }
67
68 // Accept the user message (this starts processing)
69 _, err = manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
70 if err != nil {
71 return "", fmt.Errorf("failed to accept user message: %w", err)
72 }
73
74 if !wait {
75 return fmt.Sprintf("Subagent started processing. Conversation ID: %s", conversationID), nil
76 }
77
78 // Wait for the agent to finish (or timeout)
79 return r.waitForResponse(ctx, conversationID, modelID, llmService, timeout)
80}
81
82func (r *SubagentRunner) waitForResponse(ctx context.Context, conversationID, modelID string, llmService llm.Service, timeout time.Duration) (string, error) {
83 s := r.server
84
85 deadline := time.Now().Add(timeout)
86 pollInterval := 500 * time.Millisecond
87
88 for {
89 select {
90 case <-ctx.Done():
91 return "", ctx.Err()
92 default:
93 }
94
95 if time.Now().After(deadline) {
96 // Timeout reached - generate a progress summary
97 return r.generateProgressSummary(ctx, conversationID, modelID, llmService)
98 }
99
100 // Check if agent is still working
101 working, err := r.isAgentWorking(ctx, conversationID)
102 if err != nil {
103 return "", fmt.Errorf("failed to check agent status: %w", err)
104 }
105
106 if !working {
107 // Agent is done, get the last message
108 return r.getLastAssistantResponse(ctx, conversationID)
109 }
110
111 // Wait before polling again
112 select {
113 case <-ctx.Done():
114 return "", ctx.Err()
115 case <-time.After(pollInterval):
116 }
117
118 // Don't hog the conversation manager mutex
119 s.mu.Lock()
120 if mgr, ok := s.activeConversations[conversationID]; ok {
121 mgr.Touch()
122 }
123 s.mu.Unlock()
124 }
125}
126
127func (r *SubagentRunner) isAgentWorking(ctx context.Context, conversationID string) (bool, error) {
128 s := r.server
129
130 // Get the conversation manager - it tracks the working state
131 s.mu.Lock()
132 mgr, ok := s.activeConversations[conversationID]
133 s.mu.Unlock()
134
135 if !ok {
136 // No active manager means the agent is not working
137 return false, nil
138 }
139
140 return mgr.IsAgentWorking(), nil
141}
142
143func (r *SubagentRunner) getLastAssistantResponse(ctx context.Context, conversationID string) (string, error) {
144 s := r.server
145
146 // Get the latest message
147 msg, err := s.db.GetLatestMessage(ctx, conversationID)
148 if err != nil {
149 return "", fmt.Errorf("failed to get latest message: %w", err)
150 }
151
152 // Extract text content
153 if msg.LlmData == nil {
154 return "", nil
155 }
156
157 var llmMsg llm.Message
158 if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
159 return "", fmt.Errorf("failed to parse message: %w", err)
160 }
161
162 var texts []string
163 for _, content := range llmMsg.Content {
164 if content.Type == llm.ContentTypeText && content.Text != "" {
165 texts = append(texts, content.Text)
166 }
167 }
168
169 return strings.Join(texts, "\n"), nil
170}
171
172// generateProgressSummary makes a non-conversation LLM call to summarize the subagent's progress.
173// This is called when the timeout is reached and the subagent is still working.
174func (r *SubagentRunner) generateProgressSummary(ctx context.Context, conversationID, modelID string, llmService llm.Service) (string, error) {
175 s := r.server
176
177 // Get the conversation messages
178 var messages []generated.Message
179 err := s.db.Queries(ctx, func(q *generated.Queries) error {
180 var err error
181 messages, err = q.ListMessages(ctx, conversationID)
182 return err
183 })
184 if err != nil {
185 s.logger.Error("Failed to get messages for progress summary", "error", err)
186 return "[Subagent is still working (timeout reached). Failed to generate progress summary.]", nil
187 }
188
189 if len(messages) == 0 {
190 return "[Subagent is still working (timeout reached). No messages yet.]", nil
191 }
192
193 // Build a summary of the conversation for the LLM
194 conversationSummary := r.buildConversationSummary(messages)
195
196 // Make a non-conversation LLM call to summarize progress
197 summaryPrompt := `You are summarizing the current progress of a subagent task for a parent agent.
198
199The subagent was given a task and has been working on it, but the timeout was reached before it completed.
200Below is the conversation history showing what the subagent has done so far.
201
202Please provide a brief, actionable summary (2-4 sentences) that tells the parent agent:
2031. What the subagent has accomplished so far
2042. What it appears to be currently working on
2053. Whether it seems to be making progress or stuck
206
207Conversation history:
208` + conversationSummary + `
209
210Provide your summary now:`
211
212 req := &llm.Request{
213 Messages: []llm.Message{
214 {
215 Role: llm.MessageRoleUser,
216 Content: []llm.Content{{Type: llm.ContentTypeText, Text: summaryPrompt}},
217 },
218 },
219 }
220
221 // Use a short timeout for the summary call
222 summaryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
223 defer cancel()
224
225 resp, err := llmService.Do(summaryCtx, req)
226 if err != nil {
227 s.logger.Error("Failed to generate progress summary via LLM", "error", err)
228 return "[Subagent is still working (timeout reached). Failed to generate progress summary.]", nil
229 }
230
231 // Extract the summary text
232 var summaryText string
233 for _, content := range resp.Content {
234 if content.Type == llm.ContentTypeText && content.Text != "" {
235 summaryText = content.Text
236 break
237 }
238 }
239
240 if summaryText == "" {
241 return "[Subagent is still working (timeout reached). No summary available.]", nil
242 }
243
244 return fmt.Sprintf("[Subagent is still working (timeout reached). Progress summary:]\n%s", summaryText), nil
245}
246
247// buildConversationSummary creates a text summary of the conversation messages for the LLM.
248func (r *SubagentRunner) buildConversationSummary(messages []generated.Message) string {
249 var sb strings.Builder
250
251 for _, msg := range messages {
252 // Skip system messages
253 if msg.Type == "system" {
254 continue
255 }
256
257 if msg.LlmData == nil {
258 continue
259 }
260
261 var llmMsg llm.Message
262 if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
263 continue
264 }
265
266 roleStr := "User"
267 if llmMsg.Role == llm.MessageRoleAssistant {
268 roleStr = "Assistant"
269 }
270
271 for _, content := range llmMsg.Content {
272 switch content.Type {
273 case llm.ContentTypeText:
274 if content.Text != "" {
275 // Truncate very long text
276 text := content.Text
277 if len(text) > 500 {
278 text = text[:500] + "...[truncated]"
279 }
280 sb.WriteString(fmt.Sprintf("[%s]: %s\n\n", roleStr, text))
281 }
282 case llm.ContentTypeToolUse:
283 // Truncate tool input if long
284 inputStr := string(content.ToolInput)
285 if len(inputStr) > 200 {
286 inputStr = inputStr[:200] + "...[truncated]"
287 }
288 sb.WriteString(fmt.Sprintf("[%s used tool %s]: %s\n\n", roleStr, content.ToolName, inputStr))
289 case llm.ContentTypeToolResult:
290 // Summarize tool results
291 resultText := ""
292 for _, r := range content.ToolResult {
293 if r.Type == llm.ContentTypeText && r.Text != "" {
294 resultText = r.Text
295 break
296 }
297 }
298 if len(resultText) > 300 {
299 resultText = resultText[:300] + "...[truncated]"
300 }
301 errorStr := ""
302 if content.ToolError {
303 errorStr = " (error)"
304 }
305 sb.WriteString(fmt.Sprintf("[Tool result%s]: %s\n\n", errorStr, resultText))
306 }
307 }
308 }
309
310 // Limit total size
311 result := sb.String()
312 if len(result) > 8000 {
313 // Keep the last 8000 chars (most recent activity)
314 result = "...[earlier messages truncated]...\n" + result[len(result)-8000:]
315 }
316
317 return result
318}
319
320// createSubagentToolSetConfig creates a ToolSetConfig for subagent conversations.
321// Subagent conversations don't have nested subagents to avoid complexity.
322func (s *Server) createSubagentToolSetConfig(conversationID string) claudetool.ToolSetConfig {
323 return claudetool.ToolSetConfig{
324 LLMProvider: s.llmManager,
325 EnableJITInstall: true,
326 EnableBrowser: true, // Subagents can use browser tools
327 // No SubagentRunner/DB - subagents can't spawn nested subagents
328 }
329}
330
331// Ensure SubagentRunner implements claudetool.SubagentRunner.
332var _ claudetool.SubagentRunner = (*SubagentRunner)(nil)
333
334// handleGetSubagents returns the list of subagents for a conversation.
335func (s *Server) handleGetSubagents(w http.ResponseWriter, r *http.Request, conversationID string) {
336 if r.Method != "GET" {
337 http.Error(w, "Method not allowed", 405)
338 return
339 }
340
341 subagents, err := s.db.GetSubagents(r.Context(), conversationID)
342 if err != nil {
343 s.logger.Error("Failed to get subagents", "conversationID", conversationID, "error", err)
344 http.Error(w, "Failed to get subagents", 500)
345 return
346 }
347
348 w.Header().Set("Content-Type", "application/json")
349 json.NewEncoder(w).Encode(subagents)
350}