From f4f0813980690c968ced3b62fea539fa7390cb0f Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Tue, 20 Jan 2026 02:13:47 +0000 Subject: [PATCH] shelley: add subagent tool for parallel task delegation Prompt: we're continuing work in this worktree (don't switch) on subagents. I think things are working. Can you tell me what the system prmopt is for subagents? Is it the same as the main one? how does that end up working? Prompt: 1. Fetch and rebase on origin/main. Notably, there's a UI change to indicate which conversations are working vs. not, and that might need to be plumbed into subagent tasks in the convo list. 2. Let's write a system prompt for subagents. It should be short, but include some of the basic details. It's important to understand that only the last message gets sent to the parent agent, though it can of course reference files and so on. Prompt: fetch, rebase on origin/main, and squash into one commit Adds a subagent tool that allows the main agent to spawn independent conversations for parallel task delegation. Subagents: - Have their own conversation with full tool access (except nested subagents) - Run with a minimal system prompt focused on task completion - Return their final message to the parent agent - Support both synchronous (wait) and async (fire-and-forget) modes Key changes: - New subagent tool in claudetool/subagent.go - SubagentRunner in server/subagent.go handles conversation lifecycle - Database schema adds parent_conversation_id for tracking hierarchy - Minimal subagent system prompt (vs full 17KB main prompt) - UI shows subagents nested under parent conversations - Working indicator (pulsing dot) for subagent conversations Co-authored-by: Shelley --- claudetool/subagent.go | 179 ++++++++ claudetool/subagent_test.go | 154 +++++++ claudetool/toolset.go | 18 + cmd/go2ts.go | 17 +- db/db.go | 92 +++++ db/generated/conversations.sql.go | 141 ++++++- db/generated/models.go | 15 +- db/query/conversations.sql | 22 +- db/schema/009-add-parent-conversation.sql | 5 + loop/predictable.go | 47 +++ server/convo.go | 46 ++- server/handlers.go | 3 + server/server.go | 8 +- server/subagent.go | 187 +++++++++ server/subagent_system_prompt.txt | 13 + server/system_prompt.go | 44 ++ test/server_test.go | 131 ++++++ ui/src/App.tsx | 35 +- ui/src/components/ChatInterface.tsx | 2 + ui/src/components/ConversationDrawer.tsx | 482 +++++++++++++++------- ui/src/components/Message.tsx | 19 + ui/src/components/SubagentTool.tsx | 143 +++++++ ui/src/generated-types.ts | 80 ++-- ui/src/services/api.ts | 8 + 24 files changed, 1660 insertions(+), 231 deletions(-) create mode 100644 claudetool/subagent.go create mode 100644 claudetool/subagent_test.go create mode 100644 db/schema/009-add-parent-conversation.sql create mode 100644 server/subagent.go create mode 100644 server/subagent_system_prompt.txt create mode 100644 ui/src/components/SubagentTool.tsx 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();