subagent.go

  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}