diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index 18d408a3815..7b1e20091dc 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -39,6 +39,9 @@ func BuildAWFCommand(config AWFCommandConfig) string { if err != nil { awfHelpersLog.Printf("Warning: failed to build AWF config JSON: %v", err) } else { + if config.WorkflowData != nil && config.WorkflowData.EngineConfig != nil && config.WorkflowData.EngineConfig.MaxTurnCacheMissesExpression != "" { + awfConfigJSON = injectMaxTurnCacheMissesExpression(awfConfigJSON, fmt.Sprintf("${%s}", awfMaxTurnCacheMissesVarName)) + } configFileSetup = buildAWFConfigFileSetup(config, awfConfigJSON) expandableArgs = fmt.Sprintf("--config %q ", awfConfigRuntimePathExpr) + expandableArgs awfHelpersLog.Print("Using AWF config file (--config flag)") @@ -183,7 +186,8 @@ func appendArcDindMountSettings(expandableArgs, arcDindDockerHostProbe string, i func buildAWFConfigFileSetup(config AWFCommandConfig, awfConfigJSON string) string { maxAICreditsExportLine, updatedAWFConfigJSON := buildMaxAICreditsExport(config, awfConfigJSON) - printfArg := buildAWFConfigPrintfArg(updatedAWFConfigJSON, maxAICreditsExportLine != "") + preserveMaxTurnCacheMisses := config.WorkflowData != nil && config.WorkflowData.EngineConfig != nil && config.WorkflowData.EngineConfig.MaxTurnCacheMissesExpression != "" + printfArg := buildAWFConfigPrintfArg(updatedAWFConfigJSON, maxAICreditsExportLine != "", preserveMaxTurnCacheMisses) configFileSetup := buildConfigFilePrintfLine(printfArg) if maxAICreditsExportLine != "" { configFileSetup = maxAICreditsExportLine + "\n" + configFileSetup @@ -230,11 +234,14 @@ func buildMaxAICreditsExport(config AWFCommandConfig, awfConfigJSON string) (str return fmt.Sprintf(`%s="%s"`, awfMaxAICreditsVarName, expr), awfConfigJSON } -func buildAWFConfigPrintfArg(awfConfigJSON string, hasMaxAICreditsExport bool) string { +func buildAWFConfigPrintfArg(awfConfigJSON string, hasMaxAICreditsExport, preserveMaxTurnCacheMisses bool) string { preservedVars := make([]string, 0, 2) if hasMaxAICreditsExport { preservedVars = append(preservedVars, awfMaxAICreditsVarName) } + if preserveMaxTurnCacheMisses { + preservedVars = append(preservedVars, awfMaxTurnCacheMissesVarName) + } if strings.Contains(awfConfigJSON, awfArcDindRootPathExpr) { preservedVars = append(preservedVars, "RUNNER_TEMP") } diff --git a/pkg/workflow/awf_env.go b/pkg/workflow/awf_env.go index 262c9a0b2ff..16aa77166b1 100644 --- a/pkg/workflow/awf_env.go +++ b/pkg/workflow/awf_env.go @@ -61,6 +61,26 @@ func injectMaxAICreditsExpression(awfConfigJSON string, expr string) string { return awfConfigJSON[:valueEnd] + `,"maxAiCredits":` + expr + awfConfigJSON[valueEnd:] } +// injectMaxTurnCacheMissesExpression replaces the apiProxy maxCacheMisses +// integer with a GitHub Actions expression. +func injectMaxTurnCacheMissesExpression(awfConfigJSON string, expr string) string { + const maxTurnCacheMissesKey = `"maxCacheMisses":` + idx := strings.Index(awfConfigJSON, maxTurnCacheMissesKey) + if idx == -1 { + awfHelpersLog.Print("Warning: could not find maxCacheMisses in AWF config JSON; expression not injected") + return awfConfigJSON + } + valueEnd := idx + len(maxTurnCacheMissesKey) + for valueEnd < len(awfConfigJSON) && awfConfigJSON[valueEnd] >= '0' && awfConfigJSON[valueEnd] <= '9' { + valueEnd++ + } + if valueEnd == idx+len(maxTurnCacheMissesKey) { + awfHelpersLog.Print("Warning: maxCacheMisses is not an integer in AWF config JSON; expression not injected") + return awfConfigJSON + } + return awfConfigJSON[:idx+len(maxTurnCacheMissesKey)] + expr + awfConfigJSON[valueEnd:] +} + // ComputeAWFExcludeEnvVarNames returns the list of environment variable names that must be // excluded from the agent container's visible environment via AWF's --exclude-env flag. // diff --git a/pkg/workflow/awf_env_test.go b/pkg/workflow/awf_env_test.go index 78118c19adc..a79cde36f08 100644 --- a/pkg/workflow/awf_env_test.go +++ b/pkg/workflow/awf_env_test.go @@ -23,6 +23,19 @@ func TestInjectMaxAICreditsExpressionWithoutMaxRunsLeavesJSONUnchanged(t *testin } } +func TestInjectMaxTurnCacheMissesExpression(t *testing.T) { + configJSON := `{"apiProxy":{"maxCacheMisses":5}}` + + got := injectMaxTurnCacheMissesExpression(configJSON, "${GH_AW_MAX_TURN_CACHE_MISSES}") + assert.Contains(t, got, `"maxCacheMisses":${GH_AW_MAX_TURN_CACHE_MISSES}`) +} + +func TestInjectMaxTurnCacheMissesExpressionWithInvalidValueLeavesJSONUnchanged(t *testing.T) { + configJSON := `{"apiProxy":{"maxCacheMisses":null}}` + + assert.JSONEq(t, configJSON, injectMaxTurnCacheMissesExpression(configJSON, "${GH_AW_MAX_TURN_CACHE_MISSES}")) +} + func TestApplyDefaultMaxAICreditsEnvToMapHandlesNilMap(t *testing.T) { assert.NotPanics(t, func() { applyDefaultMaxAICreditsEnvToMap(nil, nil) diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index f49a4b8c9e3..353edbcbd9c 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -17,15 +17,16 @@ import ( var awfHelpersLog = logger.New("workflow:awf_helpers") const ( - awfDockerHostVarName = "GH_AW_DOCKER_HOST" - awfToolCacheMountVarName = "GH_AW_TOOL_CACHE_MOUNT" - awfMaxAICreditsVarName = "GH_AW_MAX_AI_CREDITS" - awfConfigRuntimePathExpr = "${RUNNER_TEMP}/gh-aw/awf-config.json" - awfModelsJSONPathExpr = "/tmp/gh-aw/models.json" - awfArcDindRootPathExpr = "${RUNNER_TEMP}/gh-aw" - awfArcDindHomePathExpr = "${RUNNER_TEMP}/gh-aw/home" - awfArcDindProxyLogsDirExpr = "${RUNNER_TEMP}/gh-aw/sandbox/firewall/logs" - awfArcDindAuditDirExpr = "${RUNNER_TEMP}/gh-aw/sandbox/firewall/audit" + awfDockerHostVarName = "GH_AW_DOCKER_HOST" + awfToolCacheMountVarName = "GH_AW_TOOL_CACHE_MOUNT" + awfMaxAICreditsVarName = "GH_AW_MAX_AI_CREDITS" + awfMaxTurnCacheMissesVarName = "GH_AW_MAX_TURN_CACHE_MISSES" + awfConfigRuntimePathExpr = "${RUNNER_TEMP}/gh-aw/awf-config.json" + awfModelsJSONPathExpr = "/tmp/gh-aw/models.json" + awfArcDindRootPathExpr = "${RUNNER_TEMP}/gh-aw" + awfArcDindHomePathExpr = "${RUNNER_TEMP}/gh-aw/home" + awfArcDindProxyLogsDirExpr = "${RUNNER_TEMP}/gh-aw/sandbox/firewall/logs" + awfArcDindAuditDirExpr = "${RUNNER_TEMP}/gh-aw/sandbox/firewall/audit" // Bash regex used in [[ ... =~ ... ]] to detect TCP Docker hosts (ARC/DinD). // Any tcp:// DOCKER_HOST indicates the Docker daemon runs on a separate filesystem, // requiring --docker-host so AWF connects to the correct daemon. diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go index bede98ad489..7e7dc695e50 100644 --- a/pkg/workflow/compiler_orchestrator_engine.go +++ b/pkg/workflow/compiler_orchestrator_engine.go @@ -37,7 +37,7 @@ type engineSetupResult struct { func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, cleanPath string, content []byte, markdownDir string) (*engineSetupResult, error) { orchestratorEngineLog.Printf("Setting up engine and processing imports") engineSetting, engineConfig, model := c.ExtractEngineConfig(result.Frontmatter) - preservedMaxTurns, preservedMaxAICredits, preservedMaxRuns, preservedMaxTurnCacheMisses := extractEngineBudgetLimits(engineConfig) + preservedMaxTurns, preservedMaxAICredits, preservedMaxRuns, preservedMaxTurnCacheMisses, preservedMaxTurnCacheMissesExpression := extractEngineBudgetLimits(engineConfig) if err := c.validateAndRegisterInlineEngineConfig(engineConfig); err != nil { return nil, err } @@ -73,15 +73,16 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean return nil, err } engineConfig, model = c.applyEngineImportDefaults(engineImportDefaultsOptions{ - engineConfig: engineConfig, - model: model, - engineSetting: engineSetting, - importsResult: importsResult, - importedEngineDefinitions: importedEngineDefinitions, - preservedMaxTurns: preservedMaxTurns, - preservedMaxAICredits: preservedMaxAICredits, - preservedMaxRuns: preservedMaxRuns, - preservedMaxTurnCacheMisses: preservedMaxTurnCacheMisses, + engineConfig: engineConfig, + model: model, + engineSetting: engineSetting, + importsResult: importsResult, + importedEngineDefinitions: importedEngineDefinitions, + preservedMaxTurns: preservedMaxTurns, + preservedMaxAICredits: preservedMaxAICredits, + preservedMaxRuns: preservedMaxRuns, + preservedMaxTurnCacheMisses: preservedMaxTurnCacheMisses, + preservedMaxTurnCacheMissesExpression: preservedMaxTurnCacheMissesExpression, }) agenticEngine, configSteps, err := c.resolveEngineRuntimeConfig(engineSetting, engineConfig) if err != nil { @@ -122,11 +123,11 @@ func frontmatterDeclaresImports(frontmatter map[string]any) bool { return false } -func extractEngineBudgetLimits(engineConfig *EngineConfig) (string, int64, int, int) { +func extractEngineBudgetLimits(engineConfig *EngineConfig) (string, int64, int, int, string) { if engineConfig == nil { - return "", 0, 0, 0 + return "", 0, 0, 0, "" } - return engineConfig.MaxTurns, engineConfig.MaxAICredits, engineConfig.MaxRuns, engineConfig.MaxTurnCacheMisses + return engineConfig.MaxTurns, engineConfig.MaxAICredits, engineConfig.MaxRuns, engineConfig.MaxTurnCacheMisses, engineConfig.MaxTurnCacheMissesExpression } func defaultNetworkPermissions(networkPermissions *NetworkPermissions) *NetworkPermissions { @@ -355,15 +356,16 @@ func (c *Compiler) resolveEngineFromIncludesAndImports( } type engineImportDefaultsOptions struct { - engineConfig *EngineConfig - model string - engineSetting string - importsResult *parser.ImportsResult - importedEngineDefinitions []string - preservedMaxTurns string - preservedMaxAICredits int64 - preservedMaxRuns int - preservedMaxTurnCacheMisses int + engineConfig *EngineConfig + model string + engineSetting string + importsResult *parser.ImportsResult + importedEngineDefinitions []string + preservedMaxTurns string + preservedMaxAICredits int64 + preservedMaxRuns int + preservedMaxTurnCacheMisses int + preservedMaxTurnCacheMissesExpression string } // applyEngineImportDefaults merges import-derived engine defaults into engineConfig. @@ -388,6 +390,9 @@ func (c *Compiler) applyEngineImportDefaults(opts engineImportDefaultsOptions) ( if opts.preservedMaxTurnCacheMisses > 0 { engineConfig.MaxTurnCacheMisses = opts.preservedMaxTurnCacheMisses } + if opts.preservedMaxTurnCacheMissesExpression != "" { + engineConfig.MaxTurnCacheMissesExpression = opts.preservedMaxTurnCacheMissesExpression + } if engineConfig.MaxTurns == "" && opts.importsResult.MergedMaxTurns != "" { var importedMaxTurns any if err := json.Unmarshal([]byte(opts.importsResult.MergedMaxTurns), &importedMaxTurns); err == nil { @@ -424,12 +429,15 @@ func (c *Compiler) applyEngineImportDefaults(opts engineImportDefaultsOptions) ( } } } - if engineConfig.MaxTurnCacheMisses <= 0 && opts.importsResult.MergedMaxTurnCacheMisses != "" { + if engineConfig.MaxTurnCacheMisses <= 0 && engineConfig.MaxTurnCacheMissesExpression == "" && opts.importsResult.MergedMaxTurnCacheMisses != "" { var importedMaxTurnCacheMisses any if err := json.Unmarshal([]byte(opts.importsResult.MergedMaxTurnCacheMisses), &importedMaxTurnCacheMisses); err == nil { if parsed := parseMaxTurnCacheMissesValue(importedMaxTurnCacheMisses); parsed > 0 { engineConfig.MaxTurnCacheMisses = parsed orchestratorEngineLog.Printf("Applied max-turn-cache-misses from import") + } else if expression := parseMaxTurnCacheMissesExpression(importedMaxTurnCacheMisses); expression != "" { + engineConfig.MaxTurnCacheMissesExpression = expression + orchestratorEngineLog.Printf("Applied max-turn-cache-misses expression from import") } } } diff --git a/pkg/workflow/compiler_orchestrator_engine_test.go b/pkg/workflow/compiler_orchestrator_engine_test.go index 873ed3d6fe0..49088c0d1ea 100644 --- a/pkg/workflow/compiler_orchestrator_engine_test.go +++ b/pkg/workflow/compiler_orchestrator_engine_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/stringutil" "github.com/github/gh-aw/pkg/testutil" "github.com/github/gh-aw/pkg/workflow/compilerenv" "github.com/stretchr/testify/assert" @@ -299,6 +300,36 @@ imports: assert.Equal(t, 7, result.engineConfig.MaxTurnCacheMisses) } +func TestCompileWorkflowAllowsImportedMaxTurnCacheMissesExpression(t *testing.T) { + tmpDir := testutil.TempDir(t, "engine-imported-max-turn-cache-misses-expression") + + sharedDir := filepath.Join(tmpDir, "shared") + require.NoError(t, os.MkdirAll(sharedDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "common.md"), []byte(`--- +max-turn-cache-misses: "${{ inputs.max_turn_cache_misses }}" +--- + +# Shared Workflow +`), 0644)) + + testFile := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(testFile, []byte(`--- +on: push +engine: copilot +imports: + - shared/common.md +--- + +# Test Workflow +`), 0644)) + + require.NoError(t, NewCompiler().CompileWorkflow(testFile)) + lockContent, err := os.ReadFile(stringutil.MarkdownToLockFile(testFile)) + require.NoError(t, err) + assert.Contains(t, string(lockContent), "GH_AW_MAX_TURN_CACHE_MISSES: ${{ inputs.max_turn_cache_misses }}") + assert.Contains(t, string(lockContent), "${GH_AW_MAX_TURN_CACHE_MISSES}") +} + // TestSetupEngineAndImports_ImportedEngineVersionDefault verifies that a shared/imported // engine definition's top-level `version` field is applied as the default // EngineConfig.Version when the workflow's own `engine:` frontmatter selects the same diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 37e54288743..2c9167a34e4 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -42,29 +42,30 @@ func toEngineEnvValueString(value any) (string, bool) { // EngineConfig represents the parsed engine configuration type EngineConfig struct { - ID string - Version string - LLMProvider LLMProvider // Inference provider override for this engine (engine.provider / engine.model-provider) - PermissionMode string - MaxTurns string - MaxToolDenials string // Maximum repeated tool denials before stopping inference (copilot SDK mode only) - MaxRuns int // Maximum number of LLM invocations per run (AWF apiProxy.maxRuns) - MaxTurnCacheMisses int // Maximum number of consecutive cache misses per run (AWF apiProxy.maxCacheMisses) - MaxContinuations int // Maximum number of continuations for autopilot mode (copilot engine only; > 1 enables --autopilot) - MaxAICredits int64 // Maximum allowed AI credits per run for AWF apiProxy firewall enforcement - Concurrency string // Agent job-level concurrency configuration (YAML format) - UserAgent string - Command string // Custom executable path (when set, skip installation steps) - HarnessScript string // Custom Node.js harness script filename (replaces engine default harness script when supported) - Driver string // Custom driver script filename or command. For the copilot engine (engine.driver), supports .js/.cjs/.mjs (Node.js), .py (Python), .ts/.mts (TypeScript), .rb (Ruby), or a bare command name. For the pi engine (engine.driver), supports .js/.cjs/.mjs or a bare basename resolved from the setup-action directory. - InlineDriver *InlineEngineDriver - Env map[string]string - Auth *EngineAuthConfig // Engine-level auth config (mapped to AWF_AUTH_* env vars for API proxy sidecar auth) - Config string - Args []string - Agent string // Agent identifier for copilot --agent flag (copilot engine only) - APITarget string // Custom API endpoint hostname (e.g., "api.acme.ghe.com" or "api.enterprise.githubcopilot.com") - Bare bool // When true, disables automatic loading of context/instructions (copilot: --no-custom-instructions, claude: --bare, codex: --no-system-prompt, gemini: GEMINI_SYSTEM_MD=/dev/null) + ID string + Version string + LLMProvider LLMProvider // Inference provider override for this engine (engine.provider / engine.model-provider) + PermissionMode string + MaxTurns string + MaxToolDenials string // Maximum repeated tool denials before stopping inference (copilot SDK mode only) + MaxRuns int // Maximum number of LLM invocations per run (AWF apiProxy.maxRuns) + MaxTurnCacheMisses int // Maximum number of consecutive cache misses per run (AWF apiProxy.maxCacheMisses) + MaxTurnCacheMissesExpression string // GitHub Actions expression for AWF apiProxy.maxCacheMisses + MaxContinuations int // Maximum number of continuations for autopilot mode (copilot engine only; > 1 enables --autopilot) + MaxAICredits int64 // Maximum allowed AI credits per run for AWF apiProxy firewall enforcement + Concurrency string // Agent job-level concurrency configuration (YAML format) + UserAgent string + Command string // Custom executable path (when set, skip installation steps) + HarnessScript string // Custom Node.js harness script filename (replaces engine default harness script when supported) + Driver string // Custom driver script filename or command. For the copilot engine (engine.driver), supports .js/.cjs/.mjs (Node.js), .py (Python), .ts/.mts (TypeScript), .rb (Ruby), or a bare command name. For the pi engine (engine.driver), supports .js/.cjs/.mjs or a bare basename resolved from the setup-action directory. + InlineDriver *InlineEngineDriver + Env map[string]string + Auth *EngineAuthConfig // Engine-level auth config (mapped to AWF_AUTH_* env vars for API proxy sidecar auth) + Config string + Args []string + Agent string // Agent identifier for copilot --agent flag (copilot engine only) + APITarget string // Custom API endpoint hostname (e.g., "api.acme.ghe.com" or "api.enterprise.githubcopilot.com") + Bare bool // When true, disables automatic loading of context/instructions (copilot: --no-custom-instructions, claude: --bare, codex: --no-system-prompt, gemini: GEMINI_SYSTEM_MD=/dev/null) // Inline definition fields (populated when engine.runtime is specified in frontmatter) IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic") @@ -183,12 +184,13 @@ type EngineNetworkConfig struct { } type engineTopLevelConfig struct { - maxTurns string - maxToolDenials string - maxAICredits int64 - maxTurnCacheMisses int - maxRuns int - model string + maxTurns string + maxToolDenials string + maxAICredits int64 + maxTurnCacheMisses int + maxTurnCacheMissesExpression string + maxRuns int + model string } // GetMaxAICredits returns the configured engine AI credits budget, falling back to the default. @@ -236,11 +238,12 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng func parseTopLevelEngineConfig(frontmatter map[string]any) engineTopLevelConfig { topLevel := engineTopLevelConfig{ - maxTurns: parseMaxTurnsValue(frontmatter["max-turns"]), - maxToolDenials: parseMaxToolDenialsValue(frontmatter["max-tool-denials"]), - maxAICredits: parseMaxAICreditsValue(frontmatter["max-ai-credits"]), - maxTurnCacheMisses: parseMaxTurnCacheMissesValue(frontmatter["max-turn-cache-misses"]), - maxRuns: parseMaxRunsValue(frontmatter["max-turns"]), + maxTurns: parseMaxTurnsValue(frontmatter["max-turns"]), + maxToolDenials: parseMaxToolDenialsValue(frontmatter["max-tool-denials"]), + maxAICredits: parseMaxAICreditsValue(frontmatter["max-ai-credits"]), + maxTurnCacheMisses: parseMaxTurnCacheMissesValue(frontmatter["max-turn-cache-misses"]), + maxTurnCacheMissesExpression: parseMaxTurnCacheMissesExpression(frontmatter["max-turn-cache-misses"]), + maxRuns: parseMaxRunsValue(frontmatter["max-turns"]), } if topLevel.maxRuns == 0 { topLevel.maxRuns = parseMaxRunsValue(frontmatter["max-runs"]) @@ -252,12 +255,13 @@ func parseTopLevelEngineConfig(frontmatter map[string]any) engineTopLevelConfig func extractStringEngineConfig(engineStr string, topLevel engineTopLevelConfig) (string, *EngineConfig, string) { engineLog.Printf("Found engine in string format: %s", engineStr) return engineStr, &EngineConfig{ - ID: engineStr, - MaxTurns: topLevel.maxTurns, - MaxToolDenials: topLevel.maxToolDenials, - MaxRuns: topLevel.maxRuns, - MaxTurnCacheMisses: topLevel.maxTurnCacheMisses, - MaxAICredits: topLevel.maxAICredits, + ID: engineStr, + MaxTurns: topLevel.maxTurns, + MaxToolDenials: topLevel.maxToolDenials, + MaxRuns: topLevel.maxRuns, + MaxTurnCacheMisses: topLevel.maxTurnCacheMisses, + MaxTurnCacheMissesExpression: topLevel.maxTurnCacheMissesExpression, + MaxAICredits: topLevel.maxAICredits, }, topLevel.model } @@ -327,6 +331,7 @@ func applyInlineEngineFields(config *EngineConfig, engineObj map[string]any, top config.MaxToolDenials = topLevel.maxToolDenials config.MaxRuns = topLevel.maxRuns config.MaxTurnCacheMisses = topLevel.maxTurnCacheMisses + config.MaxTurnCacheMissesExpression = topLevel.maxTurnCacheMissesExpression config.MaxAICredits = topLevel.maxAICredits } @@ -635,18 +640,20 @@ func applyEngineTopLevelOverrides(config *EngineConfig, topLevel engineTopLevelC } config.MaxRuns = topLevel.maxRuns config.MaxTurnCacheMisses = topLevel.maxTurnCacheMisses + config.MaxTurnCacheMissesExpression = topLevel.maxTurnCacheMissesExpression config.MaxAICredits = topLevel.maxAICredits } func buildTopLevelOnlyEngineConfig(topLevel engineTopLevelConfig) (string, *EngineConfig, string) { if topLevel.maxTurns != "" || topLevel.maxToolDenials != "" || topLevel.maxAICredits != 0 || - topLevel.maxRuns > 0 || topLevel.maxTurnCacheMisses > 0 || topLevel.model != "" { + topLevel.maxRuns > 0 || topLevel.maxTurnCacheMisses > 0 || topLevel.maxTurnCacheMissesExpression != "" || topLevel.model != "" { return "", &EngineConfig{ - MaxTurns: topLevel.maxTurns, - MaxToolDenials: topLevel.maxToolDenials, - MaxRuns: topLevel.maxRuns, - MaxTurnCacheMisses: topLevel.maxTurnCacheMisses, - MaxAICredits: topLevel.maxAICredits, + MaxTurns: topLevel.maxTurns, + MaxToolDenials: topLevel.maxToolDenials, + MaxRuns: topLevel.maxRuns, + MaxTurnCacheMisses: topLevel.maxTurnCacheMisses, + MaxTurnCacheMissesExpression: topLevel.maxTurnCacheMissesExpression, + MaxAICredits: topLevel.maxAICredits, }, topLevel.model } engineLog.Print("No engine configuration found in frontmatter") diff --git a/pkg/workflow/engine_config_parser.go b/pkg/workflow/engine_config_parser.go index 2544a782262..bb6193ab728 100644 --- a/pkg/workflow/engine_config_parser.go +++ b/pkg/workflow/engine_config_parser.go @@ -36,10 +36,20 @@ func parseMaxTurnCacheMissesValue(raw any) int { return parsePositiveIntValue(raw, "max-turn-cache-misses") } +// parseMaxTurnCacheMissesExpression preserves a GitHub Actions expression for +// max-turn-cache-misses so it can be emitted into the generated workflow. +func parseMaxTurnCacheMissesExpression(raw any) string { + value := parseIntOrExpressionValue(raw, 1, "max-turn-cache-misses") + if isExpression(value) { + return value + } + return "" +} + // parsePositiveIntValue parses a strictly-positive integer from raw. // Delegates to parseIntOrExpressionValue for a single int-validation path. -// GitHub Actions expression strings (e.g. "${{ inputs.value }}") are silently -// treated as 0 (not configured) because these fields are integer-only. +// GitHub Actions expression strings (e.g. "${{ inputs.value }}") are accepted +// but cannot be resolved to an integer at compile time, so they return 0. func parsePositiveIntValue(raw any, fieldName string) int { s := parseIntOrExpressionValue(raw, 1, fieldName) if s == "" || isExpression(s) { diff --git a/pkg/workflow/engine_config_parser_test.go b/pkg/workflow/engine_config_parser_test.go index a1223ae6f11..d6eaa733e98 100644 --- a/pkg/workflow/engine_config_parser_test.go +++ b/pkg/workflow/engine_config_parser_test.go @@ -2,7 +2,11 @@ package workflow -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestParsePositiveIntValues(t *testing.T) { t.Parallel() @@ -22,6 +26,7 @@ func TestParsePositiveIntValues(t *testing.T) { {name: "max-turn-cache-misses int", parse: parseMaxTurnCacheMissesValue, raw: 2, expected: 2}, {name: "max-turn-cache-misses string", parse: parseMaxTurnCacheMissesValue, raw: "7", expected: 7}, {name: "max-turn-cache-misses invalid", parse: parseMaxTurnCacheMissesValue, raw: "bad", expected: 0}, + {name: "max-turn-cache-misses expression invalid", parse: parseMaxTurnCacheMissesValue, raw: "${{ inputs.max_turn_cache_misses }}", expected: 0}, } for _, tt := range tests { @@ -34,6 +39,14 @@ func TestParsePositiveIntValues(t *testing.T) { } } +func TestParseMaxTurnCacheMissesExpression(t *testing.T) { + t.Parallel() + + assert.Equal(t, "${{ inputs.max_turn_cache_misses }}", parseMaxTurnCacheMissesExpression(" ${{ inputs.max_turn_cache_misses }} ")) + assert.Empty(t, parseMaxTurnCacheMissesExpression(5)) + assert.Empty(t, parseMaxTurnCacheMissesExpression("invalid")) +} + func TestParseIntOrExpressionValues(t *testing.T) { t.Parallel() diff --git a/pkg/workflow/engine_helpers.go b/pkg/workflow/engine_helpers.go index 66181b958d9..ad81e2dccef 100644 --- a/pkg/workflow/engine_helpers.go +++ b/pkg/workflow/engine_helpers.go @@ -107,10 +107,15 @@ func engineEnvHasNonEmptyValue(workflowData *WorkflowData, key string) bool { // process_runner.cjs) and by shell-based engine command prefixes so the engine spawns in // the user-specified working directory rather than the default GITHUB_WORKSPACE. func applyEngineCwdEnv(env map[string]string, workflowData *WorkflowData) { - if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Cwd == "" { + if workflowData == nil || workflowData.EngineConfig == nil { return } - env["GH_AW_ENGINE_CWD"] = workflowData.EngineConfig.Cwd + if workflowData.EngineConfig.Cwd != "" { + env["GH_AW_ENGINE_CWD"] = workflowData.EngineConfig.Cwd + } + if workflowData.EngineConfig.MaxTurnCacheMissesExpression != "" { + env[awfMaxTurnCacheMissesVarName] = workflowData.EngineConfig.MaxTurnCacheMissesExpression + } } // applyEngineVersionEnv sets the GH_AW_ENGINE_VERSION environment variable in the given