diff --git a/claudetool/subagent.go b/claudetool/subagent.go new file mode 100644 index 0000000000000000000000000000000000000000..cea0fa214e1077d858b5c220c54868f1e084db4f --- /dev/null +++ b/claudetool/subagent.go @@ -0,0 +1,179 @@ +package claudetool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "shelley.exe.dev/llm" +) + +// SubagentRunner is the interface for running a subagent conversation. +// This is implemented by the server package to avoid import cycles. +type SubagentRunner interface { + // RunSubagent runs a subagent conversation and returns the last response. + // If wait is false, it starts processing in background and returns immediately. + // timeout is the maximum time to wait for a response. + RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) +} + +// SubagentDB is the database interface for subagent operations. +// This is implemented by the db package. +type SubagentDB interface { + // GetOrCreateSubagentConversation retrieves or creates a subagent conversation. + // Returns the conversation ID and the actual slug used (may differ from requested + // slug if a numeric suffix was added for uniqueness). + GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (conversationID, actualSlug string, err error) +} + +// SubagentTool provides the ability to spawn and interact with subagent conversations. +type SubagentTool struct { + DB SubagentDB + ParentConversationID string + WorkingDir *MutableWorkingDir + Runner SubagentRunner +} + +const ( + subagentName = "subagent" + subagentDescription = `Spawn or interact with a subagent conversation. + +Subagents are independent conversations that can work on subtasks in parallel. +Use subagents for: +- Long-running tasks that you want to delegate +- Token-intensive tasks that produce lots of output, little of which is needed +- Parallel exploration of different approaches +- Breaking down complex problems into independent pieces + +Each subagent has its own slug identifier within this conversation. +You can send messages to existing subagents by using the same slug. +The tool returns the subagent's last response, or a status if the timeout is reached. +` + subagentInputSchema = ` +{ + "type": "object", + "required": ["slug", "prompt"], + "properties": { + "slug": { + "type": "string", + "description": "A short identifier for this subagent (e.g., 'research-api', 'test-runner')" + }, + "prompt": { + "type": "string", + "description": "The message to send to the subagent" + }, + "timeout_seconds": { + "type": "integer", + "description": "How long to wait for a response (default: 60, max: 300)" + }, + "wait": { + "type": "boolean", + "description": "Whether to wait for completion (default: true). If false, returns immediately." + } + } +} +` +) + +type subagentInput struct { + Slug string `json:"slug"` + Prompt string `json:"prompt"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + Wait *bool `json:"wait,omitempty"` +} + +// Tool returns an llm.Tool for the subagent functionality. +func (s *SubagentTool) Tool() *llm.Tool { + return &llm.Tool{ + Name: subagentName, + Description: strings.TrimSpace(subagentDescription), + InputSchema: llm.MustSchema(subagentInputSchema), + Run: s.Run, + } +} + +func (s *SubagentTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut { + var req subagentInput + if err := json.Unmarshal(m, &req); err != nil { + return llm.ErrorfToolOut("failed to parse subagent input: %w", err) + } + + // Validate slug + if req.Slug == "" { + return llm.ErrorfToolOut("slug is required") + } + req.Slug = sanitizeSlug(req.Slug) + if req.Slug == "" { + return llm.ErrorfToolOut("slug must contain alphanumeric characters") + } + + if req.Prompt == "" { + return llm.ErrorfToolOut("prompt is required") + } + + // Set defaults + timeout := 60 * time.Second + if req.TimeoutSeconds > 0 { + if req.TimeoutSeconds > 300 { + req.TimeoutSeconds = 300 + } + timeout = time.Duration(req.TimeoutSeconds) * time.Second + } + + wait := true + if req.Wait != nil { + wait = *req.Wait + } + + // Get or create the subagent conversation + conversationID, actualSlug, err := s.DB.GetOrCreateSubagentConversation(ctx, req.Slug, s.ParentConversationID, s.WorkingDir.Get()) + if err != nil { + return llm.ErrorfToolOut("failed to get/create subagent conversation: %w", err) + } + + // Use the runner to execute the subagent + response, err := s.Runner.RunSubagent(ctx, conversationID, req.Prompt, wait, timeout) + if err != nil { + return llm.ErrorfToolOut("subagent error: %w", err) + } + + // Include actual slug in response if it differs from requested + slugNote := "" + if actualSlug != req.Slug { + slugNote = fmt.Sprintf(" (Note: slug was changed to '%s' for uniqueness. Use '%s' for future messages to this subagent.)", actualSlug, actualSlug) + } + + return llm.ToolOut{ + LLMContent: llm.TextContent(fmt.Sprintf("Subagent '%s' response:%s\n%s", actualSlug, slugNote, response)), + Display: SubagentDisplayData{ + Slug: actualSlug, + ConversationID: conversationID, + }, + } +} + +// SubagentDisplayData is the display data sent to the UI for subagent tool results. +type SubagentDisplayData struct { + Slug string `json:"slug"` + ConversationID string `json:"conversation_id"` +} + +func sanitizeSlug(slug string) string { + // Lowercase, keep alphanumeric and hyphens + var result strings.Builder + for _, r := range strings.ToLower(slug) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + result.WriteRune(r) + } else if r == ' ' || r == '_' { + result.WriteRune('-') + } + } + // Remove consecutive hyphens and trim + s := result.String() + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + return strings.Trim(s, "-") +} diff --git a/claudetool/subagent_test.go b/claudetool/subagent_test.go new file mode 100644 index 0000000000000000000000000000000000000000..265327fe13f5f790232ab5763567cc670c609bc9 --- /dev/null +++ b/claudetool/subagent_test.go @@ -0,0 +1,154 @@ +package claudetool + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// mockSubagentDB implements SubagentDB for testing. +type mockSubagentDB struct { + conversations map[string]string // slug -> conversationID +} + +func newMockSubagentDB() *mockSubagentDB { + return &mockSubagentDB{ + conversations: make(map[string]string), + } +} + +func (m *mockSubagentDB) GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (string, string, error) { + key := parentID + ":" + slug + if id, ok := m.conversations[key]; ok { + return id, slug, nil + } + id := "subagent-" + slug + m.conversations[key] = id + return id, slug, nil +} + +// mockSubagentRunner implements SubagentRunner for testing. +type mockSubagentRunner struct { + response string + err error +} + +func (m *mockSubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) { + if m.err != nil { + return "", m.err + } + return m.response, nil +} + +func TestSubagentTool_SanitizeSlug(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"test-slug", "test-slug"}, + {"Test Slug", "test-slug"}, + {"test_slug", "test-slug"}, + {"test--slug", "test-slug"}, + {"-test-slug-", "test-slug"}, + {"test@slug!", "testslug"}, + {"123-abc", "123-abc"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := sanitizeSlug(tt.input) + if result != tt.expected { + t.Errorf("sanitizeSlug(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSubagentTool_Run(t *testing.T) { + wd := NewMutableWorkingDir("/tmp") + db := newMockSubagentDB() + runner := &mockSubagentRunner{response: "Task completed successfully"} + + tool := &SubagentTool{ + DB: db, + ParentConversationID: "parent-123", + WorkingDir: wd, + Runner: runner, + } + + input := subagentInput{ + Slug: "test-task", + Prompt: "Do something useful", + } + inputJSON, _ := json.Marshal(input) + + result := tool.Run(context.Background(), inputJSON) + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + + if len(result.LLMContent) == 0 { + t.Fatal("expected LLM content") + } + + if result.LLMContent[0].Text == "" { + t.Error("expected non-empty response text") + } + + // Check display data + if result.Display == nil { + t.Error("expected display data") + } + displayData, ok := result.Display.(SubagentDisplayData) + if !ok { + t.Error("display data should be SubagentDisplayData") + } + if displayData.Slug != "test-task" { + t.Errorf("expected slug 'test-task', got %q", displayData.Slug) + } +} + +func TestSubagentTool_Validation(t *testing.T) { + wd := NewMutableWorkingDir("/tmp") + db := newMockSubagentDB() + runner := &mockSubagentRunner{response: "OK"} + + tool := &SubagentTool{ + DB: db, + ParentConversationID: "parent-123", + WorkingDir: wd, + Runner: runner, + } + + // Test empty slug + t.Run("empty slug", func(t *testing.T) { + input := subagentInput{Slug: "", Prompt: "test"} + inputJSON, _ := json.Marshal(input) + result := tool.Run(context.Background(), inputJSON) + if result.Error == nil { + t.Error("expected error for empty slug") + } + }) + + // Test empty prompt + t.Run("empty prompt", func(t *testing.T) { + input := subagentInput{Slug: "test", Prompt: ""} + inputJSON, _ := json.Marshal(input) + result := tool.Run(context.Background(), inputJSON) + if result.Error == nil { + t.Error("expected error for empty prompt") + } + }) + + // Test invalid slug (only special chars) + t.Run("invalid slug", func(t *testing.T) { + input := subagentInput{Slug: "@#$%", Prompt: "test"} + inputJSON, _ := json.Marshal(input) + result := tool.Run(context.Background(), inputJSON) + if result.Error == nil { + t.Error("expected error for invalid slug") + } + }) +} diff --git a/claudetool/toolset.go b/claudetool/toolset.go index 6524f86a3e1378f8bfa7a2c50d8e0c4c0acddb7e..12c9b259d9c171281be4511382ec9f1d13747889 100644 --- a/claudetool/toolset.go +++ b/claudetool/toolset.go @@ -50,6 +50,13 @@ type ToolSetConfig struct { // OnWorkingDirChange is called when the working directory changes. // This can be used to persist the change to a database. OnWorkingDirChange func(newDir string) + // SubagentRunner is the runner for subagent conversations. + // If set, the subagent tool will be available. + SubagentRunner SubagentRunner + // SubagentDB is the database for subagent conversations. + SubagentDB SubagentDB + // ParentConversationID is the ID of the parent conversation (for subagent tool). + ParentConversationID string } // ToolSet holds a set of tools for a single conversation. @@ -120,6 +127,17 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet { changeDirTool.Tool(), } + // Add subagent tool if configured + if cfg.SubagentRunner != nil && cfg.SubagentDB != nil && cfg.ParentConversationID != "" { + subagentTool := &SubagentTool{ + DB: cfg.SubagentDB, + ParentConversationID: cfg.ParentConversationID, + WorkingDir: wd, + Runner: cfg.SubagentRunner, + } + tools = append(tools, subagentTool.Tool()) + } + var cleanup func() if cfg.EnableBrowser { // Get max image dimension from the LLM service diff --git a/cmd/go2ts.go b/cmd/go2ts.go index da90c50e3a9baea5655eeb482c55302f60e5af5b..2f1c383bb02532b41178af221b2f2607761920a6 100644 --- a/cmd/go2ts.go +++ b/cmd/go2ts.go @@ -94,14 +94,15 @@ type conversationStateForTS struct { } type conversationWithStateForTS struct { - ConversationID string `json:"conversation_id"` - Slug *string `json:"slug"` - UserInitiated bool `json:"user_initiated"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Cwd *string `json:"cwd"` - Archived bool `json:"archived"` - Working bool `json:"working"` + ConversationID string `json:"conversation_id"` + Slug *string `json:"slug"` + UserInitiated bool `json:"user_initiated"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Cwd *string `json:"cwd"` + Archived bool `json:"archived"` + ParentConversationID *string `json:"parent_conversation_id"` + Working bool `json:"working"` } type streamResponseForTS struct { diff --git a/db/db.go b/db/db.go index 9cf9fc89541e859943f8acca8b24cf9c6b39113f..7f4b4ad55c48d59e886ea34ece4d7afd265bfca4 100644 --- a/db/db.go +++ b/db/db.go @@ -597,3 +597,95 @@ func (db *DB) DeleteConversation(ctx context.Context, conversationID string) err return q.DeleteConversation(ctx, conversationID) }) } + +// CreateSubagentConversation creates a new subagent conversation with a parent +func (db *DB) CreateSubagentConversation(ctx context.Context, slug, parentID string, cwd *string) (*generated.Conversation, error) { + conversationID, err := generateConversationID() + if err != nil { + return nil, fmt.Errorf("failed to generate conversation ID: %w", err) + } + var conversation generated.Conversation + err = db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error { + q := generated.New(tx.Conn()) + conversation, err = q.CreateSubagentConversation(ctx, generated.CreateSubagentConversationParams{ + ConversationID: conversationID, + Slug: &slug, + Cwd: cwd, + ParentConversationID: &parentID, + }) + return err + }) + return &conversation, err +} + +// GetSubagents retrieves all subagent conversations for a parent conversation +func (db *DB) GetSubagents(ctx context.Context, parentID string) ([]generated.Conversation, error) { + var conversations []generated.Conversation + err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error { + q := generated.New(rx.Conn()) + var err error + conversations, err = q.GetSubagents(ctx, &parentID) + return err + }) + return conversations, err +} + +// GetConversationBySlugAndParent retrieves a subagent conversation by slug and parent ID +func (db *DB) GetConversationBySlugAndParent(ctx context.Context, slug, parentID string) (*generated.Conversation, error) { + var conversation generated.Conversation + err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error { + q := generated.New(rx.Conn()) + var err error + conversation, err = q.GetConversationBySlugAndParent(ctx, generated.GetConversationBySlugAndParentParams{ + Slug: &slug, + ParentConversationID: &parentID, + }) + return err + }) + if err == sql.ErrNoRows { + return nil, nil // Not found, return nil without error + } + return &conversation, err +} + +// SubagentDBAdapter adapts *DB to the claudetool.SubagentDB interface. +type SubagentDBAdapter struct { + DB *DB +} + +// GetOrCreateSubagentConversation implements claudetool.SubagentDB. +// Returns the conversation ID and the actual slug used (may differ if a suffix was added). +func (a *SubagentDBAdapter) GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (string, string, error) { + // Try to find existing with exact slug + existing, err := a.DB.GetConversationBySlugAndParent(ctx, slug, parentID) + if err != nil { + return "", "", err + } + if existing != nil { + return existing.ConversationID, *existing.Slug, nil + } + + // Try to create new, handling unique constraint violations by appending numbers + baseSlug := slug + actualSlug := slug + for attempt := 0; attempt < 100; attempt++ { + conv, err := a.DB.CreateSubagentConversation(ctx, actualSlug, parentID, &cwd) + if err == nil { + return conv.ConversationID, actualSlug, nil + } + + // Check if this is a unique constraint violation + errLower := strings.ToLower(err.Error()) + if strings.Contains(errLower, "unique constraint") || + strings.Contains(errLower, "duplicate") { + // Try with a numeric suffix + actualSlug = fmt.Sprintf("%s-%d", baseSlug, attempt+1) + continue + } + + // Some other error occurred + return "", "", err + } + + return "", "", fmt.Errorf("failed to create unique subagent slug after 100 attempts") +} diff --git a/db/generated/conversations.sql.go b/db/generated/conversations.sql.go index 5683f7f9875f7ad19101d609f433b06a60f69c11..f5ab40b65afdcd2b71c8b1f5238c8c4584ea630f 100644 --- a/db/generated/conversations.sql.go +++ b/db/generated/conversations.sql.go @@ -13,7 +13,7 @@ const archiveConversation = `-- name: ArchiveConversation :one UPDATE conversations SET archived = TRUE, updated_at = CURRENT_TIMESTAMP WHERE conversation_id = ? -RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived +RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id ` func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string) (Conversation, error) { @@ -27,6 +27,7 @@ func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ) return i, err } @@ -43,7 +44,7 @@ func (q *Queries) CountArchivedConversations(ctx context.Context) (int64, error) } const countConversations = `-- name: CountConversations :one -SELECT COUNT(*) FROM conversations WHERE archived = FALSE +SELECT COUNT(*) FROM conversations WHERE archived = FALSE AND parent_conversation_id IS NULL ` func (q *Queries) CountConversations(ctx context.Context) (int64, error) { @@ -56,7 +57,7 @@ func (q *Queries) CountConversations(ctx context.Context) (int64, error) { const createConversation = `-- name: CreateConversation :one INSERT INTO conversations (conversation_id, slug, user_initiated, cwd) VALUES (?, ?, ?, ?) -RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived +RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id ` type CreateConversationParams struct { @@ -82,6 +83,41 @@ func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversation &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, + ) + return i, err +} + +const createSubagentConversation = `-- name: CreateSubagentConversation :one +INSERT INTO conversations (conversation_id, slug, user_initiated, cwd, parent_conversation_id) +VALUES (?, ?, FALSE, ?, ?) +RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id +` + +type CreateSubagentConversationParams struct { + ConversationID string `json:"conversation_id"` + Slug *string `json:"slug"` + Cwd *string `json:"cwd"` + ParentConversationID *string `json:"parent_conversation_id"` +} + +func (q *Queries) CreateSubagentConversation(ctx context.Context, arg CreateSubagentConversationParams) (Conversation, error) { + row := q.db.QueryRowContext(ctx, createSubagentConversation, + arg.ConversationID, + arg.Slug, + arg.Cwd, + arg.ParentConversationID, + ) + var i Conversation + err := row.Scan( + &i.ConversationID, + &i.Slug, + &i.UserInitiated, + &i.CreatedAt, + &i.UpdatedAt, + &i.Cwd, + &i.Archived, + &i.ParentConversationID, ) return i, err } @@ -97,7 +133,7 @@ func (q *Queries) DeleteConversation(ctx context.Context, conversationID string) } const getConversation = `-- name: GetConversation :one -SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations WHERE conversation_id = ? ` @@ -112,12 +148,13 @@ func (q *Queries) GetConversation(ctx context.Context, conversationID string) (C &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ) return i, err } const getConversationBySlug = `-- name: GetConversationBySlug :one -SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations WHERE slug = ? ` @@ -132,12 +169,77 @@ func (q *Queries) GetConversationBySlug(ctx context.Context, slug *string) (Conv &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ) return i, err } +const getConversationBySlugAndParent = `-- name: GetConversationBySlugAndParent :one +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations +WHERE slug = ? AND parent_conversation_id = ? +` + +type GetConversationBySlugAndParentParams struct { + Slug *string `json:"slug"` + ParentConversationID *string `json:"parent_conversation_id"` +} + +func (q *Queries) GetConversationBySlugAndParent(ctx context.Context, arg GetConversationBySlugAndParentParams) (Conversation, error) { + row := q.db.QueryRowContext(ctx, getConversationBySlugAndParent, arg.Slug, arg.ParentConversationID) + var i Conversation + err := row.Scan( + &i.ConversationID, + &i.Slug, + &i.UserInitiated, + &i.CreatedAt, + &i.UpdatedAt, + &i.Cwd, + &i.Archived, + &i.ParentConversationID, + ) + return i, err +} + +const getSubagents = `-- name: GetSubagents :many +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations +WHERE parent_conversation_id = ? +ORDER BY created_at ASC +` + +func (q *Queries) GetSubagents(ctx context.Context, parentConversationID *string) ([]Conversation, error) { + rows, err := q.db.QueryContext(ctx, getSubagents, parentConversationID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Conversation{} + for rows.Next() { + var i Conversation + if err := rows.Scan( + &i.ConversationID, + &i.Slug, + &i.UserInitiated, + &i.CreatedAt, + &i.UpdatedAt, + &i.Cwd, + &i.Archived, + &i.ParentConversationID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listArchivedConversations = `-- name: ListArchivedConversations :many -SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations WHERE archived = TRUE ORDER BY updated_at DESC LIMIT ? OFFSET ? @@ -165,6 +267,7 @@ func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchive &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ); err != nil { return nil, err } @@ -180,8 +283,8 @@ func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchive } const listConversations = `-- name: ListConversations :many -SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations -WHERE archived = FALSE +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations +WHERE archived = FALSE AND parent_conversation_id IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ? ` @@ -208,6 +311,7 @@ func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsPa &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ); err != nil { return nil, err } @@ -223,7 +327,7 @@ func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsPa } const searchArchivedConversations = `-- name: SearchArchivedConversations :many -SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations WHERE slug LIKE '%' || ? || '%' AND archived = TRUE ORDER BY updated_at DESC LIMIT ? OFFSET ? @@ -252,6 +356,7 @@ func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArc &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ); err != nil { return nil, err } @@ -267,8 +372,8 @@ func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArc } const searchConversations = `-- name: SearchConversations :many -SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations -WHERE slug LIKE '%' || ? || '%' AND archived = FALSE +SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations +WHERE slug LIKE '%' || ? || '%' AND archived = FALSE AND parent_conversation_id IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ? ` @@ -296,6 +401,7 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ); err != nil { return nil, err } @@ -311,7 +417,7 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio } const searchConversationsWithMessages = `-- name: SearchConversationsWithMessages :many -SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived FROM conversations c +SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived, c.parent_conversation_id FROM conversations c LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent') WHERE c.archived = FALSE AND ( @@ -332,6 +438,7 @@ type SearchConversationsWithMessagesParams struct { } // Search conversations by slug OR message content (user messages and agent responses, not system prompts) +// Includes both top-level conversations and subagent conversations func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg SearchConversationsWithMessagesParams) ([]Conversation, error) { rows, err := q.db.QueryContext(ctx, searchConversationsWithMessages, arg.Column1, @@ -355,6 +462,7 @@ func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg Searc &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ); err != nil { return nil, err } @@ -373,7 +481,7 @@ const unarchiveConversation = `-- name: UnarchiveConversation :one UPDATE conversations SET archived = FALSE, updated_at = CURRENT_TIMESTAMP WHERE conversation_id = ? -RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived +RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id ` func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID string) (Conversation, error) { @@ -387,6 +495,7 @@ func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID stri &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ) return i, err } @@ -395,7 +504,7 @@ const updateConversationCwd = `-- name: UpdateConversationCwd :one UPDATE conversations SET cwd = ?, updated_at = CURRENT_TIMESTAMP WHERE conversation_id = ? -RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived +RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id ` type UpdateConversationCwdParams struct { @@ -414,6 +523,7 @@ func (q *Queries) UpdateConversationCwd(ctx context.Context, arg UpdateConversat &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ) return i, err } @@ -422,7 +532,7 @@ const updateConversationSlug = `-- name: UpdateConversationSlug :one UPDATE conversations SET slug = ?, updated_at = CURRENT_TIMESTAMP WHERE conversation_id = ? -RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived +RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id ` type UpdateConversationSlugParams struct { @@ -441,6 +551,7 @@ func (q *Queries) UpdateConversationSlug(ctx context.Context, arg UpdateConversa &i.UpdatedAt, &i.Cwd, &i.Archived, + &i.ParentConversationID, ) return i, err } diff --git a/db/generated/models.go b/db/generated/models.go index 13e3abb1eee3b6411a76143e7088e51b656b361f..62fa24d19fbf1c35f86574a688b14469690262b6 100644 --- a/db/generated/models.go +++ b/db/generated/models.go @@ -9,13 +9,14 @@ import ( ) type Conversation struct { - ConversationID string `json:"conversation_id"` - Slug *string `json:"slug"` - UserInitiated bool `json:"user_initiated"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Cwd *string `json:"cwd"` - Archived bool `json:"archived"` + ConversationID string `json:"conversation_id"` + Slug *string `json:"slug"` + UserInitiated bool `json:"user_initiated"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Cwd *string `json:"cwd"` + Archived bool `json:"archived"` + ParentConversationID *string `json:"parent_conversation_id"` } type Message struct { diff --git a/db/query/conversations.sql b/db/query/conversations.sql index 42b859ece91f540341b1de07913183a8f851459f..22b2ddfae90c2d33fb30ece19a2e3cc5869e5bdc 100644 --- a/db/query/conversations.sql +++ b/db/query/conversations.sql @@ -13,7 +13,7 @@ WHERE slug = ?; -- name: ListConversations :many SELECT * FROM conversations -WHERE archived = FALSE +WHERE archived = FALSE AND parent_conversation_id IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ?; @@ -25,12 +25,13 @@ LIMIT ? OFFSET ?; -- name: SearchConversations :many SELECT * FROM conversations -WHERE slug LIKE '%' || ? || '%' AND archived = FALSE +WHERE slug LIKE '%' || ? || '%' AND archived = FALSE AND parent_conversation_id IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ?; -- name: SearchConversationsWithMessages :many -- Search conversations by slug OR message content (user messages and agent responses, not system prompts) +-- Includes both top-level conversations and subagent conversations SELECT DISTINCT c.* FROM conversations c LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent') WHERE c.archived = FALSE @@ -64,7 +65,7 @@ DELETE FROM conversations WHERE conversation_id = ?; -- name: CountConversations :one -SELECT COUNT(*) FROM conversations WHERE archived = FALSE; +SELECT COUNT(*) FROM conversations WHERE archived = FALSE AND parent_conversation_id IS NULL; -- name: CountArchivedConversations :one SELECT COUNT(*) FROM conversations WHERE archived = TRUE; @@ -86,3 +87,18 @@ UPDATE conversations SET cwd = ?, updated_at = CURRENT_TIMESTAMP WHERE conversation_id = ? RETURNING *; + + +-- name: CreateSubagentConversation :one +INSERT INTO conversations (conversation_id, slug, user_initiated, cwd, parent_conversation_id) +VALUES (?, ?, FALSE, ?, ?) +RETURNING *; + +-- name: GetSubagents :many +SELECT * FROM conversations +WHERE parent_conversation_id = ? +ORDER BY created_at ASC; + +-- name: GetConversationBySlugAndParent :one +SELECT * FROM conversations +WHERE slug = ? AND parent_conversation_id = ?; diff --git a/db/schema/009-add-parent-conversation.sql b/db/schema/009-add-parent-conversation.sql new file mode 100644 index 0000000000000000000000000000000000000000..9c0e6c4cf0d8df8582c0716c0bf5a73ce941588c --- /dev/null +++ b/db/schema/009-add-parent-conversation.sql @@ -0,0 +1,5 @@ +-- Add parent_conversation_id column for subagent conversations +ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT REFERENCES conversations(conversation_id); + +-- Index for efficient parent-child lookups +CREATE INDEX idx_conversations_parent_id ON conversations(parent_conversation_id) WHERE parent_conversation_id IS NOT NULL; diff --git a/loop/predictable.go b/loop/predictable.go index 8dcf8401859489a1ecdcfb0113b58aa5c9577eae..860127e33978aeae68afcfc7393f03cc50d3c6c2 100644 --- a/loop/predictable.go +++ b/loop/predictable.go @@ -21,6 +21,7 @@ import ( // - "echo: " - echoes the text back // - "bash: " - triggers bash tool with command // - "think: " - triggers think tool +// - "subagent: " - triggers subagent tool // - "delay: " - delays response by specified seconds // - See Do() method for complete list of supported patterns type PredictableService struct { @@ -164,6 +165,17 @@ func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Res return s.makeScreenshotToolResponse(selector, inputTokens), nil } + if strings.HasPrefix(inputText, "subagent: ") { + // Format: "subagent: " + parts := strings.SplitN(strings.TrimPrefix(inputText, "subagent: "), " ", 2) + slug := parts[0] + prompt := "do the task" + if len(parts) > 1 { + prompt = parts[1] + } + return s.makeSubagentToolResponse(slug, prompt, inputTokens), nil + } + if strings.HasPrefix(inputText, "delay: ") { delayStr := strings.TrimPrefix(inputText, "delay: ") delaySeconds, err := strconv.ParseFloat(delayStr, 64) @@ -520,6 +532,41 @@ func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTo } } +func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response { + toolInputData := map[string]any{ + "slug": slug, + "prompt": prompt, + } + toolInputBytes, _ := json.Marshal(toolInputData) + toolInput := json.RawMessage(toolInputBytes) + responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug) + outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4) + if outputTokens == 0 { + outputTokens = 1 + } + return &llm.Response{ + ID: fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()), + Type: "message", + Role: llm.MessageRoleAssistant, + Model: "predictable-v1", + Content: []llm.Content{ + {Type: llm.ContentTypeText, Text: responseText}, + { + ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000), + Type: llm.ContentTypeToolUse, + ToolName: "subagent", + ToolInput: toolInput, + }, + }, + StopReason: llm.StopReasonToolUse, + Usage: llm.Usage{ + InputTokens: inputTokens, + OutputTokens: outputTokens, + CostUSD: 0.0, + }, + } +} + // makeToolSmorgasbordResponse creates a response that uses all available tool types func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response { baseNano := time.Now().UnixNano() diff --git a/server/convo.go b/server/convo.go index c26d4983c12e0571ca6c5a2f72c75ec4b5b1862b..73444de7fe5b802c64b4c6543df575df7ad9383f 100644 --- a/server/convo.go +++ b/server/convo.go @@ -124,8 +124,19 @@ func (cm *ConversationManager) Hydrate(ctx context.Context) error { return fmt.Errorf("failed to get conversation history: %w", err) } - if conversation.UserInitiated && !hasSystemMessage(messages) { - systemMsg, err := cm.createSystemPrompt(ctx) + // Generate system prompt if missing: + // - For user-initiated conversations: full system prompt + // - For subagent conversations (has parent): minimal subagent prompt + if !hasSystemMessage(messages) { + var systemMsg *generated.Message + var err error + if conversation.ParentConversationID != nil { + // Subagent conversation - use minimal prompt + systemMsg, err = cm.createSubagentSystemPrompt(ctx) + } else if conversation.UserInitiated { + // User-initiated conversation - use full prompt + systemMsg, err = cm.createSystemPrompt(ctx) + } if err != nil { return err } @@ -253,6 +264,36 @@ func (cm *ConversationManager) createSystemPrompt(ctx context.Context) (*generat return created, nil } +func (cm *ConversationManager) createSubagentSystemPrompt(ctx context.Context) (*generated.Message, error) { + systemPrompt, err := GenerateSubagentSystemPrompt(cm.cwd) + if err != nil { + return nil, fmt.Errorf("failed to generate subagent system prompt: %w", err) + } + + if systemPrompt == "" { + cm.logger.Info("Skipping empty subagent system prompt generation") + return nil, nil + } + + systemMessage := llm.Message{ + Role: llm.MessageRoleUser, + Content: []llm.Content{{Type: llm.ContentTypeText, Text: systemPrompt}}, + } + + created, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{ + ConversationID: cm.conversationID, + Type: db.MessageTypeSystem, + LLMData: systemMessage, + UsageData: llm.Usage{}, + }) + if err != nil { + return nil, fmt.Errorf("failed to store subagent system prompt: %w", err) + } + + cm.logger.Info("Stored subagent system prompt", "length", len(systemPrompt)) + return created, nil +} + func (cm *ConversationManager) partitionMessages(messages []generated.Message) ([]llm.Message, []llm.SystemContent) { var history []llm.Message var system []llm.SystemContent @@ -321,6 +362,7 @@ func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) e // Create tools for this conversation with the conversation's working directory toolSetConfig.WorkingDir = cwd toolSetConfig.ModelID = modelID + toolSetConfig.ParentConversationID = conversationID // For subagent tool toolSetConfig.OnWorkingDirChange = func(newDir string) { // Persist working directory change to database if err := db.UpdateConversationCwd(context.Background(), conversationID, newDir); err != nil { diff --git a/server/handlers.go b/server/handlers.go index c54dfc724fb3796ebce54f941b05c80328365dfd..66f559686221231a38c24ea679687ff0d5f4bcdf 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -552,6 +552,9 @@ func (s *Server) conversationMux() *http.ServeMux { mux.HandleFunc("POST /{id}/rename", func(w http.ResponseWriter, r *http.Request) { s.handleRenameConversation(w, r, r.PathValue("id")) }) + mux.HandleFunc("GET /{id}/subagents", func(w http.ResponseWriter, r *http.Request) { + s.handleGetSubagents(w, r, r.PathValue("id")) + }) return mux } diff --git a/server/server.go b/server/server.go index f64db957136eb02ba5d8eff08198e8e260a4635b..0705cedeb37f1c796d70d230636caf3ab4e7e125 100644 --- a/server/server.go +++ b/server/server.go @@ -221,7 +221,7 @@ type Server struct { // NewServer creates a new server instance func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool.ToolSetConfig, logger *slog.Logger, predictableOnly bool, terminalURL, defaultModel, requireHeader string, links []Link) *Server { - return &Server{ + s := &Server{ db: database, llmManager: llmManager, toolSetConfig: toolSetConfig, @@ -233,6 +233,12 @@ func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool requireHeader: requireHeader, links: links, } + + // Set up subagent support + s.toolSetConfig.SubagentRunner = NewSubagentRunner(s) + s.toolSetConfig.SubagentDB = &db.SubagentDBAdapter{DB: database} + + return s } // RegisterRoutes registers HTTP routes on the given mux diff --git a/server/subagent.go b/server/subagent.go new file mode 100644 index 0000000000000000000000000000000000000000..944d377fa01b1261ca6edd08bf4291c24fd0e301 --- /dev/null +++ b/server/subagent.go @@ -0,0 +1,187 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "shelley.exe.dev/claudetool" + "shelley.exe.dev/llm" +) + +// SubagentRunner implements claudetool.SubagentRunner. +type SubagentRunner struct { + server *Server +} + +// NewSubagentRunner creates a new SubagentRunner. +func NewSubagentRunner(s *Server) *SubagentRunner { + return &SubagentRunner{server: s} +} + +// RunSubagent implements claudetool.SubagentRunner. +func (r *SubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) { + s := r.server + + // Get or create conversation manager for the subagent + manager, err := s.getOrCreateConversationManager(ctx, conversationID) + if err != nil { + return "", fmt.Errorf("failed to get conversation manager: %w", err) + } + + // Get the model ID from the server's default + // In predictable-only mode, use "predictable" as the model + modelID := s.defaultModel + if modelID == "" && s.predictableOnly { + modelID = "predictable" + } + + // Get LLM service + llmService, err := s.llmManager.GetService(modelID) + if err != nil { + return "", fmt.Errorf("failed to get LLM service: %w", err) + } + + // Create user message + userMessage := llm.Message{ + Role: llm.MessageRoleUser, + Content: []llm.Content{{Type: llm.ContentTypeText, Text: prompt}}, + } + + // Accept the user message (this starts processing) + _, err = manager.AcceptUserMessage(ctx, llmService, modelID, userMessage) + if err != nil { + return "", fmt.Errorf("failed to accept user message: %w", err) + } + + if !wait { + return fmt.Sprintf("Subagent started processing. Conversation ID: %s", conversationID), nil + } + + // Wait for the agent to finish (or timeout) + return r.waitForResponse(ctx, conversationID, timeout) +} + +func (r *SubagentRunner) waitForResponse(ctx context.Context, conversationID string, timeout time.Duration) (string, error) { + s := r.server + + deadline := time.Now().Add(timeout) + pollInterval := 500 * time.Millisecond + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + if time.Now().After(deadline) { + return "Subagent is still working (timeout reached). Send another message to check status.", nil + } + + // Check if agent is still working + working, err := r.isAgentWorking(ctx, conversationID) + if err != nil { + return "", fmt.Errorf("failed to check agent status: %w", err) + } + + if !working { + // Agent is done, get the last message + return r.getLastAssistantResponse(ctx, conversationID) + } + + // Wait before polling again + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(pollInterval): + } + + // Don't hog the conversation manager mutex + s.mu.Lock() + if mgr, ok := s.activeConversations[conversationID]; ok { + mgr.Touch() + } + s.mu.Unlock() + } +} + +func (r *SubagentRunner) isAgentWorking(ctx context.Context, conversationID string) (bool, error) { + s := r.server + + // Get the conversation manager - it tracks the working state + s.mu.Lock() + mgr, ok := s.activeConversations[conversationID] + s.mu.Unlock() + + if !ok { + // No active manager means the agent is not working + return false, nil + } + + return mgr.IsAgentWorking(), nil +} + +func (r *SubagentRunner) getLastAssistantResponse(ctx context.Context, conversationID string) (string, error) { + s := r.server + + // Get the latest message + msg, err := s.db.GetLatestMessage(ctx, conversationID) + if err != nil { + return "", fmt.Errorf("failed to get latest message: %w", err) + } + + // Extract text content + if msg.LlmData == nil { + return "", nil + } + + var llmMsg llm.Message + if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil { + return "", fmt.Errorf("failed to parse message: %w", err) + } + + var texts []string + for _, content := range llmMsg.Content { + if content.Type == llm.ContentTypeText && content.Text != "" { + texts = append(texts, content.Text) + } + } + + return strings.Join(texts, "\n"), nil +} + +// createSubagentToolSetConfig creates a ToolSetConfig for subagent conversations. +// Subagent conversations don't have nested subagents to avoid complexity. +func (s *Server) createSubagentToolSetConfig(conversationID string) claudetool.ToolSetConfig { + return claudetool.ToolSetConfig{ + LLMProvider: s.llmManager, + EnableJITInstall: true, + EnableBrowser: true, // Subagents can use browser tools + // No SubagentRunner/DB - subagents can't spawn nested subagents + } +} + +// Ensure SubagentRunner implements claudetool.SubagentRunner. +var _ claudetool.SubagentRunner = (*SubagentRunner)(nil) + +// handleGetSubagents returns the list of subagents for a conversation. +func (s *Server) handleGetSubagents(w http.ResponseWriter, r *http.Request, conversationID string) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", 405) + return + } + + subagents, err := s.db.GetSubagents(r.Context(), conversationID) + if err != nil { + s.logger.Error("Failed to get subagents", "conversationID", conversationID, "error", err) + http.Error(w, "Failed to get subagents", 500) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(subagents) +} diff --git a/server/subagent_system_prompt.txt b/server/subagent_system_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..07193779bd616b0a12ed5ae7a81f51db59ff0a2e --- /dev/null +++ b/server/subagent_system_prompt.txt @@ -0,0 +1,13 @@ +You are a subagent of Shelley, a coding agent. You have been delegated a specific task by the parent agent. + +Key constraints: +- Complete your assigned task thoroughly +- Your final message will be returned to the parent agent as the result +- Write important findings to files if the parent may need them later +- Be concise in your final response - summarize what you did and the outcome +- If you encounter blocking issues, explain them clearly so the parent can help + +Working directory: {{.WorkingDirectory}} +{{if .GitInfo}} +Git repository root: {{.GitInfo.Root}} +{{end}} diff --git a/server/system_prompt.go b/server/system_prompt.go index 3c306189ebe60ed3c942c769ee1a0d6e6548a738..3cf358d4c668a641083854f9a25ddd8a4b339993 100644 --- a/server/system_prompt.go +++ b/server/system_prompt.go @@ -13,6 +13,9 @@ import ( //go:embed system_prompt.txt var systemPromptTemplate string +//go:embed subagent_system_prompt.txt +var subagentSystemPromptTemplate string + // SystemPromptData contains all the data needed to render the system prompt template type SystemPromptData struct { WorkingDirectory string @@ -287,3 +290,44 @@ func isSudoAvailable() bool { _, err := cmd.CombinedOutput() return err == nil } + +// SubagentSystemPromptData contains data for subagent system prompts (minimal subset) +type SubagentSystemPromptData struct { + WorkingDirectory string + GitInfo *GitInfo +} + +// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations. +func GenerateSubagentSystemPrompt(workingDir string) (string, error) { + wd := workingDir + if wd == "" { + var err error + wd, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } + } + + data := &SubagentSystemPromptData{ + WorkingDirectory: wd, + } + + // Try to collect git info + gitInfo, err := collectGitInfo() + if err == nil { + data.GitInfo = gitInfo + } + + tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse subagent template: %w", err) + } + + var buf strings.Builder + err = tmpl.Execute(&buf, data) + if err != nil { + return "", fmt.Errorf("failed to execute subagent template: %w", err) + } + + return buf.String(), nil +} diff --git a/test/server_test.go b/test/server_test.go index 583f21b64e18233a07060702d76538c424037e63..19c101a13ea379510d62f2722863a73549f347b3 100644 --- a/test/server_test.go +++ b/test/server_test.go @@ -1132,3 +1132,134 @@ func TestGitStateChangeCreatesGitInfoMessage(t *testing.T) { t.Fatal("Expected a gitinfo message to be created after git commit, but none was found") } } + +func TestSubagentEndToEnd(t *testing.T) { + // Create temporary database + tempDB := t.TempDir() + "/test.db" + database, err := db.New(db.Config{DSN: tempDB}) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer database.Close() + + // Run migrations + if err := database.Migrate(context.Background()); err != nil { + t.Fatalf("Failed to migrate database: %v", err) + } + + // Create logger + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + + // Create LLM service manager with predictable service + llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil) + + // Set up tools config + toolSetConfig := claudetool.ToolSetConfig{ + WorkingDir: t.TempDir(), + EnableBrowser: false, + } + + // Create server (predictable-only mode) + svr := server.NewServer(database, llmManager, toolSetConfig, logger, true, "", "", "", nil) + + // Set up HTTP server + mux := http.NewServeMux() + svr.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + + client := &http.Client{Timeout: 60 * time.Second} + + // Create a new conversation that will spawn a subagent + // The predictable service will respond with a subagent tool call for "subagent: test-worker echo hello" + chatReq := map[string]interface{}{ + "message": "subagent: test-worker echo hello", + "model": "predictable", + } + reqBody, _ := json.Marshal(chatReq) + + resp, err := client.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + t.Fatalf("Failed to create conversation: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected 200/201, got %d: %s", resp.StatusCode, string(body)) + } + + var createResp struct { + ConversationID string `json:"conversation_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + parentConvID := createResp.ConversationID + t.Logf("Created parent conversation: %s", parentConvID) + + // Wait for the conversation to complete (subagent should be created and executed) + time.Sleep(3 * time.Second) + + // Check that subagents were created + subagentsResp, err := client.Get(ts.URL + "/api/conversation/" + parentConvID + "/subagents") + if err != nil { + t.Fatalf("Failed to get subagents: %v", err) + } + defer subagentsResp.Body.Close() + + var subagents []generated.Conversation + if err := json.NewDecoder(subagentsResp.Body).Decode(&subagents); err != nil { + t.Fatalf("Failed to decode subagents: %v", err) + } + + if len(subagents) == 0 { + t.Fatal("Expected at least one subagent to be created") + } + + t.Logf("Created %d subagent(s)", len(subagents)) + for _, sub := range subagents { + t.Logf(" - Subagent: %s (slug: %v)", sub.ConversationID, sub.Slug) + } + + // Verify the subagent has the expected slug (or a suffixed version) + foundExpectedSlug := false + for _, sub := range subagents { + if sub.Slug != nil && (strings.HasPrefix(*sub.Slug, "test-worker")) { + foundExpectedSlug = true + break + } + } + if !foundExpectedSlug { + t.Errorf("Expected to find subagent with slug starting with 'test-worker'") + } + + // Verify the subagent has a parent_conversation_id set + for _, sub := range subagents { + if sub.ParentConversationID == nil || *sub.ParentConversationID != parentConvID { + t.Errorf("Subagent %s has wrong parent_conversation_id: %v", sub.ConversationID, sub.ParentConversationID) + } + } + + // Verify the subagent conversation has messages + subConvResp, err := client.Get(ts.URL + "/api/conversation/" + subagents[0].ConversationID) + if err != nil { + t.Fatalf("Failed to get subagent conversation: %v", err) + } + defer subConvResp.Body.Close() + + var subConvData struct { + Messages []json.RawMessage `json:"messages"` + } + if err := json.NewDecoder(subConvResp.Body).Decode(&subConvData); err != nil { + t.Fatalf("Failed to decode subagent conversation: %v", err) + } + + if len(subConvData.Messages) == 0 { + t.Error("Expected subagent conversation to have messages") + } + t.Logf("Subagent conversation has %d messages", len(subConvData.Messages)) +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 9c864bdb9ef3eda78ead59add8daed13a4f6994b..2145866127261fd1d0789b21d8fc41eeaa6a4e13 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -67,6 +67,8 @@ function App() { const [diffViewerTrigger, setDiffViewerTrigger] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [subagentUpdate, setSubagentUpdate] = useState(null); + const [subagentStateUpdate, setSubagentStateUpdate] = useState<{ conversation_id: string; working: boolean } | null>(null); const initialSlugResolved = useRef(false); // Resolve initial slug from URL - uses the captured initialSlugFromUrl @@ -120,6 +122,11 @@ function App() { // Handle conversation list updates from the message stream const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => { if (update.type === "update" && update.conversation) { + // Handle subagent conversations separately + if (update.conversation.parent_conversation_id) { + setSubagentUpdate(update.conversation); + return; + } setConversations((prev) => { // Check if this conversation already exists const existingIndex = prev.findIndex( @@ -148,13 +155,21 @@ function App() { // Handle conversation state updates (working state changes) const handleConversationStateUpdate = useCallback( (state: { conversation_id: string; working: boolean }) => { - setConversations((prev) => - prev.map((conv) => - conv.conversation_id === state.conversation_id - ? { ...conv, working: state.working } - : conv, - ), - ); + // Check if this is a top-level conversation + setConversations((prev) => { + const found = prev.find((conv) => conv.conversation_id === state.conversation_id); + if (found) { + return prev.map((conv) => + conv.conversation_id === state.conversation_id + ? { ...conv, working: state.working } + : conv, + ); + } + // Not a top-level conversation, might be a subagent + // Pass the state update to the drawer + setSubagentStateUpdate(state); + return prev; + }); }, [], ); @@ -209,6 +224,10 @@ function App() { }; const updateConversation = (updatedConversation: Conversation) => { + // Skip subagent conversations for the main list + if (updatedConversation.parent_conversation_id) { + return; + } setConversations((prev) => prev.map((conv) => conv.conversation_id === updatedConversation.conversation_id @@ -307,6 +326,8 @@ function App() { onConversationArchived={handleConversationArchived} onConversationUnarchived={handleConversationUnarchived} onConversationRenamed={handleConversationRenamed} + subagentUpdate={subagentUpdate} + subagentStateUpdate={subagentStateUpdate} /> {/* Main chat interface */} diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index a193b17e66099a263bd40dcc8fc902c3c3fca461..9ad2333762f9b4e2152564349f70eaf9a2b24189 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -23,6 +23,7 @@ import ReadImageTool from "./ReadImageTool"; import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool"; import ChangeDirTool from "./ChangeDirTool"; import BrowserResizeTool from "./BrowserResizeTool"; +import SubagentTool from "./SubagentTool"; import DirectoryPickerModal from "./DirectoryPickerModal"; interface ContextUsageBarProps { @@ -152,6 +153,7 @@ const TOOL_COMPONENTS: Record> = { browser_clear_console_logs: BrowserConsoleLogsTool, change_dir: ChangeDirTool, browser_resize: BrowserResizeTool, + subagent: SubagentTool, }; function CoalescedToolCall({ diff --git a/ui/src/components/ConversationDrawer.tsx b/ui/src/components/ConversationDrawer.tsx index b16194deb2a4f603f90f31f42bd46be746ad9afc..1ea662d3ba25a2bd5b8c8923bc307fca837e97c5 100644 --- a/ui/src/components/ConversationDrawer.tsx +++ b/ui/src/components/ConversationDrawer.tsx @@ -14,6 +14,8 @@ interface ConversationDrawerProps { onConversationArchived?: (id: string) => void; onConversationUnarchived?: (conversation: Conversation) => void; onConversationRenamed?: (conversation: Conversation) => void; + subagentUpdate?: Conversation | null; // When a subagent is created/updated + subagentStateUpdate?: { conversation_id: string; working: boolean } | null; // When a subagent's working state changes } function ConversationDrawer({ @@ -28,12 +30,16 @@ function ConversationDrawer({ onConversationArchived, onConversationUnarchived, onConversationRenamed, + subagentUpdate, + subagentStateUpdate, }: ConversationDrawerProps) { const [showArchived, setShowArchived] = useState(false); const [archivedConversations, setArchivedConversations] = useState([]); const [loadingArchived, setLoadingArchived] = useState(false); const [editingId, setEditingId] = useState(null); const [editingSlug, setEditingSlug] = useState(""); + const [subagents, setSubagents] = useState>({}); + const [expandedSubagents, setExpandedSubagents] = useState>(new Set()); const renameInputRef = React.useRef(null); useEffect(() => { @@ -42,6 +48,88 @@ function ConversationDrawer({ } }, [showArchived]); + // Load subagents for the current conversation + useEffect(() => { + if (currentConversationId && !showArchived) { + loadSubagents(currentConversationId); + // Auto-expand the current conversation's subagents + setExpandedSubagents((prev) => new Set([...prev, currentConversationId])); + } + }, [currentConversationId, showArchived]); + + // Handle real-time subagent updates + useEffect(() => { + if (subagentUpdate && subagentUpdate.parent_conversation_id) { + const parentId = subagentUpdate.parent_conversation_id; + setSubagents((prev) => { + const existing = prev[parentId] || []; + // Check if this subagent already exists + const existingIndex = existing.findIndex( + (s) => s.conversation_id === subagentUpdate.conversation_id, + ); + if (existingIndex >= 0) { + // Update existing, preserving working state + const updated = [...existing]; + updated[existingIndex] = { ...subagentUpdate, working: existing[existingIndex].working }; + return { ...prev, [parentId]: updated }; + } else { + // Add new subagent (not working by default) + return { ...prev, [parentId]: [...existing, { ...subagentUpdate, working: false }] }; + } + }); + // Auto-expand parent to show the new subagent + setExpandedSubagents((prev) => new Set([...prev, parentId])); + } + }, [subagentUpdate]); + + // Handle subagent working state updates + useEffect(() => { + if (subagentStateUpdate) { + setSubagents((prev) => { + // Find which parent contains this subagent + for (const [parentId, subs] of Object.entries(prev)) { + const subIndex = subs.findIndex((s) => s.conversation_id === subagentStateUpdate.conversation_id); + if (subIndex >= 0) { + const updated = [...subs]; + updated[subIndex] = { ...updated[subIndex], working: subagentStateUpdate.working }; + return { ...prev, [parentId]: updated }; + } + } + return prev; + }); + } + }, [subagentStateUpdate]); + + const loadSubagents = async (conversationId: string) => { + // Skip if already loaded + if (subagents[conversationId]) return; + try { + const subs = await api.getSubagents(conversationId); + if (subs && subs.length > 0) { + // Add working: false to each subagent + const subsWithState = subs.map((s) => ({ ...s, working: false })); + setSubagents((prev) => ({ ...prev, [conversationId]: subsWithState })); + } + } catch (err) { + console.error("Failed to load subagents:", err); + } + }; + + const toggleSubagents = (e: React.MouseEvent, conversationId: string) => { + e.stopPropagation(); + setExpandedSubagents((prev) => { + const next = new Set(prev); + if (next.has(conversationId)) { + next.delete(conversationId); + } else { + next.add(conversationId); + // Load subagents if not already loaded + loadSubagents(conversationId); + } + return next; + }); + }; + const loadArchivedConversations = async () => { setLoadingArchived(true); try { @@ -267,171 +355,267 @@ function ConversationDrawer({
{displayedConversations.map((conversation) => { const isActive = conversation.conversation_id === currentConversationId; + const hasSubagents = subagents[conversation.conversation_id]?.length > 0; + const isExpanded = expandedSubagents.has(conversation.conversation_id); + const conversationSubagents = subagents[conversation.conversation_id] || []; return ( -
{ - if (!showArchived) { - onSelectConversation(conversation.conversation_id); - } - }} - style={{ cursor: showArchived ? "default" : "pointer" }} - > -
-
-
- {editingId === conversation.conversation_id ? ( - setEditingSlug(e.target.value)} - onBlur={() => handleRename(conversation.conversation_id)} - onKeyDown={(e) => - handleRenameKeyDown(e, conversation.conversation_id) - } - onClick={(e) => e.stopPropagation()} - autoFocus - className="conversation-title" + +
{ + if (!showArchived) { + onSelectConversation(conversation.conversation_id); + } + }} + style={{ cursor: showArchived ? "default" : "pointer" }} + > +
+
+
+ {editingId === conversation.conversation_id ? ( + setEditingSlug(e.target.value)} + onBlur={() => handleRename(conversation.conversation_id)} + onKeyDown={(e) => + handleRenameKeyDown(e, conversation.conversation_id) + } + onClick={(e) => e.stopPropagation()} + autoFocus + className="conversation-title" + style={{ + width: "100%", + background: "transparent", + border: "none", + borderBottom: "1px solid var(--text-secondary)", + outline: "none", + padding: 0, + font: "inherit", + color: "inherit", + }} + /> + ) : ( +
+ {getConversationPreview(conversation)} +
+ )} +
+ {(conversation as ConversationWithState).working && ( + - ) : ( -
- {getConversationPreview(conversation)} -
)}
- {(conversation as ConversationWithState).working && ( - - )} -
-
- - {formatDate(conversation.updated_at)} - - {conversation.cwd && ( - - {formatCwdForDisplay(conversation.cwd)} +
+ + {formatDate(conversation.updated_at)} - )} - {!showArchived && ( -
- - + -
- )} + + + + + {/* Subagent count indicator */} + {hasSubagents && ( + + )} +
+ )} +
-
- {showArchived && ( -
- - + + + + + +
+ )} +
+ {/* Render subagents if expanded */} + {!showArchived && isExpanded && conversationSubagents.length > 0 && ( +
+ {conversationSubagents.map((sub) => { + const isSubActive = sub.conversation_id === currentConversationId; + return ( +
onSelectConversation(sub.conversation_id)} + style={{ + cursor: "pointer", + fontSize: "0.9em", + paddingLeft: "0.5rem", + borderLeft: "2px solid var(--border-color)", + }} + > +
+
+
+
+ {sub.slug || sub.conversation_id} +
+
+ {sub.working && ( + + )} +
+
+ + {formatDate(sub.updated_at)} + +
+
+
+ ); + })}
)} -
+ ); })}
diff --git a/ui/src/components/Message.tsx b/ui/src/components/Message.tsx index d328d8f74ec8b3284c6d108d3ed5362c8c5d701e..e0e9c84c8d67e3a8df34e505e899682d36c3676a 100644 --- a/ui/src/components/Message.tsx +++ b/ui/src/components/Message.tsx @@ -13,6 +13,7 @@ import ReadImageTool from "./ReadImageTool"; import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool"; import ChangeDirTool from "./ChangeDirTool"; import BrowserResizeTool from "./BrowserResizeTool"; +import SubagentTool from "./SubagentTool"; import UsageDetailModal from "./UsageDetailModal"; import MessageActionBar from "./MessageActionBar"; @@ -370,6 +371,10 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp if (content.ToolName === "browser_resize") { return ; } + // Use specialized component for subagent tool + if (content.ToolName === "subagent") { + return ; + } // Use specialized component for browser console logs tools if ( content.ToolName === "browser_recent_console_logs" || @@ -569,6 +574,20 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp ); } + // Use specialized component for subagent tool + if (toolName === "subagent") { + return ( + + ); + } + // Use specialized component for browser console logs tools if ( toolName === "browser_recent_console_logs" || diff --git a/ui/src/components/SubagentTool.tsx b/ui/src/components/SubagentTool.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c22cc8b0b380e4e66359c6d971d0f8b3a9dd088 --- /dev/null +++ b/ui/src/components/SubagentTool.tsx @@ -0,0 +1,143 @@ +import React, { useState } from "react"; +import { LLMContent } from "../types"; + +interface SubagentToolProps { + // For tool_use (pending state) + toolInput?: unknown; // { slug: string, prompt: string, timeout_seconds?: number, wait?: boolean } + isRunning?: boolean; + + // For tool_result (completed state) + toolResult?: LLMContent[]; + hasError?: boolean; + executionTime?: string; + displayData?: { slug?: string; conversation_id?: string }; +} + +function SubagentTool({ + toolInput, + isRunning, + toolResult, + hasError, + executionTime, + displayData, +}: SubagentToolProps) { + const [isExpanded, setIsExpanded] = useState(false); + + // Extract fields from toolInput + const input = + typeof toolInput === "object" && toolInput !== null + ? (toolInput as { slug?: string; prompt?: string; timeout_seconds?: number; wait?: boolean }) + : {}; + + const slug = input.slug || displayData?.slug || "subagent"; + const prompt = input.prompt || ""; + const wait = input.wait !== false; + const timeout = input.timeout_seconds || 60; + + // Extract result text + const resultText = + toolResult + ?.filter((r) => r.Type === 2) // ContentTypeText + .map((r) => r.Text) + .join("\n") || ""; + + // Truncate prompt for display + const truncateText = (text: string, maxLen: number = 60) => { + if (!text) return ""; + const firstLine = text.split("\n")[0]; + if (firstLine.length <= maxLen) return firstLine; + return firstLine.substring(0, maxLen) + "..."; + }; + + const displayPrompt = truncateText(prompt); + const isComplete = !isRunning && toolResult !== undefined; + + return ( +
+
setIsExpanded(!isExpanded)}> +
+ + subagent + {isComplete && hasError && } + {isComplete && !hasError && } + + Subagent '{slug}' {isRunning ? (wait ? "running..." : "started") : ""} + {displayPrompt && !isRunning && ` ${displayPrompt}`} + +
+ +
+ + {isExpanded && ( +
+
+
+ Prompt to '{slug}': + {!wait && fire-and-forget} + {timeout !== 60 && timeout: {timeout}s} +
+
{prompt || "(no prompt)"}
+
+ + {isComplete && ( +
+
+ Response: + {executionTime && {executionTime}} +
+
+ {resultText || "(no response)"} +
+
+ )} + + {displayData?.conversation_id && ( + + )} +
+ )} +
+ ); +} + +export default SubagentTool; diff --git a/ui/src/generated-types.ts b/ui/src/generated-types.ts index 6666f86fdfeec35a473473e222396f1a11c17104..ec418d8abee3ba8eabfb6f0d7d3a176adff0dbf6 100644 --- a/ui/src/generated-types.ts +++ b/ui/src/generated-types.ts @@ -4,59 +4,61 @@ // DO NOT EDIT. This file is automatically generated. export interface Conversation { - conversation_id: string; - slug: string | null; - user_initiated: boolean; - created_at: string; - updated_at: string; - cwd: string | null; - archived: boolean; + conversation_id: string; + slug: string | null; + user_initiated: boolean; + created_at: string; + updated_at: string; + cwd: string | null; + archived: boolean; + parent_conversation_id: string | null; } export interface Usage { - input_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - output_tokens: number; - cost_usd: number; - model?: string; - start_time?: string | null; - end_time?: string | null; + input_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + output_tokens: number; + cost_usd: number; + model?: string; + start_time?: string | null; + end_time?: string | null; } export interface ApiMessageForTS { - message_id: string; - conversation_id: string; - sequence_id: number; - type: string; - llm_data?: string | null; - user_data?: string | null; - usage_data?: string | null; - created_at: string; - display_data?: string | null; - end_of_turn?: boolean | null; + message_id: string; + conversation_id: string; + sequence_id: number; + type: string; + llm_data?: string | null; + user_data?: string | null; + usage_data?: string | null; + created_at: string; + display_data?: string | null; + end_of_turn?: boolean | null; } export interface ConversationStateForTS { - conversation_id: string; - working: boolean; + conversation_id: string; + working: boolean; } export interface StreamResponseForTS { - messages: ApiMessageForTS[] | null; - conversation: Conversation; - conversation_state?: ConversationStateForTS | null; + messages: ApiMessageForTS[] | null; + conversation: Conversation; + conversation_state?: ConversationStateForTS | null; } export interface ConversationWithStateForTS { - conversation_id: string; - slug: string | null; - user_initiated: boolean; - created_at: string; - updated_at: string; - cwd: string | null; - archived: boolean; - working: boolean; + conversation_id: string; + slug: string | null; + user_initiated: boolean; + created_at: string; + updated_at: string; + cwd: string | null; + archived: boolean; + parent_conversation_id: string | null; + working: boolean; } -export type MessageType = "user" | "agent" | "tool" | "error" | "system" | "gitinfo"; +export type MessageType = 'user' | 'agent' | 'tool' | 'error' | 'system' | 'gitinfo'; diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index b1d3ba14605db7cee119b0affecd149b48de06c7..ca246ad437367df0f1476419b75a44e6206a6e79 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -200,6 +200,14 @@ class ApiService { } return response.json(); } + + async getSubagents(conversationId: string): Promise { + const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/subagents`); + if (!response.ok) { + throw new Error(`Failed to get subagents: ${response.statusText}`); + } + return response.json(); + } } export const api = new ApiService();