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
17 changes: 14 additions & 3 deletions docs/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The YAML frontmatter supports standard GitHub Actions properties plus additional
- `engine`: AI engine configuration (claude/codex)
- `tools`: Available tools and MCP servers for the AI engine
- `stop-time`: Deadline when workflow should stop running (absolute or relative time)
- `max-turns`: Maximum number of chat iterations per run
- `alias`: Alias name for the workflow
- `ai-reaction`: Emoji reaction to add/remove on triggering GitHub item
- `cache`: Cache configuration for workflow dependencies
Expand Down Expand Up @@ -186,14 +187,24 @@ engine:

## Cost Control Options

### Stop Time (`stop-time:`)
### Maximum Turns (`max-turns:`)

Automatically disable workflow after a deadline. Supports both absolute timestamps and relative time deltas:
Limit the number of chat iterations within a single agentic run:

**Absolute time (multiple formats supported):**
```yaml
max-turns: 5
```

**Behavior:**
1. Passes the limit to the AI engine (e.g., Claude Code action)
2. Engine stops iterating when the turn limit is reached
3. Helps prevent runaway chat loops and control costs
4. Only applies to engines that support turn limiting (currently Claude)

### Stop Time (`stop-time:`)

Automatically disable workflow after a deadline:

**Relative time delta (calculated from compilation time):**
```yaml
stop-time: "+25h" # 25 hours from now
Expand Down
4 changes: 3 additions & 1 deletion pkg/cli/templates/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ The YAML frontmatter supports these fields:
- `claude:` - Claude-specific tools
- Custom tool names for MCP servers

- **`max-turns:`** - Maximum chat iterations per run (integer)
- **`stop-time:`** - Deadline for workflow. Can be absolute timestamp ("YYYY-MM-DD HH:MM:SS") or relative delta (+25h, +3d, +1d12h30m)
- **`alias:`** - Alternative workflow name (string)
- **`cache:`** - Cache configuration for workflow dependencies (object or array)
Expand Down Expand Up @@ -444,7 +445,8 @@ Agentic workflows compile to GitHub Actions YAML:
5. **Test with `gh aw compile`** before committing
6. **Review generated `.lock.yml`** files before deploying
7. **Set `stop-time`** for cost-sensitive workflows
8. **Use specific tool permissions** rather than broad access
8. **Set `max-turns`** to limit chat iterations and prevent runaway loops
9. **Use specific tool permissions** rather than broad access

## Validation

Expand Down
4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@
]
}
},
"max-turns": {
"type": "integer",
"description": "Maximum number of chat iterations per run"
},
"stop-time": {
"type": "string",
"description": "Time when workflow should stop running. Supports multiple formats: absolute dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.) or relative time deltas (+25h, +3d, +1d12h30m)"
Expand Down
8 changes: 8 additions & 0 deletions pkg/workflow/agentic_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ type AgenticEngine interface {
// SupportsHTTPTransport returns true if this engine supports HTTP transport for MCP servers
SupportsHTTPTransport() bool

// SupportsMaxTurns returns true if this engine supports the max-turns feature
SupportsMaxTurns() bool

// GetInstallationSteps returns the GitHub Actions steps needed to install this engine
GetInstallationSteps(engineConfig *EngineConfig) []GitHubActionStep

Expand Down Expand Up @@ -68,6 +71,7 @@ type BaseEngine struct {
experimental bool
supportsToolsWhitelist bool
supportsHTTPTransport bool
supportsMaxTurns bool
}

func (e *BaseEngine) GetID() string {
Expand All @@ -94,6 +98,10 @@ func (e *BaseEngine) SupportsHTTPTransport() bool {
return e.supportsHTTPTransport
}

func (e *BaseEngine) SupportsMaxTurns() bool {
return e.supportsMaxTurns
}

// EngineRegistry manages available agentic engines
type EngineRegistry struct {
engines map[string]AgenticEngine
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/claude_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func NewClaudeEngine() *ClaudeEngine {
experimental: false,
supportsToolsWhitelist: true,
supportsHTTPTransport: true, // Claude supports both stdio and HTTP transport
supportsMaxTurns: true, // Claude supports max-turns feature
},
}
}
Expand All @@ -51,6 +52,7 @@ func (e *ClaudeEngine) GetExecutionConfig(workflowName string, logFile string, e
"claude_env": "|\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
"allowed_tools": "", // Will be filled in during generation
"timeout_minutes": "", // Will be filled in during generation
"max_turns": "", // Will be filled in during generation
},
Environment: map[string]string{
"GH_TOKEN": "${{ secrets.GITHUB_TOKEN }}",
Expand Down
6 changes: 5 additions & 1 deletion pkg/workflow/claude_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ func TestClaudeEngine(t *testing.T) {
t.Error("Expected timeout_minutes input to be present")
}

if _, hasMaxTurns := config.Inputs["max_turns"]; !hasMaxTurns {
t.Error("Expected max_turns input to be present")
}

// Check environment variables
if config.Environment["GH_TOKEN"] != "${{ secrets.GITHUB_TOKEN }}" {
t.Errorf("Expected GH_TOKEN environment variable, got '%s'", config.Environment["GH_TOKEN"])
Expand Down Expand Up @@ -109,7 +113,7 @@ func TestClaudeEngineConfiguration(t *testing.T) {
}

// Verify all required inputs are present
requiredInputs := []string{"prompt_file", "anthropic_api_key", "mcp_config", "claude_env", "allowed_tools", "timeout_minutes"}
requiredInputs := []string{"prompt_file", "anthropic_api_key", "mcp_config", "claude_env", "allowed_tools", "timeout_minutes", "max_turns"}
for _, input := range requiredInputs {
if _, exists := config.Inputs[input]; !exists {
t.Errorf("Expected input '%s' to be present", input)
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/codex_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func NewCodexEngine() *CodexEngine {
experimental: true,
supportsToolsWhitelist: true,
supportsHTTPTransport: false, // Codex only supports stdio transport
supportsMaxTurns: false, // Codex does not support max-turns feature
},
}
}
Expand Down
31 changes: 31 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ type WorkflowData struct {
AllowedTools string
AI string // "claude" or "codex" (for backwards compatibility)
EngineConfig *EngineConfig // Extended engine configuration
MaxTurns string
StopTime string
Alias string // for @alias trigger support
AliasOtherEvents map[string]any // for merging alias with other events
Expand Down Expand Up @@ -593,6 +594,11 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error)
}
}

// Validate max-turns support for the current engine
if err := c.validateMaxTurnsSupport(result.Frontmatter, agenticEngine); err != nil {
return nil, fmt.Errorf("max-turns not supported: %w", err)
}

// Process @include directives in markdown content
markdownContent, err := parser.ExpandIncludes(result.Markdown, markdownDir, false)
if err != nil {
Expand Down Expand Up @@ -639,6 +645,7 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error)
workflowData.PostSteps = c.extractTopLevelYAMLSection(result.Frontmatter, "post-steps")
workflowData.RunsOn = c.extractTopLevelYAMLSection(result.Frontmatter, "runs-on")
workflowData.Cache = c.extractTopLevelYAMLSection(result.Frontmatter, "cache")
workflowData.MaxTurns = c.extractYAMLValue(result.Frontmatter, "max-turns")
workflowData.StopTime = c.extractYAMLValue(result.Frontmatter, "stop-time")

// Resolve relative stop-time to absolute time if needed
Expand Down Expand Up @@ -2094,6 +2101,10 @@ func (c *Compiler) generateEngineExecutionSteps(yaml *strings.Builder, data *Wor
if data.TimeoutMinutes != "" {
yaml.WriteString(" " + data.TimeoutMinutes + "\n")
}
} else if key == "max_turns" {
if data.MaxTurns != "" {
yaml.WriteString(fmt.Sprintf(" max_turns: %s\n", data.MaxTurns))
}
} else if value != "" {
yaml.WriteString(fmt.Sprintf(" %s: %s\n", key, value))
}
Expand Down Expand Up @@ -2193,3 +2204,23 @@ func (c *Compiler) validateHTTPTransportSupport(tools map[string]any, engine Age

return nil
}

// validateMaxTurnsSupport validates that max-turns is only used with engines that support this feature
func (c *Compiler) validateMaxTurnsSupport(frontmatter map[string]any, engine AgenticEngine) error {
// Check if max-turns is specified in the frontmatter
_, hasMaxTurns := frontmatter["max-turns"]
if !hasMaxTurns {
// No max-turns specified, no validation needed
return nil
}

// max-turns is specified, check if the engine supports it
if !engine.SupportsMaxTurns() {
return fmt.Errorf("max-turns not supported: engine '%s' does not support the max-turns feature", engine.GetID())
}

// Engine supports max-turns - additional validation could be added here if needed
// For now, we rely on JSON schema validation for format checking

return nil
}
Loading