Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 49 additions & 14 deletions pkg/cli/slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"io"
"os"
"strconv"

"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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 == "" {
Expand Down
191 changes: 191 additions & 0 deletions pkg/cli/slice_planner.go
Original file line number Diff line number Diff line change
@@ -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: <reason>".
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: <number>
repo: <owner/name>
contract: |
<shared interfaces and decisions>
shared_identifiers:
- id: <exact string every slice must use verbatim>
defined_by: <slice name>
referenced_by: [<slice name>, ...]
slices:
- name: <kebab-case>
files:
- <path>
task: |
<scoped instructions; name the exact shared_identifiers this slice uses>

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
}
Loading
Loading