diff --git a/go.mod b/go.mod index 853c4fa9e77..9c927db2b16 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( require ( github.com/rhysd/actionlint v1.7.12 + go.yaml.in/yaml/v3 v3.0.5 golang.org/x/net v0.59.0 ) @@ -112,7 +113,6 @@ require ( go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 361cfcaf2c9..7a42b980fb9 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -577,6 +577,12 @@ const AwPromptsFileExpr = GhAwRootDir + "/aw-prompts/prompt.txt" // AwPromptsFileShell is the runtime prompt file path in shell env-var form for host-side paths. const AwPromptsFileShell = GhAwRootDirShell + "/aw-prompts/prompt.txt" +// AwPromptsUserFile is the runtime task-only prompt file path. +const AwPromptsUserFile = TmpGhAwDir + "/aw-prompts/user.txt" + +// AwPromptsUserFileExpr is the host-side task-only prompt path in GitHub Actions expression form. +const AwPromptsUserFileExpr = GhAwRootDir + "/aw-prompts/user.txt" + // TmpMcpConfigDir is the mcp-config directory in the /tmp/gh-aw tree. // Engines that require a writable MCP config directory (e.g. Codex) use this path. const TmpMcpConfigDir = TmpGhAwDir + "/mcp-config" diff --git a/pkg/workflow/model_routing.go b/pkg/workflow/model_routing.go index 78bfc843981..c40a0cd70a4 100644 --- a/pkg/workflow/model_routing.go +++ b/pkg/workflow/model_routing.go @@ -282,9 +282,12 @@ func generateModelRoutingConversationStep(yaml *strings.Builder, data *WorkflowD if !isModelRoutingEnabled(data) { return } + promptFilePath := constants.AwPromptsUserFile + fallbackFilePath := constants.AwPromptsFile yaml.WriteString(" - name: Prepare model-routing conversation\n") yaml.WriteString(" env:\n") - yaml.WriteString(" GH_AW_ROUTING_PROMPT: " + constants.AwPromptsFileExpr + "\n") + yaml.WriteString(" GH_AW_ROUTING_PROMPT: " + promptFilePath + "\n") + yaml.WriteString(" GH_AW_ROUTING_PROMPT_FALLBACK: " + fallbackFilePath + "\n") yaml.WriteString(" GH_AW_ROUTING_CONVERSATION_FILE: " + modelRoutingConversationFile + "\n") - yaml.WriteString(" run: node -e \"const fs=require('node:fs'); const path=require('node:path'); const prompt=fs.readFileSync(process.env.GH_AW_ROUTING_PROMPT,'utf8'); if (!prompt.trim()) throw new Error('Rendered workflow prompt is empty; cannot route this task'); const destination=process.env.GH_AW_ROUTING_CONVERSATION_FILE; fs.mkdirSync(path.dirname(destination),{recursive:true,mode:0o700}); fs.writeFileSync(destination,JSON.stringify([{role:'user',parts:[{text:prompt}]}]),{mode:0o600});\"\n") + yaml.WriteString(" run: node -e \"const fs=require('node:fs'); const path=require('node:path'); const readPrompt=file=>{try{return fs.readFileSync(file,'utf8');}catch(error){if(error.code==='ENOENT')return '';throw error;}}; let prompt=readPrompt(process.env.GH_AW_ROUTING_PROMPT); if (!prompt.trim()) prompt=readPrompt(process.env.GH_AW_ROUTING_PROMPT_FALLBACK); if (!prompt.trim()) throw new Error('Rendered workflow prompt is empty; cannot route this task'); const destination=process.env.GH_AW_ROUTING_CONVERSATION_FILE; fs.mkdirSync(path.dirname(destination),{recursive:true,mode:0o700}); fs.writeFileSync(destination,JSON.stringify([{role:'user',parts:[{text:prompt}]}]),{mode:0o600});\"\n") } diff --git a/pkg/workflow/model_routing_test.go b/pkg/workflow/model_routing_test.go index 54f43fd2495..c9b845be069 100644 --- a/pkg/workflow/model_routing_test.go +++ b/pkg/workflow/model_routing_test.go @@ -2,10 +2,15 @@ package workflow import ( "encoding/json" + "os" + "os/exec" + "path/filepath" "strings" "testing" + "github.com/github/gh-aw/pkg/constants" "github.com/stretchr/testify/require" + yamlv3 "go.yaml.in/yaml/v3" ) func TestBuildAWFConfigJSON_ModelRouting(t *testing.T) { @@ -63,15 +68,132 @@ func TestValidateModelRoutingRequiresMinimumAWFVersion(t *testing.T) { } func TestGenerateModelRoutingConversationStep(t *testing.T) { + for _, tc := range []struct { + name string + topology RunnerTopology + prompt string + fallback string + }{ + {"hosted", "", constants.AwPromptsUserFile, constants.AwPromptsFile}, + {"arc-dind", RunnerTopologyArcDind, constants.AwPromptsUserFile, constants.AwPromptsFile}, + } { + t.Run(tc.name, func(t *testing.T) { + var output strings.Builder + generateModelRoutingConversationStep(&output, &WorkflowData{ + EngineConfig: &EngineConfig{ModelRouting: &CopilotModelRoutingConfig{}}, + RunnerConfig: &RunnerConfig{Topology: tc.topology}, + }) + + require.Contains(t, output.String(), "Prepare model-routing conversation") + require.Contains(t, output.String(), "GH_AW_ROUTING_PROMPT: "+tc.prompt+"\n") + require.Contains(t, output.String(), "GH_AW_ROUTING_PROMPT_FALLBACK: "+tc.fallback+"\n") + require.Contains(t, output.String(), modelRoutingConversationFile) + require.Contains(t, output.String(), "role:'user',parts:[{text:prompt}]") + require.NotContains(t, output.String(), "${{ runner.temp }}") + require.NotContains(t, output.String(), "${RUNNER_TEMP}") + + source := `--- +on: workflow_dispatch +strict: false +engine: + id: copilot + model-routing: + goal: cost + mode: balanced + allowed-models: [gpt-5.4-mini] +network: + allowed: [defaults] +` + if tc.topology != "" { + source += "runner:\n topology: " + string(tc.topology) + "\n" + source += "sandbox:\n agent:\n images:\n build-tools: registry.example.com/build-tools:v0.28.4@sha256:" + strings.Repeat("1", 64) + "\n" + } + source += "---\nSay hello.\n" + dir := t.TempDir() + workflowPath := filepath.Join(dir, "routed.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(source), 0o600)) + require.NoError(t, NewCompiler().CompileWorkflow(workflowPath)) + compiled, err := os.ReadFile(filepath.Join(dir, "routed.lock.yml")) + require.NoError(t, err) + compiledYAML := string(compiled) + require.Contains(t, compiledYAML, output.String()) + if tc.topology == RunnerTopologyArcDind { + routingStepIndex := strings.Index(compiledYAML, "- name: Prepare model-routing conversation") + promptStagingIndex := strings.Index(compiledYAML, "cp -a /tmp/gh-aw/aw-prompts") + require.NotEqual(t, -1, routingStepIndex) + require.NotEqual(t, -1, promptStagingIndex) + require.Greater(t, promptStagingIndex, routingStepIndex) + } + }) + } +} + +func TestModelRoutingConversationPromptFallback(t *testing.T) { var yaml strings.Builder generateModelRoutingConversationStep(&yaml, &WorkflowData{ EngineConfig: &EngineConfig{ModelRouting: &CopilotModelRoutingConfig{}}, }) - require.Contains(t, yaml.String(), "Prepare model-routing conversation") - require.Contains(t, yaml.String(), "GH_AW_ROUTING_PROMPT") - require.Contains(t, yaml.String(), modelRoutingConversationFile) - require.Contains(t, yaml.String(), "role:'user',parts:[{text:prompt}]") + var steps []struct { + Run string + } + require.NoError(t, yamlv3.Unmarshal([]byte(yaml.String()), &steps)) + require.Len(t, steps, 1) + + task := " Task with \"quotes\", $variables, and a newline\n" + fullPrompt := "System instructions\nFull workflow prompt" + for _, tc := range []struct { + name string + task *string + fallback *string + want string + }{ + {"task only", &task, &fullPrompt, task}, + {"task without fallback", &task, nil, task}, + {"missing task", nil, &fullPrompt, fullPrompt}, + {"blank task", new(" \n\t"), &fullPrompt, fullPrompt}, + {"empty task", new(""), &fullPrompt, fullPrompt}, + {"both missing", nil, nil, ""}, + {"both blank", new("\n"), new(" \t"), ""}, + {"blank task missing fallback", new(" "), nil, ""}, + {"missing task blank fallback", nil, new("\n"), ""}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + taskPath := filepath.Join(dir, "user.txt") + fallbackPath := filepath.Join(dir, "prompt.txt") + conversationPath := filepath.Join(dir, "output", "routing-conversation.json") + for file, text := range map[string]*string{taskPath: tc.task, fallbackPath: tc.fallback} { + if text != nil { + require.NoError(t, os.WriteFile(file, []byte(*text), 0o600)) + } + } + cmd := exec.Command("bash", "-c", steps[0].Run) + cmd.Env = append(os.Environ(), + "GH_AW_ROUTING_PROMPT="+taskPath, + "GH_AW_ROUTING_PROMPT_FALLBACK="+fallbackPath, + "GH_AW_ROUTING_CONVERSATION_FILE="+conversationPath, + ) + output, err := cmd.CombinedOutput() + if tc.want == "" { + require.Error(t, err) + require.Contains(t, string(output), "Rendered workflow prompt is empty; cannot route this task") + require.NoFileExists(t, conversationPath) + return + } + require.NoError(t, err, string(output)) + conversation, err := os.ReadFile(conversationPath) + require.NoError(t, err) + expected, err := json.Marshal([]any{map[string]any{ + "role": "user", "parts": []any{map[string]any{"text": tc.want}}, + }}) + require.NoError(t, err) + require.JSONEq(t, string(expected), string(conversation)) + info, err := os.Stat(conversationPath) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + }) + } } func TestExtractEngineConfig_ModelRouting(t *testing.T) {