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
5 changes: 5 additions & 0 deletions .changeset/patch-extract-safe-output-tools-max-expressions.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 50 additions & 1 deletion pkg/workflow/safe_outputs_prompt_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,56 @@ func TestBuildSafeOutputsSectionsCustomToolsConsistency(t *testing.T) {
}
}

// extractToolNamesFromSections parses the <safe-output-tools> opening section and returns
// TestBuildSafeOutputsSectionsMaxExpressionExtraction verifies that ${{ }} expressions
// in safe-output max: values are extracted to GH_AW_* env vars and replaced with
// __GH_AW_*__ placeholders in the <safe-output-tools> prompt block.
// This prevents ${{ }} from appearing in the run: heredoc, which is subject to the
// GitHub Actions 21KB expression-size limit (regression guard for gh-aw#21158).
func TestBuildSafeOutputsSectionsMaxExpressionExtraction(t *testing.T) {
maxExpr := "${{ inputs.review-comment-max }}"
sections := buildSafeOutputsSections(&SafeOutputsConfig{
CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{
BaseSafeOutputConfig: BaseSafeOutputConfig{
Max: &maxExpr,
},
},
NoOp: &NoOpConfig{},
})

require.NotNil(t, sections, "Expected non-nil sections")

// Find the opening <safe-output-tools> section
var openingSection *PromptSection
for i := range sections {
if !sections[i].IsFile && strings.HasPrefix(sections[i].Content, "<safe-output-tools>") {
openingSection = &sections[i]
break
}
}
require.NotNil(t, openingSection, "Expected to find <safe-output-tools> opening section")

// The raw ${{ }} expression must NOT appear in the content (would hit the 21KB limit)
assert.NotContains(t, openingSection.Content, "${{",
"${{ }} expressions must not appear in the tools content (triggers 21KB expression-size limit)")

// A __GH_AW_*__ placeholder must appear instead
assert.Contains(t, openingSection.Content, "__GH_AW_",
"A __GH_AW_*__ placeholder should replace the ${{ }} expression")

// The EnvVars map must have an entry mapping the placeholder key to the original expression
require.NotEmpty(t, openingSection.EnvVars,
"EnvVars must be populated so the substitution step can resolve the placeholder")

var foundExpr bool
for _, v := range openingSection.EnvVars {
if v == "${{ inputs.review-comment-max }}" {
foundExpr = true
break
}
}
assert.True(t, foundExpr, "EnvVars must contain the original ${{ inputs.review-comment-max }} expression")
}

// the list of tool names in the order they appear, stripping any max-budget annotations
// (e.g. "noop(max:5)" → "noop").
func extractToolNamesFromSections(t *testing.T, sections []PromptSection) []string {
Expand Down
25 changes: 23 additions & 2 deletions pkg/workflow/unified_prompt_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,9 @@ func toolWithMaxBudget(name string, max *string) string {
// buildSafeOutputsSections returns the PromptSections that form the <safe-output-tools> block.
// The block contains:
// 1. An inline opening tag with a compact Tools list (dynamic, depends on which tools are enabled).
// Any ${{ }} expressions in max: values are extracted to GH_AW_* env vars and replaced
// with __GH_AW_*__ placeholders so they do not appear in the run: heredoc, avoiding the
// GitHub Actions 21KB expression-size limit.
// 2. File references for tools that require multi-step instructions (create_pull_request,
// push_to_pull_request_branch, auto-injected create_issue notice).
// 3. An inline closing tag.
Expand Down Expand Up @@ -724,10 +727,28 @@ func buildSafeOutputsSections(safeOutputs *SafeOutputsConfig) []PromptSection {

var sections []PromptSection

// Inline opening: XML tag + compact tools list
// Build the inline opening: XML tag + compact tools list.
// Extract any ${{ }} expressions from max: values so they do not appear in the
// run: heredoc (which is subject to GitHub Actions' 21KB expression-size limit).
// Expressions are replaced with __GH_AW_...__ placeholders and added to EnvVars
// so the placeholder substitution step can resolve them at runtime.
toolsContent := "<safe-output-tools>\nTools: " + strings.Join(tools, ", ")
envVars := make(map[string]string)
extractor := NewExpressionExtractor()
exprMappings, err := extractor.ExtractExpressions(toolsContent)
if err == nil && len(exprMappings) > 0 {
safeOutputsPromptLog.Printf("Extracted %d expression(s) from safe-output-tools block", len(exprMappings))
toolsContent = extractor.ReplaceExpressionsWithEnvVars(toolsContent)
for _, mapping := range exprMappings {
envVars[mapping.EnvVar] = fmt.Sprintf("${{ %s }}", mapping.Content)
}
}

// Inline opening: XML tag + compact tools list (with placeholders for any expressions)
sections = append(sections, PromptSection{
Content: "<safe-output-tools>\nTools: " + strings.Join(tools, ", "),
Content: toolsContent,
IsFile: false,
EnvVars: envVars,
})

// File sections for tools with multi-step instructions
Expand Down
Loading