Skip to content

Commit 515c8ef

Browse files
authored
feat: parameterize tools.timeout and tools.startup-timeout to accept GitHub Actions expressions (#23888)
1 parent 9c30582 commit 515c8ef

15 files changed

Lines changed: 269 additions & 133 deletions

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4029,15 +4029,34 @@
40294029
]
40304030
},
40314031
"timeout": {
4032-
"type": "integer",
4033-
"minimum": 1,
4034-
"description": "Timeout in seconds for tool/MCP server operations. Applies to all tools and MCP servers if supported by the engine. Default varies by engine (Claude: 60s, Codex: 120s).",
4035-
"examples": [60, 120, 300]
4032+
"description": "Timeout in seconds for tool/MCP server operations. Applies to all tools and MCP servers if supported by the engine. Default: 60 seconds (for both Claude and Codex). Supports GitHub Actions expressions for reusable workflow_call workflows.",
4033+
"oneOf": [
4034+
{
4035+
"type": "integer",
4036+
"minimum": 1,
4037+
"examples": [60, 120, 300]
4038+
},
4039+
{
4040+
"type": "string",
4041+
"description": "GitHub Actions expression (e.g. '${{ inputs.tool-timeout }}')",
4042+
"pattern": "^\\$\\{\\{.*\\}\\}$"
4043+
}
4044+
]
40364045
},
40374046
"startup-timeout": {
4038-
"type": "integer",
4039-
"minimum": 1,
4040-
"description": "Timeout in seconds for MCP server startup. Applies to MCP server initialization if supported by the engine. Default: 120 seconds."
4047+
"description": "Timeout in seconds for MCP server startup. Applies to MCP server initialization if supported by the engine. Default: 120 seconds. Supports GitHub Actions expressions for reusable workflow_call workflows.",
4048+
"oneOf": [
4049+
{
4050+
"type": "integer",
4051+
"minimum": 1,
4052+
"examples": [30, 60, 120]
4053+
},
4054+
{
4055+
"type": "string",
4056+
"description": "GitHub Actions expression (e.g. '${{ inputs.startup-timeout }}')",
4057+
"pattern": "^\\$\\{\\{.*\\}\\}$"
4058+
}
4059+
]
40414060
},
40424061
"serena": {
40434062
"description": "REMOVED: Built-in support for Serena has been removed. Use the shared/mcp/serena.md workflow instead.",

pkg/workflow/claude_engine.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,15 +399,17 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
399399

400400
// Set timeout environment variables for Claude Code
401401
// Use tools.startup-timeout if specified, otherwise default to DefaultMCPStartupTimeout
402+
// For expressions, fall back to default (can't compute ms value at compile time)
402403
startupTimeoutMs := int(constants.DefaultMCPStartupTimeout / time.Millisecond)
403-
if workflowData.ToolsStartupTimeout > 0 {
404-
startupTimeoutMs = workflowData.ToolsStartupTimeout * 1000 // convert seconds to milliseconds
404+
if n := templatableIntValue(&workflowData.ToolsStartupTimeout); n > 0 {
405+
startupTimeoutMs = n * 1000 // convert seconds to milliseconds
405406
}
406407

407408
// Use tools.timeout if specified, otherwise default to DefaultToolTimeout
409+
// For expressions, fall back to default (can't compute ms value at compile time)
408410
timeoutMs := int(constants.DefaultToolTimeout / time.Millisecond)
409-
if workflowData.ToolsTimeout > 0 {
410-
timeoutMs = workflowData.ToolsTimeout * 1000 // convert seconds to milliseconds
411+
if n := templatableIntValue(&workflowData.ToolsTimeout); n > 0 {
412+
timeoutMs = n * 1000 // convert seconds to milliseconds
411413
}
412414

413415
env["MCP_TIMEOUT"] = strconv.Itoa(startupTimeoutMs)
@@ -419,13 +421,15 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
419421
applySafeOutputEnvToMap(env, workflowData)
420422

421423
// Add GH_AW_STARTUP_TIMEOUT environment variable (in seconds) if startup-timeout is specified
422-
if workflowData.ToolsStartupTimeout > 0 {
423-
env["GH_AW_STARTUP_TIMEOUT"] = strconv.Itoa(workflowData.ToolsStartupTimeout)
424+
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.startup-timeout }}")
425+
if workflowData.ToolsStartupTimeout != "" {
426+
env["GH_AW_STARTUP_TIMEOUT"] = workflowData.ToolsStartupTimeout
424427
}
425428

426429
// Add GH_AW_TOOL_TIMEOUT environment variable (in seconds) if timeout is specified
427-
if workflowData.ToolsTimeout > 0 {
428-
env["GH_AW_TOOL_TIMEOUT"] = strconv.Itoa(workflowData.ToolsTimeout)
430+
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.tool-timeout }}")
431+
if workflowData.ToolsTimeout != "" {
432+
env["GH_AW_TOOL_TIMEOUT"] = workflowData.ToolsTimeout
429433
}
430434

431435
if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxTurns != "" {

pkg/workflow/codex_engine.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"maps"
66
"regexp"
77
"sort"
8-
"strconv"
98
"strings"
109

1110
"github.com/github/gh-aw/pkg/constants"
@@ -332,13 +331,15 @@ mkdir -p "$CODEX_HOME/logs"
332331
}
333332

334333
// Add GH_AW_STARTUP_TIMEOUT environment variable (in seconds) if startup-timeout is specified
335-
if workflowData.ToolsStartupTimeout > 0 {
336-
env["GH_AW_STARTUP_TIMEOUT"] = strconv.Itoa(workflowData.ToolsStartupTimeout)
334+
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.startup-timeout }}")
335+
if workflowData.ToolsStartupTimeout != "" {
336+
env["GH_AW_STARTUP_TIMEOUT"] = workflowData.ToolsStartupTimeout
337337
}
338338

339339
// Add GH_AW_TOOL_TIMEOUT environment variable (in seconds) if timeout is specified
340-
if workflowData.ToolsTimeout > 0 {
341-
env["GH_AW_TOOL_TIMEOUT"] = strconv.Itoa(workflowData.ToolsTimeout)
340+
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.tool-timeout }}")
341+
if workflowData.ToolsTimeout != "" {
342+
env["GH_AW_TOOL_TIMEOUT"] = workflowData.ToolsTimeout
342343
}
343344

344345
// Set the model environment variable.

pkg/workflow/compiler_orchestrator_tools.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ var orchestratorToolsLog = logger.New("workflow:compiler_orchestrator_tools")
1717
type toolsProcessingResult struct {
1818
tools map[string]any
1919
runtimes map[string]any
20-
toolsTimeout int
21-
toolsStartupTimeout int
20+
toolsTimeout string
21+
toolsStartupTimeout string
2222
markdownContent string
2323
importedMarkdown string // Only imports WITH inputs (for compile-time substitution)
2424
importPaths []string // Import paths for runtime-import macro generation (imports without inputs)

pkg/workflow/compiler_orchestrator_tools_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ tools:
252252
require.NoError(t, err)
253253
require.NotNil(t, result)
254254

255-
assert.Equal(t, 600, result.toolsTimeout, "Tools timeout should be extracted")
255+
assert.Equal(t, "600", result.toolsTimeout, "Tools timeout should be extracted")
256256
}
257257

258258
// TestProcessToolsAndMarkdown_StartupTimeout tests startup timeout extraction
@@ -296,7 +296,7 @@ tools:
296296
require.NoError(t, err)
297297
require.NotNil(t, result)
298298

299-
assert.Equal(t, 120, result.toolsStartupTimeout, "Startup timeout should be extracted")
299+
assert.Equal(t, "120", result.toolsStartupTimeout, "Startup timeout should be extracted")
300300
}
301301

302302
// TestProcessToolsAndMarkdown_InvalidTimeout tests invalid timeout values

pkg/workflow/compiler_orchestrator_workflow_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ func TestBuildInitialWorkflowData_BasicFields(t *testing.T) {
3838
markdownContent: "Full markdown content",
3939
tools: map[string]any{"bash": []string{"echo"}},
4040
runtimes: map[string]any{"node": "18"},
41-
toolsTimeout: 300,
42-
toolsStartupTimeout: 60,
41+
toolsTimeout: "300",
42+
toolsStartupTimeout: "60",
4343
needsTextOutput: true,
4444
safeOutputs: &SafeOutputsConfig{},
4545
secretMasking: &SecretMaskingConfig{},
@@ -80,8 +80,8 @@ func TestBuildInitialWorkflowData_BasicFields(t *testing.T) {
8080
assert.NotNil(t, workflowData.ParsedTools)
8181
assert.NotNil(t, workflowData.NetworkPermissions)
8282
assert.NotNil(t, workflowData.SandboxConfig)
83-
assert.Equal(t, 300, workflowData.ToolsTimeout)
84-
assert.Equal(t, 60, workflowData.ToolsStartupTimeout)
83+
assert.Equal(t, "300", workflowData.ToolsTimeout)
84+
assert.Equal(t, "60", workflowData.ToolsStartupTimeout)
8585
assert.True(t, workflowData.NeedsTextOutput)
8686
assert.Equal(t, "agent.md", workflowData.AgentFile)
8787
}
@@ -1396,8 +1396,8 @@ func TestBuildInitialWorkflowData_FieldMapping(t *testing.T) {
13961396
workflowName: "Test Name",
13971397
frontmatterName: "Frontmatter Name",
13981398
trackerID: "TRK-001",
1399-
toolsTimeout: 500,
1400-
toolsStartupTimeout: 100,
1399+
toolsTimeout: "500",
1400+
toolsStartupTimeout: "100",
14011401
needsTextOutput: true,
14021402
markdownContent: "# Content",
14031403
importedMarkdown: "Imported",
@@ -1428,8 +1428,8 @@ func TestBuildInitialWorkflowData_FieldMapping(t *testing.T) {
14281428
assert.Equal(t, "Test Name", workflowData.Name)
14291429
assert.Equal(t, "Frontmatter Name", workflowData.FrontmatterName)
14301430
assert.Equal(t, "TRK-001", workflowData.TrackerID)
1431-
assert.Equal(t, 500, workflowData.ToolsTimeout)
1432-
assert.Equal(t, 100, workflowData.ToolsStartupTimeout)
1431+
assert.Equal(t, "500", workflowData.ToolsTimeout)
1432+
assert.Equal(t, "100", workflowData.ToolsStartupTimeout)
14331433
assert.True(t, workflowData.NeedsTextOutput)
14341434
assert.Equal(t, "# Content", workflowData.MarkdownContent)
14351435
assert.Equal(t, "Imported", workflowData.ImportedMarkdown)

pkg/workflow/compiler_types.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,8 @@ type WorkflowData struct {
416416
RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration
417417
QmdConfig *QmdToolConfig // parsed qmd tool configuration (docs globs)
418418
Runtimes map[string]any // runtime version overrides from frontmatter
419-
ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default)
420-
ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default)
419+
ToolsTimeout string // timeout for tool/MCP operations: numeric string (seconds) or GitHub Actions expression (empty = use engine default)
420+
ToolsStartupTimeout string // timeout for MCP server startup: numeric string (seconds) or GitHub Actions expression (empty = use engine default)
421421
Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values)
422422
ActionCache *ActionCache // cache for action pin resolutions
423423
ActionResolver *ActionResolver // resolver for action pins

pkg/workflow/copilot_engine_execution.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,15 @@ COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"
309309
applySafeOutputEnvToMap(env, workflowData)
310310

311311
// Add GH_AW_STARTUP_TIMEOUT environment variable (in seconds) if startup-timeout is specified
312-
if workflowData.ToolsStartupTimeout > 0 {
313-
env["GH_AW_STARTUP_TIMEOUT"] = strconv.Itoa(workflowData.ToolsStartupTimeout)
312+
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.startup-timeout }}")
313+
if workflowData.ToolsStartupTimeout != "" {
314+
env["GH_AW_STARTUP_TIMEOUT"] = workflowData.ToolsStartupTimeout
314315
}
315316

316317
// Add GH_AW_TOOL_TIMEOUT environment variable (in seconds) if timeout is specified
317-
if workflowData.ToolsTimeout > 0 {
318-
env["GH_AW_TOOL_TIMEOUT"] = strconv.Itoa(workflowData.ToolsTimeout)
318+
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.tool-timeout }}")
319+
if workflowData.ToolsTimeout != "" {
320+
env["GH_AW_TOOL_TIMEOUT"] = workflowData.ToolsTimeout
319321
}
320322

321323
if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxTurns != "" {

pkg/workflow/frontmatter_extraction_metadata.go

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package workflow
33
import (
44
"fmt"
55
"maps"
6+
"strconv"
67
"strings"
78

89
"github.com/github/gh-aw/pkg/logger"
@@ -144,18 +145,27 @@ func buildSourceURL(source string) string {
144145
}
145146

146147
// extractToolsTimeout extracts the timeout setting from tools
147-
// Returns 0 if not set (engines will use their own defaults)
148-
// Returns error if timeout is explicitly set but invalid (< 1)
149-
func (c *Compiler) extractToolsTimeout(tools map[string]any) (int, error) {
148+
// Returns "" if not set (engines will use their own defaults)
149+
// Returns error if timeout is explicitly set but invalid (< 1 for literals, or non-expression string)
150+
func (c *Compiler) extractToolsTimeout(tools map[string]any) (string, error) {
150151
if tools == nil {
151-
return 0, nil // Use engine defaults
152+
return "", nil // Use engine defaults
152153
}
153154

154155
// Check if timeout is explicitly set in tools
155156
if timeoutValue, exists := tools["timeout"]; exists {
156157
frontmatterMetadataLog.Printf("Extracting tools.timeout value: type=%T", timeoutValue)
157-
var timeout int
158+
// Handle GitHub Actions expression strings
159+
if strVal, ok := timeoutValue.(string); ok {
160+
if isExpressionString(strVal) {
161+
frontmatterMetadataLog.Printf("Extracted tools.timeout as expression: %s", strVal)
162+
return strVal, nil
163+
}
164+
frontmatterMetadataLog.Printf("Invalid tools.timeout string (not an expression): %s", strVal)
165+
return "", fmt.Errorf("tools.timeout must be an integer or a GitHub Actions expression (e.g. '${{ inputs.tool-timeout }}'), got string %q", strVal)
166+
}
158167
// Handle different numeric types with safe conversions to prevent overflow
168+
var timeout int
159169
switch v := timeoutValue.(type) {
160170
case int:
161171
timeout = v
@@ -169,33 +179,40 @@ func (c *Compiler) extractToolsTimeout(tools map[string]any) (int, error) {
169179
timeout = int(v)
170180
default:
171181
frontmatterMetadataLog.Printf("Invalid tools.timeout type: %T", timeoutValue)
172-
return 0, fmt.Errorf("tools.timeout must be an integer, got %T", timeoutValue)
182+
return "", fmt.Errorf("tools.timeout must be an integer or a GitHub Actions expression, got %T", timeoutValue)
173183
}
174184

175185
// Validate minimum value per schema constraint
176186
if timeout < 1 {
177187
frontmatterMetadataLog.Printf("Invalid tools.timeout value: %d (must be >= 1)", timeout)
178-
return 0, fmt.Errorf("tools.timeout must be at least 1 second, got %d. Example:\ntools:\n timeout: 60", timeout)
188+
return "", fmt.Errorf("tools.timeout must be at least 1 second, got %d. Example:\ntools:\n timeout: 60", timeout)
179189
}
180190

181191
frontmatterMetadataLog.Printf("Extracted tools.timeout: %d seconds", timeout)
182-
return timeout, nil
192+
return strconv.Itoa(timeout), nil
183193
}
184194

185-
// Default to 0 (use engine defaults)
186-
return 0, nil
195+
// Default to "" (use engine defaults)
196+
return "", nil
187197
}
188198

189199
// extractToolsStartupTimeout extracts the startup-timeout setting from tools
190-
// Returns 0 if not set (engines will use their own defaults)
191-
// Returns error if startup-timeout is explicitly set but invalid (< 1)
192-
func (c *Compiler) extractToolsStartupTimeout(tools map[string]any) (int, error) {
200+
// Returns "" if not set (engines will use their own defaults)
201+
// Returns error if startup-timeout is explicitly set but invalid (< 1 for literals, or non-expression string)
202+
func (c *Compiler) extractToolsStartupTimeout(tools map[string]any) (string, error) {
193203
if tools == nil {
194-
return 0, nil // Use engine defaults
204+
return "", nil // Use engine defaults
195205
}
196206

197207
// Check if startup-timeout is explicitly set in tools
198208
if timeoutValue, exists := tools["startup-timeout"]; exists {
209+
// Handle GitHub Actions expression strings
210+
if strVal, ok := timeoutValue.(string); ok {
211+
if isExpressionString(strVal) {
212+
return strVal, nil
213+
}
214+
return "", fmt.Errorf("tools.startup-timeout must be an integer or a GitHub Actions expression (e.g. '${{ inputs.startup-timeout }}'), got string %q", strVal)
215+
}
199216
var timeout int
200217
// Handle different numeric types with safe conversions to prevent overflow
201218
switch v := timeoutValue.(type) {
@@ -210,19 +227,19 @@ func (c *Compiler) extractToolsStartupTimeout(tools map[string]any) (int, error)
210227
case float64:
211228
timeout = int(v)
212229
default:
213-
return 0, fmt.Errorf("tools.startup-timeout must be an integer, got %T", timeoutValue)
230+
return "", fmt.Errorf("tools.startup-timeout must be an integer or a GitHub Actions expression, got %T", timeoutValue)
214231
}
215232

216233
// Validate minimum value per schema constraint
217234
if timeout < 1 {
218-
return 0, fmt.Errorf("tools.startup-timeout must be at least 1 second, got %d. Example:\ntools:\n startup-timeout: 120", timeout)
235+
return "", fmt.Errorf("tools.startup-timeout must be at least 1 second, got %d. Example:\ntools:\n startup-timeout: 120", timeout)
219236
}
220237

221-
return timeout, nil
238+
return strconv.Itoa(timeout), nil
222239
}
223240

224-
// Default to 0 (use engine defaults)
225-
return 0, nil
241+
// Default to "" (use engine defaults)
242+
return "", nil
226243
}
227244

228245
// extractToolsFromFrontmatter extracts tools section from frontmatter map

0 commit comments

Comments
 (0)