diff --git a/pkg/cli/slice.go b/pkg/cli/slice.go index 5d315227..fd95195e 100644 --- a/pkg/cli/slice.go +++ b/pkg/cli/slice.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "os" + "strconv" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" @@ -55,6 +56,10 @@ type planSlice struct { type sliceOptions struct { planFile string + repo string + plannerURL string + plannerModel string + repomapFile string namespace string coderAgent string integrateAgent string @@ -67,40 +72,51 @@ type sliceOptions struct { func newSliceCommand() *cobra.Command { opts := &sliceOptions{} cmd := &cobra.Command{ - Use: "slice --plan FILE", - Short: "Render a sliced Workload from a slice plan and apply it", - Long: `Render a Workload from a slice plan (the planner's output) and apply it. + Use: "slice [ISSUE]", + Short: "Plan an issue into disjoint slices and render a sliced Workload", + Long: `Render a sliced Workload and apply it, from either a pre-made plan or by +planning an issue. -The plan decomposes one issue into disjoint-file slices. This command renders a -Workload whose pipeline is: one issue-fix step per slice, then an integrate step +The pipeline is one issue-fix step per disjoint slice, then an integrate step that unions the slice branches, then a reconcile step that checks the union against the plan's pinned shared identifiers. +Two input modes: + --plan FILE render from a slice plan the planner already produced. + ISSUE --planner-url plan the issue with a model, then render. + Examples: - # Dry-run: print the rendered Workload without applying it: + # Render a pre-made plan, dry-run: llmkube foreman slice --plan slice-plan-700.yaml --dry-run - # Apply against a cluster: - llmkube foreman slice --plan slice-plan-700.yaml --coder-agent coder-metal`, - RunE: func(cmd *cobra.Command, _ []string) error { - return runSlice(cmd.Context(), cmd.OutOrStdout(), opts) + # Plan issue 700 with a local model, then apply: + llmkube foreman slice 700 --repo defilantech/LLMKube \ + --planner-url http://localhost:18080 --planner-model ornith-35b \ + --repomap /tmp/repomap.txt --coder-agent coder-metal`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSlice(cmd.Context(), cmd.OutOrStdout(), args, opts) }, } f := cmd.Flags() - f.StringVar(&opts.planFile, "plan", "", "Path to the slice plan YAML (required)") + f.StringVar(&opts.planFile, "plan", "", "Path to a slice plan YAML (skips planning)") + f.StringVar(&opts.repo, "repo", "", "Target repo as owner/name (required when planning an issue)") + f.StringVar(&opts.plannerURL, "planner-url", "", + "OpenAI-compatible base URL of the planner model (required when planning an issue)") + f.StringVar(&opts.plannerModel, "planner-model", "", "Planner model name to send in the request") + f.StringVar(&opts.repomapFile, "repomap", "", "Path to a repository map file to give the planner") f.StringVar(&opts.namespace, "namespace", "default", "Namespace to create the Workload in") f.StringVar(&opts.coderAgent, "coder-agent", "coder-metal", "Agent that runs each slice's issue-fix step") f.StringVar(&opts.integrateAgent, "integrate-agent", "integrate", "Deterministic agent that runs the integrate step") f.StringVar(&opts.reconcileAgent, "reconcile-agent", "reconcile", "Deterministic agent that runs the reconcile step") f.StringVar(&opts.baseBranch, "base-branch", "main", "Base branch the slices are cut from and unioned onto") f.BoolVar(&opts.dryRun, "dry-run", false, "Print the rendered Workload without applying it") - _ = cmd.MarkFlagRequired("plan") return cmd } -func runSlice(ctx context.Context, out io.Writer, opts *sliceOptions) error { - plan, err := loadSlicePlan(opts.planFile) +func runSlice(ctx context.Context, out io.Writer, args []string, opts *sliceOptions) error { + plan, err := resolveSlicePlan(ctx, args, opts) if err != nil { return err } @@ -129,6 +145,25 @@ func runSlice(ctx context.Context, out io.Writer, opts *sliceOptions) error { return nil } +// resolveSlicePlan produces the plan from either --plan FILE or by planning an +// ISSUE argument with the planner model. +func resolveSlicePlan(ctx context.Context, args []string, opts *sliceOptions) (slicePlan, error) { + if opts.planFile != "" { + return loadSlicePlan(opts.planFile) + } + if len(args) == 1 { + issue, err := strconv.ParseInt(args[0], 10, 32) + if err != nil || issue <= 0 { + return slicePlan{}, fmt.Errorf("ISSUE must be a positive integer, got %q", args[0]) + } + if opts.repo == "" || opts.plannerURL == "" { + return slicePlan{}, fmt.Errorf("planning an issue requires --repo and --planner-url") + } + return planIssue(ctx, int32(issue), opts, httpPlannerCall(opts.plannerURL, opts.plannerModel)) + } + return slicePlan{}, fmt.Errorf("provide --plan FILE or an ISSUE with --repo and --planner-url") +} + func loadSlicePlan(path string) (slicePlan, error) { var p slicePlan if path == "" { diff --git a/pkg/cli/slice_planner.go b/pkg/cli/slice_planner.go new file mode 100644 index 00000000..989a11a4 --- /dev/null +++ b/pkg/cli/slice_planner.go @@ -0,0 +1,191 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/defilantech/llmkube/pkg/foreman/agent/githubissue" +) + +// plannerPrompt is the one-shot pinning planner. It decomposes an issue into +// disjoint-file slices and pins the exact cross-slice identifiers, so the union +// is interface-consistent. The two %s are the issue text and the repo map. +const plannerPrompt = `You are a planning agent. You are given a GitHub issue and a repository map. +Decompose the issue into 2 to 4 slices that can be implemented INDEPENDENTLY and +combined by concatenation. + +Hard rules: +1. Disjoint files. Each slice owns a distinct set of files. No file may appear in + two slices. If the issue cannot be split into disjoint-file slices, say so + explicitly and stop: output only "UNSLICEABLE: ". +2. Contract first. Before the slices, write a contract: the shared interfaces, + signatures, config keys, and file boundaries all slices must agree on. +3. Each slice is small enough for a mid-size local model to finish in one pass. +4. Pin shared identifiers. For every identifier that crosses a slice boundary + (metric name, config key, CRD field, function signature), pin the EXACT string + in a shared_identifiers list with which slice defines it and which reference + it. In each slice's task, name the exact identifiers that slice uses, VERBATIM. +5. Output VALID YAML in exactly this shape and nothing else: + +issue: +repo: +contract: | + +shared_identifiers: + - id: + defined_by: + referenced_by: [, ...] +slices: + - name: + files: + - + task: | + + +Issue: +%s + +Repository map: +%s +` + +// PlannerCaller sends the planner prompt to a model and returns the completion. +// Defaults to an HTTP call against an OpenAI-compatible endpoint; tests inject +// a stub. +type PlannerCaller func(ctx context.Context, prompt string) (string, error) + +// planIssue fetches the issue, builds the planner prompt, calls the planner +// model, and parses its SlicePlan. The CLI knows the real issue number and +// repo, so it overrides whatever the planner emitted for those (the planner +// routinely slips the header fields). +func planIssue(ctx context.Context, issue int32, opts *sliceOptions, call PlannerCaller) (slicePlan, error) { + owner, repo, err := githubissue.ParseRepo(opts.repo) + if err != nil { + return slicePlan{}, fmt.Errorf("--repo: %w", err) + } + iss, err := githubissue.NewClient().Fetch(ctx, owner, repo, int(issue), os.Getenv("GITHUB_TOKEN")) + if err != nil { + return slicePlan{}, fmt.Errorf("fetch issue #%d: %w", issue, err) + } + repomap := "" + if opts.repomapFile != "" { + b, rerr := os.ReadFile(opts.repomapFile) + if rerr != nil { + return slicePlan{}, fmt.Errorf("read repomap %s: %w", opts.repomapFile, rerr) + } + repomap = string(b) + } + + prompt := fmt.Sprintf(plannerPrompt, formatIssuePrompt(iss), repomap) + raw, err := call(ctx, prompt) + if err != nil { + return slicePlan{}, fmt.Errorf("planner call: %w", err) + } + plan, err := parseSlicePlan(raw) + if err != nil { + return slicePlan{}, err + } + // The CLI is the source of truth for these; the planner slips them. + plan.Issue = issue + plan.Repo = opts.repo + return plan, nil +} + +var fenceRE = regexp.MustCompile("(?s)```(?:yaml)?\\s*(.*?)```") + +// parseSlicePlan extracts the SlicePlan from a planner completion: it strips a +// code fence if present, surfaces an UNSLICEABLE refusal as an error, and +// unmarshals the YAML. +func parseSlicePlan(raw string) (slicePlan, error) { + body := strings.TrimSpace(raw) + if m := fenceRE.FindStringSubmatch(body); m != nil { + body = strings.TrimSpace(m[1]) + } + if strings.HasPrefix(strings.ToUpper(body), "UNSLICEABLE") { + return slicePlan{}, fmt.Errorf("planner refused: %s", firstLine(body)) + } + var p slicePlan + if err := yaml.Unmarshal([]byte(body), &p); err != nil { + return slicePlan{}, fmt.Errorf("parse planner output as a slice plan: %w", err) + } + if len(p.Slices) == 0 { + return slicePlan{}, fmt.Errorf("planner output had no slices:\n%s", body) + } + return p, nil +} + +// httpPlannerCall posts the prompt to an OpenAI-compatible /v1/chat/completions +// endpoint and returns the assistant message content. +func httpPlannerCall(url, model string) PlannerCaller { + return func(ctx context.Context, prompt string) (string, error) { + reqBody, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []map[string]string{{"role": "user", "content": prompt}}, + "temperature": 0.2, + "max_tokens": 2400, + }) + endpoint := strings.TrimSuffix(url, "/") + "/v1/chat/completions" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("planner endpoint returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(b, &parsed); err != nil { + return "", fmt.Errorf("decode planner response: %w", err) + } + if len(parsed.Choices) == 0 { + return "", fmt.Errorf("planner response had no choices") + } + return parsed.Choices[0].Message.Content, nil + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/pkg/cli/slice_planner_test.go b/pkg/cli/slice_planner_test.go new file mode 100644 index 00000000..9be1ff9d --- /dev/null +++ b/pkg/cli/slice_planner_test.go @@ -0,0 +1,125 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const planYAMLBody = `issue: 0 +repo: wrong/repo +contract: | + agree on names +shared_identifiers: + - id: foo_metric + defined_by: a + referenced_by: [b] +slices: + - name: a + files: [x.yaml] + task: define foo_metric + - name: b + files: [y.json] + task: use foo_metric +` + +func TestParseSlicePlan(t *testing.T) { + t.Run("bare yaml", func(t *testing.T) { + p, err := parseSlicePlan(planYAMLBody) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(p.Slices) != 2 || p.SharedIdentifiers[0].ID != "foo_metric" { + t.Fatalf("parsed wrong: %+v", p) + } + }) + + t.Run("fenced yaml", func(t *testing.T) { + p, err := parseSlicePlan("here you go:\n```yaml\n" + planYAMLBody + "```\n") + if err != nil { + t.Fatalf("parse fenced: %v", err) + } + if len(p.Slices) != 2 { + t.Fatalf("fenced parse wrong: %+v", p) + } + }) + + t.Run("unsliceable", func(t *testing.T) { + _, err := parseSlicePlan("UNSLICEABLE: the change is one tangled file") + if err == nil || !strings.Contains(err.Error(), "refused") { + t.Fatalf("want refusal error, got %v", err) + } + }) + + t.Run("no slices", func(t *testing.T) { + if _, err := parseSlicePlan("issue: 1\nrepo: a/b\n"); err == nil { + t.Fatal("want error for a plan with no slices") + } + }) +} + +func TestHTTPPlannerCall(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]string{"content": "```yaml\n" + planYAMLBody + "```"}}, + }, + }) + })) + defer srv.Close() + + out, err := httpPlannerCall(srv.URL, "planner")(context.Background(), "prompt") + if err != nil { + t.Fatalf("call: %v", err) + } + if !strings.Contains(out, "foo_metric") { + t.Fatalf("content missing plan: %q", out) + } + + // non-200 surfaces an error. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer bad.Close() + if _, err := httpPlannerCall(bad.URL, "m")(context.Background(), "p"); err == nil { + t.Fatal("want error on non-200") + } +} + +func TestResolveSlicePlan_RequiresInput(t *testing.T) { + // no --plan and no issue arg -> error. + if _, err := resolveSlicePlan(context.Background(), nil, &sliceOptions{}); err == nil { + t.Fatal("want error when neither --plan nor ISSUE is given") + } + // issue arg without --repo/--planner-url -> error. + if _, err := resolveSlicePlan(context.Background(), []string{"700"}, &sliceOptions{}); err == nil { + t.Fatal("want error for issue without --repo/--planner-url") + } + // non-numeric issue -> error. + badOpts := &sliceOptions{repo: "a/b", plannerURL: "u"} + if _, err := resolveSlicePlan(context.Background(), []string{"abc"}, badOpts); err == nil { + t.Fatal("want error for non-numeric issue") + } +} diff --git a/pkg/cli/slice_test.go b/pkg/cli/slice_test.go index d3c1cd3b..38fc0bde 100644 --- a/pkg/cli/slice_test.go +++ b/pkg/cli/slice_test.go @@ -161,7 +161,7 @@ slices: opts.dryRun = true var buf bytes.Buffer - if err := runSlice(context.Background(), &buf, opts); err != nil { + if err := runSlice(context.Background(), &buf, nil, opts); err != nil { t.Fatalf("runSlice: %v", err) } out := buf.String()