From 01483b8e58a93bd03b3dcf46ac5607afc5513ab9 Mon Sep 17 00:00:00 2001 From: Michael Clifford Date: Tue, 14 Oct 2025 17:15:05 -0400 Subject: [PATCH] add agent selector to RFE Workspace page Signed-off-by: Michael Clifford --- components/backend/handlers/rfe.go | 288 ++++++++++++++++++ components/backend/main.go | 1 + .../[name]/rfe-workflows/[id]/agents/route.ts | 35 +++ .../src/app/projects/[name]/rfe/[id]/page.tsx | 167 +++++++++- 4 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts diff --git a/components/backend/handlers/rfe.go b/components/backend/handlers/rfe.go index cf832dadf..e6ad8195f 100644 --- a/components/backend/handlers/rfe.go +++ b/components/backend/handlers/rfe.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/base64" + "encoding/json" "fmt" "io" "io/ioutil" @@ -574,6 +575,293 @@ func RemoveProjectRFEWorkflowSession(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Session unlinked from RFE", "session": sessionName, "rfe": id}) } +// GetProjectRFEWorkflowAgents fetches agent definitions from the workflow's umbrella repository +// GET /api/projects/:projectName/rfe-workflows/:id/agents +func GetProjectRFEWorkflowAgents(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get the workflow + gvr := GetRFEWorkflowResource() + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil || reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), id, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + wf := RfeFromUnstructured(item) + if wf == nil || wf.UmbrellaRepo == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No umbrella repo configured"}) + return + } + + // Get user ID from forwarded identity middleware + userID, _ := c.Get("userID") + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) + return + } + + githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Parse repo owner/name from umbrella repo URL + repoURL := wf.UmbrellaRepo.URL + owner, repoName, err := parseOwnerRepoFromURL(repoURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository URL: %v", err)}) + return + } + + // Get ref (branch) + ref := "main" + if wf.UmbrellaRepo.Branch != nil { + ref = *wf.UmbrellaRepo.Branch + } + + // Fetch agents from .claude/agents directory + agents, err := fetchAgentsFromRepo(c.Request.Context(), owner, repoName, ref, githubToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"agents": agents}) +} + +// parseOwnerRepoFromURL extracts owner and repo name from a GitHub URL +func parseOwnerRepoFromURL(repoURL string) (string, string, error) { + // Remove .git suffix + repoURL = strings.TrimSuffix(repoURL, ".git") + + // Handle https://github.com/owner/repo + if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { + parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(repoURL, "https://"), "http://"), "/") + if len(parts) >= 3 { + return parts[1], parts[2], nil + } + } + + // Handle git@github.com:owner/repo + if strings.Contains(repoURL, "@") { + parts := strings.Split(repoURL, ":") + if len(parts) == 2 { + repoParts := strings.Split(parts[1], "/") + if len(repoParts) == 2 { + return repoParts[0], repoParts[1], nil + } + } + } + + // Handle owner/repo format + parts := strings.Split(repoURL, "/") + if len(parts) == 2 { + return parts[0], parts[1], nil + } + + return "", "", fmt.Errorf("unable to parse repository URL") +} + +// Agent represents an agent definition from .claude/agents directory +type Agent struct { + Persona string `json:"persona"` + Name string `json:"name"` + Role string `json:"role"` + Description string `json:"description"` +} + +// fetchAgentsFromRepo fetches and parses agent definitions from .claude/agents directory +func fetchAgentsFromRepo(ctx context.Context, owner, repo, ref, token string) ([]Agent, error) { + api := "https://api.github.com" + agentsPath := ".claude/agents" + + // Fetch directory listing + treeURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentsPath, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, treeURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GitHub request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // No .claude/agents directory - return empty array + return []Agent{}, nil + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body)) + } + + var treeEntries []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&treeEntries); err != nil { + return nil, fmt.Errorf("failed to parse GitHub response: %w", err) + } + + // Filter for .md files + var agentFiles []string + for _, entry := range treeEntries { + name, _ := entry["name"].(string) + typ, _ := entry["type"].(string) + if typ == "file" && strings.HasSuffix(name, ".md") { + agentFiles = append(agentFiles, name) + } + } + + // Fetch and parse each agent file + agents := make([]Agent, 0, len(agentFiles)) + for _, filename := range agentFiles { + agent, err := fetchAndParseAgentFile(ctx, api, owner, repo, ref, filename, token) + if err != nil { + log.Printf("Warning: failed to parse agent file %s: %v", filename, err) + continue + } + agents = append(agents, agent) + } + + return agents, nil +} + +// fetchAndParseAgentFile fetches a single agent file and parses its metadata +func fetchAndParseAgentFile(ctx context.Context, api, owner, repo, ref, filename, token string) (Agent, error) { + agentPath := fmt.Sprintf(".claude/agents/%s", filename) + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentPath, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Agent{}, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Agent{}, fmt.Errorf("GitHub request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Agent{}, fmt.Errorf("GitHub returned status %d", resp.StatusCode) + } + + var fileData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&fileData); err != nil { + return Agent{}, fmt.Errorf("failed to parse GitHub response: %w", err) + } + + // Decode base64 content + content, _ := fileData["content"].(string) + encoding, _ := fileData["encoding"].(string) + + var decodedContent string + if strings.ToLower(encoding) == "base64" { + raw := strings.ReplaceAll(content, "\n", "") + data, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return Agent{}, fmt.Errorf("failed to decode base64 content: %w", err) + } + decodedContent = string(data) + } else { + decodedContent = content + } + + // Parse persona from filename + persona := strings.TrimSuffix(filename, ".md") + + // Generate default name from filename + nameParts := strings.FieldsFunc(persona, func(r rune) bool { + return r == '-' || r == '_' + }) + for i, part := range nameParts { + if len(part) > 0 { + nameParts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + name := strings.Join(nameParts, " ") + + role := "" + description := "" + + // Try to extract metadata from YAML frontmatter + // Simple regex-based parsing (consider using a YAML library for production) + lines := strings.Split(decodedContent, "\n") + inFrontmatter := false + for i, line := range lines { + if i == 0 && strings.TrimSpace(line) == "---" { + inFrontmatter = true + continue + } + if inFrontmatter && strings.TrimSpace(line) == "---" { + break + } + if inFrontmatter { + if strings.HasPrefix(line, "name:") { + name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } else if strings.HasPrefix(line, "role:") { + role = strings.TrimSpace(strings.TrimPrefix(line, "role:")) + } else if strings.HasPrefix(line, "description:") { + description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + } + } + } + + // If no description found, use first non-empty line after frontmatter + if description == "" { + afterFrontmatter := false + for _, line := range lines { + if afterFrontmatter { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + description = trimmed + if len(description) > 150 { + description = description[:150] + } + break + } + } + if strings.TrimSpace(line) == "---" { + if afterFrontmatter { + break + } + afterFrontmatter = true + } + } + } + + if description == "" { + description = "No description available" + } + + return Agent{ + Persona: persona, + Name: name, + Role: role, + Description: description, + }, nil +} + // GetWorkflowJira proxies Jira issue fetch for a linked path // GET /api/projects/:projectName/rfe-workflows/:id/jira?path=... func GetWorkflowJira(c *gin.Context) { diff --git a/components/backend/main.go b/components/backend/main.go index b1dd76ad2..470bede96 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -179,6 +179,7 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { projectGroup.DELETE("/rfe-workflows/:id", handlers.DeleteProjectRFEWorkflow) projectGroup.POST("/rfe-workflows/:id/seed", handlers.SeedProjectRFEWorkflow) projectGroup.GET("/rfe-workflows/:id/check-seeding", handlers.CheckProjectRFEWorkflowSeeding) + projectGroup.GET("/rfe-workflows/:id/agents", handlers.GetProjectRFEWorkflowAgents) projectGroup.GET("/sessions/:sessionId/ws", websocket.HandleSessionWebSocket) projectGroup.GET("/sessions/:sessionId/messages", websocket.GetSessionMessagesWS) diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts new file mode 100644 index 000000000..9259b4c05 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { buildForwardHeadersAsync } from '@/lib/auth'; +import { BACKEND_URL } from '@/lib/config'; + +type RouteContext = { + params: Promise<{ + name: string; + id: string; + }>; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + const { name: projectName, id: workflowId } = await context.params; + + const headers = await buildForwardHeadersAsync(request); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/rfe-workflows/${encodeURIComponent(workflowId)}/agents`, + { + method: 'GET', + headers, + } + ); + + if (!resp.ok) { + const text = await resp.text(); + return NextResponse.json( + { error: text || 'Failed to fetch agents' }, + { status: resp.status } + ); + } + + const data = await resp.json(); + return NextResponse.json(data); +} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx index 26ddc9c02..fc3dd143a 100644 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx +++ b/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx @@ -11,8 +11,9 @@ import { getApiUrl } from "@/lib/config"; import { formatDistanceToNow } from "date-fns"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { AgenticSession, CreateAgenticSessionRequest, RFEWorkflow, WorkflowPhase } from "@/types/agentic-session"; -import { WORKFLOW_PHASE_LABELS } from "@/lib/agents"; -import { ArrowLeft, Play, Loader2, FolderTree, Plus, Trash2, AlertCircle, Sprout, Upload, CheckCircle2 } from "lucide-react"; +import { WORKFLOW_PHASE_LABELS, AVAILABLE_AGENTS } from "@/lib/agents"; +import { ArrowLeft, Play, Loader2, FolderTree, Plus, Trash2, AlertCircle, Sprout, Upload, CheckCircle2, Bot } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import RepoBrowser from "@/components/RepoBrowser"; import type { GitHubFork } from "@/types"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -72,6 +73,9 @@ export default function ProjectRFEDetailPage() { checking: true, isSeeded: false, }); + const [selectedAgents, setSelectedAgents] = useState([]); + const [repoAgents, setRepoAgents] = useState([]); + const [loadingAgents, setLoadingAgents] = useState(false); const load = useCallback(async () => { try { @@ -119,6 +123,55 @@ export default function ProjectRFEDetailPage() { } }, [project, id, workflow?.umbrellaRepo]); + const fetchRepoAgents = useCallback(async () => { + if (!project || !id || !workflow) return; + + try { + setLoadingAgents(true); + + // Create cache key based on workflow ID + const cacheKey = `agents:workflow:${id}`; + + // Try to load from localStorage cache + const cached = localStorage.getItem(cacheKey); + if (cached) { + try { + const cachedAgents = JSON.parse(cached); + setRepoAgents(cachedAgents); + setLoadingAgents(false); + return; + } catch (e) { + console.debug('Failed to parse cached agents, fetching fresh', e); + } + } + + // Fetch agents from backend endpoint (workflow-specific) + const agentsResp = await fetch(`/api/projects/${encodeURIComponent(project)}/rfe-workflows/${encodeURIComponent(id)}/agents`); + + if (agentsResp.ok) { + const data = await agentsResp.json(); + const fetchedAgents = data.agents || []; + setRepoAgents(fetchedAgents); + + // Cache the results in localStorage + try { + localStorage.setItem(cacheKey, JSON.stringify(fetchedAgents)); + } catch (e) { + console.debug('Failed to cache agents', e); + } + } else { + // No .claude/agents directory or error, fall back to default agents + setRepoAgents(AVAILABLE_AGENTS); + } + } catch (e) { + console.debug('Failed to fetch repo agents:', e); + // Fall back to default agents on error + setRepoAgents(AVAILABLE_AGENTS); + } finally { + setLoadingAgents(false); + } + }, [project, id, workflow]); + const checkPhaseDocuments = useCallback(async () => { if (!project || !id || !workflow?.umbrellaRepo) return; @@ -179,11 +232,11 @@ export default function ProjectRFEDetailPage() { }, [project, id, workflow?.umbrellaRepo]); const refreshAll = useCallback(async () => { - await Promise.all([load(), loadSessions(), checkPhaseDocuments()]); - }, [load, loadSessions, checkPhaseDocuments]); + await Promise.all([load(), loadSessions(), checkPhaseDocuments(), fetchRepoAgents()]); + }, [load, loadSessions, checkPhaseDocuments, fetchRepoAgents]); useEffect(() => { if (project && id) { load(); loadSessions(); } }, [project, id, load, loadSessions]); - useEffect(() => { if (workflow) { checkSeeding(); checkPhaseDocuments(); } }, [workflow, checkSeeding, checkPhaseDocuments]); + useEffect(() => { if (workflow) { checkSeeding(); checkPhaseDocuments(); fetchRepoAgents(); } }, [workflow, checkSeeding, checkPhaseDocuments, fetchRepoAgents]); // Workspace probing removed @@ -244,6 +297,28 @@ export default function ProjectRFEDetailPage() { } }, [project, id, checkSeeding]); + // Helper function to generate agent instructions based on selected agents + const getAgentInstructions = useCallback(() => { + if (selectedAgents.length === 0) return ''; + + const selectedAgentDetails = selectedAgents + .map(persona => repoAgents.find(a => a.persona === persona)) + .filter(Boolean); + + if (selectedAgentDetails.length === 0) return ''; + + const agentList = selectedAgentDetails + .map(agent => `- ${agent!.name} (${agent!.role})`) + .join('\n'); + + return `\n\nIMPORTANT - Selected Agents for this workflow: +The following agents have been selected to participate in this workflow. Invoke them by name to get their specialized perspectives: + +${agentList} + +You can invoke agents by using their name in your prompts. For example: "Let's get input from ${selectedAgentDetails[0]!.name} on this approach."`; + }, [selectedAgents, repoAgents]); + if (loading) return
Loading…
; if (error || !workflow) return (
@@ -369,6 +444,80 @@ export default function ProjectRFEDetailPage() { + + + + + Agents + + + {loadingAgents ? 'Loading agents from repository...' : 'Select agents to participate in workflow sessions'} + + + + {loadingAgents ? ( +
+ +
+ ) : repoAgents.length === 0 ? ( +
+ +

No agents found in repository .claude/agents directory

+

Seed the repository to add agent definitions

+
+ ) : ( + <> +
+ {repoAgents.map((agent) => { + const isSelected = selectedAgents.includes(agent.persona); + return ( +
+ +
+ ); + })} +
+ {selectedAgents.length > 0 && ( +
+
Selected Agents ({selectedAgents.length})
+
+ {selectedAgents.map(persona => { + const agent = repoAgents.find(a => a.persona === persona); + return agent ? ( + + {agent.name} + + ) : null; + })} +
+
+ )} + + )} +
+
+ Overview @@ -473,7 +622,8 @@ export default function ProjectRFEDetailPage() { onClick={async () => { try { setStartingPhase(phase); - const prompt = `IMPORTANT: The result of this interactive chat session MUST produce rfe.md at the workspace root. The rfe.md should be formatted as markdown in the following way:\n\n# Feature Title\n\n**Feature Overview:** \n*An elevator pitch (value statement) that describes the Feature in a clear, concise way. ie: Executive Summary of the user goal or problem that is being solved, why does this matter to the user? The \"What & Why\"...* \n\n* Text\n\n**Goals:**\n\n*Provide high-level goal statement, providing user context and expected user outcome(s) for this Feature. Who benefits from this Feature, and how? What is the difference between today's current state and a world with this Feature?*\n\n* Text\n\n**Out of Scope:**\n\n*High-level list of items or personas that are out of scope.*\n\n* Text\n\n**Requirements:**\n\n*A list of specific needs, capabilities, or objectives that a Feature must deliver to satisfy the Feature. Some requirements will be flagged as MVP. If an MVP gets shifted, the Feature shifts. If a non MVP requirement slips, it does not shift the feature.*\n\n* Text\n\n**Done - Acceptance Criteria:**\n\n*Acceptance Criteria articulates and defines the value proposition - what is required to meet the goal and intent of this Feature. The Acceptance Criteria provides a detailed definition of scope and the expected outcomes - from a users point of view*\n\n* Text\n\n**Use Cases - i.e. User Experience & Workflow:**\n\n*Include use case diagrams, main success scenarios, alternative flow scenarios.*\n\n* Text\n\n**Documentation Considerations:**\n\n*Provide information that needs to be considered and planned so that documentation will meet customer needs. If the feature extends existing functionality, provide a link to its current documentation..*\n\n* Text\n\n**Questions to answer:**\n\n*Include a list of refinement / architectural questions that may need to be answered before coding can begin.*\n\n* Text\n\n**Background & Strategic Fit:**\n\n*Provide any additional context is needed to frame the feature.*\n\n* Text\n\n**Customer Considerations**\n\n*Provide any additional customer-specific considerations that must be made when designing and delivering the Feature.*\n\n* Text`; + const basePrompt = `IMPORTANT: The result of this interactive chat session MUST produce rfe.md at the workspace root. The rfe.md should be formatted as markdown in the following way:\n\n# Feature Title\n\n**Feature Overview:** \n*An elevator pitch (value statement) that describes the Feature in a clear, concise way. ie: Executive Summary of the user goal or problem that is being solved, why does this matter to the user? The \"What & Why\"...* \n\n* Text\n\n**Goals:**\n\n*Provide high-level goal statement, providing user context and expected user outcome(s) for this Feature. Who benefits from this Feature, and how? What is the difference between today's current state and a world with this Feature?*\n\n* Text\n\n**Out of Scope:**\n\n*High-level list of items or personas that are out of scope.*\n\n* Text\n\n**Requirements:**\n\n*A list of specific needs, capabilities, or objectives that a Feature must deliver to satisfy the Feature. Some requirements will be flagged as MVP. If an MVP gets shifted, the Feature shifts. If a non MVP requirement slips, it does not shift the feature.*\n\n* Text\n\n**Done - Acceptance Criteria:**\n\n*Acceptance Criteria articulates and defines the value proposition - what is required to meet the goal and intent of this Feature. The Acceptance Criteria provides a detailed definition of scope and the expected outcomes - from a users point of view*\n\n* Text\n\n**Use Cases - i.e. User Experience & Workflow:**\n\n*Include use case diagrams, main success scenarios, alternative flow scenarios.*\n\n* Text\n\n**Documentation Considerations:**\n\n*Provide information that needs to be considered and planned so that documentation will meet customer needs. If the feature extends existing functionality, provide a link to its current documentation..*\n\n* Text\n\n**Questions to answer:**\n\n*Include a list of refinement / architectural questions that may need to be answered before coding can begin.*\n\n* Text\n\n**Background & Strategic Fit:**\n\n*Provide any additional context is needed to frame the feature.*\n\n* Text\n\n**Customer Considerations**\n\n*Provide any additional customer-specific considerations that must be made when designing and delivering the Feature.*\n\n* Text`; + const prompt = basePrompt + getAgentInstructions(); const payload: CreateAgenticSessionRequest = { prompt, displayName: `${workflow.title} - ${phase}`, @@ -538,9 +688,10 @@ export default function ProjectRFEDetailPage() { try { setStartingPhase(phase); const isSpecify = phase === "specify"; - const prompt = isSpecify + const basePrompt = isSpecify ? `/specify Develop a new feature based on rfe.md or if that does not exist, follow these feature requirements: ${workflow.description}` - : `/${phase}` + : `/${phase}`; + const prompt = basePrompt + getAgentInstructions(); const payload: CreateAgenticSessionRequest = { prompt, displayName: `${workflow.title} - ${phase}`,