From 5262ba066b87fdd4abfd571a125f1d02205339c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:33:21 +0000 Subject: [PATCH 01/23] feat: add inline sub-agent syntax using separator Adds support for defining sub-agents inline within a workflow markdown file. A special separator line `` marks the start of each sub-agent's frontmatter + prompt block. During compilation: - Sub-agent sections are extracted from the markdown body - The main workflow uses only the content before the first separator - Each sub-agent is written to .github/agents/.md The separator: - Is an HTML comment (hidden in rendered markdown) - Uses @agent: prefix that is very unlikely to occur naturally - Name must start with a letter (alphanumeric, hyphens, underscores only) - Duplicate names within the same file produce a compile error Files changed: - pkg/parser/sub_agent_extractor.go: new ExtractInlineSubAgents() function - pkg/parser/sub_agent_extractor_test.go: comprehensive parser tests - pkg/workflow/compiler_types.go: InlineSubAgents field on WorkflowData - pkg/workflow/compiler_orchestrator_tools.go: extraction in processToolsAndMarkdown - pkg/workflow/compiler_orchestrator_workflow.go: pass rawMainMarkdown to extractAdditionalConfigurations - pkg/workflow/workflow_builder.go: populate InlineSubAgents on WorkflowData - pkg/workflow/compiler.go: writeInlineSubAgentFiles() and resolveAgentsDir() - pkg/workflow/compiler_inline_sub_agents_test.go: end-to-end compilation tests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b4ccf8aa-3004-4c65-b20c-bc4daf8621fb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/agents/sidecar.md | 4 + pkg/parser/sub_agent_extractor.go | 157 +++++++++++++ pkg/parser/sub_agent_extractor_test.go | 213 ++++++++++++++++++ pkg/workflow/compiler.go | 75 +++++- .../compiler_inline_sub_agents_test.go | 181 +++++++++++++++ pkg/workflow/compiler_orchestrator_tools.go | 23 +- .../compiler_orchestrator_workflow.go | 2 +- pkg/workflow/compiler_types.go | 11 +- pkg/workflow/workflow_builder.go | 1 + 9 files changed, 654 insertions(+), 13 deletions(-) create mode 100644 .github/agents/sidecar.md create mode 100644 pkg/parser/sub_agent_extractor.go create mode 100644 pkg/parser/sub_agent_extractor_test.go create mode 100644 pkg/workflow/compiler_inline_sub_agents_test.go diff --git a/.github/agents/sidecar.md b/.github/agents/sidecar.md new file mode 100644 index 00000000000..a2126738b6e --- /dev/null +++ b/.github/agents/sidecar.md @@ -0,0 +1,4 @@ +--- +engine: copilot +--- +UNIQUE_AGENT_PROMPT_SENTINEL diff --git a/pkg/parser/sub_agent_extractor.go b/pkg/parser/sub_agent_extractor.go new file mode 100644 index 00000000000..ac62b8a0075 --- /dev/null +++ b/pkg/parser/sub_agent_extractor.go @@ -0,0 +1,157 @@ +// Package parser — sub_agent_extractor.go +// +// This file provides inline sub-agent parsing for workflow markdown files. +// +// # Inline Sub-Agents +// +// A sub-agent is a secondary agent definition embedded directly in the same +// markdown file as the primary workflow. Each sub-agent has its own frontmatter +// block plus a prompt body. Sub-agents appear after the main workflow body and +// are separated from it (and from each other) by the special separator line: +// +// +// +// The separator must appear on its own line (optional surrounding whitespace is +// allowed). The agent name must start with a letter and contain only +// alphanumeric characters, hyphens, and underscores. +// +// # Example +// +// --- +// engine: copilot +// on: +// issues: +// types: [opened] +// --- +// # Handle issue +// Triage the issue and delegate work to sub-agents. +// +// +// --- +// engine: copilot +// tools: +// github: +// toolsets: [issues, pull_requests] +// --- +// You are a planning specialist. +// +// +// --- +// engine: copilot +// tools: +// github: +// toolsets: [pull_requests] +// --- +// You are an execution specialist. +// +// # Compilation Output +// +// During compilation the extracted sub-agents are written to the repository: +// - Copilot engine: .github/agents/.md +// - Other engines: handled by the engine-specific compiler path +// +// # Wire-Up +// +// ExtractInlineSubAgents is called early in processToolsAndMarkdown so that +// the main workflow content (returned as mainMarkdown) is used for all +// subsequent prompt generation, while InlineSubAgents is populated on +// WorkflowData for the compilation output step. + +package parser + +import ( + "fmt" + "regexp" + "strings" +) + +// InlineSubAgent holds a single sub-agent definition extracted from a workflow +// markdown file's body using the separator syntax. +type InlineSubAgent struct { + // Name is the identifier taken from the separator line. + // It is safe to use as a filename (alphanumeric, hyphens, underscores). + Name string + + // Content is the raw text that follows the separator line up to the next + // separator (or end of file). It typically includes a YAML frontmatter + // block (---...---) followed by the sub-agent's prompt body, but the + // format is not enforced — it varies by engine. + Content string +} + +// subAgentSeparatorRegex matches the inline sub-agent separator line. +// +// Format (anchored to line boundaries via (?m)): +// +// +// +// Rules: +// - Optional horizontal whitespace before and after the comment +// - Exactly one or more whitespace characters between "@agent:" and the name +// - Agent name: starts with a letter, followed by alphanumeric / hyphen / underscore +// - Optional horizontal whitespace after the name before "-->" +var subAgentSeparatorRegex = regexp.MustCompile(`(?m)^[ \t]*[ \t]*$`) + +// ExtractInlineSubAgents splits markdown into the main workflow section and any +// inline sub-agent definitions. +// +// It scans the markdown body for separator lines. Content +// before the first separator is returned as mainMarkdown (trimmed of trailing +// newlines). Each separator starts a new sub-agent whose content spans until +// the next separator or the end of the file. +// +// If no separators are found the original markdown is returned unchanged and +// agents is nil. +func ExtractInlineSubAgents(markdown string) (mainMarkdown string, agents []InlineSubAgent, err error) { + allMatches := subAgentSeparatorRegex.FindAllStringSubmatchIndex(markdown, -1) + + if len(allMatches) == 0 { + // No inline sub-agents — return the markdown unchanged. + return markdown, nil, nil + } + + // Validate that all agent names are unique. + seen := make(map[string]struct{}, len(allMatches)) + for _, match := range allMatches { + name := markdown[match[2]:match[3]] + if _, exists := seen[name]; exists { + return "", nil, fmt.Errorf("duplicate inline sub-agent name %q", name) + } + seen[name] = struct{}{} + } + + // Main markdown is everything before the first separator, with trailing newlines stripped. + mainMarkdown = strings.TrimRight(markdown[:allMatches[0][0]], "\n") + + agents = make([]InlineSubAgent, 0, len(allMatches)) + for i, match := range allMatches { + // match[0]:match[1] = full separator line (including any leading/trailing spaces) + // match[2]:match[3] = agent name capture group + + name := markdown[match[2]:match[3]] + + // Agent content starts immediately after the separator line. + contentStart := match[1] + // Skip the single newline that terminates the separator line (if present). + if contentStart < len(markdown) && markdown[contentStart] == '\n' { + contentStart++ + } + + // Agent content ends at the start of the next separator line, or at EOF. + var contentEnd int + if i+1 < len(allMatches) { + contentEnd = allMatches[i+1][0] + } else { + contentEnd = len(markdown) + } + + content := strings.TrimSpace(markdown[contentStart:contentEnd]) + + agents = append(agents, InlineSubAgent{ + Name: name, + Content: content, + }) + } + + return mainMarkdown, agents, nil +} diff --git a/pkg/parser/sub_agent_extractor_test.go b/pkg/parser/sub_agent_extractor_test.go new file mode 100644 index 00000000000..6e473c498a4 --- /dev/null +++ b/pkg/parser/sub_agent_extractor_test.go @@ -0,0 +1,213 @@ +//go:build !integration + +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractInlineSubAgents_NoSeparators(t *testing.T) { + markdown := "# Hello\n\nThis is a workflow." + mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "no separators should not produce an error") + assert.Equal(t, markdown, mainMarkdown, "markdown should be unchanged when no separators present") + assert.Nil(t, agents, "agents should be nil when no separators found") +} + +func TestExtractInlineSubAgents_EmptyMarkdown(t *testing.T) { + mainMarkdown, agents, err := ExtractInlineSubAgents("") + + require.NoError(t, err, "empty markdown should not produce an error") + assert.Empty(t, mainMarkdown, "empty markdown should return empty main") + assert.Nil(t, agents, "agents should be nil for empty markdown") +} + +func TestExtractInlineSubAgents_SingleAgent(t *testing.T) { + markdown := `# Main workflow + +Handle the issue. + + +--- +engine: copilot +--- +You are a planning assistant.` + + mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "single sub-agent should parse without error") + assert.Equal(t, "# Main workflow\n\nHandle the issue.", mainMarkdown, "main markdown should exclude agent section") + require.Len(t, agents, 1, "should extract one sub-agent") + assert.Equal(t, "planner", agents[0].Name, "agent name should be 'planner'") + assert.Equal(t, "---\nengine: copilot\n---\nYou are a planning assistant.", agents[0].Content, "agent content should be trimmed") +} + +func TestExtractInlineSubAgents_MultipleAgents(t *testing.T) { + markdown := `# Main workflow + +Main prompt. + + +--- +engine: copilot +--- +You are a planner. + + +--- +engine: copilot +--- +You are an executor.` + + mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "multiple sub-agents should parse without error") + assert.Equal(t, "# Main workflow\n\nMain prompt.", mainMarkdown, "main markdown should exclude agent sections") + require.Len(t, agents, 2, "should extract two sub-agents") + + assert.Equal(t, "planner", agents[0].Name, "first agent name should be 'planner'") + assert.Contains(t, agents[0].Content, "You are a planner.", "first agent content should contain prompt") + + assert.Equal(t, "executor", agents[1].Name, "second agent name should be 'executor'") + assert.Contains(t, agents[1].Content, "You are an executor.", "second agent content should contain prompt") +} + +func TestExtractInlineSubAgents_AgentAtStartOfFile(t *testing.T) { + markdown := ` +--- +engine: copilot +--- +Agent prompt.` + + mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "agent at start of file should parse without error") + assert.Empty(t, mainMarkdown, "main markdown should be empty when agent is first") + require.Len(t, agents, 1, "should extract one sub-agent") + assert.Equal(t, "only-agent", agents[0].Name, "agent name should be 'only-agent'") +} + +func TestExtractInlineSubAgents_AgentWithoutFrontmatter(t *testing.T) { + markdown := `Main workflow. + + +Just a prompt, no frontmatter.` + + _, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "agent without frontmatter should parse without error") + require.Len(t, agents, 1, "should extract one sub-agent") + assert.Equal(t, "simple", agents[0].Name, "agent name should be 'simple'") + assert.Equal(t, "Just a prompt, no frontmatter.", agents[0].Content, "agent content should be the prompt") +} + +func TestExtractInlineSubAgents_SeparatorWithWhitespace(t *testing.T) { + // Leading and trailing whitespace around the separator should be tolerated + markdown := "Main.\n\n \nAgent content." + + _, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "separator with surrounding whitespace should be recognized") + require.Len(t, agents, 1, "should extract one sub-agent") + assert.Equal(t, "padded", agents[0].Name, "agent name should be 'padded'") +} + +func TestExtractInlineSubAgents_InvalidNameNotRecognized(t *testing.T) { + tests := []struct { + name string + separator string + }{ + { + name: "name starts with digit", + separator: "", + }, + { + name: "name contains spaces", + separator: "", + }, + { + name: "name contains slash", + separator: "", + }, + { + name: "missing name", + separator: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + markdown := "Main content.\n\n" + tt.separator + "\nAgent content." + mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "invalid separator should be treated as regular text") + assert.Equal(t, markdown, mainMarkdown, "invalid separator should not consume main markdown") + assert.Nil(t, agents, "invalid separator should not produce agents") + }) + } +} + +func TestExtractInlineSubAgents_DuplicateNameError(t *testing.T) { + markdown := `Main. + + +Content 1. + + +Content 2.` + + _, _, err := ExtractInlineSubAgents(markdown) + + require.Error(t, err, "duplicate agent name should produce an error") + assert.Contains(t, err.Error(), "duplicate", "error should mention duplicate") + assert.Contains(t, err.Error(), "planner", "error should include the duplicate name") +} + +func TestExtractInlineSubAgents_NameVariants(t *testing.T) { + tests := []struct { + name string + separator string + agentName string + }{ + {"with hyphens", "", "my-agent"}, + {"with underscores", "", "my_agent"}, + {"with digits", "", "agent1"}, + {"mixed case", "", "MyAgent"}, + {"single letter", "", "a"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + markdown := "Main.\n\n" + tt.separator + "\nContent." + _, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "valid agent name %q should parse without error", tt.agentName) + require.Len(t, agents, 1, "should extract one sub-agent") + assert.Equal(t, tt.agentName, agents[0].Name, "agent name should match") + }) + } +} + +func TestExtractInlineSubAgents_ContentTrimmed(t *testing.T) { + // Content after the separator should have leading/trailing whitespace trimmed + markdown := "Main.\n\n\n\n\n Agent content here. \n\n" + + _, agents, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "content trimming should not produce an error") + require.Len(t, agents, 1, "should extract one sub-agent") + assert.Equal(t, "Agent content here.", agents[0].Content, "agent content should be trimmed") +} + +func TestExtractInlineSubAgents_MainMarkdownTrailingNewlinesStripped(t *testing.T) { + markdown := "Line 1.\nLine 2.\n\n\n\nContent." + + mainMarkdown, _, err := ExtractInlineSubAgents(markdown) + + require.NoError(t, err, "should parse without error") + assert.Equal(t, "Line 1.\nLine 2.", mainMarkdown, "trailing newlines should be stripped from main markdown") +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 0780576c2db..93cf30d8e98 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -12,6 +12,7 @@ import ( "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/gitutil" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/stringutil" "github.com/goccy/go-yaml" ) @@ -295,7 +296,66 @@ func (c *Compiler) writeWorkflowOutput(lockFile, yamlContent string, markdownPat return nil } -// validateTemplateInjection checks compiled YAML for template injection vulnerabilities +// writeInlineSubAgentFiles writes extracted inline sub-agent definitions to the +// .github/agents/ directory. Each agent is written as .md alongside any +// existing agent files in the repository. +// +// For the copilot engine this makes the sub-agent available via --agent . +// The format of the content is intentionally left unprocessed — it is written +// verbatim so that any engine-specific frontmatter is preserved. +func (c *Compiler) writeInlineSubAgentFiles(agents []parser.InlineSubAgent, markdownPath string) error { + agentsDir, err := c.resolveAgentsDir(markdownPath) + if err != nil { + return formatCompilerError(markdownPath, "error", + fmt.Sprintf("failed to resolve .github/agents directory: %v", err), err) + } + + if err := os.MkdirAll(agentsDir, 0755); err != nil { + return formatCompilerError(markdownPath, "error", + fmt.Sprintf("failed to create .github/agents directory: %v", err), err) + } + + for _, agent := range agents { + agentPath := filepath.Join(agentsDir, agent.Name+".md") + agentContent := agent.Content + if !strings.HasSuffix(agentContent, "\n") { + agentContent += "\n" + } + if err := os.WriteFile(agentPath, []byte(agentContent), 0644); err != nil { + return formatCompilerError(markdownPath, "error", + fmt.Sprintf("failed to write sub-agent file %q: %v", agentPath, err), err) + } + if c.fileTracker != nil { + c.fileTracker.TrackCreated(agentPath) + } + if c.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("✓ Sub-agent written: "+console.ToRelativePath(agentPath))) + } + log.Printf("Sub-agent written: %s", agentPath) + } + + return nil +} + +// resolveAgentsDir returns the absolute path to the .github/agents/ directory +// for the repository that contains markdownPath. +// +// Resolution order: +// 1. c.gitRoot (auto-detected at compiler creation) — most reliable +// 2. Two-level parent of markdownPath — fallback for workflows stored in +// .github/workflows/ (the standard location) +func (c *Compiler) resolveAgentsDir(markdownPath string) (string, error) { + if c.gitRoot != "" { + return filepath.Join(c.gitRoot, ".github", "agents"), nil + } + + // Fall back to navigating up two directories from the workflow file's directory. + // This assumes the workflow is under .github/workflows/ which is standard. + markdownDir := filepath.Dir(markdownPath) + repoRoot := filepath.Clean(filepath.Join(markdownDir, "..", "..")) + return filepath.Join(repoRoot, ".github", "agents"), nil +} + // (unsafe GitHub Actions expressions used directly in run: blocks). // // When parsedWorkflow is non-nil the YAML was already parsed for schema validation; @@ -492,7 +552,18 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath } // Write output - return c.writeWorkflowOutput(lockFile, yamlContent, markdownPath) + if err := c.writeWorkflowOutput(lockFile, yamlContent, markdownPath); err != nil { + return err + } + + // Write any inline sub-agent files extracted from the workflow markdown. + if !c.noEmit && len(workflowData.InlineSubAgents) > 0 { + if err := c.writeInlineSubAgentFiles(workflowData.InlineSubAgents, markdownPath); err != nil { + return err + } + } + + return nil } // ParseWorkflowFile parses a markdown workflow file and extracts all necessary data diff --git a/pkg/workflow/compiler_inline_sub_agents_test.go b/pkg/workflow/compiler_inline_sub_agents_test.go new file mode 100644 index 00000000000..4ba1e03df7f --- /dev/null +++ b/pkg/workflow/compiler_inline_sub_agents_test.go @@ -0,0 +1,181 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCompileWorkflow_InlineSubAgents verifies that inline sub-agent definitions +// are extracted from the markdown body and written as separate files under +// .github/agents/ during compilation. +func TestCompileWorkflow_InlineSubAgents(t *testing.T) { + // Create a temporary directory structure that mimics a repository layout: + // / + // .github/ + // workflows/ + // my-workflow.md + // agents/ ← sub-agent files should land here + tmpDir := testutil.TempDir(t, "inline-sub-agents") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755), "create workflows dir") + + workflowContent := `--- +engine: copilot +on: + issues: + types: [opened] +--- +# Handle issue + +Triage the issue. + + +--- +engine: copilot +tools: + github: + toolsets: [default] +--- +You are a planning assistant. + + +--- +engine: copilot +tools: + github: + toolsets: [default] +--- +You are an execution specialist. +` + + workflowPath := filepath.Join(workflowsDir, "my-workflow.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0644), "write workflow") + + compiler := NewCompiler() + // Use gitRoot so resolveAgentsDir does not rely on the ../.. heuristic. + compiler.gitRoot = tmpDir + + err := compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "compilation should succeed with inline sub-agents") + + agentsDir := filepath.Join(tmpDir, ".github", "agents") + + // Verify planner agent file + plannerPath := filepath.Join(agentsDir, "planner.md") + plannerContent, err := os.ReadFile(plannerPath) + require.NoError(t, err, "planner.md should exist in .github/agents/") + assert.Contains(t, string(plannerContent), "You are a planning assistant.", "planner.md should contain the agent prompt") + assert.Contains(t, string(plannerContent), "engine: copilot", "planner.md should contain frontmatter") + + // Verify executor agent file + executorPath := filepath.Join(agentsDir, "executor.md") + executorContent, err := os.ReadFile(executorPath) + require.NoError(t, err, "executor.md should exist in .github/agents/") + assert.Contains(t, string(executorContent), "You are an execution specialist.", "executor.md should contain the agent prompt") + + // Verify the main workflow prompt does NOT contain the sub-agent sections + lockPath := workflowPath[:len(workflowPath)-len(".md")] + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + require.NoError(t, err, "lock file should be generated") + assert.NotContains(t, string(lockContent), " +--- +engine: copilot +--- +You are a helper. +` + + workflowPath := filepath.Join(workflowsDir, "noemit-workflow.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0644), "write workflow") + + compiler := NewCompiler(WithNoEmit(true)) + compiler.gitRoot = tmpDir + + err := compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "no-emit compilation should succeed") + + // Neither the lock file nor agent files should exist + agentsDir := filepath.Join(tmpDir, ".github", "agents") + _, err = os.Stat(filepath.Join(agentsDir, "helper.md")) + assert.True(t, os.IsNotExist(err), "agent file should NOT be written in no-emit mode") +} + +// TestCompileWorkflow_InlineSubAgents_MainContentPreserved verifies that the main +// workflow content (before the first sub-agent separator) is used for the compiled +// prompt and does not include sub-agent sections. +func TestCompileWorkflow_InlineSubAgents_MainContentPreserved(t *testing.T) { + tmpDir := testutil.TempDir(t, "inline-sub-agents-content") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755), "create workflows dir") + + workflowContent := `--- +engine: copilot +on: + issues: + types: [opened] +--- +# My Workflow + +Main workflow prompt. + + +--- +engine: copilot +--- +Sub-agent prompt content. +` + + workflowPath := filepath.Join(workflowsDir, "content-test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0644), "write workflow") + + compiler := NewCompiler() + compiler.gitRoot = tmpDir + + err := compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "compilation should succeed") + + // The lock file uses {{#runtime-import}} for the prompt, so actual content is not + // inlined at compile time. Instead, verify the separator syntax does NOT appear in + // the compiled YAML and that the runtime-import macro points to the workflow file. + lockPath := workflowPath[:len(workflowPath)-len(".md")] + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + require.NoError(t, err, "lock file should exist") + + lockStr := string(lockContent) + assert.NotContains(t, lockStr, " separator syntax + RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging StopTime string SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 6e7349e9204..c0d3739216a 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -55,6 +55,7 @@ func (c *Compiler) buildInitialWorkflowData( EngineConfig: engineSetup.engineConfig, AgentFile: agentFile, AgentImportSpec: agentImportSpec, + InlineSubAgents: toolsResult.inlineSubAgents, RepositoryImports: importsResult.RepositoryImports, NetworkPermissions: engineSetup.networkPermissions, SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig), From 90fb2c1c7e109dff3f97821828a030f3429d7ec7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:35:10 +0000 Subject: [PATCH 02/23] fix: remove test artifact sidecar.md accidentally committed to .github/agents/ Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b4ccf8aa-3004-4c65-b20c-bc4daf8621fb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/agents/sidecar.md | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .github/agents/sidecar.md diff --git a/.github/agents/sidecar.md b/.github/agents/sidecar.md deleted file mode 100644 index a2126738b6e..00000000000 --- a/.github/agents/sidecar.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -engine: copilot ---- -UNIQUE_AGENT_PROMPT_SENTINEL From c6c357465fd2e4cc55e5cc6d489c3678f2e6ec09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:37:01 +0000 Subject: [PATCH 03/23] fix: improve resolveAgentsDir comment and restore validateTemplateInjection doc comment Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b4ccf8aa-3004-4c65-b20c-bc4daf8621fb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 93cf30d8e98..f8009c62aad 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -342,8 +342,9 @@ func (c *Compiler) writeInlineSubAgentFiles(agents []parser.InlineSubAgent, mark // // Resolution order: // 1. c.gitRoot (auto-detected at compiler creation) — most reliable -// 2. Two-level parent of markdownPath — fallback for workflows stored in -// .github/workflows/ (the standard location) +// 2. Parent's parent directory of markdownPath — fallback for workflows stored +// in .github/workflows/ (the standard location: going up from the workflow +// file's directory to .github/, then up again to the repository root) func (c *Compiler) resolveAgentsDir(markdownPath string) (string, error) { if c.gitRoot != "" { return filepath.Join(c.gitRoot, ".github", "agents"), nil @@ -356,6 +357,7 @@ func (c *Compiler) resolveAgentsDir(markdownPath string) (string, error) { return filepath.Join(repoRoot, ".github", "agents"), nil } +// validateTemplateInjection checks compiled YAML for template injection vulnerabilities // (unsafe GitHub Actions expressions used directly in run: blocks). // // When parsedWorkflow is non-nil the YAML was already parsed for schema validation; From d02a91f98f691c0d096deabc8611d1b5612d4660 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:55:34 +0000 Subject: [PATCH 04/23] feat: change sub-agent separator from HTML comment to ## @agent: name heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The separator was replaced with ## @agent: name (a level-2 Markdown heading). This format renders visibly and nicely in any Markdown preview (GitHub, VS Code, etc.) while remaining clearly distinguishable from regular document headings thanks to the @agent: prefix. H1 and H3 headings are not treated as separators — only ## is recognized. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c8ebe799-71f2-43c9-8bcf-979101db0816 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/sub_agent_extractor.go | 27 +++++----- pkg/parser/sub_agent_extractor_test.go | 52 +++++++++++-------- .../compiler_inline_sub_agents_test.go | 12 ++--- 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/pkg/parser/sub_agent_extractor.go b/pkg/parser/sub_agent_extractor.go index ac62b8a0075..bfcac845c8b 100644 --- a/pkg/parser/sub_agent_extractor.go +++ b/pkg/parser/sub_agent_extractor.go @@ -9,11 +9,13 @@ // block plus a prompt body. Sub-agents appear after the main workflow body and // are separated from it (and from each other) by the special separator line: // -// +// ## @agent: name // -// The separator must appear on its own line (optional surrounding whitespace is -// allowed). The agent name must start with a letter and contain only -// alphanumeric characters, hyphens, and underscores. +// The separator is a level-2 Markdown heading using the @agent: keyword. It +// renders as a visible section heading in any Markdown preview (GitHub, VS Code, +// etc.) while remaining clearly distinguishable from regular document headings. +// The agent name must start with a letter and contain only alphanumeric +// characters, hyphens, and underscores. // // # Example // @@ -26,7 +28,7 @@ // # Handle issue // Triage the issue and delegate work to sub-agents. // -// +// ## @agent: planner // --- // engine: copilot // tools: @@ -35,7 +37,7 @@ // --- // You are a planning specialist. // -// +// ## @agent: executor // --- // engine: copilot // tools: @@ -66,7 +68,7 @@ import ( ) // InlineSubAgent holds a single sub-agent definition extracted from a workflow -// markdown file's body using the separator syntax. +// markdown file's body using the ## @agent: name separator syntax. type InlineSubAgent struct { // Name is the identifier taken from the separator line. // It is safe to use as a filename (alphanumeric, hyphens, underscores). @@ -83,19 +85,20 @@ type InlineSubAgent struct { // // Format (anchored to line boundaries via (?m)): // -// +// ## @agent: name // // Rules: -// - Optional horizontal whitespace before and after the comment +// - A level-2 Markdown heading (##) +// - One or more whitespace characters between "##" and "@agent:" // - Exactly one or more whitespace characters between "@agent:" and the name // - Agent name: starts with a letter, followed by alphanumeric / hyphen / underscore -// - Optional horizontal whitespace after the name before "-->" -var subAgentSeparatorRegex = regexp.MustCompile(`(?m)^[ \t]*[ \t]*$`) +// - Optional trailing whitespace +var subAgentSeparatorRegex = regexp.MustCompile(`(?m)^##[ \t]+@agent:[ \t]+([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*$`) // ExtractInlineSubAgents splits markdown into the main workflow section and any // inline sub-agent definitions. // -// It scans the markdown body for separator lines. Content +// It scans the markdown body for ## @agent: name separator lines. Content // before the first separator is returned as mainMarkdown (trimmed of trailing // newlines). Each separator starts a new sub-agent whose content spans until // the next separator or the end of the file. diff --git a/pkg/parser/sub_agent_extractor_test.go b/pkg/parser/sub_agent_extractor_test.go index 6e473c498a4..e169e0581bb 100644 --- a/pkg/parser/sub_agent_extractor_test.go +++ b/pkg/parser/sub_agent_extractor_test.go @@ -31,7 +31,7 @@ func TestExtractInlineSubAgents_SingleAgent(t *testing.T) { Handle the issue. - +## @agent: planner --- engine: copilot --- @@ -51,13 +51,13 @@ func TestExtractInlineSubAgents_MultipleAgents(t *testing.T) { Main prompt. - +## @agent: planner --- engine: copilot --- You are a planner. - +## @agent: executor --- engine: copilot --- @@ -77,7 +77,7 @@ You are an executor.` } func TestExtractInlineSubAgents_AgentAtStartOfFile(t *testing.T) { - markdown := ` + markdown := `## @agent: only-agent --- engine: copilot --- @@ -94,7 +94,7 @@ Agent prompt.` func TestExtractInlineSubAgents_AgentWithoutFrontmatter(t *testing.T) { markdown := `Main workflow. - +## @agent: simple Just a prompt, no frontmatter.` _, agents, err := ExtractInlineSubAgents(markdown) @@ -105,13 +105,13 @@ Just a prompt, no frontmatter.` assert.Equal(t, "Just a prompt, no frontmatter.", agents[0].Content, "agent content should be the prompt") } -func TestExtractInlineSubAgents_SeparatorWithWhitespace(t *testing.T) { - // Leading and trailing whitespace around the separator should be tolerated - markdown := "Main.\n\n \nAgent content." +func TestExtractInlineSubAgents_SeparatorWithTrailingWhitespace(t *testing.T) { + // Trailing whitespace after the name should be tolerated + markdown := "Main.\n\n## @agent: padded \nAgent content." _, agents, err := ExtractInlineSubAgents(markdown) - require.NoError(t, err, "separator with surrounding whitespace should be recognized") + require.NoError(t, err, "separator with trailing whitespace should be recognized") require.Len(t, agents, 1, "should extract one sub-agent") assert.Equal(t, "padded", agents[0].Name, "agent name should be 'padded'") } @@ -123,19 +123,27 @@ func TestExtractInlineSubAgents_InvalidNameNotRecognized(t *testing.T) { }{ { name: "name starts with digit", - separator: "", + separator: "## @agent: 1agent", }, { name: "name contains spaces", - separator: "", + separator: "## @agent: my agent", }, { name: "name contains slash", - separator: "", + separator: "## @agent: my/agent", }, { name: "missing name", - separator: "", + separator: "## @agent:", + }, + { + name: "wrong heading level (H1)", + separator: "# @agent: myagent", + }, + { + name: "wrong heading level (H3)", + separator: "### @agent: myagent", }, } @@ -154,10 +162,10 @@ func TestExtractInlineSubAgents_InvalidNameNotRecognized(t *testing.T) { func TestExtractInlineSubAgents_DuplicateNameError(t *testing.T) { markdown := `Main. - +## @agent: planner Content 1. - +## @agent: planner Content 2.` _, _, err := ExtractInlineSubAgents(markdown) @@ -173,11 +181,11 @@ func TestExtractInlineSubAgents_NameVariants(t *testing.T) { separator string agentName string }{ - {"with hyphens", "", "my-agent"}, - {"with underscores", "", "my_agent"}, - {"with digits", "", "agent1"}, - {"mixed case", "", "MyAgent"}, - {"single letter", "", "a"}, + {"with hyphens", "## @agent: my-agent", "my-agent"}, + {"with underscores", "## @agent: my_agent", "my_agent"}, + {"with digits", "## @agent: agent1", "agent1"}, + {"mixed case", "## @agent: MyAgent", "MyAgent"}, + {"single letter", "## @agent: a", "a"}, } for _, tt := range tests { @@ -194,7 +202,7 @@ func TestExtractInlineSubAgents_NameVariants(t *testing.T) { func TestExtractInlineSubAgents_ContentTrimmed(t *testing.T) { // Content after the separator should have leading/trailing whitespace trimmed - markdown := "Main.\n\n\n\n\n Agent content here. \n\n" + markdown := "Main.\n\n## @agent: trim-test\n\n\n Agent content here. \n\n" _, agents, err := ExtractInlineSubAgents(markdown) @@ -204,7 +212,7 @@ func TestExtractInlineSubAgents_ContentTrimmed(t *testing.T) { } func TestExtractInlineSubAgents_MainMarkdownTrailingNewlinesStripped(t *testing.T) { - markdown := "Line 1.\nLine 2.\n\n\n\nContent." + markdown := "Line 1.\nLine 2.\n\n\n## @agent: a\nContent." mainMarkdown, _, err := ExtractInlineSubAgents(markdown) diff --git a/pkg/workflow/compiler_inline_sub_agents_test.go b/pkg/workflow/compiler_inline_sub_agents_test.go index 4ba1e03df7f..a1cbee8a55d 100644 --- a/pkg/workflow/compiler_inline_sub_agents_test.go +++ b/pkg/workflow/compiler_inline_sub_agents_test.go @@ -36,7 +36,7 @@ on: Triage the issue. - +## @agent: planner --- engine: copilot tools: @@ -45,7 +45,7 @@ tools: --- You are a planning assistant. - +## @agent: executor --- engine: copilot tools: @@ -84,7 +84,7 @@ You are an execution specialist. lockPath := workflowPath[:len(workflowPath)-len(".md")] + ".lock.yml" lockContent, err := os.ReadFile(lockPath) require.NoError(t, err, "lock file should be generated") - assert.NotContains(t, string(lockContent), " +## @agent: helper --- engine: copilot --- @@ -144,7 +144,7 @@ on: Main workflow prompt. - +## @agent: sidecar --- engine: copilot --- @@ -168,7 +168,7 @@ Sub-agent prompt content. require.NoError(t, err, "lock file should exist") lockStr := string(lockContent) - assert.NotContains(t, lockStr, " separator syntax - RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging + AI string // "claude" or "codex" (for backwards compatibility) + EngineConfig *EngineConfig // Extended engine configuration + AgentFile string // Path to custom agent file (from imports) + AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") + RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging StopTime string SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index c0d3739216a..6e7349e9204 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -55,7 +55,6 @@ func (c *Compiler) buildInitialWorkflowData( EngineConfig: engineSetup.engineConfig, AgentFile: agentFile, AgentImportSpec: agentImportSpec, - InlineSubAgents: toolsResult.inlineSubAgents, RepositoryImports: importsResult.RepositoryImports, NetworkPermissions: engineSetup.networkPermissions, SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig), From 4c7899b72d0b7cae4a37b05775cd8d0216f7c8f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:21:27 +0000 Subject: [PATCH 08/23] feat: update sub-agent syntax to backtick name, lowercase, H2 boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Marker syntax: ## agent: `name` (backtick-enclosed) - Name must be lowercase: [a-z][a-z0-9_-]* - Remove ## end: name marker — agent block ends at next H2 heading (##) or EOF - Updated Go parser (sub_agent_extractor.go): new regex, new h2HeadingRegex, simplified ExtractInlineSubAgents using H2-boundary approach - Updated Go tests: new syntax, removed end-marker tests, added H2-ending tests - Updated JS extractor (extract_inline_sub_agents.cjs): same changes - Updated JS tests: new syntax, removed end-marker tests, added H2-ending tests - Updated quick-check regex in interpolate_prompt.cjs Agent-Logs-Url: https://github.com/github/gh-aw/sessions/fcec8b9b-76e3-490a-ae8a-6e1b9892b01f Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/extract_inline_sub_agents.cjs | 97 +++---- .../js/extract_inline_sub_agents.test.cjs | 132 ++------- actions/setup/js/interpolate_prompt.cjs | 2 +- pkg/parser/sub_agent_extractor.go | 261 ++++++------------ pkg/parser/sub_agent_extractor_test.go | 234 +++++++--------- 5 files changed, 253 insertions(+), 473 deletions(-) diff --git a/actions/setup/js/extract_inline_sub_agents.cjs b/actions/setup/js/extract_inline_sub_agents.cjs index 3dc2b8bb09d..0b444020683 100644 --- a/actions/setup/js/extract_inline_sub_agents.cjs +++ b/actions/setup/js/extract_inline_sub_agents.cjs @@ -3,8 +3,8 @@ // extract_inline_sub_agents.cjs // -// Parses ## agent: name / ## end: name markers from workflow markdown and -// writes each agent block as a separate .md file under .github/agents/. +// Parses ## agent: `name` markers from workflow markdown and writes each agent +// block as a separate .md file under .github/agents/. // // This step runs AFTER {{#runtime-import}} macros have been fully inlined by // processRuntimeImports() in interpolate_prompt.cjs, ensuring that any imports @@ -12,15 +12,12 @@ // // Marker syntax // ───────────── -// ## agent: name Opens an agent block. name must start with a -// letter and contain only alphanumeric chars, hyphens, -// or underscores (safe for filenames). +// ## agent: `name` Opens an agent block. name must start with a +// lowercase letter and contain only lowercase letters, +// digits, hyphens, or underscores (safe for filenames). // -// ## end: name Optionally closes the named agent block. When the -// name matches the currently-open agent the block is -// terminated; content after the marker is excluded -// from the agent file. A mismatched name or an end -// marker with no open agent is treated as plain text. +// An agent block ends at the next level-2 Markdown heading (## ...) or EOF. +// There is no explicit end marker — any H2 heading closes the agent block. // // If no ## agent: markers are present the content is returned unchanged and no // files are written. @@ -28,11 +25,12 @@ const fs = require("fs"); const path = require("path"); -// Regex for the start marker: ## agent: name -const START_MARKER_RE = /^##[ \t]+agent:[ \t]+([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*$/gm; +// Regex for the start marker: ## agent: `name` (lowercase identifier) +const START_MARKER_RE = /^##[ \t]+agent:[ \t]+`([a-z][a-z0-9_-]*)`[ \t]*$/gm; -// Regex for the optional end marker: ## end: name -const END_MARKER_RE = /^##[ \t]+end:[ \t]+([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*$/gm; +// Regex that matches the start of any level-2 Markdown heading (## ). +// Used to find the boundary where each agent block ends. +const H2_HEADING_RE = /^##[ \t]/gm; /** * Extracts inline sub-agents from markdown content. @@ -40,66 +38,45 @@ const END_MARKER_RE = /^##[ \t]+end:[ \t]+([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*$/gm; * Returns the main content (everything before the first ## agent: marker, with * trailing newlines stripped) and an array of extracted agents. * + * An agent block extends from its start marker to the next H2 heading or EOF. + * * @param {string} content - Markdown with potential inline sub-agent blocks. * @returns {{ mainContent: string, agents: Array<{name: string, content: string}> }} */ function extractInlineSubAgents(content) { - /** @type {Array<{kind: "start"|"end", name: string, lineStart: number, lineEnd: number}>} */ - const markers = []; - - for (const m of content.matchAll(START_MARKER_RE)) { - if (m.index === undefined) continue; - let lineEnd = m.index + m[0].length; - if (lineEnd < content.length && content[lineEnd] === "\n") lineEnd++; - markers.push({ kind: "start", name: m[1], lineStart: m.index, lineEnd }); - } + const startMatches = [...content.matchAll(START_MARKER_RE)]; - for (const m of content.matchAll(END_MARKER_RE)) { - if (m.index === undefined) continue; - let lineEnd = m.index + m[0].length; - if (lineEnd < content.length && content[lineEnd] === "\n") lineEnd++; - markers.push({ kind: "end", name: m[1], lineStart: m.index, lineEnd }); + if (startMatches.length === 0) { + return { mainContent: content, agents: [] }; } - // Sort all markers by their position in the document. - markers.sort((a, b) => a.lineStart - b.lineStart); - - // Find the first start marker. - const firstStartIdx = markers.findIndex(m => m.kind === "start"); - if (firstStartIdx === -1) { + // Main content is everything before the first start marker (trailing newlines stripped). + const firstMatch = startMatches[0]; + if (firstMatch.index === undefined) { return { mainContent: content, agents: [] }; } + const mainContent = content.slice(0, firstMatch.index).replace(/\n+$/, ""); - // Main content is everything before the first start marker (trailing newlines stripped). - const mainContent = content.slice(0, markers[firstStartIdx].lineStart).replace(/\n+$/, ""); + // Collect all H2 heading positions for block boundary detection. + const h2Positions = [...content.matchAll(H2_HEADING_RE)].map(m => m.index).filter(i => i !== undefined); /** @type {Array<{name: string, content: string}>} */ const agents = []; - let currentName = /** @type {string | null} */ null; - let contentStart = 0; - - for (let i = firstStartIdx; i < markers.length; i++) { - const m = markers[i]; - - if (m.kind === "start") { - // Close any currently open agent. - if (currentName !== null) { - agents.push({ name: currentName, content: content.slice(contentStart, m.lineStart).trim() }); - } - // Open the new agent. - currentName = m.name; - contentStart = m.lineEnd; - } else if (m.kind === "end" && m.name === currentName) { - // Matching end marker — close the agent. - agents.push({ name: currentName, content: content.slice(contentStart, m.lineStart).trim() }); - currentName = null; - } - // Mismatched end markers (name doesn't match open agent) are plain text — no action. - } - // Close any agent still open at EOF. - if (currentName !== null) { - agents.push({ name: currentName, content: content.slice(contentStart).trim() }); + for (const m of startMatches) { + if (m.index === undefined) continue; + + const name = m[1]; + + // Content starts on the line after the start marker. + let lineEnd = m.index + m[0].length; + if (lineEnd < content.length && content[lineEnd] === "\n") lineEnd++; + + // Content ends at the next H2 heading after the start marker line, or EOF. + const contentEnd = h2Positions.find(pos => pos >= lineEnd) ?? content.length; + + const agentContent = content.slice(lineEnd, contentEnd).trim(); + agents.push({ name, content: agentContent }); } return { mainContent, agents }; diff --git a/actions/setup/js/extract_inline_sub_agents.test.cjs b/actions/setup/js/extract_inline_sub_agents.test.cjs index c785bcd9475..196959d32aa 100644 --- a/actions/setup/js/extract_inline_sub_agents.test.cjs +++ b/actions/setup/js/extract_inline_sub_agents.test.cjs @@ -16,6 +16,9 @@ global.core = { const { extractInlineSubAgents, writeInlineSubAgents } = require("./extract_inline_sub_agents.cjs"); +// Helper: returns a ## agent: `name` start marker line. +const agentMarker = name => `## agent: \`${name}\``; + // ───────────────────────────────────────────────────────────────────────────── // extractInlineSubAgents — unit tests // ───────────────────────────────────────────────────────────────────────────── @@ -35,15 +38,7 @@ describe("extractInlineSubAgents", () => { }); it("extracts a single agent block", () => { - const content = `# Main workflow - -Handle the issue. - -## agent: planner ---- -engine: copilot ---- -You are a planning assistant.`; + const content = ["# Main workflow", "", "Handle the issue.", "", agentMarker("planner"), "---", "engine: copilot", "---", "You are a planning assistant."].join("\n"); const { mainContent, agents } = extractInlineSubAgents(content); @@ -55,13 +50,7 @@ You are a planning assistant.`; }); it("extracts multiple agent blocks", () => { - const content = `Main prompt. - -## agent: planner -Planner prompt. - -## agent: executor -Executor prompt.`; + const content = ["Main prompt.", "", agentMarker("planner"), "Planner prompt.", "", agentMarker("executor"), "Executor prompt."].join("\n"); const { mainContent, agents } = extractInlineSubAgents(content); @@ -73,14 +62,8 @@ Executor prompt.`; expect(agents[1].content).toBe("Executor prompt."); }); - it("respects ## end: name marker", () => { - const content = `Main prompt. - -## agent: planner -Planner content. -## end: planner - -This content is outside any agent block.`; + it("agent block ends at next H2 heading", () => { + const content = ["Main prompt.", "", agentMarker("planner"), "Planner content.", "", "## Summary", "This content is outside the agent block."].join("\n"); const { mainContent, agents } = extractInlineSubAgents(content); @@ -88,21 +71,12 @@ This content is outside any agent block.`; expect(agents).toHaveLength(1); expect(agents[0].name).toBe("planner"); expect(agents[0].content).toBe("Planner content."); - expect(agents[0].content).not.toContain("outside any agent block"); + expect(agents[0].content).not.toContain("Summary"); + expect(agents[0].content).not.toContain("outside the agent block"); }); - it("end marker stops agent block; content between blocks is excluded", () => { - const content = `Main. - -## agent: planner -Planner. -## end: planner - -Between-agents content. - -## agent: executor -Executor. -## end: executor`; + it("next agent marker (H2) ends the previous agent block", () => { + const content = ["Main.", "", agentMarker("planner"), "Planner.", "", agentMarker("executor"), "Executor."].join("\n"); const { agents } = extractInlineSubAgents(content); @@ -111,38 +85,8 @@ Executor. expect(agents[1].content).toBe("Executor."); }); - it("mismatched end marker is treated as plain text", () => { - const content = `Main. - -## agent: planner -Planner content. -## end: executor -More planner content.`; - - const { agents } = extractInlineSubAgents(content); - - expect(agents).toHaveLength(1); - expect(agents[0].content).toContain("Planner content."); - expect(agents[0].content).toContain("More planner content."); - }); - - it("orphan end marker (no open agent) is treated as plain text", () => { - const content = "Main.\n## end: nobody\nMore main."; - const { mainContent, agents } = extractInlineSubAgents(content); - expect(mainContent).toBe(content); - expect(agents).toHaveLength(0); - }); - - it("end marker with trailing whitespace is recognised", () => { - const content = "Main.\n\n## agent: a\nContent.\n## end: a \nTrailing."; - const { agents } = extractInlineSubAgents(content); - expect(agents).toHaveLength(1); - expect(agents[0].content).toBe("Content."); - }); - it("agent at start of file produces empty main content", () => { - const content = `## agent: only -Agent content.`; + const content = agentMarker("only") + "\nAgent content."; const { mainContent, agents } = extractInlineSubAgents(content); expect(mainContent).toBe(""); expect(agents).toHaveLength(1); @@ -150,34 +94,28 @@ Agent content.`; }); it("agent content is trimmed", () => { - const content = "Main.\n\n## agent: a\n\n\n Trimmed. \n\n"; + const content = "Main.\n\n" + agentMarker("a") + "\n\n\n Trimmed. \n\n"; const { agents } = extractInlineSubAgents(content); expect(agents[0].content).toBe("Trimmed."); }); it("trailing newlines are stripped from main content", () => { - const content = "Line 1.\nLine 2.\n\n\n## agent: a\nContent."; + const content = "Line 1.\nLine 2.\n\n\n" + agentMarker("a") + "\nContent."; const { mainContent } = extractInlineSubAgents(content); expect(mainContent).toBe("Line 1.\nLine 2."); }); - it("accepts valid name variants", () => { - const cases = [ - { sep: "## agent: my-agent", name: "my-agent" }, - { sep: "## agent: my_agent", name: "my_agent" }, - { sep: "## agent: agent1", name: "agent1" }, - { sep: "## agent: MyAgent", name: "MyAgent" }, - { sep: "## agent: a", name: "a" }, - ]; - for (const { sep, name } of cases) { - const { agents } = extractInlineSubAgents(`Main.\n\n${sep}\nContent.`); + it("accepts valid lowercase name variants", () => { + const cases = [{ name: "my-agent" }, { name: "my_agent" }, { name: "agent1" }, { name: "a" }, { name: "planner-v2" }]; + for (const { name } of cases) { + const { agents } = extractInlineSubAgents("Main.\n\n" + agentMarker(name) + "\nContent."); expect(agents).toHaveLength(1); expect(agents[0].name).toBe(name); } }); it("does not recognize invalid separator forms", () => { - const invalids = ["## agent: 1agent", "## agent: my agent", "## agent: my/agent", "## agent:", "# agent: myagent", "### agent: myagent"]; + const invalids = ["## agent: `1agent`", "## agent: `my agent`", "## agent: `my/agent`", "## agent:", "## agent: myagent", "## agent: `MyAgent`", "# agent: `myagent`", "### agent: `myagent`"]; for (const sep of invalids) { const content = `Main.\n\n${sep}\nContent.`; const { mainContent, agents } = extractInlineSubAgents(content); @@ -213,15 +151,7 @@ describe("writeInlineSubAgents", () => { }); it("writes a single agent file and returns main content", () => { - const content = `# Workflow - -Main prompt. - -## agent: helper ---- -engine: copilot ---- -You are a helper.`; + const content = ["# Workflow", "", "Main prompt.", "", agentMarker("helper"), "---", "engine: copilot", "---", "You are a helper."].join("\n"); const result = writeInlineSubAgents(content, tmpDir); @@ -235,13 +165,7 @@ You are a helper.`; }); it("writes multiple agent files", () => { - const content = `Main. - -## agent: planner -Planner. - -## agent: executor -Executor.`; + const content = ["Main.", "", agentMarker("planner"), "Planner.", "", agentMarker("executor"), "Executor."].join("\n"); writeInlineSubAgents(content, tmpDir); @@ -250,28 +174,22 @@ Executor.`; }); it("agent file content ends with a newline", () => { - const content = "Main.\n\n## agent: a\nContent without trailing newline"; + const content = "Main.\n\n" + agentMarker("a") + "\nContent without trailing newline"; writeInlineSubAgents(content, tmpDir); const written = fs.readFileSync(path.join(tmpDir, ".github", "agents", "a.md"), "utf8"); expect(written.endsWith("\n")).toBe(true); }); it("creates .github/agents directory if it does not exist", () => { - const content = "Main.\n\n## agent: new\nContent."; + const content = "Main.\n\n" + agentMarker("new") + "\nContent."; const agentsDir = path.join(tmpDir, ".github", "agents"); expect(fs.existsSync(agentsDir)).toBe(false); writeInlineSubAgents(content, tmpDir); expect(fs.existsSync(agentsDir)).toBe(true); }); - it("strips agent blocks but content after end marker stays out of agent file", () => { - const content = `Main. - -## agent: a -Agent body. -## end: a - -Footer content that should not appear in the agent file.`; + it("agent block ends at H2 — content after is not written to agent file", () => { + const content = ["Main.", "", agentMarker("a"), "Agent body.", "", "## Notes", "Footer content that should not appear in the agent file."].join("\n"); const result = writeInlineSubAgents(content, tmpDir); diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index e72c9b406f0..944bcb02793 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -227,7 +227,7 @@ async function main() { core.info("\n========================================"); core.info("[main] STEP 1.5: Inline Sub-Agent Extraction"); core.info("========================================"); - const hasAgentMarkers = /^##[ \t]+agent:[ \t]+[a-zA-Z]/m.test(content); + const hasAgentMarkers = /^##[ \t]+agent:[ \t]+`[a-z]/m.test(content); if (hasAgentMarkers) { const beforeExtraction = content.length; content = writeInlineSubAgents(content, workspaceDir); diff --git a/pkg/parser/sub_agent_extractor.go b/pkg/parser/sub_agent_extractor.go index 5fd48245aa7..f3d2dd9e67d 100644 --- a/pkg/parser/sub_agent_extractor.go +++ b/pkg/parser/sub_agent_extractor.go @@ -7,51 +7,45 @@ // A sub-agent is a secondary agent definition embedded directly in the same // markdown file as the primary workflow. Each sub-agent has its own frontmatter // block plus a prompt body. Sub-agents appear after the main workflow body and -// are delimited by a pair of level-2 Markdown headings: +// are delimited by level-2 Markdown headings: // -// ## agent: name ← opens a sub-agent block -// ## end: name ← optionally closes it (same name required) +//## agent: `name` ← opens a sub-agent block // -// Both markers render as visible section headings in any Markdown preview -// (GitHub, VS Code, etc.) while remaining clearly distinguishable from regular -// document headings. The agent name must start with a letter and contain only -// alphanumeric characters, hyphens, and underscores. +// An agent block ends at the next level-2 Markdown heading (## ...) or end +// of file. The name must be a lowercase identifier (letters, digits, hyphens, +// underscores; must start with a letter). // -// The end marker is optional: if absent, the block extends to the next -// ## agent: line or end of file. Using ## end: name is recommended when other -// content may be inserted after the agent block (e.g. auto-generated sections), -// so that inserted text is not accidentally captured as part of the agent. +// Both the agent marker and any subsequent H2 section heading render as visible +// section headings in any Markdown preview (GitHub, VS Code, etc.). // // # Example // -// --- -// engine: copilot -// on: -// issues: -// types: [opened] -// --- -// # Handle issue -// Triage the issue and delegate work to sub-agents. -// -// ## agent: planner -// --- -// engine: copilot -// tools: -// github: -// toolsets: [issues, pull_requests] -// --- -// You are a planning specialist. -// ## end: planner -// -// ## agent: executor -// --- -// engine: copilot -// tools: -// github: -// toolsets: [pull_requests] -// --- -// You are an execution specialist. -// ## end: executor +//--- +//engine: copilot +//on: +// issues: +// types: [opened] +//--- +//# Handle issue +//Triage the issue and delegate work to sub-agents. +// +//## agent: `planner` +//--- +//engine: copilot +//tools: +// github: +// toolsets: [issues, pull_requests] +//--- +//You are a planning specialist. +// +//## agent: `executor` +//--- +//engine: copilot +//tools: +// github: +// toolsets: [pull_requests] +//--- +//You are an execution specialist. // // # Compilation Output // @@ -63,29 +57,28 @@ // // ExtractInlineSubAgents is called early in processToolsAndMarkdown so that // the main workflow content (returned as mainMarkdown) is used for all -// subsequent prompt generation, while InlineSubAgents is populated on -// WorkflowData for the compilation output step. +// subsequent prompt generation, while the sub-agent files are written at +// runtime by interpolate_prompt.cjs after runtime imports are inlined. package parser import ( "fmt" "regexp" - "sort" "strings" ) // InlineSubAgent holds a single sub-agent definition extracted from a workflow -// markdown file's body using the ## agent: name / ## end: name syntax. +// markdown file's body using the ## agent: `name` syntax. type InlineSubAgent struct { - // Name is the identifier taken from the ## agent: name line. - // It is safe to use as a filename (alphanumeric, hyphens, underscores). + // Name is the identifier taken from the ## agent: `name` line. + // It is lowercase and safe to use as a filename. Name string - // Content is the raw text between the ## agent: name and ## end: name lines - // (or the next ## agent: line / EOF when no end marker is present). It - // typically includes a YAML frontmatter block (---...---) followed by the - // sub-agent's prompt body, but the format is not enforced — it varies by engine. + // Content is the raw text between the ## agent: `name` line and the next + // level-2 Markdown heading (## ...) or EOF. It typically includes a YAML + // frontmatter block (---...---) followed by the sub-agent's prompt body, + // but the format is not enforced — it varies by engine. Content string } @@ -93,155 +86,85 @@ type InlineSubAgent struct { // // Format (anchored to line boundaries via (?m)): // -// ## agent: name +// ## agent: `name` // // Rules: // - A level-2 Markdown heading (##) // - One or more whitespace characters between "##" and "agent:" -// - One or more whitespace characters between "agent:" and the name -// - Agent name: starts with a letter, followed by alphanumeric / hyphen / underscore +// - One or more whitespace characters between "agent:" and the backtick-enclosed name +// - Agent name: starts with a lowercase letter, followed by lowercase letters, +// digits, hyphens, or underscores // - Optional trailing whitespace -var subAgentSeparatorRegex = regexp.MustCompile(`(?m)^##[ \t]+agent:[ \t]+([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*$`) +var subAgentSeparatorRegex = regexp.MustCompile("(?m)^##[ \t]+agent:[ \t]+`([a-z][a-z0-9_-]*)`[ \t]*$") -// subAgentEndRegex matches the optional inline sub-agent end marker line. -// -// Format (anchored to line boundaries via (?m)): -// -// ## end: name -// -// Rules mirror subAgentSeparatorRegex; the name must match the corresponding -// ## agent: name line for the marker to take effect. -var subAgentEndRegex = regexp.MustCompile(`(?m)^##[ \t]+end:[ \t]+([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*$`) - -// markerKind distinguishes start markers (## agent: name) from end markers -// (## end: name) during event-driven parsing. -type markerKind int - -const ( - startMarkerKind markerKind = iota - endMarkerKind -) - -// agentMarker represents a single parsed marker line. -type agentMarker struct { - kind markerKind - name string - lineStart int // byte offset of the first character of the marker line - lineEnd int // byte offset of the first character after the marker line (past '\n') -} +// h2HeadingRegex matches the start of any level-2 Markdown heading (## space/tab). +// An agent block extends from its start marker to the next H2 heading or EOF. +var h2HeadingRegex = regexp.MustCompile(`(?m)^##[ \t]`) // ExtractInlineSubAgents splits markdown into the main workflow section and any // inline sub-agent definitions. // -// It scans the markdown body for ## agent: name start markers and optional -// ## end: name end markers. Content before the first start marker is returned -// as mainMarkdown (trimmed of trailing newlines). Each start marker opens a -// sub-agent whose content spans to the matching ## end: name line, the next -// ## agent: line, or EOF — whichever comes first. -// -// An ## end: name line whose name does not match the currently open agent is -// silently treated as plain text (included in the current agent's content). +// It scans the markdown body for ## agent: `name` start markers. Content before +// the first start marker is returned as mainMarkdown (trimmed of trailing +// newlines). Each start marker opens a sub-agent whose content spans to the +// next level-2 Markdown heading (## ...) or EOF — whichever comes first. // // If no start markers are found the original markdown is returned unchanged and // agents is nil. func ExtractInlineSubAgents(markdown string) (mainMarkdown string, agents []InlineSubAgent, err error) { - // Collect all start and end markers in document order. - var markers []agentMarker - - for _, m := range subAgentSeparatorRegex.FindAllStringSubmatchIndex(markdown, -1) { - lineEnd := m[1] - if lineEnd < len(markdown) && markdown[lineEnd] == '\n' { - lineEnd++ - } - markers = append(markers, agentMarker{ - kind: startMarkerKind, - name: markdown[m[2]:m[3]], - lineStart: m[0], - lineEnd: lineEnd, - }) - } - - for _, m := range subAgentEndRegex.FindAllStringSubmatchIndex(markdown, -1) { - lineEnd := m[1] - if lineEnd < len(markdown) && markdown[lineEnd] == '\n' { - lineEnd++ - } - markers = append(markers, agentMarker{ - kind: endMarkerKind, - name: markdown[m[2]:m[3]], - lineStart: m[0], - lineEnd: lineEnd, - }) - } - - // Sort all markers by their position in the document. - sort.Slice(markers, func(i, j int) bool { - return markers[i].lineStart < markers[j].lineStart - }) - - // Find the index of the first start marker. - firstStart := -1 - for i, m := range markers { - if m.kind == startMarkerKind { - firstStart = i - break - } - } - - if firstStart == -1 { + // Find all start markers (returned in document order by FindAllStringSubmatchIndex). + allStarts := subAgentSeparatorRegex.FindAllStringSubmatchIndex(markdown, -1) + if len(allStarts) == 0 { // No start markers — return unchanged. return markdown, nil, nil } - // Validate that all start-marker names are unique. + // Validate that all agent names are unique. seen := make(map[string]struct{}) - for _, m := range markers { - if m.kind == startMarkerKind { - if _, exists := seen[m.name]; exists { - return "", nil, fmt.Errorf("duplicate inline sub-agent name %q", m.name) - } - seen[m.name] = struct{}{} + for _, m := range allStarts { + name := markdown[m[2]:m[3]] + if _, exists := seen[name]; exists { + return "", nil, fmt.Errorf("duplicate inline sub-agent name %q", name) } + seen[name] = struct{}{} } // Main markdown is everything before the first start marker. - mainMarkdown = strings.TrimRight(markdown[:markers[firstStart].lineStart], "\n") - - // Walk markers from the first start marker, tracking the open agent. - var currentName string - var contentStart int + mainMarkdown = strings.TrimRight(markdown[:allStarts[0][0]], "\n") - for i := firstStart; i < len(markers); i++ { - m := markers[i] + // Collect the byte offset of every H2 heading in the document. + // These positions are used to find the boundary where each agent block ends. + var h2Positions []int + for _, m := range h2HeadingRegex.FindAllStringIndex(markdown, -1) { + h2Positions = append(h2Positions, m[0]) + } - switch m.kind { - case startMarkerKind: - // Close the currently open agent (if any) at this line's start. - if currentName != "" { - content := strings.TrimSpace(markdown[contentStart:m.lineStart]) - agents = append(agents, InlineSubAgent{Name: currentName, Content: content}) - } - // Open the new agent; its content starts on the line after this marker. - currentName = m.name - contentStart = m.lineEnd - - case endMarkerKind: - // Only take effect when the name matches the currently open agent. - if currentName == m.name { - content := strings.TrimSpace(markdown[contentStart:m.lineStart]) - agents = append(agents, InlineSubAgent{Name: currentName, Content: content}) - currentName = "" + // nextH2After returns the byte offset of the first H2 heading at or after + // 'offset', or len(markdown) when none exists. + nextH2After := func(offset int) int { + for _, pos := range h2Positions { + if pos >= offset { + return pos } - // If the name does not match (or no agent is open), the line is plain - // text that is already included in contentStart..next-event range; no - // action needed here. } + return len(markdown) } - // Close any agent that was still open at EOF. - if currentName != "" { - content := strings.TrimSpace(markdown[contentStart:]) - agents = append(agents, InlineSubAgent{Name: currentName, Content: content}) + // Extract each agent block. + for _, m := range allStarts { + name := markdown[m[2]:m[3]] + + // Content starts on the line after the start marker. + lineEnd := m[1] + if lineEnd < len(markdown) && markdown[lineEnd] == '\n' { + lineEnd++ + } + + // Content ends at the next H2 heading after the start marker line, or EOF. + contentEnd := nextH2After(lineEnd) + + content := strings.TrimSpace(markdown[lineEnd:contentEnd]) + agents = append(agents, InlineSubAgent{Name: name, Content: content}) } return mainMarkdown, agents, nil diff --git a/pkg/parser/sub_agent_extractor_test.go b/pkg/parser/sub_agent_extractor_test.go index 4f0e816556e..86c6589a889 100644 --- a/pkg/parser/sub_agent_extractor_test.go +++ b/pkg/parser/sub_agent_extractor_test.go @@ -3,12 +3,19 @@ package parser import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// agentLine returns a ## agent: `name` start marker line for use in test fixtures. +func agentLine(name string) string { + return fmt.Sprintf("## agent: `%s`", name) +} + func TestExtractInlineSubAgents_NoSeparators(t *testing.T) { markdown := "# Hello\n\nThis is a workflow." mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) @@ -27,15 +34,17 @@ func TestExtractInlineSubAgents_EmptyMarkdown(t *testing.T) { } func TestExtractInlineSubAgents_SingleAgent(t *testing.T) { - markdown := `# Main workflow - -Handle the issue. - -## agent: planner ---- -engine: copilot ---- -You are a planning assistant.` + markdown := strings.Join([]string{ + "# Main workflow", + "", + "Handle the issue.", + "", + agentLine("planner"), + "---", + "engine: copilot", + "---", + "You are a planning assistant.", + }, "\n") mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) @@ -47,21 +56,23 @@ You are a planning assistant.` } func TestExtractInlineSubAgents_MultipleAgents(t *testing.T) { - markdown := `# Main workflow - -Main prompt. - -## agent: planner ---- -engine: copilot ---- -You are a planner. - -## agent: executor ---- -engine: copilot ---- -You are an executor.` + markdown := strings.Join([]string{ + "# Main workflow", + "", + "Main prompt.", + "", + agentLine("planner"), + "---", + "engine: copilot", + "---", + "You are a planner.", + "", + agentLine("executor"), + "---", + "engine: copilot", + "---", + "You are an executor.", + }, "\n") mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) @@ -77,11 +88,7 @@ You are an executor.` } func TestExtractInlineSubAgents_AgentAtStartOfFile(t *testing.T) { - markdown := `## agent: only-agent ---- -engine: copilot ---- -Agent prompt.` + markdown := agentLine("only-agent") + "\n---\nengine: copilot\n---\nAgent prompt." mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) @@ -92,10 +99,7 @@ Agent prompt.` } func TestExtractInlineSubAgents_AgentWithoutFrontmatter(t *testing.T) { - markdown := `Main workflow. - -## agent: simple -Just a prompt, no frontmatter.` + markdown := "Main workflow.\n\n" + agentLine("simple") + "\nJust a prompt, no frontmatter." _, agents, err := ExtractInlineSubAgents(markdown) @@ -106,8 +110,8 @@ Just a prompt, no frontmatter.` } func TestExtractInlineSubAgents_SeparatorWithTrailingWhitespace(t *testing.T) { - // Trailing whitespace after the name should be tolerated - markdown := "Main.\n\n## agent: padded \nAgent content." + // Trailing whitespace after the closing backtick should be tolerated + markdown := "Main.\n\n" + agentLine("padded") + " \nAgent content." _, agents, err := ExtractInlineSubAgents(markdown) @@ -123,27 +127,35 @@ func TestExtractInlineSubAgents_InvalidNameNotRecognized(t *testing.T) { }{ { name: "name starts with digit", - separator: "## agent: 1agent", + separator: "## agent: `1agent`", }, { name: "name contains spaces", - separator: "## agent: my agent", + separator: "## agent: `my agent`", }, { name: "name contains slash", - separator: "## agent: my/agent", + separator: "## agent: `my/agent`", }, { name: "missing name", separator: "## agent:", }, + { + name: "name not in backticks", + separator: "## agent: myagent", + }, + { + name: "name uppercase", + separator: "## agent: `MyAgent`", + }, { name: "wrong heading level (H1)", - separator: "# agent: myagent", + separator: "# agent: `myagent`", }, { name: "wrong heading level (H3)", - separator: "### agent: myagent", + separator: "### agent: `myagent`", }, } @@ -160,13 +172,7 @@ func TestExtractInlineSubAgents_InvalidNameNotRecognized(t *testing.T) { } func TestExtractInlineSubAgents_DuplicateNameError(t *testing.T) { - markdown := `Main. - -## agent: planner -Content 1. - -## agent: planner -Content 2.` + markdown := "Main.\n\n" + agentLine("planner") + "\nContent 1.\n\n" + agentLine("planner") + "\nContent 2." _, _, err := ExtractInlineSubAgents(markdown) @@ -178,19 +184,18 @@ Content 2.` func TestExtractInlineSubAgents_NameVariants(t *testing.T) { tests := []struct { name string - separator string agentName string }{ - {"with hyphens", "## agent: my-agent", "my-agent"}, - {"with underscores", "## agent: my_agent", "my_agent"}, - {"with digits", "## agent: agent1", "agent1"}, - {"mixed case", "## agent: MyAgent", "MyAgent"}, - {"single letter", "## agent: a", "a"}, + {"with hyphens", "my-agent"}, + {"with underscores", "my_agent"}, + {"with digits", "agent1"}, + {"single letter", "a"}, + {"mixed pattern", "planner-v2"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - markdown := "Main.\n\n" + tt.separator + "\nContent." + markdown := "Main.\n\n" + agentLine(tt.agentName) + "\nContent." _, agents, err := ExtractInlineSubAgents(markdown) require.NoError(t, err, "valid agent name %q should parse without error", tt.agentName) @@ -202,7 +207,7 @@ func TestExtractInlineSubAgents_NameVariants(t *testing.T) { func TestExtractInlineSubAgents_ContentTrimmed(t *testing.T) { // Content after the separator should have leading/trailing whitespace trimmed - markdown := "Main.\n\n## agent: trim-test\n\n\n Agent content here. \n\n" + markdown := "Main.\n\n" + agentLine("trim-test") + "\n\n\n Agent content here. \n\n" _, agents, err := ExtractInlineSubAgents(markdown) @@ -211,101 +216,58 @@ func TestExtractInlineSubAgents_ContentTrimmed(t *testing.T) { assert.Equal(t, "Agent content here.", agents[0].Content, "agent content should be trimmed") } -func TestExtractInlineSubAgents_EndMarker(t *testing.T) { - // Content after the end marker should NOT be included in the agent's content. - markdown := `# Main workflow - -Main prompt. - -## agent: planner ---- -engine: copilot ---- -You are a planner. -## end: planner - -This content is outside any agent block.` +func TestExtractInlineSubAgents_AgentEndsAtNextH2(t *testing.T) { + // An agent block must end at the next H2 heading (any ##), not just ## agent:. + markdown := strings.Join([]string{ + "# Main workflow", + "", + "Main prompt.", + "", + agentLine("planner"), + "---", + "engine: copilot", + "---", + "You are a planner.", + "", + "## Summary", + "This content is outside the agent block.", + }, "\n") mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) - require.NoError(t, err, "end marker should parse without error") - assert.Equal(t, "# Main workflow\n\nMain prompt.", mainMarkdown, "main markdown should exclude agent sections") - require.Len(t, agents, 1, "should extract one sub-agent") - assert.Equal(t, "planner", agents[0].Name, "agent name should be 'planner'") + require.NoError(t, err, "H2 ending should parse without error") + assert.Equal(t, "# Main workflow\n\nMain prompt.", mainMarkdown, "main markdown should exclude agent section") + require.Len(t, agents, 1, "should extract one agent") + assert.Equal(t, "planner", agents[0].Name) assert.Contains(t, agents[0].Content, "You are a planner.", "agent content should contain prompt") - assert.NotContains(t, agents[0].Content, "outside any agent block", "content after end marker must not appear in agent") + assert.NotContains(t, agents[0].Content, "Summary", "content after H2 must not appear in agent") + assert.NotContains(t, agents[0].Content, "outside the agent block", "content after H2 must not appear in agent") } -func TestExtractInlineSubAgents_EndMarkerMultipleAgents(t *testing.T) { - // With end markers, content between agent blocks is excluded from both agents. - markdown := `Main. - -## agent: planner -Planner prompt. -## end: planner - -Inserted content between agents. - -## agent: executor -Executor prompt. -## end: executor` +func TestExtractInlineSubAgents_AgentEndsAtNextAgentH2(t *testing.T) { + // A new ## agent: `name` marker (which is itself an H2) also ends the previous agent. + markdown := strings.Join([]string{ + "Main.", + "", + agentLine("planner"), + "Planner prompt.", + "", + agentLine("executor"), + "Executor prompt.", + }, "\n") _, agents, err := ExtractInlineSubAgents(markdown) - require.NoError(t, err, "multiple agents with end markers should parse without error") - require.Len(t, agents, 2, "should extract two sub-agents") - + require.NoError(t, err, "multiple agents should parse without error") + require.Len(t, agents, 2, "should extract two agents") assert.Equal(t, "planner", agents[0].Name) - assert.Equal(t, "Planner prompt.", agents[0].Content, "planner content must stop at end marker") - + assert.Equal(t, "Planner prompt.", agents[0].Content, "planner content must stop at next agent marker") assert.Equal(t, "executor", agents[1].Name) - assert.Equal(t, "Executor prompt.", agents[1].Content, "executor content must stop at end marker") -} - -func TestExtractInlineSubAgents_EndMarkerMismatch(t *testing.T) { - // An end marker whose name does not match the open agent is treated as plain - // text and included in the current agent's content. - markdown := `Main. - -## agent: planner -Planner content. -## end: executor -More planner content.` - - _, agents, err := ExtractInlineSubAgents(markdown) - - require.NoError(t, err, "mismatched end marker should not cause an error") - require.Len(t, agents, 1, "should still extract one sub-agent") - assert.Contains(t, agents[0].Content, "Planner content.", "content before mismatched end marker should be included") - assert.Contains(t, agents[0].Content, "More planner content.", "content after mismatched end marker should also be included") -} - -func TestExtractInlineSubAgents_EndMarkerWithoutOpenAgent(t *testing.T) { - // An end marker that appears before any agent (or after all agents are closed) - // is treated as plain text and does not affect parsing. - markdown := `Main content. -## end: nobody -More main content.` - - mainMarkdown, agents, err := ExtractInlineSubAgents(markdown) - - require.NoError(t, err, "orphan end marker should not cause an error") - assert.Equal(t, markdown, mainMarkdown, "markdown should be returned unchanged when no start markers present") - assert.Nil(t, agents, "no agents should be produced") -} - -func TestExtractInlineSubAgents_EndMarkerWithTrailingWhitespace(t *testing.T) { - markdown := "Main.\n\n## agent: a\nContent.\n## end: a \nTrailing." - - _, agents, err := ExtractInlineSubAgents(markdown) - - require.NoError(t, err, "end marker with trailing whitespace should be recognized") - require.Len(t, agents, 1, "should extract one sub-agent") - assert.Equal(t, "Content.", agents[0].Content, "content should stop at the end marker") + assert.Equal(t, "Executor prompt.", agents[1].Content) } func TestExtractInlineSubAgents_MainMarkdownTrailingNewlinesStripped(t *testing.T) { - markdown := "Line 1.\nLine 2.\n\n\n## agent: a\nContent." + markdown := "Line 1.\nLine 2.\n\n\n" + agentLine("a") + "\nContent." mainMarkdown, _, err := ExtractInlineSubAgents(markdown) From a5bd59888d91c5a033c2909e05160292408e5840 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:29:08 +0000 Subject: [PATCH 09/23] feat: write sub-agent files to .agents/agents/ with .agent.md extension - Change default output directory from .github/agents/ to .agents/agents/ - Change file suffix from .md to .agent.md - Update log message to reflect new path - Update tests accordingly Agent-Logs-Url: https://github.com/github/gh-aw/sessions/fe94365f-773b-4e4a-8f16-aabd2bb7d69f Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/extract_inline_sub_agents.cjs | 10 +++++----- .../setup/js/extract_inline_sub_agents.test.cjs | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/extract_inline_sub_agents.cjs b/actions/setup/js/extract_inline_sub_agents.cjs index 0b444020683..4852f73283d 100644 --- a/actions/setup/js/extract_inline_sub_agents.cjs +++ b/actions/setup/js/extract_inline_sub_agents.cjs @@ -4,7 +4,7 @@ // extract_inline_sub_agents.cjs // // Parses ## agent: `name` markers from workflow markdown and writes each agent -// block as a separate .md file under .github/agents/. +// block as a separate .agent.md file under .agents/agents/. // // This step runs AFTER {{#runtime-import}} macros have been fully inlined by // processRuntimeImports() in interpolate_prompt.cjs, ensuring that any imports @@ -84,7 +84,7 @@ function extractInlineSubAgents(content) { /** * Extracts inline sub-agents from content and writes each one to - * /.github/agents/.md. + * /.agents/agents/.agent.md. * * Returns the main content (before the first ## agent: marker) after stripping * all agent blocks. When no agent markers are found the original content is @@ -101,14 +101,14 @@ function writeInlineSubAgents(content, workspaceDir) { return content; } - const agentsDir = path.join(workspaceDir, ".github", "agents"); + const agentsDir = path.join(workspaceDir, ".agents", "agents"); fs.mkdirSync(agentsDir, { recursive: true }); for (const agent of agents) { - const agentPath = path.join(agentsDir, agent.name + ".md"); + const agentPath = path.join(agentsDir, agent.name + ".agent.md"); const agentContent = agent.content.endsWith("\n") ? agent.content : agent.content + "\n"; fs.writeFileSync(agentPath, agentContent, "utf8"); - core.info(`[extractInlineSubAgents] Written sub-agent: .github/agents/${agent.name}.md`); + core.info(`[extractInlineSubAgents] Written sub-agent: .agents/agents/${agent.name}.agent.md`); } return mainContent; diff --git a/actions/setup/js/extract_inline_sub_agents.test.cjs b/actions/setup/js/extract_inline_sub_agents.test.cjs index 196959d32aa..e0281f60860 100644 --- a/actions/setup/js/extract_inline_sub_agents.test.cjs +++ b/actions/setup/js/extract_inline_sub_agents.test.cjs @@ -146,7 +146,7 @@ describe("writeInlineSubAgents", () => { const content = "# Workflow\n\nNo agents here."; const result = writeInlineSubAgents(content, tmpDir); expect(result).toBe(content); - const agentsDir = path.join(tmpDir, ".github", "agents"); + const agentsDir = path.join(tmpDir, ".agents", "agents"); expect(fs.existsSync(agentsDir)).toBe(false); }); @@ -157,7 +157,7 @@ describe("writeInlineSubAgents", () => { expect(result).toBe("# Workflow\n\nMain prompt."); - const agentPath = path.join(tmpDir, ".github", "agents", "helper.md"); + const agentPath = path.join(tmpDir, ".agents", "agents", "helper.agent.md"); expect(fs.existsSync(agentPath)).toBe(true); const written = fs.readFileSync(agentPath, "utf8"); expect(written).toContain("You are a helper."); @@ -169,20 +169,20 @@ describe("writeInlineSubAgents", () => { writeInlineSubAgents(content, tmpDir); - expect(fs.existsSync(path.join(tmpDir, ".github", "agents", "planner.md"))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, ".github", "agents", "executor.md"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".agents", "agents", "planner.agent.md"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".agents", "agents", "executor.agent.md"))).toBe(true); }); it("agent file content ends with a newline", () => { const content = "Main.\n\n" + agentMarker("a") + "\nContent without trailing newline"; writeInlineSubAgents(content, tmpDir); - const written = fs.readFileSync(path.join(tmpDir, ".github", "agents", "a.md"), "utf8"); + const written = fs.readFileSync(path.join(tmpDir, ".agents", "agents", "a.agent.md"), "utf8"); expect(written.endsWith("\n")).toBe(true); }); - it("creates .github/agents directory if it does not exist", () => { + it("creates .agents/agents directory if it does not exist", () => { const content = "Main.\n\n" + agentMarker("new") + "\nContent."; - const agentsDir = path.join(tmpDir, ".github", "agents"); + const agentsDir = path.join(tmpDir, ".agents", "agents"); expect(fs.existsSync(agentsDir)).toBe(false); writeInlineSubAgents(content, tmpDir); expect(fs.existsSync(agentsDir)).toBe(true); @@ -194,7 +194,7 @@ describe("writeInlineSubAgents", () => { const result = writeInlineSubAgents(content, tmpDir); expect(result).toBe("Main."); - const written = fs.readFileSync(path.join(tmpDir, ".github", "agents", "a.md"), "utf8"); + const written = fs.readFileSync(path.join(tmpDir, ".agents", "agents", "a.agent.md"), "utf8"); expect(written).toContain("Agent body."); expect(written).not.toContain("Footer content"); }); From 902dea067dd69d1560840091c6f8894a713151c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:37:15 +0000 Subject: [PATCH 10/23] feat: add file-summarizer sub-agent to smoke-copilot workflow - Add inline sub-agent `## agent: \`file-summarizer\`` with model: claude-haiku-4.5 (no engine) - Sub-agent summarizes a file in 2-4 concise sentences - Add test step 15 to smoke-copilot to invoke the sub-agent - Recompile smoke-copilot.lock.yml Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c05b2212-b2ef-42ed-b4fc-fabb8590cfbd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 3f0df33ce2c..769d63f6892 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -173,6 +173,7 @@ These are **not** MCP protocol tools — they are bash executables. Call them wi 12. **Workflow Dispatch Testing**: Use the `dispatch_workflow` safe output tool to trigger the `haiku-printer` workflow with a haiku as the message input. Create an original, creative haiku about software testing or automation. 13. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. To test `reply_to_pull_request_review_comment`: use the `pull_request_read` tool (with `method: "get_review_comments"` and `pullNumber: ${{ github.event.pull_request.number }}`) to fetch the PR's existing review comments, then reply to the most recent one using `reply_to_pull_request_review_comment` with its actual numeric `id` as the `comment_id`. Note: `create_pull_request_review_comment` does not return a `comment_id` — you must fetch existing comment IDs from the GitHub API. If the PR has no existing review comments, skip the reply sub-test. 14. **Comment Memory Testing**: Append an original 3-line haiku to the comment-memory markdown file(s) in `/tmp/gh-aw/comment-memory/*.md` without removing existing content. +15. **Sub-Agent Testing**: Use the `agentic-workflows` MCP `run_agent` tool to invoke the `file-summarizer` sub-agent with the path `.github/workflows/smoke-copilot.md` as input. Verify the sub-agent returns a brief summary (a few sentences). Mark this test as ❌ if the tool is unavailable or returns an error. ## Output @@ -203,3 +204,11 @@ If all tests pass and this workflow was triggered by a pull_request event: - Use the `remove_labels` safe-output tool to remove the label `smoke` from the pull request (omit the `item_number` parameter to auto-target the triggering PR) {{#runtime-import shared/noop-reminder.md}} + +## agent: `file-summarizer` +--- +model: claude-haiku-4.5 +description: Summarizes the content of a file in a few concise sentences +--- +You are a file summarization assistant. When given a file path, read the file and return a brief summary (2–4 sentences) describing its purpose and key contents. Be concise and factual. + From 6082a907b8d0bac25136563dfd02646906819f71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 05:04:09 +0000 Subject: [PATCH 11/23] chore: add inline sub-agent syntax changeset --- .changeset/patch-add-inline-sub-agent-syntax.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-add-inline-sub-agent-syntax.md diff --git a/.changeset/patch-add-inline-sub-agent-syntax.md b/.changeset/patch-add-inline-sub-agent-syntax.md new file mode 100644 index 00000000000..87f1b8db4c2 --- /dev/null +++ b/.changeset/patch-add-inline-sub-agent-syntax.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add inline sub-agent syntax using `## agent: \`name\`` H2 headings in workflow markdown. From cc21fb26e00bed9c53b77bad15db2c8191b80059 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 05:07:12 +0000 Subject: [PATCH 12/23] fix: update smoke-copilot sub-agent test step to verify file creation via bash The `run_agent` tool doesn't exist in the agenticworkflows CLI. The sub-agent file is correctly written to `.agents/agents/file-summarizer.agent.md` at runtime (confirmed in CI logs). Update test step 15 to verify the file exists using bash instead of invoking a non-existent tool. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/456e0127-4d5b-41e3-9f92-54f9e7d60b83 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 769d63f6892..1d94058da7f 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -173,7 +173,7 @@ These are **not** MCP protocol tools — they are bash executables. Call them wi 12. **Workflow Dispatch Testing**: Use the `dispatch_workflow` safe output tool to trigger the `haiku-printer` workflow with a haiku as the message input. Create an original, creative haiku about software testing or automation. 13. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. To test `reply_to_pull_request_review_comment`: use the `pull_request_read` tool (with `method: "get_review_comments"` and `pullNumber: ${{ github.event.pull_request.number }}`) to fetch the PR's existing review comments, then reply to the most recent one using `reply_to_pull_request_review_comment` with its actual numeric `id` as the `comment_id`. Note: `create_pull_request_review_comment` does not return a `comment_id` — you must fetch existing comment IDs from the GitHub API. If the PR has no existing review comments, skip the reply sub-test. 14. **Comment Memory Testing**: Append an original 3-line haiku to the comment-memory markdown file(s) in `/tmp/gh-aw/comment-memory/*.md` without removing existing content. -15. **Sub-Agent Testing**: Use the `agentic-workflows` MCP `run_agent` tool to invoke the `file-summarizer` sub-agent with the path `.github/workflows/smoke-copilot.md` as input. Verify the sub-agent returns a brief summary (a few sentences). Mark this test as ❌ if the tool is unavailable or returns an error. +15. **Sub-Agent Testing**: Use bash to verify the inline sub-agent was extracted from the workflow at runtime: run `ls -la .agents/agents/file-summarizer.agent.md && head -5 .agents/agents/file-summarizer.agent.md` and confirm the file exists and starts with a YAML frontmatter block. Mark this test as ❌ if the file is missing. ## Output From 8d409ff8810e726dd2c446913b1735357e7093b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 05:16:09 +0000 Subject: [PATCH 13/23] fix: update smoke step 15 to invoke file-summarizer sub-agent via prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-agents are a native Copilot CLI feature — the CLI discovers agent files in `.agents/agents/` automatically. Update step 15 to prompt the CLI to use the `file-summarizer` sub-agent to summarize smoke-copilot.md, rather than just checking the file exists with bash. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f899b9cc-69ba-41df-b92e-b3692152bb9e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 1d94058da7f..e497416e643 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -173,7 +173,7 @@ These are **not** MCP protocol tools — they are bash executables. Call them wi 12. **Workflow Dispatch Testing**: Use the `dispatch_workflow` safe output tool to trigger the `haiku-printer` workflow with a haiku as the message input. Create an original, creative haiku about software testing or automation. 13. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. To test `reply_to_pull_request_review_comment`: use the `pull_request_read` tool (with `method: "get_review_comments"` and `pullNumber: ${{ github.event.pull_request.number }}`) to fetch the PR's existing review comments, then reply to the most recent one using `reply_to_pull_request_review_comment` with its actual numeric `id` as the `comment_id`. Note: `create_pull_request_review_comment` does not return a `comment_id` — you must fetch existing comment IDs from the GitHub API. If the PR has no existing review comments, skip the reply sub-test. 14. **Comment Memory Testing**: Append an original 3-line haiku to the comment-memory markdown file(s) in `/tmp/gh-aw/comment-memory/*.md` without removing existing content. -15. **Sub-Agent Testing**: Use bash to verify the inline sub-agent was extracted from the workflow at runtime: run `ls -la .agents/agents/file-summarizer.agent.md && head -5 .agents/agents/file-summarizer.agent.md` and confirm the file exists and starts with a YAML frontmatter block. Mark this test as ❌ if the file is missing. +15. **Sub-Agent Testing**: Use the `file-summarizer` sub-agent to summarize the file `.github/workflows/smoke-copilot.md`. The sub-agent is defined inline in this workflow and is automatically available in `.agents/agents/file-summarizer.agent.md`. Verify the sub-agent returns a brief summary (2–4 sentences). Mark this test as ❌ if the sub-agent is unavailable or returns an error. ## Output From 80d01ab7cbe8b739e57e2c726cb18c3e98350402 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 09:34:27 +0000 Subject: [PATCH 14/23] docs: add inline sub-agents reference documentation - Create docs/src/content/docs/reference/inline-sub-agents.md with full syntax reference (## agent: `name`, name constraints, frontmatter fields, runtime behavior, example, multiple sub-agents) - Update markdown.md with short "Inline Sub-Agents" section + link - Update workflow-structure.md with "Inline Sub-Agent Blocks" section - Update copilot-custom-agents.md with "Defining Agents Inline" section - Add Inline Sub-Agents entry to astro.config.mjs sidebar Build passes: 163 pages built, all internal links valid. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4f051c29-2926-4d28-a1fe-4e2159d5fe08 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/astro.config.mjs | 1 + .../src/content/docs/agent-factory-status.mdx | 2 + .../docs/reference/copilot-custom-agents.md | 19 +++ .../docs/reference/inline-sub-agents.md | 128 ++++++++++++++++++ docs/src/content/docs/reference/markdown.md | 18 +++ .../docs/reference/workflow-structure.md | 13 ++ 6 files changed, 181 insertions(+) create mode 100644 docs/src/content/docs/reference/inline-sub-agents.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index f6ea55602cb..f26106a16b6 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -352,6 +352,7 @@ export default defineConfig({ { label: 'Imports', link: '/reference/imports/' }, { label: 'Imports (APM)', link: '/reference/dependencies/' }, { label: 'Imports (Copilot Agent Files)', link: '/reference/copilot-custom-agents/' }, + { label: 'Inline Sub-Agents', link: '/reference/inline-sub-agents/' }, { label: 'Imports (Dependabot)', link: '/reference/dependabot/' }, { label: 'Indexing (QMD)', link: '/reference/qmd/' }, { label: 'Markdown', link: '/reference/markdown/' }, diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index 1031300698f..507d6e38a39 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -51,6 +51,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Copilot PR Prompt Pattern Analysis](https://github.com/github/gh-aw/blob/main/.github/workflows/copilot-pr-prompt-analysis.md) | copilot | [![Copilot PR Prompt Pattern Analysis](https://github.com/github/gh-aw/actions/workflows/copilot-pr-prompt-analysis.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/copilot-pr-prompt-analysis.lock.yml) | - | - | | [Copilot Session Insights](https://github.com/github/gh-aw/blob/main/.github/workflows/copilot-session-insights.md) | claude | [![Copilot Session Insights](https://github.com/github/gh-aw/actions/workflows/copilot-session-insights.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/copilot-session-insights.lock.yml) | - | - | | [Copilot Token Usage Optimizer](https://github.com/github/gh-aw/blob/main/.github/workflows/copilot-token-optimizer.md) | copilot | [![Copilot Token Usage Optimizer](https://github.com/github/gh-aw/actions/workflows/copilot-token-optimizer.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/copilot-token-optimizer.lock.yml) | `daily around 14:00 on weekdays` | - | +| [Daily A/B Testing Advisor](https://github.com/github/gh-aw/blob/main/.github/workflows/ab-testing-advisor.md) | copilot | [![Daily A/B Testing Advisor](https://github.com/github/gh-aw/actions/workflows/ab-testing-advisor.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/ab-testing-advisor.lock.yml) | `daily around 10:00` | - | | [Daily AstroStyleLite Markdown Spellcheck](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-astrostylelite-markdown-spellcheck.md) | claude | [![Daily AstroStyleLite Markdown Spellcheck](https://github.com/github/gh-aw/actions/workflows/daily-astrostylelite-markdown-spellcheck.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-astrostylelite-markdown-spellcheck.lock.yml) | - | - | | [Daily AW Cross-Repo Compile Check](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-aw-cross-repo-compile-check.md) | claude | [![Daily AW Cross-Repo Compile Check](https://github.com/github/gh-aw/actions/workflows/daily-aw-cross-repo-compile-check.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-aw-cross-repo-compile-check.lock.yml) | - | - | | [Daily Cache Strategy Analyzer](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-cache-strategy-analyzer.md) | codex | [![Daily Cache Strategy Analyzer](https://github.com/github/gh-aw/actions/workflows/daily-cache-strategy-analyzer.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-cache-strategy-analyzer.lock.yml) | - | - | @@ -94,6 +95,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Daily Testify Uber Super Expert](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-testify-uber-super-expert.md) | copilot | [![Daily Testify Uber Super Expert](https://github.com/github/gh-aw/actions/workflows/daily-testify-uber-super-expert.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-testify-uber-super-expert.lock.yml) | - | - | | [Daily Token Consumption Report (Sentry OTel)](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-token-consumption-report.md) | claude | [![Daily Token Consumption Report (Sentry OTel)](https://github.com/github/gh-aw/actions/workflows/daily-token-consumption-report.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-token-consumption-report.lock.yml) | - | - | | [Daily Workflow Updater](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-workflow-updater.md) | copilot | [![Daily Workflow Updater](https://github.com/github/gh-aw/actions/workflows/daily-workflow-updater.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-workflow-updater.lock.yml) | - | - | +| [daily-experiment-report](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-experiment-report.md) | copilot | [![daily-experiment-report](https://github.com/github/gh-aw/actions/workflows/daily-experiment-report.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-experiment-report.lock.yml) | - | - | | [Dead Code Removal Agent](https://github.com/github/gh-aw/blob/main/.github/workflows/dead-code-remover.md) | copilot | [![Dead Code Removal Agent](https://github.com/github/gh-aw/actions/workflows/dead-code-remover.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/dead-code-remover.lock.yml) | - | - | | [DeepReport - Intelligence Gathering Agent](https://github.com/github/gh-aw/blob/main/.github/workflows/deep-report.md) | claude | [![DeepReport - Intelligence Gathering Agent](https://github.com/github/gh-aw/actions/workflows/deep-report.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/deep-report.lock.yml) | `daily around 15:00 on weekdays` | - | | [Delight](https://github.com/github/gh-aw/blob/main/.github/workflows/delight.md) | copilot | [![Delight](https://github.com/github/gh-aw/actions/workflows/delight.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/delight.lock.yml) | - | - | diff --git a/docs/src/content/docs/reference/copilot-custom-agents.md b/docs/src/content/docs/reference/copilot-custom-agents.md index 99eaf2a7bd0..52d3b7fbb18 100644 --- a/docs/src/content/docs/reference/copilot-custom-agents.md +++ b/docs/src/content/docs/reference/copilot-custom-agents.md @@ -130,8 +130,27 @@ safe-outputs: Perform detailed security analysis using specialized agent files and tools. ``` +## Defining Agents Inline + +Instead of (or alongside) importing agent files from `.github/agents/`, you can define agents directly inside the workflow markdown file using a `## agent: \`name\`` heading: + +```markdown +## agent: `code-reviewer` +--- +model: claude-sonnet-4.5 +description: Reviews code for quality and correctness +--- +You are a code review agent. Analyze the provided code for bugs, style issues, +and potential improvements. Be specific and actionable. +``` + +At runtime, each inline sub-agent block is extracted to `.agents/agents/.agent.md`. The Copilot CLI discovers these files natively, so you can invoke the agent by name in your workflow prompt without any additional configuration. + +See [Inline Sub-Agents](/gh-aw/reference/inline-sub-agents/) for the complete syntax reference, including name constraints and frontmatter fields. + ## Related Documentation - [Imports Reference](/gh-aw/reference/imports/) - Complete import system documentation +- [Inline Sub-Agents](/gh-aw/reference/inline-sub-agents/) - Defining sub-agents inside a workflow file - [Reusing Workflows](/gh-aw/guides/packaging-imports/) - Managing workflow imports - [Frontmatter](/gh-aw/reference/frontmatter/) - Configuration options reference \ No newline at end of file diff --git a/docs/src/content/docs/reference/inline-sub-agents.md b/docs/src/content/docs/reference/inline-sub-agents.md new file mode 100644 index 00000000000..3b101699098 --- /dev/null +++ b/docs/src/content/docs/reference/inline-sub-agents.md @@ -0,0 +1,128 @@ +--- +title: Inline Sub-Agents +description: Define sub-agents directly inside a workflow markdown file using a level-2 heading delimiter. +sidebar: + order: 645 +--- + +An inline sub-agent is a named agent definition embedded directly in a workflow markdown file. Instead of creating a separate file in `.github/agents/`, you define the agent's frontmatter and instructions in a dedicated section of the same workflow file. + +## Syntax + +Start a sub-agent block with a level-2 heading in the following form: + +```markdown +## agent: `name` +``` + +The block continues until the next `##` heading or end of file. There is no explicit closing marker. + +### Name constraints + +- Must start with a lowercase letter (`a–z`) +- May contain only `a–z`, `0–9`, `_`, and `-` +- Examples: `file-summarizer`, `code_reviewer`, `pr-analyst` + +### Structure + +Each sub-agent block contains: + +1. **YAML frontmatter** (optional) — wrapped in `---` delimiters +2. **Instructions** — natural language prompt for the agent + +```markdown +## agent: `file-summarizer` +--- +model: claude-haiku-4.5 +description: Summarizes the content of a file in a few concise sentences +--- +You are a file summarization assistant. When given a file path, read the file +and return a brief summary (2–4 sentences) describing its purpose and key +contents. Be concise and factual. +``` + +## Frontmatter fields + +| Field | Required | Description | +|---|---|---| +| `model` | No | AI model to use (e.g. `claude-haiku-4.5`). Defaults to the parent workflow's model. | +| `description` | No | Short description of the sub-agent's purpose. | + +> [!NOTE] +> Sub-agents do **not** accept an `engine` field. They run within the parent workflow's engine. + +## Runtime behavior + +At runtime, `actions/setup` extracts each inline sub-agent block and writes it to: + +```text +.agents/agents/.agent.md +``` + +The Copilot CLI discovers agent files in `.agents/agents/` natively. To use a sub-agent, instruct the parent workflow's prompt to invoke it by name: + +```aw wrap +## Test Requirements + +15. **Sub-Agent Testing**: Use the `file-summarizer` sub-agent to summarize the + file `.github/workflows/smoke-copilot.md`. Verify the sub-agent returns a + brief summary (2–4 sentences). Mark this test as ❌ if the sub-agent is + unavailable or returns an error. +``` + +The Copilot CLI finds `.agents/agents/file-summarizer.agent.md` and invokes it automatically. + +## Complete example + +The following excerpt shows a full workflow that defines and uses an inline sub-agent. + +```aw wrap +--- +on: + workflow_dispatch: +engine: copilot +--- + +# File Summary Task + +Use the `file-summarizer` sub-agent to summarize `README.md` and add a comment +to the current pull request with the result. + +## agent: `file-summarizer` +--- +model: claude-haiku-4.5 +description: Summarizes the content of a file in a few concise sentences +--- +You are a file summarization assistant. When given a file path, read the file +and return a brief summary (2–4 sentences) describing its purpose and key +contents. Be concise and factual. +``` + +The sub-agent block at the bottom is extracted before the workflow runs and has no effect on the parent workflow's instructions. + +## Multiple sub-agents + +A single workflow file may contain more than one sub-agent block. Each block starts with its own `## agent: \`name\`` heading and ends at the next `##` heading or EOF. + +```aw wrap +## agent: `summarizer` +--- +model: claude-haiku-4.5 +description: Summarizes files concisely +--- +Summarize the given file in 2–4 sentences. + +## agent: `reviewer` +--- +model: claude-sonnet-4.5 +description: Reviews code for quality issues +--- +Review the given code for bugs, style issues, and potential improvements. +``` + +## Related Documentation + +- [Importing Copilot Agent Files](/gh-aw/reference/copilot-custom-agents/) — Importing agents from `.github/agents/` +- [Markdown](/gh-aw/reference/markdown/) — Workflow markdown body reference +- [Workflow Structure](/gh-aw/reference/workflow-structure/) — Overall workflow file organization +- [Frontmatter](/gh-aw/reference/frontmatter/) — YAML configuration options diff --git a/docs/src/content/docs/reference/markdown.md b/docs/src/content/docs/reference/markdown.md index e21100fb734..9c4db777316 100644 --- a/docs/src/content/docs/reference/markdown.md +++ b/docs/src/content/docs/reference/markdown.md @@ -84,9 +84,27 @@ See [Editing Workflows](/gh-aw/guides/editing-workflows/) for complete guidance The markdown body of workflows (excluding frontmatter) is automatically scanned for malicious content when added via `gh aw add`, during trial mode, and at compile time for imported files. The scanner rejects workflows containing: Unicode abuse (zero-width characters, bidirectional overrides), hidden content (suspicious HTML comments, CSS-hidden elements), obfuscated links (data URIs, `javascript:` URLs, IP-based URLs, URL shorteners), dangerous HTML tags (`