{"repo": "agentra-ai/agentra", "files": {"server/internal/service/planner.go": {"e67946e342a1282f1bf4d42bd84082bdd94e5f5e": {"t": 1786068295.258809, "n": 17239, "h": "479ca19de51ec4e3", "c": "package service\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/agentra-ai/agentra/server/internal/util\"\n\tagenttypes \"github.com/agentra-ai/agentra/server/pkg/agent/types\"\n\tagentproviders \"github.com/agentra-ai/agentra/server/pkg/agent/providers\"\n\tdb \"github.com/agentra-ai/agentra/server/pkg/db/generated\"\n\t\"github.com/agentra-ai/agentra/pkg/taskgraph\"\n)\n\n// ErrNoDescription is returned when an issue has no description.\nvar ErrNoDescription = errors.New(\"issue has no description\")\n\n// ErrGraphExists is returned when a task graph already exists for an issue.\nvar ErrGraphExists = errors.New(\"task graph already exists for this issue\")\n\n// ErrInvalidDAG is returned when the LLM returns an invalid DAG structure.\nvar ErrInvalidDAG = errors.New(\"invalid DAG structure returned by planner\")\n\n// PlannerService decomposes goal issues into task DAGs via LLM.\ntype PlannerService struct {\n\tqueries *db.Queries\n\tgraphStore *taskgraph.GraphStore\n}\n\n// NewPlannerService creates a new PlannerService.\nfunc NewPlannerService(q *db.Queries, gs *taskgraph.GraphStore) *PlannerService {\n\treturn &PlannerService{queries: q, graphStore: gs}\n}\n\n// DecomposeOptions configures the auto-decomposition request.\ntype DecomposeOptions struct {\n\tProvider string\n\tModel string\n\tMaxNodes int\n\tAdditionalContext string\n\tForce bool\n}\n\n// defaultDecomposeOpts fills defaults for unset options.\nfunc defaultDecomposeOpts(opts DecomposeOptions) DecomposeOptions {\n\tif opts.Provider == \"\" {\n\t\topts.Provider = \"anthropic\"\n\t}\n\tif opts.Model == \"\" {\n\t\topts.Model = \"claude-sonnet-4-20250514\"\n\t}\n\tif opts.MaxNodes <= 0 {\n\t\topts.MaxNodes = 10\n\t}\n\treturn opts\n}\n\n// DecomposeResult holds the full decomposition output.\ntype DecomposeResult struct {\n\tNodes []taskgraph.GraphNode `json:\"nodes\"`\n\tEdges []taskgraph.GraphEdge `json:\"edges\"`\n\tPlan string `json:\"plan\"`\n\tTokenUsage map[string]any `json:\"token_usage,omitempty\"`\n}\n\n// plannerNode is the raw node from the LLM JSON response.\ntype plannerNode struct {\n\tNodeType string `json:\"node_type\"`\n\tContext plannerContext `json:\"context\"`\n\tDepth int `json:\"depth\"`\n\tDependencies []int `json:\"dependencies\"`\n}\n\ntype plannerContext struct {\n\tDescription string `json:\"description\"`\n\tSuggestedAgent string `json:\"suggested_agent\"`\n\tEstimatedEffort string `json:\"estimated_effort\"`\n\tDeliverable string `json:\"deliverable\"`\n\tAcceptanceCriteria []string `json:\"acceptance_criteria\"`\n}\n\n// plannerResponse is the top-level JSON the LLM returns.\ntype plannerResponse struct {\n\tPlan string `json:\"plan\"`\n\tNodes []plannerNode `json:\"nodes\"`\n}\n\n// DecomposeIssue decomposes a goal issue into a task DAG.\nfunc (s *PlannerService) DecomposeIssue(ctx context.Context, workspaceID, issueID string, opts DecomposeOptions) (*DecomposeResult, error) {\n\topts = defaultDecomposeOpts(opts)\n\n\t// 1. Load the issue.\n\tissue, err := s.queries.GetIssue(ctx, util.ParseUUID(issueID))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"load issue: %w\", err)\n\t}\n\tif !issue.Description.Valid || strings.TrimSpace(issue.Description.String) == \"\" {\n\t\treturn nil, ErrNoDescription\n\t}\n\n\t// 2. Check if graph already exists.\n\tif !opts.Force {\n\t\texisting, err := s.graphStore.ListNodesByIssue(ctx, issueID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"check existing graph: %w\", err)\n\t\t}\n\t\tif len(existing) > 0 {\n\t\t\treturn nil, ErrGraphExists\n\t\t}\n\t}\n\n\t// 3. Load available workspace agents.\n\tagents, err := s.queries.ListAgents(ctx, util.ParseUUID(workspaceID))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"list agents: %w\", err)\n\t}\n\n\t// 4. Build prompts.\n\tagentList := formatAgentList(agents)\n\tsystemPrompt := buildSystemPrompt(opts.MaxNodes)\n\tuserPrompt := buildUserPrompt(issue.Title, issue.Description.String, agentList, opts.AdditionalContext)\n\n\t// 5. Call the LLM provider.\n\tapiKey := resolveAPIKey(opts.Provider)\n\tprovider, err := agentproviders.NewProvider(opts.Provider, agentproviders.APIConfig{\n\t\tAPIKey: apiKey,\n\t\tWorkspaceID: workspaceID,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create provider: %w\", err)\n\t}\n\n\texecOpts := agentproviders.ExecOptions{\n\t\tModel: opts.Model,\n\t\tSystemPrompt: systemPrompt,\n\t\tMaxTurns: 1,\n\t\tTimeout: 60 * time.Second,\n\t}\n\n\tsession, err := provider.Execute(ctx, userPrompt, execOpts)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"execute planner: %w\", err)\n\t}\n\n\t// Wait for result with context cancellation.\n\tvar llmResult agenttypes.Result\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn nil, fmt.Errorf(\"planner execution cancelled: %w\", ctx.Err())\n\tcase res, ok := <-session.Result:\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"planner session closed unexpectedly\")\n\t\t}\n\t\tllmResult = res\n\t}\n\n\tif llmResult.Status != \"completed\" {\n\t\ttruncated := truncateForError(llmResult.Error)\n\t\treturn nil, fmt.Errorf(\"planner LLM failed (status=%s): %s\", llmResult.Status, truncated)\n\t}\n\n\t// 6. Parse the JSON response.\n\tplan, err := parsePlannerJSON(llmResult.Output)\n\tif err != nil {\n\t\ttruncated := truncateForError(llmResult.Output)\n\t\treturn nil, fmt.Errorf(\"%w: %v (raw output: %s)\", ErrInvalidDAG, err, truncated)\n\t}\n\n\t// 7. Validate DAG structure.\n\tif err := validateDAG(plan, opts.MaxNodes); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %v\", ErrInvalidDAG, err)\n\t}\n\n\t// 8. Resolve agent names to agent IDs.\n\tassignments := resolveAgentAssignments(plan.Nodes, agents)\n\n\t// 9. Create nodes in the graph store.\n\tnodes, err := s.createDAGNodes(ctx, workspaceID, issueID, plan, assignments)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create nodes: %w\", err)\n\t}\n\n\t// 10. Create edges.\n\tedges, err := s.createDAGEdges(ctx, plan, nodes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create edges: %w\", err)\n\t}\n\n\ttokenUsage := make(map[string]any)\n\tif llmResult.TokenUsage != nil {\n\t\ttokenUsage[\"input_tokens\"] = llmResult.TokenUsage.InputTokens\n\t\ttokenUsage[\"output_tokens\"] = llmResult.TokenUsage.OutputTokens\n\t}\n\n\treturn &DecomposeResult{\n\t\tNodes: nodes,\n\t\tEdges: edges,\n\t\tPlan: plan.Plan,\n\t\tTokenUsage: tokenUsage,\n\t}, nil\n}\n\n// createDAGNodes creates all nodes in the graph store, assigns agent IDs, and computes layout.\nfunc (s *PlannerService) createDAGNodes(ctx context.Context, workspaceID, issueID string, plan *plannerResponse, assignments map[int]string) ([]taskgraph.GraphNode, error) {\n\t// Group nodes by depth for layout.\n\tnodesByDepth := make(map[int]int)\n\tmaxDepth := 0\n\tfor i := range plan.Nodes {\n\t\td := plan.Nodes[i].Depth\n\t\tnodesByDepth[d]++\n\t\tif d > maxDepth {\n\t\t\tmaxDepth = d\n\t\t}\n\t}\n\n\t// Compute per-depth index for position_x.\n\tperDepthIndex := make(map[int]int)\n\n\tcreated := make([]taskgraph.GraphNode, len(plan.Nodes))\n\tfor i, pn := range plan.Nodes {\n\t\td := pn.Depth\n\n\t\t// Compute position.\n\t\tdepthCount := nodesByDepth[d]\n\t\tidx := perDepthIndex[d]\n\t\tposX := computePositionX(idx, depthCount)\n\t\tposY := float64(d * 150)\n\t\tperDepthIndex[d]++\n\n\t\t// Build context JSON.\n\t\tctxMap := map[string]any{\n\t\t\t\"description\": pn.Context.Description,\n\t\t\t\"suggested_agent\": pn.Context.SuggestedAgent,\n\t\t\t\"estimated_effort\": pn.Context.EstimatedEffort,\n\t\t\t\"deliverable\": pn.Context.Deliverable,\n\t\t\t\"acceptance_criteria\": pn.Context.AcceptanceCriteria,\n\t\t}\n\t\tcontextJSON, err := json.Marshal(ctxMap)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"marshal context for node %d: %w\", i, err)\n\t\t}\n\n\t\tnodeType := taskgraph.NodeType(pn.NodeType)\n\n\t\tnode, err := s.graphStore.CreateNode(ctx, workspaceID, issueID, nodeType, d, contextJSON)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create node %d: %w\", i, err)\n\t\t}\n\n\t\t// Assign agent and position via update.\n\t\tvar agentIDStr *string\n\t\tif agentID, ok := assignments[i]; ok {\n\t\t\tagentIDStr = &agentID\n\t\t}\n\n\t\tupdated, err := s.graphStore.UpdateNode(ctx, node.ID, &taskgraph.UpdateNodeParams{\n\t\t\tAgentID: agentIDStr,\n\t\t\tPositionX: &posX,\n\t\t\tPositionY: &posY,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"update node %d: %w\", i, err)\n\t\t}\n\t\tcreated[i] = *updated\n\t}\n\treturn created, nil\n}\n\n// createDAGEdges creates dependency edges for all nodes.\nfunc (s *PlannerService) createDAGEdges(ctx context.Context, plan *plannerResponse, nodes []taskgraph.GraphNode) ([]taskgraph.GraphEdge, error) {\n\tvar edges []taskgraph.GraphEdge\n\tfor i, pn := range plan.Nodes {\n\t\tfor _, depIdx := range pn.Dependencies {\n\t\t\ttoNode := nodes[i]\n\t\t\tfromNode := nodes[depIdx]\n\n\t\t\tedge, err := s.graphStore.CreateEdge(ctx, fromNode.ID, toNode.ID, taskgraph.EdgeDependsOn, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"create edge %d->%d: %w\", depIdx, i, err)\n\t\t\t}\n\t\t\tedges = append(edges, *edge)\n\t\t}\n\t}\n\treturn edges, nil\n}\n\n// computePositionX computes the x position for a node within its depth layer.\n// Nodes are centered around 0 with 300px spacing.\nfunc computePositionX(index, total int) float64 {\n\tcenter := float64(total-1) / 2.0\n\treturn (float64(index) - center) * 300.0\n}\n\n// systemPromptTemplate is the planner's system prompt.\nvar systemPromptTemplate = `You are a task decomposition planner for Agentra, an AI-native task management platform.\n\nYour job: Given a goal (issue title + description) and a team of available agents, produce a Task DAG that decomposes the goal into executable subtasks with dependencies.\n\n## Rules\n1. Each node must be a concrete, independently executable task.\n2. Edges represent \"depends_on\" relationships. Node B depends on Node A if B cannot start until A completes.\n3. Nodes with no mutual dependencies should be at the same depth for parallel execution.\n4. Assign each node a node_type: \"executor\" (work task) or \"synthesis\" (combines results).\n5. Suggest an agent from the available team for each node.\n6. Prefer parallelism over long chains. Minimize critical path length.\n7. Maximum nodes: %d.\n\n## Output Format\nRespond with ONLY a single JSON object (no markdown wrappers, no code fences):\n{\n \"plan\": \"markdown execution plan summary\",\n \"nodes\": [\n {\n \"node_type\": \"executor|synthesis\",\n \"context\": {\n \"description\": \"what this node does\",\n \"suggested_agent\": \"name of recommended agent\",\n \"estimated_effort\": \"low|medium|high\",\n \"deliverable\": \"concrete output\",\n \"acceptance_criteria\": [\"criterion\"]\n },\n \"depth\": 0,\n \"dependencies\": []\n }\n ]\n}\n\ndependencies array contains 0-based indices of upstream nodes. First node = index 0. Nodes at depth 0 have empty dependencies.`\n\nfunc buildSystemPrompt(maxNodes int) string {\n\treturn fmt.Sprintf(systemPromptTemplate, maxNodes)\n}\n\n// userPromptTemplate is the user prompt for the planner.\nvar userPromptTemplate = `## Goal\n**Title**: %s\n**Description**: %s\n\n## Available Agents\n%s\n\n## Additional Context\n%s\n\nDecompose this goal into a task DAG. Return the JSON.`\n\nfunc buildUserPrompt(title, description, agentList, additionalContext string) string {\n\tif additionalContext == \"\" {\n\t\tadditionalContext = \"(none)\"\n\t}\n\treturn fmt.Sprintf(userPromptTemplate, title, description, agentList, additionalContext)\n}\n\n// formatAgentList formats agents for the LLM prompt.\nfunc formatAgentList(agents []db.Agent) string {\n\tif len(agents) == 0 {\n\t\treturn \"(no agents available)\"\n\t}\n\tvar b strings.Builder\n\tfor i, a := range agents {\n\t\tfmt.Fprintf(&b, \"%d. **%s**\", i+1, a.Name)\n\t\tif a.Description != \"\" {\n\t\t\tfmt.Fprintf(&b, \" - %s\", a.Description)\n\t\t}\n\t\tb.WriteString(\"\\n\")\n\t}\n\treturn b.String()\n}\n\n// parsePlannerJSON extracts a plannerResponse from LLM output, stripping markdown fences.\nfunc parsePlannerJSON(raw string) (*plannerResponse, error) {\n\tcleaned := strings.TrimSpace(raw)\n\n\t// Strip markdown code fences if present.\n\tif strings.HasPrefix(cleaned, \"```\") {\n\t\t// Find the opening fence end.\n\t\tnewline := strings.Index(cleaned, \"\\n\")\n\t\tif newline >= 0 {\n\t\t\tcleaned = cleaned[newline+1:]\n\t\t}\n\t\t// Strip closing fence.\n\t\tif strings.HasSuffix(cleaned, \"```\") {\n\t\t\tcleaned = cleaned[:len(cleaned)-3]\n\t\t}\n\t\tcleaned = strings.TrimSpace(cleaned)\n\t}\n\n\t// Try to find JSON within the string (handles stray markdown or prose).\n\tstart := strings.Index(cleaned, \"{\")\n\tif start < 0 {\n\t\treturn nil, errors.New(\"no JSON object found in response\")\n\t}\n\tcleaned = cleaned[start:]\n\n\t// Find matching closing brace.\n\tend := strings.LastIndex(cleaned, \"}\")\n\tif end < 0 {\n\t\treturn nil, errors.New(\"no closing brace found in response\")\n\t}\n\tcleaned = cleaned[:end+1]\n\n\tvar resp plannerResponse\n\tif err := json.Unmarshal([]byte(cleaned), &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse JSON: %w\", err)\n\t}\n\treturn &resp, nil\n}\n\n// validateDAG validates the parsed DAG structure.\nfunc validateDAG(plan *plannerResponse, maxNodes int) error {\n\tif len(plan.Nodes) == 0 {\n\t\treturn errors.New(\"at least 1 node is required\")\n\t}\n\tif len(plan.Nodes) > maxNodes {\n\t\treturn fmt.Errorf(\"node count %d exceeds maximum %d\", len(plan.Nodes), maxNodes)\n\t}\n\n\ttotalNodes := len(plan.Nodes)\n\tfor i, node := range plan.Nodes {\n\t\t// Validate node_type.\n\t\tswitch node.NodeType {\n\t\tcase \"executor\", \"synthesis\":\n\t\t\t// Valid.\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"node %d: invalid node_type %q (must be executor or synthesis)\", i, node.NodeType)\n\t\t}\n\n\t\t// Validate dependencies.\n\t\tfor _, depIdx := range node.Dependencies {\n\t\t\tif depIdx < 0 || depIdx >= totalNodes {\n\t\t\t\treturn fmt.Errorf(\"node %d: dependency index %d out of range (0-%d)\", i, depIdx, totalNodes-1)\n\t\t\t}\n\t\t\tif depIdx == i {\n\t\t\t\treturn fmt.Errorf(\"node %d: cannot depend on itself\", i)\n\t\t\t}\n\t\t}\n\n\t\t// Validate context has at least a description.\n\t\tif strings.TrimSpace(node.Context.Description) == \"\" {\n\t\t\treturn fmt.Errorf(\"node %d: context.description is required\", i)\n\t\t}\n\t}\n\treturn nil\n}\n\n// resolveAgentAssignments matches suggested agent names to real agent IDs.\n// Uses case-insensitive word-level substring matching.\nfunc resolveAgentAssignments(nodes []plannerNode, agents []db.Agent) map[int]string {\n\tassignments := make(map[int]string)\n\n\ttype candidate struct {\n\t\tidx int\n\t\tname string\n\t\tagentID string\n\t}\n\n\tvar candidates []candidate\n\tfor i, node := range nodes {\n\t\tsuggested := strings.TrimSpace(node.Context.SuggestedAgent)\n\t\tif suggested == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcandidates = append(candidates, candidate{idx: i, name: suggested})\n\t}\n\n\tif len(candidates) == 0 || len(agents) == 0 {\n\t\treturn assignments\n\t}\n\n\t// Precompute lowercase agent names.\n\ttype agentEntry struct {\n\t\tid string\n\t\tname string\n\t\tnameLower string\n\t\twordsLower []string // split by spaces, hyphens, underscores\n\t}\n\tagentEntries := make([]agentEntry, len(agents))\n\tfor i, a := range agents {\n\t\tnameLower := strings.ToLower(a.Name)\n\t\tagentEntries[i] = agentEntry{\n\t\t\tid: util.UUIDToString(a.ID),\n\t\t\tname: a.Name,\n\t\t\tnameLower: nameLower,\n\t\t\twordsLower: splitNameWords(nameLower),\n\t\t}\n\t}\n\n\tfor _, c := range candidates {\n\t\tsuggestedLower := strings.ToLower(c.name)\n\t\tsuggestedWords := splitNameWords(suggestedLower)\n\n\t\tbestID := \"\"\n\t\tbestScore := 0\n\n\t\tfor _, ae := range agentEntries {\n\t\t\tscore := matchScore(suggestedLower, suggestedWords, ae.nameLower, ae.wordsLower)\n\t\t\tif score > bestScore {\n\t\t\t\tbestScore = score\n\t\t\t\tbestID = ae.id\n\t\t\t}\n\t\t}\n\n\t\tif bestID != \"\" {\n\t\t\tassignments[c.idx] = bestID\n\t\t}\n\t}\n\n\treturn assignments\n}\n\n// splitNameWords splits a name into words by common delimiters.\nfunc splitNameWords(name string) []string {\n\t// Use FieldsFunc for space and common separators.\n\tdelim := func(c rune) bool {\n\t\treturn c == ' ' || c == '-' || c == '_' || c == '.'\n\t}\n\treturn strings.FieldsFunc(name, delim)\n}\n\n// matchScore computes how well a suggested name matches an agent name.\n// Higher score = better match.\nfunc matchScore(suggestedLower string, suggestedWords []string, agentLower string, agentWords []string) int {\n\t// Exact match gets highest score.\n\tif suggestedLower == agentLower {\n\t\treturn 1000\n\t}\n\n\tscore := 0\n\n\t// Full suggested name is contained in agent name or vice versa.\n\tif strings.Contains(agentLower, suggestedLower) {\n\t\tscore += 200\n\t} else if strings.Contains(suggestedLower, agentLower) {\n\t\tscore += 200\n\t}\n\n\t// Word-level matching.\n\tfor _, sw := range suggestedWords {\n\t\tif len(sw) < 2 {\n\t\t\tcontinue // skip short noise words\n\t\t}\n\t\tfor _, aw := range agentWords {\n\t\t\tif sw == aw {\n\t\t\t\tscore += 50\n\t\t\t} else if strings.Contains(aw, sw) {\n\t\t\t\tscore += 30\n\t\t\t} else if strings.Contains(sw, aw) {\n\t\t\t\tscore += 20\n\t\t\t}\n\t\t}\n\t}\n\n\treturn score\n}\n\n// resolveAPIKey returns the API key for a provider from environment variables.\nfunc resolveAPIKey(provider string) string {\n\tswitch provider {\n\tcase \"anthropic\":\n\t\treturn os.Getenv(\"ANTHROPIC_API_KEY\")\n\tcase \"openai\":\n\t\treturn os.Getenv(\"OPENAI_API_KEY\")\n\tcase \"openrouter\":\n\t\treturn os.Getenv(\"OPENROUTER_API_KEY\")\n\tcase \"ollama\":\n\t\treturn \"\" // ollama is local, no key needed\n\tcase \"deepseek\":\n\t\treturn os.Getenv(\"DEEPSEEK_API_KEY\")\n\tcase \"groq\":\n\t\treturn os.Getenv(\"GROQ_API_KEY\")\n\tcase \"together\":\n\t\treturn os.Getenv(\"TOGETHER_API_KEY\")\n\tcase \"mistral\":\n\t\treturn os.Getenv(\"MISTRAL_API_KEY\")\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// truncateForError truncates a string for error messages (500 char max).\nfunc truncateForError(s string) string {\n\tconst maxLen = 500\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\tsuffix := \"... (truncated)\"\n\treturn s[:maxLen-len(suffix)] + suffix\n}\n"}}}}