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
7 changes: 7 additions & 0 deletions changes/unreleased/staging-rules.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
kind: feature

description: >
Tell agents to write ephemeral files (scripts they run, scratch notes,
intermediate output) to /tmp rather than into the repository working tree,
whose commits the harness pushes to the user's branch. Prompt assembly moves
to internal/prompt with an explicit Layers API.
41 changes: 8 additions & 33 deletions harness/cmd/migration-harness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/konveyor/migration-harness/internal/goose"
"github.com/konveyor/migration-harness/internal/hub"
"github.com/konveyor/migration-harness/internal/logging"
"github.com/konveyor/migration-harness/internal/prompt"
"github.com/konveyor/migration-harness/internal/watcher"
)

Expand Down Expand Up @@ -153,7 +154,12 @@ func runStage(cmd *cobra.Command, args []string) error {
}

// 7. Build prompt from context layers
prompt := buildPrompt(skillContent)
stagePrompt := prompt.Build(prompt.Layers{
AgentPrompt: cfg.AgentPrompt,
WorkflowGuide: cfg.WorkflowGuide,
Skill: skillContent,
StageTask: cfg.StageInstructions,
})

// 8. Start filesystem watcher BEFORE blocking prompt
pushFn := func() error {
Expand All @@ -172,7 +178,7 @@ func runStage(cmd *cobra.Command, args []string) error {
logging.Header("Running Stage")
logging.Info("max turns: %d", cfg.MaxTurns)
_, err = session.SendPrompt(ctx, sessionID, []acp.ContentBlock{
{Type: "text", Text: prompt},
{Type: "text", Text: stagePrompt},
}, cfg.MaxTurns)

if err != nil {
Expand Down Expand Up @@ -250,37 +256,6 @@ func discoverSkills() (string, []string, error) {
return combined.String(), matches, nil
}

func buildPrompt(skillContent string) string {
var b strings.Builder

if v := os.Getenv("KONVEYOR_PROMPT"); v != "" {
b.WriteString(v)
b.WriteString("\n\n")
}

if v := os.Getenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS"); v != "" {
b.WriteString("## Migration Context\n\n")
b.WriteString(v)
b.WriteString("\n\n")
}

if skillContent != "" {
b.WriteString("## Skill Instructions\n\n")
b.WriteString(skillContent)
b.WriteString("\n\n")
} else {
b.WriteString("## Working Guidelines\n\n")
b.WriteString("Commit your changes to git with a descriptive message when your work is complete.\n\n")
}

if v := os.Getenv("KONVEYOR_INSTRUCTIONS"); v != "" {
b.WriteString("## Stage Task\n\n")
b.WriteString(v)
}

return b.String()
}

func resolveFromHub(cfg *config.Config) (*git.Credentials, *hub.Client, error) {
logging.Header("Hub Resolution")

Expand Down
23 changes: 0 additions & 23 deletions harness/cmd/migration-harness/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,26 +69,3 @@ func TestDiscoverSkills_EmptySkillFile(t *testing.T) {
t.Errorf("expected 1 path (skill is mounted), got: %v", paths)
}
}

func TestBuildPrompt_NoSkills(t *testing.T) {
t.Setenv("KONVEYOR_PROMPT", "hello")
t.Setenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS", "")
t.Setenv("KONVEYOR_INSTRUCTIONS", "do work")

prompt := buildPrompt("")
expected := "hello\n\n## Working Guidelines\n\nCommit your changes to git with a descriptive message when your work is complete.\n\n## Stage Task\n\ndo work"
if prompt != expected {
t.Errorf("unexpected prompt:\n%s", prompt)
}
}

func TestBuildPrompt_WithSkills(t *testing.T) {
t.Setenv("KONVEYOR_PROMPT", "hello")
t.Setenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS", "")
t.Setenv("KONVEYOR_INSTRUCTIONS", "do work")

prompt := buildPrompt("skill content here")
if got := prompt; got != "hello\n\n## Skill Instructions\n\nskill content here\n\n## Stage Task\n\ndo work" {
t.Errorf("unexpected prompt:\n%s", got)
}
}
21 changes: 21 additions & 0 deletions harness/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ type Config struct {
ACPSecretKey string

TargetBranch string

// Prompt context layers, composed by internal/prompt.
AgentPrompt string
WorkflowGuide string
StageInstructions string
}

func LoadFromEnv() (*Config, error) {
Expand Down Expand Up @@ -51,6 +56,10 @@ func LoadFromEnv() (*Config, error) {
AppID: required["APP_ID"],
ACPSecretKey: required["KONVEYOR_ACP_SECRET_KEY"],
TargetBranch: required["TARGET_BRANCH"],

AgentPrompt: os.Getenv("KONVEYOR_PROMPT"),
WorkflowGuide: workflowGuideFromEnv(),
StageInstructions: os.Getenv("KONVEYOR_INSTRUCTIONS"),
}

if n, err := strconv.Atoi(os.Getenv("KONVEYOR_PARAM_MAX_TURNS")); err == nil && n > 0 {
Expand All @@ -59,3 +68,15 @@ func LoadFromEnv() (*Config, error) {

return cfg, nil
}

// workflowGuideFromEnv reads the workflow guide the controller injects.
//
// konveyor/agentic-controller#80 renames KONVEYOR_PLAYBOOK_INSTRUCTIONS to
// KONVEYOR_WORKFLOW_GUIDE. Reading both means the harness works either side of
// that merge; drop the fallback once #80 has landed everywhere.
func workflowGuideFromEnv() string {
if v := os.Getenv("KONVEYOR_WORKFLOW_GUIDE"); v != "" {
return v
}
return os.Getenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS")
}
57 changes: 57 additions & 0 deletions harness/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ func clearKonveyorEnv(t *testing.T) {
"APP_ID",
"KONVEYOR_ACP_SECRET_KEY",
"TARGET_BRANCH",
"KONVEYOR_PROMPT",
"KONVEYOR_PLAYBOOK_INSTRUCTIONS",
"KONVEYOR_WORKFLOW_GUIDE",
"KONVEYOR_INSTRUCTIONS",
} {
t.Setenv(k, "")
os.Unsetenv(k)
Expand Down Expand Up @@ -131,3 +135,56 @@ func TestLoadFromEnv(t *testing.T) {
}
})
}

func TestLoadFromEnvReadsPromptLayers(t *testing.T) {
clearKonveyorEnv(t)
setRequiredEnv(t)
t.Setenv("KONVEYOR_PROMPT", "AGENT PROMPT")
t.Setenv("KONVEYOR_WORKFLOW_GUIDE", "WORKFLOW GUIDE")
t.Setenv("KONVEYOR_INSTRUCTIONS", "STAGE TASK")

cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv: %v", err)
}
if cfg.AgentPrompt != "AGENT PROMPT" {
t.Errorf("AgentPrompt = %q", cfg.AgentPrompt)
}
if cfg.WorkflowGuide != "WORKFLOW GUIDE" {
t.Errorf("WorkflowGuide = %q", cfg.WorkflowGuide)
}
if cfg.StageInstructions != "STAGE TASK" {
t.Errorf("StageInstructions = %q", cfg.StageInstructions)
}
}

// #80 renames the env var; the harness reads either so merge order does not
// matter. Remove with the fallback once #80 has landed everywhere.
func TestLoadFromEnvFallsBackToPlaybookInstructions(t *testing.T) {
clearKonveyorEnv(t)
setRequiredEnv(t)
t.Setenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS", "OLD NAME")

cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv: %v", err)
}
if cfg.WorkflowGuide != "OLD NAME" {
t.Errorf("WorkflowGuide = %q, want the KONVEYOR_PLAYBOOK_INSTRUCTIONS value", cfg.WorkflowGuide)
}
}

func TestLoadFromEnvPrefersWorkflowGuide(t *testing.T) {
clearKonveyorEnv(t)
setRequiredEnv(t)
t.Setenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS", "OLD NAME")
t.Setenv("KONVEYOR_WORKFLOW_GUIDE", "NEW NAME")

cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv: %v", err)
}
if cfg.WorkflowGuide != "NEW NAME" {
t.Errorf("WorkflowGuide = %q, want the KONVEYOR_WORKFLOW_GUIDE value", cfg.WorkflowGuide)
}
}
81 changes: 81 additions & 0 deletions harness/internal/prompt/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Package prompt assembles the prompt sent to the agent for a stage.
//
// The prompt is layered: environment rules the harness imposes, then the
// Agent's standing prompt, then the workflow guide, then the skill, then the
// stage task. Later layers are more specific; the environment rules come first
// because they constrain everything after them.
package prompt

import "strings"

// stagingRules apply to every agent and skill. The working directory is the git
// worktree whose commits the harness pushes on exit, so ephemeral files left
// there are ambiguous at best, and get swept in if the agent stages broadly.
const stagingRules = `## Working Environment

Your working directory is a git repository. Anything you commit is pushed to the
user's branch when the stage ends.

Write ephemeral files to /tmp, never into the repository working tree:
- scripts you need to run: write them to /tmp, make them executable there,
Comment thread
djzager marked this conversation as resolved.
and run them from there
- scratch notes, plans, logs, and intermediate output
Comment on lines +19 to +22

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the bit that makes scripts work. Rest of the prompt is unchanged, output's byte-identical to main once you strip this block off the front.


Only files the user actually asked for belong in the repository. Keep the
worktree clean: anything left there is ambiguous, and gets swept into your
commits if you stage broadly. This applies even when a skill's instructions do
not say where to put something.
`

// Layers are the context layers composed into a stage prompt, ordered from
// least to most specific. Any of them may be empty except Skill.
type Layers struct {
// AgentPrompt is the Agent's standing prompt.
AgentPrompt string
// WorkflowGuide is the workflow's ambient guide.
WorkflowGuide string
// Skill is the content discovered from the mounted SkillCards.
Comment thread
djzager marked this conversation as resolved.
Skill string
// StageTask is the task for this stage.
StageTask string
}

// Build composes the stage prompt. The harness's environment rules always come
// first, so a skill cannot be read as overriding them.
func Build(l Layers) string {
var b strings.Builder

b.WriteString(stagingRules)
b.WriteString("\n")

if l.AgentPrompt != "" {
b.WriteString(l.AgentPrompt)
b.WriteString("\n\n")
}

if l.WorkflowGuide != "" {
b.WriteString("## Workflow Guide\n\n")
b.WriteString(l.WorkflowGuide)
b.WriteString("\n\n")
}

// Skills are optional (#82): with none mounted the agent still needs to be
// told to commit, since nothing else in the prompt says so.
if l.Skill != "" {
b.WriteString("## Skill Instructions\n\n")
b.WriteString(l.Skill)
b.WriteString("\n\n")
} else {
b.WriteString("## Working Guidelines\n\n")
b.WriteString("Commit your changes to git with a descriptive message when your work is complete.\n\n")
}

if l.StageTask != "" {
b.WriteString("## Stage Task\n\n")
b.WriteString(l.StageTask)
}

// Normalise the ending: sections above end with either "\n\n" or, for
// StageTask, no newline at all. Always finish with exactly one.
return strings.TrimRight(b.String(), "\n") + "\n"
}
99 changes: 99 additions & 0 deletions harness/internal/prompt/prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package prompt

import (
"strings"
"testing"
)

func fullLayers() Layers {
return Layers{
AgentPrompt: "AGENT PROMPT",
WorkflowGuide: "WORKFLOW GUIDE",
Skill: "SKILL BODY",
StageTask: "STAGE TASK",
}
}

func TestBuildPutsStagingRulesFirst(t *testing.T) {
got := Build(fullLayers())

if !strings.Contains(got, "Write ephemeral files to /tmp") {
t.Fatalf("staging rules missing from prompt:\n%s", got)
}

// Order matters as much as presence: the rules have to precede the skill,
// so a skill that says where to write cannot read as overriding them.
rules := strings.Index(got, "Working Environment")
for _, later := range []string{"AGENT PROMPT", "WORKFLOW GUIDE", "SKILL BODY", "STAGE TASK"} {
if strings.Index(got, later) < rules {
t.Errorf("%q appears before the staging rules; rules must come first", later)
}
}
}

func TestBuildOrdersLayersLeastToMostSpecific(t *testing.T) {
got := Build(fullLayers())

order := []string{"AGENT PROMPT", "WORKFLOW GUIDE", "SKILL BODY", "STAGE TASK"}
for i := 1; i < len(order); i++ {
if strings.Index(got, order[i]) < strings.Index(got, order[i-1]) {
t.Errorf("%q should come after %q", order[i], order[i-1])
}
}
}

func TestBuildOmitsEmptyLayers(t *testing.T) {
got := Build(Layers{Skill: "SKILL BODY"})

for _, header := range []string{"## Workflow Guide", "## Stage Task"} {
if strings.Contains(got, header) {
t.Errorf("empty layer produced %q header", header)
}
}
if !strings.Contains(got, "## Skill Instructions") {
t.Error("skill content should always be included")
}
}

// Skills are optional (#82). With none mounted the agent still needs to be told
// to commit, since nothing else in the prompt says so.
func TestBuildFallsBackWhenNoSkills(t *testing.T) {
got := Build(Layers{AgentPrompt: "AGENT PROMPT"})

if strings.Contains(got, "## Skill Instructions") {
t.Error("empty skill should not produce a Skill Instructions header")
}
if !strings.Contains(got, "## Working Guidelines") {
t.Fatalf("no skills should fall back to Working Guidelines:\n%s", got)
}
if !strings.Contains(got, "Commit your changes to git") {
t.Error("fallback should tell the agent to commit")
}
}

// The ending differed depending on whether StageTask was set: sections append
// "\n\n" but StageTask appended nothing.
func TestBuildEndsWithExactlyOneNewline(t *testing.T) {
cases := map[string]Layers{
"with stage task": fullLayers(),
"without stage task": {Skill: "SKILL BODY"},
}
for name, layers := range cases {
t.Run(name, func(t *testing.T) {
got := Build(layers)
if !strings.HasSuffix(got, "\n") {
t.Errorf("prompt does not end with a newline: %q", tail(got))
}
if strings.HasSuffix(got, "\n\n") {
t.Errorf("prompt ends with more than one newline: %q", tail(got))
}
})
}
}

func tail(s string) string {
if len(s) < 20 {
return s
}
return s[len(s)-20:]
}
Loading