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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions pkg/workflow/model_routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
130 changes: 126 additions & 4 deletions pkg/workflow/model_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading