-
Notifications
You must be signed in to change notification settings - Fork 391
Expand file tree
/
Copy pathagentic_engine.go
More file actions
498 lines (422 loc) · 17.8 KB
/
agentic_engine.go
File metadata and controls
498 lines (422 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
package workflow
import (
"fmt"
"strings"
"sync"
"github.com/githubnext/gh-aw/pkg/constants"
"github.com/goccy/go-yaml"
)
// GitHubActionStep represents the YAML lines for a single step in a GitHub Actions workflow
type GitHubActionStep []string
// CodingAgentEngine represents an AI coding agent that can be used as an engine to execute agentic workflows
type CodingAgentEngine interface {
// GetID returns the unique identifier for this engine
GetID() string
// GetDisplayName returns the human-readable name for this engine
GetDisplayName() string
// GetDescription returns a description of this engine's capabilities
GetDescription() string
// IsExperimental returns true if this engine is experimental
IsExperimental() bool
// SupportsToolsAllowlist returns true if this engine supports MCP tool allow-listing
SupportsToolsAllowlist() bool
// 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
// SupportsWebFetch returns true if this engine has built-in support for the web-fetch tool
SupportsWebFetch() bool
// SupportsWebSearch returns true if this engine has built-in support for the web-search tool
SupportsWebSearch() bool
// GetDeclaredOutputFiles returns a list of output files that this engine may produce
// These files will be automatically uploaded as artifacts if they exist
GetDeclaredOutputFiles() []string
// GetInstallationSteps returns the GitHub Actions steps needed to install this engine
GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep
// GetExecutionSteps returns the GitHub Actions steps for executing this engine
GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep
// RenderMCPConfig renders the MCP configuration for this engine to the given YAML builder
RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData)
// ParseLogMetrics extracts metrics from engine-specific log content
ParseLogMetrics(logContent string, verbose bool) LogMetrics
// GetLogParserScriptId returns the name of the JavaScript script to parse logs for this engine
GetLogParserScriptId() string
// GetLogFileForParsing returns the log file path to use for JavaScript parsing in the workflow
// This may be different from the stdout/stderr log file if the engine produces separate detailed logs
GetLogFileForParsing() string
// GetErrorPatterns returns regex patterns for extracting error messages from logs
GetErrorPatterns() []ErrorPattern
// GetVersionCommand returns the command to get the version of the agent (e.g., "copilot --version")
// Returns empty string if the engine does not support version reporting
GetVersionCommand() string
// GetOIDCConfig returns the OIDC configuration for this engine
// Returns nil if the engine does not support OIDC or OIDC is not configured
GetOIDCConfig(workflowData *WorkflowData) *OIDCConfig
// GetTokenEnvVarName returns the environment variable name for API key authentication tokens
// For Claude: ANTHROPIC_API_KEY (used as fallback when OIDC fails)
// For Copilot: GITHUB_TOKEN
// For Codex: OPENAI_API_KEY
GetTokenEnvVarName() string
// GetOAuthTokenEnvVarName returns the environment variable name for OAuth tokens obtained via OIDC
// For Claude: CLAUDE_CODE_OAUTH_TOKEN
// For other engines: typically same as GetTokenEnvVarName()
GetOAuthTokenEnvVarName() string
}
// ErrorPattern represents a regex pattern for extracting error information from logs
type ErrorPattern struct {
// ID is a unique identifier for this error pattern
ID string `json:"id"`
// Pattern is the regular expression to match log lines
Pattern string `json:"pattern"`
// LevelGroup is the capture group index (1-based) that contains the error level (error, warning, etc.)
// If 0, the level will be inferred from the pattern name or content
LevelGroup int `json:"level_group"`
// MessageGroup is the capture group index (1-based) that contains the error message
// If 0, the entire match will be used as the message
MessageGroup int `json:"message_group"`
// Description is a human-readable description of what this pattern matches
Description string `json:"description"`
// Severity explicitly sets the level for this pattern, overriding inference
// Valid values: "error", "warning", or empty string (use inference)
Severity string `json:"severity,omitempty"`
}
// BaseEngine provides common functionality for agentic engines
type BaseEngine struct {
id string
displayName string
description string
experimental bool
supportsToolsAllowlist bool
supportsHTTPTransport bool
supportsMaxTurns bool
supportsWebFetch bool
supportsWebSearch bool
}
func (e *BaseEngine) GetID() string {
return e.id
}
func (e *BaseEngine) GetDisplayName() string {
return e.displayName
}
func (e *BaseEngine) GetDescription() string {
return e.description
}
func (e *BaseEngine) IsExperimental() bool {
return e.experimental
}
func (e *BaseEngine) SupportsToolsAllowlist() bool {
return e.supportsToolsAllowlist
}
func (e *BaseEngine) SupportsHTTPTransport() bool {
return e.supportsHTTPTransport
}
func (e *BaseEngine) SupportsMaxTurns() bool {
return e.supportsMaxTurns
}
func (e *BaseEngine) SupportsWebFetch() bool {
return e.supportsWebFetch
}
func (e *BaseEngine) SupportsWebSearch() bool {
return e.supportsWebSearch
}
// GetDeclaredOutputFiles returns an empty list by default (engines can override)
func (e *BaseEngine) GetDeclaredOutputFiles() []string {
return []string{}
}
// GetErrorPatterns returns an empty list by default (engines can override)
func (e *BaseEngine) GetErrorPatterns() []ErrorPattern {
return []ErrorPattern{}
}
// GetVersionCommand returns empty string by default (engines can override)
func (e *BaseEngine) GetVersionCommand() string {
return ""
}
// GetOIDCConfig returns nil by default (engines can override for OIDC support)
func (e *BaseEngine) GetOIDCConfig(workflowData *WorkflowData) *OIDCConfig {
return nil
}
// GetOIDCConfigWithDefault returns OIDC config from workflow data or falls back to default
// This helper method allows engines to provide default OIDC configurations
func (e *BaseEngine) GetOIDCConfigWithDefault(workflowData *WorkflowData, defaultConfig *OIDCConfig) *OIDCConfig {
// If explicit OIDC config is provided, use it
if workflowData.EngineConfig != nil && workflowData.EngineConfig.OIDC != nil && workflowData.EngineConfig.OIDC.TokenExchangeURL != "" {
return workflowData.EngineConfig.OIDC
}
// Return default OIDC configuration
return defaultConfig
}
// GetTokenEnvVarName returns the default API token environment variable name
// Engines should override this to return engine-specific values
func (e *BaseEngine) GetTokenEnvVarName() string {
return "GITHUB_TOKEN"
}
// GetOAuthTokenEnvVarName returns the default OAuth token environment variable name
// By default, uses the same as API token. Engines can override for different OAuth token variables.
func (e *BaseEngine) GetOAuthTokenEnvVarName() string {
return e.GetTokenEnvVarName()
}
// GetLogFileForParsing returns the default log file path for parsing
// Engines can override this to use engine-specific log files
func (e *BaseEngine) GetLogFileForParsing() string {
// Default to agent-stdio.log which contains stdout/stderr
return "/tmp/gh-aw/agent-stdio.log"
}
// EngineRegistry manages available agentic engines
type EngineRegistry struct {
engines map[string]CodingAgentEngine
}
var (
globalRegistry *EngineRegistry
registryInitOnce sync.Once
)
// NewEngineRegistry creates a new engine registry with built-in engines
func NewEngineRegistry() *EngineRegistry {
registry := &EngineRegistry{
engines: make(map[string]CodingAgentEngine),
}
// Register built-in engines
registry.Register(NewClaudeEngine())
registry.Register(NewCodexEngine())
registry.Register(NewCopilotEngine())
registry.Register(NewCustomEngine())
return registry
}
// GetGlobalEngineRegistry returns the singleton engine registry
func GetGlobalEngineRegistry() *EngineRegistry {
registryInitOnce.Do(func() {
globalRegistry = NewEngineRegistry()
})
return globalRegistry
}
// Register adds an engine to the registry
func (r *EngineRegistry) Register(engine CodingAgentEngine) {
r.engines[engine.GetID()] = engine
}
// GetEngine retrieves an engine by ID
func (r *EngineRegistry) GetEngine(id string) (CodingAgentEngine, error) {
engine, exists := r.engines[id]
if !exists {
return nil, fmt.Errorf("unknown engine: %s", id)
}
return engine, nil
}
// GetSupportedEngines returns a list of all supported engine IDs
func (r *EngineRegistry) GetSupportedEngines() []string {
var engines []string
for id := range r.engines {
engines = append(engines, id)
}
return engines
}
// IsValidEngine checks if an engine ID is valid
func (r *EngineRegistry) IsValidEngine(id string) bool {
_, exists := r.engines[id]
return exists
}
// GetDefaultEngine returns the default engine (Copilot)
func (r *EngineRegistry) GetDefaultEngine() CodingAgentEngine {
return r.engines["copilot"]
}
// GetEngineByPrefix returns an engine that matches the given prefix
// This is useful for backward compatibility with strings like "codex-experimental"
func (r *EngineRegistry) GetEngineByPrefix(prefix string) (CodingAgentEngine, error) {
for id, engine := range r.engines {
if strings.HasPrefix(prefix, id) {
return engine, nil
}
}
return nil, fmt.Errorf("no engine found matching prefix: %s", prefix)
}
// GenerateSecretValidationStep creates a GitHub Actions step that validates required secrets are available
// secretName: the name of the secret to validate (e.g., "ANTHROPIC_API_KEY")
// engineName: the display name of the engine (e.g., "Claude Code")
// docsURL: URL to the documentation page for setting up the secret
func GenerateSecretValidationStep(secretName, engineName, docsURL string) GitHubActionStep {
stepLines := []string{
fmt.Sprintf(" - name: Validate %s secret", secretName),
" run: |",
fmt.Sprintf(" if [ -z \"$%s\" ]; then", secretName),
fmt.Sprintf(" echo \"Error: %s secret is not set\"", secretName),
fmt.Sprintf(" echo \"The %s engine requires the %s secret to be configured.\"", engineName, secretName),
" echo \"Please configure this secret in your repository settings.\"",
fmt.Sprintf(" echo \"Documentation: %s\"", docsURL),
" exit 1",
" fi",
fmt.Sprintf(" echo \"%s secret is configured\"", secretName),
" env:",
fmt.Sprintf(" %s: ${{ secrets.%s }}", secretName, secretName),
}
return GitHubActionStep(stepLines)
}
// GenerateMultiSecretValidationStep creates a GitHub Actions step that validates at least one of multiple secrets is available
// secretNames: slice of secret names to validate (e.g., []string{"CODEX_API_KEY", "OPENAI_API_KEY"})
// engineName: the display name of the engine (e.g., "Codex")
// docsURL: URL to the documentation page for setting up the secret
func GenerateMultiSecretValidationStep(secretNames []string, engineName, docsURL string) GitHubActionStep {
if len(secretNames) == 0 {
panic("GenerateMultiSecretValidationStep requires at least one secret name")
}
// Build the step name
stepName := fmt.Sprintf(" - name: Validate %s secret", strings.Join(secretNames, " or "))
// Build the condition for checking if all secrets are empty
conditions := make([]string, len(secretNames))
for i, secretName := range secretNames {
conditions[i] = fmt.Sprintf("[ -z \"$%s\" ]", secretName)
}
allEmptyCondition := strings.Join(conditions, " && ")
// Build error message
var errorMsg string
if len(secretNames) == 2 {
errorMsg = fmt.Sprintf("Neither %s nor %s secret is set", secretNames[0], secretNames[1])
} else {
errorMsg = fmt.Sprintf("None of the following secrets are set: %s", strings.Join(secretNames, ", "))
}
requirementMsg := fmt.Sprintf("The %s engine requires either %s secret to be configured.", engineName, strings.Join(secretNames, " or "))
stepLines := []string{
stepName,
" run: |",
fmt.Sprintf(" if %s; then", allEmptyCondition),
fmt.Sprintf(" echo \"Error: %s\"", errorMsg),
fmt.Sprintf(" echo \"%s\"", requirementMsg),
" echo \"Please configure one of these secrets in your repository settings.\"",
fmt.Sprintf(" echo \"Documentation: %s\"", docsURL),
" exit 1",
" fi",
}
// Add conditional messages for each secret
for i, secretName := range secretNames {
if i == 0 {
stepLines = append(stepLines, fmt.Sprintf(" if [ -n \"$%s\" ]; then", secretName))
stepLines = append(stepLines, fmt.Sprintf(" echo \"%s secret is configured\"", secretName))
} else if i == len(secretNames)-1 {
stepLines = append(stepLines, " else")
if len(secretNames) == 2 {
stepLines = append(stepLines, fmt.Sprintf(" echo \"%s secret is configured (using as fallback for %s)\"", secretName, secretNames[0]))
} else {
stepLines = append(stepLines, fmt.Sprintf(" echo \"%s secret is configured\"", secretName))
}
} else {
stepLines = append(stepLines, fmt.Sprintf(" elif [ -n \"$%s\" ]; then", secretName))
stepLines = append(stepLines, fmt.Sprintf(" echo \"%s secret is configured\"", secretName))
}
}
stepLines = append(stepLines, " fi")
// Add env section with all secrets
stepLines = append(stepLines, " env:")
for _, secretName := range secretNames {
stepLines = append(stepLines, fmt.Sprintf(" %s: ${{ secrets.%s }}", secretName, secretName))
}
return GitHubActionStep(stepLines)
}
// GetAllEngines returns all registered engines
func (r *EngineRegistry) GetAllEngines() []CodingAgentEngine {
var engines []CodingAgentEngine
for _, engine := range r.engines {
engines = append(engines, engine)
}
return engines
}
// GetCopilotAgentPlaywrightTools returns the list of playwright tools available in the copilot agent
// This matches the tools available in the copilot agent MCP server configuration
// This is a shared function used by all engines for consistent playwright tool configuration
func GetCopilotAgentPlaywrightTools() []any {
tools := []string{
"browser_click",
"browser_close",
"browser_console_messages",
"browser_drag",
"browser_evaluate",
"browser_file_upload",
"browser_fill_form",
"browser_handle_dialog",
"browser_hover",
"browser_install",
"browser_navigate",
"browser_navigate_back",
"browser_network_requests",
"browser_press_key",
"browser_resize",
"browser_select_option",
"browser_snapshot",
"browser_tabs",
"browser_take_screenshot",
"browser_type",
"browser_wait_for",
}
// Convert []string to []any for compatibility with the configuration system
result := make([]any, len(tools))
for i, tool := range tools {
result[i] = tool
}
return result
}
// ConvertStepToYAML converts a step map to YAML string with proper indentation
// This is a shared utility function used by all engines and the compiler
func ConvertStepToYAML(stepMap map[string]any) (string, error) {
// Use OrderMapFields to get ordered MapSlice
orderedStep := OrderMapFields(stepMap, constants.PriorityStepFields)
// Wrap in array for step list format and marshal with proper options
yamlBytes, err := yaml.MarshalWithOptions([]yaml.MapSlice{orderedStep},
yaml.Indent(2), // Use 2-space indentation
yaml.UseLiteralStyleIfMultiline(true), // Use literal block scalars for multiline strings
)
if err != nil {
return "", fmt.Errorf("failed to marshal step to YAML: %w", err)
}
// Convert to string and adjust base indentation to match GitHub Actions format
yamlStr := string(yamlBytes)
// Add 6 spaces to the beginning of each line to match GitHub Actions step indentation
lines := strings.Split(strings.TrimSpace(yamlStr), "\n")
var result strings.Builder
for _, line := range lines {
if strings.TrimSpace(line) == "" {
result.WriteString("\n")
} else {
result.WriteString(" " + line + "\n")
}
}
return result.String(), nil
}
// GetCommonErrorPatterns returns error patterns that are common across all engines.
// These patterns detect standard GitHub Actions workflow commands and other universal error formats.
func GetCommonErrorPatterns() []ErrorPattern {
return []ErrorPattern{
// GitHub Actions workflow commands - standard error/warning/notice syntax
{
ID: "common-gh-actions-error",
Pattern: `::(error)(?:\s+[^:]*)?::(.+)`,
LevelGroup: 1, // "error" is in the first capture group
MessageGroup: 2, // message is in the second capture group
Description: "GitHub Actions workflow command - error",
},
{
ID: "common-gh-actions-warning",
Pattern: `::(warning)(?:\s+[^:]*)?::(.+)`,
LevelGroup: 1, // "warning" is in the first capture group
MessageGroup: 2, // message is in the second capture group
Description: "GitHub Actions workflow command - warning",
},
{
ID: "common-gh-actions-notice",
Pattern: `::(notice)(?:\s+[^:]*)?::(.+)`,
LevelGroup: 1, // "notice" is in the first capture group
MessageGroup: 2, // message is in the second capture group
Description: "GitHub Actions workflow command - notice",
},
// Generic error/warning patterns - common log formats
{
ID: "common-generic-error",
Pattern: `(ERROR|Error):\s+(.+)`,
LevelGroup: 1, // "ERROR" or "Error" is in the first capture group
MessageGroup: 2, // error message is in the second capture group
Description: "Generic ERROR messages",
},
{
ID: "common-generic-warning",
Pattern: `(WARNING|Warning):\s+(.+)`,
LevelGroup: 1, // "WARNING" or "Warning" is in the first capture group
MessageGroup: 2, // warning message is in the second capture group
Description: "Generic WARNING messages",
},
}
}