Skip to content
Open
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
11 changes: 0 additions & 11 deletions .github/workflows/glossary-maintainer.lock.yml

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

11 changes: 0 additions & 11 deletions .github/workflows/hourly-ci-cleaner.lock.yml

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

11 changes: 0 additions & 11 deletions .github/workflows/technical-doc-writer.lock.yml

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

16 changes: 8 additions & 8 deletions pkg/parser/agent_import_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@ This workflow imports a custom agent with array-format tools.`
t.Fatalf("ProcessImportsFromFrontmatterWithManifest() error = %v, want nil", err)
}

// Verify that the agent file was detected and stored
if result.AgentFile == "" {
t.Errorf("Expected AgentFile to be set, got empty string")
}

expectedAgentPath := ".github/agents/feature-flag-remover.agent.md"
if result.AgentFile != expectedAgentPath {
t.Errorf("AgentFile = %q, want %q", result.AgentFile, expectedAgentPath)
// Verify that the agent file was NOT set as AgentFile (local imports use runtime-import path)
// Local agent imports (same repository) are handled like snippets via the runtime-import macro,
// not via the special AGENT_CONTENT engine path.
if result.AgentFile != "" {
t.Errorf("Expected AgentFile to be empty for local agent import (snippet-style path), got: %q", result.AgentFile)
}
if result.AgentImportSpec != "" {
t.Errorf("Expected AgentImportSpec to be empty for local agent import, got: %q", result.AgentImportSpec)
}

// Verify that the import path was added for runtime-import macro (new behavior)
Expand Down
40 changes: 27 additions & 13 deletions pkg/parser/import_bfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,27 +180,41 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
// Check if this is a custom agent file (any markdown file under .github/agents)
isAgentFile := strings.Contains(item.fullPath, "/.github/agents/") && strings.HasSuffix(strings.ToLower(item.fullPath), ".md")
if isAgentFile {
Comment on lines 180 to 182
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

item.fullPath is an OS-native path (uses \ on Windows). The agent-file detection strings.Contains(item.fullPath, "/.github/agents/") will fail on Windows and cause agent imports to be treated as regular imports. Consider reusing the existing isCustomAgentFile() helper (it normalizes via filepath.ToSlash) or normalize item.fullPath before this check.

This issue also appears on line 188 of the same file.

Copilot uses AI. Check for mistakes.
if acc.agentFile != "" {
// Multiple agent files found - error
log.Printf("Multiple agent files found: %s and %s", acc.agentFile, item.importPath)
return nil, fmt.Errorf("multiple agent files found in imports: '%s' and '%s'. Only one agent file is allowed per workflow", acc.agentFile, item.importPath)
if acc.firstAgentPath != "" {
// Multiple agent files found - error (applies to both local and remote)
log.Printf("Multiple agent files found: %s and %s", acc.firstAgentPath, item.importPath)
return nil, fmt.Errorf("multiple agent files found in imports: '%s' and '%s'. Only one agent file is allowed per workflow", acc.firstAgentPath, item.importPath)
}
// Extract relative path from repository root (from .github/ onwards)
// This ensures the path works at runtime with $GITHUB_WORKSPACE
var importRelPath string
if idx := strings.Index(item.fullPath, "/.github/"); idx >= 0 {
acc.agentFile = item.fullPath[idx+1:] // +1 to skip the leading slash
importRelPath = acc.agentFile
importRelPath = item.fullPath[idx+1:] // +1 to skip the leading slash
} else {
acc.agentFile = item.fullPath
importRelPath = item.fullPath
}
log.Printf("Found agent file: %s (resolved to: %s)", item.fullPath, acc.agentFile)

// Store the original import specification for remote agents
// This allows runtime detection and .github folder merging
acc.agentImportSpec = item.importPath
log.Printf("Agent import specification: %s", acc.agentImportSpec)
// Track the first agent seen (for subsequent duplicate checks)
acc.firstAgentPath = item.importPath
log.Printf("Found agent file: %s (resolved to: %s)", item.fullPath, importRelPath)

// For remote agent imports, set agentFile/agentImportSpec to enable special engine handling
// (AGENT_CONTENT extraction at runtime) and .github folder merging.
// For local agent imports (same repository), the file is already available in the workspace
// via the normal checkout, so it is treated like a snippet import: content is injected via
// the runtime-import macro (importPaths) which is the robust path used by snippets.
//
// Using the AGENT_CONTENT path for local imports is both unnecessary and fragile:
// - When AWF (firewall) is enabled, the engine sets AGENT_CONTENT/PROMPT_TEXT as shell
// variables on the host, but only exported environment variables reach the AWF container;
// unexported shell variables are invisible inside the container, causing an empty prompt.
// - The snippet/runtime-import path is simpler, correct, and does not have this issue.
if item.remoteOrigin != nil {
acc.agentFile = importRelPath
acc.agentImportSpec = item.importPath
log.Printf("Remote agent import - set agentFile=%s agentImportSpec=%s", acc.agentFile, acc.agentImportSpec)
} else {
log.Printf("Local agent import - using runtime-import path (snippet-style): %s", importRelPath)
}

// Track import path for runtime-import macro generation (only if no inputs)
// Imports with inputs must be inlined for compile-time substitution
Expand Down
5 changes: 3 additions & 2 deletions pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ type importAccumulator struct {
skipBotsSet map[string]bool
caches []string
features []map[string]any
agentFile string
agentImportSpec string
agentFile string // Only set for remote agent imports
agentImportSpec string // Only set for remote agent imports
firstAgentPath string // Tracks first agent seen (local or remote) for duplicate check
repositoryImports []string
importInputs map[string]any
}
Expand Down
11 changes: 8 additions & 3 deletions pkg/workflow/claude_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,16 +219,21 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
}

// Build the agent command - prepend custom agent file content if specified (via imports)
// Note: AgentFile is only set for remote agent imports. Local agent imports use the
// runtime-import macro path (snippet-style) and do not set AgentFile.
// The AGENT_CONTENT approach reads the file at runtime from $GITHUB_WORKSPACE; it is only
// appropriate for remote imports where the file has been checked out separately.
var promptSetup string
var promptCommand string
if workflowData.AgentFile != "" {
agentPath := ResolveAgentFilePath(workflowData.AgentFile)
claudeLog.Printf("Using custom agent file: %s", workflowData.AgentFile)
// Extract markdown body from custom agent file and prepend to prompt
// Extract markdown body from custom agent file (skip YAML frontmatter) and prepend to prompt.
promptSetup = fmt.Sprintf(`# Extract markdown body from custom agent file (skip frontmatter)
AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)"
%s
# Combine agent content with prompt
PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`, agentPath)
PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`,
AgentFileBodyExtractCmd(agentPath))
promptCommand = "\"$PROMPT_TEXT\""
Comment on lines 221 to 237
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In AWF mode, promptSetup runs on the host before invoking awf ... -- <container command> (see BuildAWFCommand), but it currently sets AGENT_CONTENT/PROMPT_TEXT as unexported shell variables. Because AWF uses --env-all, only exported environment variables propagate into the container, so the Claude prompt argument "$PROMPT_TEXT" will be empty when AgentFile is set. Export these variables (or move the agent-body extraction + prompt assembly into the container command) so remote agent imports work under AWF.

See below for a potential fix:

          export AGENT_CONTENT
          # Combine agent content with prompt
          PROMPT_TEXT="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"
          export PROMPT_TEXT`,

Copilot uses AI. Check for mistakes.
} else {
promptCommand = "\"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)\""
Expand Down
12 changes: 8 additions & 4 deletions pkg/workflow/codex_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,13 @@ func (e *CodexEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri
npmPathSetup := GetNpmBinPathSetup()

// Codex reads both agent file and prompt inside AWF container (PATH setup + agent file reading + codex command)
// Note: AgentFile is only set for remote agent imports. Local agent imports use the
// runtime-import macro path (snippet-style) and do not set AgentFile.
var codexCommandWithSetup string
if workflowData.AgentFile != "" {
agentPath := ResolveAgentFilePath(workflowData.AgentFile)
// Read agent file and prompt inside AWF container, with PATH setup for npm binaries
codexCommandWithSetup = fmt.Sprintf(`%s && AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)" && INSTRUCTION="$(printf "%%s\n\n%%s" "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && %s`, npmPathSetup, agentPath, codexCommand)
// Read agent file and prompt inside AWF container, with PATH setup for npm binaries.
codexCommandWithSetup = fmt.Sprintf(`%s && %s && INSTRUCTION="$(printf "%%s\n\n%%s" "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && %s`, npmPathSetup, AgentFileBodyExtractCmd(agentPath), codexCommand)
} else {
// Read prompt inside AWF container to avoid Docker Compose interpolation issues, with PATH setup
codexCommandWithSetup = fmt.Sprintf(`%s && INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" && %s`, npmPathSetup, codexCommand)
Expand All @@ -233,13 +235,15 @@ func (e *CodexEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri
} else {
// Build the command without AWF wrapping
// Reuse commandName already determined above
// Note: AgentFile is only set for remote agent imports. Local agent imports use the
// runtime-import macro path (snippet-style) and do not set AgentFile.
if workflowData.AgentFile != "" {
agentPath := ResolveAgentFilePath(workflowData.AgentFile)
command = fmt.Sprintf(`set -o pipefail
AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)"
%s
INSTRUCTION="$(printf "%%s\n\n%%s" "$AGENT_CONTENT" "$(cat "$GH_AW_PROMPT")")"
mkdir -p "$CODEX_HOME/logs"
%s %sexec%s%s%s"$INSTRUCTION" 2>&1 | tee %s`, agentPath, commandName, modelParam, webSearchParam, fullAutoParam, customArgsParam, logFile)
%s %sexec%s%s%s"$INSTRUCTION" 2>&1 | tee %s`, AgentFileBodyExtractCmd(agentPath), commandName, modelParam, webSearchParam, fullAutoParam, customArgsParam, logFile)
} else {
command = fmt.Sprintf(`set -o pipefail
INSTRUCTION="$(cat "$GH_AW_PROMPT")"
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/engine_agent_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ func TestCopilotEngineWithoutAgentFlag(t *testing.T) {
}

// TestClaudeEngineWithAgentFromImports tests that claude engine prepends agent file content to prompt
// when AgentFile is set. AgentFile is only set for remote agent imports; local agent imports
// use the runtime-import macro path (snippet-style) and do not set AgentFile.
func TestClaudeEngineWithAgentFromImports(t *testing.T) {
engine := NewClaudeEngine()
workflowData := &WorkflowData{
Expand Down Expand Up @@ -178,6 +180,8 @@ func TestClaudeEngineWithoutAgentFile(t *testing.T) {
}

// TestCodexEngineWithAgentFromImports tests that codex engine prepends agent file content to prompt
// when AgentFile is set. AgentFile is only set for remote agent imports; local agent imports
// use the runtime-import macro path (snippet-style) and do not set AgentFile.
func TestCodexEngineWithAgentFromImports(t *testing.T) {
engine := NewCodexEngine()
workflowData := &WorkflowData{
Expand Down
22 changes: 21 additions & 1 deletion pkg/workflow/engine_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,27 @@ func ResolveAgentFilePath(agentFile string) string {
return fmt.Sprintf("\"${GITHUB_WORKSPACE}/%s\"", agentFile)
}

// BuildStandardNpmEngineInstallSteps creates standard npm installation steps for engines
// agentFileBodyExtractAwk is the awk program used to extract the markdown body from a custom
// agent file, skipping any YAML frontmatter delimited by --- lines.
//
// How it works:
// - `n` counts how many `---` separator lines have been seen (max 2 counted).
// - Each `---` line that counts towards the frontmatter is consumed (next).
// - `n!=1` means: print the line unless we are inside the frontmatter (1 separator seen).
// - n==0: before any `---` (no frontmatter) → printed (whole file output)
// - n==1: inside frontmatter (between 1st and 2nd `---`) → skipped
// - n>=2: after closing `---` (markdown body) → printed
//
// Files without YAML frontmatter (n stays 0) are printed in full.
const agentFileBodyExtractAwk = `'/^---$/ && n<2{n++;next} n!=1'`

// AgentFileBodyExtractCmd builds the shell command fragment that assigns the markdown body
// of an agent file (AGENT_CONTENT variable), stripping YAML frontmatter.
// agentPath must already be shell-quoted (e.g. the output of ResolveAgentFilePath).
func AgentFileBodyExtractCmd(agentPath string) string {
return fmt.Sprintf(`AGENT_CONTENT="$(awk %s %s)"`, agentFileBodyExtractAwk, agentPath)
}

// This helper extracts the common pattern shared by Copilot, Codex, and Claude engines.
//
// Parameters:
Expand Down
21 changes: 11 additions & 10 deletions pkg/workflow/inline_imports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ engine: copilot
assert.Equal(t, hash1, hash2, "same content should produce the same hash")
}

// TestInlinedImports_AgentFileError verifies that when inlined-imports: true and a custom agent
// file is imported, ParseWorkflowFile returns a compilation error.
// Agent files require runtime access and will not be resolved without sources.
func TestInlinedImports_AgentFileError(t *testing.T) {
// TestInlinedImports_AgentFileLocalWorks verifies that when inlined-imports: true and a local
// agent file is imported, ParseWorkflowFile succeeds. Local agent imports are treated like
// snippets (runtime-import path) and their content is inlined at compile time.
func TestInlinedImports_AgentFileLocalWorks(t *testing.T) {
tmpDir := t.TempDir()

// Create the .github/agents directory and agent file
Expand Down Expand Up @@ -78,16 +78,17 @@ Do something.
WithSkipValidation(true),
)

// Local agent import + inlined-imports: true should succeed now
// (local agents are treated like snippets, not the special AGENT_CONTENT path)
_, err := compiler.ParseWorkflowFile(workflowFile)
require.Error(t, err, "should return an error when inlined-imports is used with an agent file")
assert.Contains(t, err.Error(), "inlined-imports cannot be used with agent file imports",
"error message should explain the conflict")
assert.Contains(t, err.Error(), "my-agent.md",
"error message should include the agent file path")
require.NoError(t, err, "local agent import with inlined-imports should succeed")
}

// TestInlinedImports_AgentFileCleared verifies that buildInitialWorkflowData clears the AgentFile
// field when inlined-imports is true. Note: ParseWorkflowFile will error before this state is used.
// field when inlined-imports is true. This simulates a remote agent import scenario
// (local imports no longer set AgentFile at all). For remote agent imports, ParseWorkflowFile
// would error before this state is used in production (the inlined-imports + remote agent check
// at compiler_orchestrator_workflow.go fires first).
func TestInlinedImports_AgentFileCleared(t *testing.T) {
compiler := NewCompiler()

Expand Down
Loading