From e38acf773835b5cffde53caf9e4e21d58c07fe82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:48:08 +0000 Subject: [PATCH 1/5] Initial plan From 130c4022ba12489d899458c8b13be2e60d023306 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:22:23 +0000 Subject: [PATCH 2/5] Resolve and record Claude/Codex sub-agent models before engine startup Co-authored-by: SivaKesava1 <11771739+SivaKesava1@users.noreply.github.com> --- .github/workflows/ab-testing-advisor.lock.yml | 1 + .../workflows/agent-persona-explorer.lock.yml | 1 + .../workflows/api-consumption-report.lock.yml | 1 + .../aw-failure-investigator.lock.yml | 1 + .../claude-code-user-docs-review.lock.yml | 1 + .../daily-agentrx-trace-optimizer.lock.yml | 1 + .../workflows/daily-arxiv-researcher.lock.yml | 1 + ...daily-awf-spec-compiler-surfacing.lock.yml | 1 + .../daily-safeoutputs-git-simulator.lock.yml | 1 + .../detection-analysis-report.lock.yml | 1 + .../developer-docs-consolidator.lock.yml | 1 + .../github-mcp-tools-report.lock.yml | 1 + .github/workflows/purelock.lock.yml | 1 + .../smoke-claude-sub-agents.lock.yml | 1612 +++++++++++++++++ .github/workflows/smoke-claude-sub-agents.md | 113 ++ .github/workflows/spec-enforcer.lock.yml | 1 + .github/workflows/typist.lock.yml | 1 + actions/setup/js/claude_harness.cjs | 18 + actions/setup/js/claude_harness.test.cjs | 91 + actions/setup/js/codex_harness.cjs | 18 + actions/setup/js/codex_harness.test.cjs | 61 + actions/setup/js/engine_subagent_models.cjs | 408 +++++ .../setup/js/engine_subagent_models.test.cjs | 301 +++ .../setup/js/extract_inline_sub_agents.cjs | 23 +- .../js/extract_inline_sub_agents.test.cjs | 65 +- ...awf-reflect-claude-typist-39415087125.json | 79 + .../awf-reflect-codex-arxiv-39394965127.json | 213 +++ .../.codex/agents/opportunity-extractor.md | 23 + .../.codex/agents/paper-screener.md | 27 + .../.codex/agents/preliminary-investigator.md | 15 + .../.codex/agents/relevance-ranker.md | 20 + .../.claude/agents/duplicate-type-finder.md | 42 + .../fixtures/claude_codex_subagent_runs.cjs | 99 + actions/setup/js/generate_aw_info.test.cjs | 15 + .../setup/js/smoke_claude_sub_agents.test.cjs | 125 ++ ...claude_codex_subagent_declarations_test.go | 110 ++ pkg/workflow/compiler_yaml_step_lifecycle.go | 43 +- 37 files changed, 3531 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/smoke-claude-sub-agents.lock.yml create mode 100644 .github/workflows/smoke-claude-sub-agents.md create mode 100644 actions/setup/js/engine_subagent_models.cjs create mode 100644 actions/setup/js/engine_subagent_models.test.cjs create mode 100644 actions/setup/js/fixtures/awf-reflect-claude-typist-39415087125.json create mode 100644 actions/setup/js/fixtures/awf-reflect-codex-arxiv-39394965127.json create mode 100644 actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/opportunity-extractor.md create mode 100644 actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/paper-screener.md create mode 100644 actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/preliminary-investigator.md create mode 100644 actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/relevance-ranker.md create mode 100644 actions/setup/js/fixtures/claude-codex-sub-agents/typist-39415087125/.claude/agents/duplicate-type-finder.md create mode 100644 actions/setup/js/fixtures/claude_codex_subagent_runs.cjs create mode 100644 actions/setup/js/smoke_claude_sub_agents.test.cjs create mode 100644 pkg/workflow/claude_codex_subagent_declarations_test.go diff --git a/.github/workflows/ab-testing-advisor.lock.yml b/.github/workflows/ab-testing-advisor.lock.yml index 72a34c6847f..d669ef9d6cc 100644 --- a/.github/workflows/ab-testing-advisor.lock.yml +++ b/.github/workflows/ab-testing-advisor.lock.yml @@ -186,6 +186,7 @@ jobs: GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_MODEL_COSTS: '{"providers":{"openai":{"models":{"gpt-6.1-sol":{"cost":{"cache_read":"1e-07","cache_write":"2.5e-06","input":"2e-06","output":"1e-05"}}}}}}' GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"workflow-characterizer\",\"patterns\":[\"openai/gpt-5*mini*\",\"openai/gpt-5*nano*\"]},{\"model\":\"small\",\"name\":\"field-presence-checker\",\"patterns\":[\"openai/gpt-5*mini*\",\"openai/gpt-5*nano*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/agent-persona-explorer.lock.yml b/.github/workflows/agent-persona-explorer.lock.yml index 402d615dbc3..8f4515d3e73 100644 --- a/.github/workflows/agent-persona-explorer.lock.yml +++ b/.github/workflows/agent-persona-explorer.lock.yml @@ -219,6 +219,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐ŸŽญ" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"inherit\",\"name\":\"persona-evaluator\",\"patterns\":[]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/api-consumption-report.lock.yml b/.github/workflows/api-consumption-report.lock.yml index 9cffb5e649d..18c10006df5 100644 --- a/.github/workflows/api-consumption-report.lock.yml +++ b/.github/workflows/api-consumption-report.lock.yml @@ -186,6 +186,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ“Š" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"metrics-aggregator\",\"patterns\":[\"github-copilot/*haiku*\",\"github-copilot/gpt-5*mini*\",\"github-copilot/gpt-5*nano*\",\"github-copilot/gemini-*flash*lite*\"]},{\"model\":\"small\",\"name\":\"history-appender\",\"patterns\":[\"github-copilot/*haiku*\",\"github-copilot/gpt-5*mini*\",\"github-copilot/gpt-5*nano*\",\"github-copilot/gemini-*flash*lite*\"]},{\"model\":\"small\",\"name\":\"chart-script-writer\",\"patterns\":[\"github-copilot/*haiku*\",\"github-copilot/gpt-5*mini*\",\"github-copilot/gpt-5*nano*\",\"github-copilot/gemini-*flash*lite*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/aw-failure-investigator.lock.yml b/.github/workflows/aw-failure-investigator.lock.yml index 57057b693dd..ea884d66114 100644 --- a/.github/workflows/aw-failure-investigator.lock.yml +++ b/.github/workflows/aw-failure-investigator.lock.yml @@ -230,6 +230,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ”" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"failure-classifier\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"issue-matcher\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"cluster-evidence-extractor\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/claude-code-user-docs-review.lock.yml b/.github/workflows/claude-code-user-docs-review.lock.yml index 902ccfeddba..2975a9e5ef1 100644 --- a/.github/workflows/claude-code-user-docs-review.lock.yml +++ b/.github/workflows/claude-code-user-docs-review.lock.yml @@ -180,6 +180,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ“" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"doc-reader\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"engine-example-counter\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/daily-agentrx-trace-optimizer.lock.yml b/.github/workflows/daily-agentrx-trace-optimizer.lock.yml index 04609158648..3a720e6d360 100644 --- a/.github/workflows/daily-agentrx-trace-optimizer.lock.yml +++ b/.github/workflows/daily-agentrx-trace-optimizer.lock.yml @@ -223,6 +223,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "โšก" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"trajectory-builder\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"artifacts-summarizer\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"failure-pattern-classifier\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/daily-arxiv-researcher.lock.yml b/.github/workflows/daily-arxiv-researcher.lock.yml index 56c7dbf44da..183aa3875fd 100644 --- a/.github/workflows/daily-arxiv-researcher.lock.yml +++ b/.github/workflows/daily-arxiv-researcher.lock.yml @@ -173,6 +173,7 @@ jobs: GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_MODEL_COSTS: '{"providers":{"openai":{"models":{"gpt-5.6-sol":{"cost":{"cache_read":"4e-07","cache_write":"5e-06","input":"4e-06","output":"2e-05"}}}}}}' GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"openai/gpt-5.4-mini\",\"name\":\"preliminary-investigator\",\"patterns\":[\"openai/gpt-5.4-mini\"]},{\"model\":\"openai/gpt-5.4-mini\",\"name\":\"paper-screener\",\"patterns\":[\"openai/gpt-5.4-mini\"]},{\"model\":\"openai/gpt-5.4-mini\",\"name\":\"relevance-ranker\",\"patterns\":[\"openai/gpt-5.4-mini\"]},{\"model\":\"openai/gpt-5.6-sol\",\"name\":\"opportunity-extractor\",\"patterns\":[\"openai/gpt-5.6-sol\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/daily-awf-spec-compiler-surfacing.lock.yml b/.github/workflows/daily-awf-spec-compiler-surfacing.lock.yml index 35317b7c2a5..ed4abd68490 100644 --- a/.github/workflows/daily-awf-spec-compiler-surfacing.lock.yml +++ b/.github/workflows/daily-awf-spec-compiler-surfacing.lock.yml @@ -170,6 +170,7 @@ jobs: GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_MODEL_COSTS: '{"providers":{"github-copilot":{"models":{"gpt-6-sol":{"cost":{"cache_read":"2e-07","cache_write":"2.5e-06","input":"2e-06","output":"1e-05"}}}}}}' GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"awf-change-detector\",\"patterns\":[\"github-copilot/*haiku*\",\"github-copilot/gpt-5*mini*\",\"github-copilot/gpt-5*nano*\",\"github-copilot/gemini-*flash*lite*\"]},{\"model\":\"small\",\"name\":\"surfacing-gap-evaluator\",\"patterns\":[\"github-copilot/*haiku*\",\"github-copilot/gpt-5*mini*\",\"github-copilot/gpt-5*nano*\",\"github-copilot/gemini-*flash*lite*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/daily-safeoutputs-git-simulator.lock.yml b/.github/workflows/daily-safeoutputs-git-simulator.lock.yml index fb2306bf32c..f7e9e68a345 100644 --- a/.github/workflows/daily-safeoutputs-git-simulator.lock.yml +++ b/.github/workflows/daily-safeoutputs-git-simulator.lock.yml @@ -169,6 +169,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿงช" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"large\",\"name\":\"config-simulator\",\"patterns\":[\"anthropic/*sonnet*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/detection-analysis-report.lock.yml b/.github/workflows/detection-analysis-report.lock.yml index c7b92da1ff9..ad80fcf87b6 100644 --- a/.github/workflows/detection-analysis-report.lock.yml +++ b/.github/workflows/detection-analysis-report.lock.yml @@ -184,6 +184,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ”Ž" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"update-detection-trending\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index 12445f75d04..89cd261dc58 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -184,6 +184,7 @@ jobs: GH_AW_INFO_CACHE_MEMORY: "true" GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ“" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"file-cataloger\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"tone-analyzer\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index ce53cbb748c..a562d09fd40 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -178,6 +178,7 @@ jobs: GH_AW_INFO_CACHE_MEMORY: "true" GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ“Š" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"tool-list-diff\",\"patterns\":[\"anthropic/*haiku*\"]},{\"model\":\"small\",\"name\":\"tool-doc-writer\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/purelock.lock.yml b/.github/workflows/purelock.lock.yml index 35fca2d165c..125df9a3c10 100644 --- a/.github/workflows/purelock.lock.yml +++ b/.github/workflows/purelock.lock.yml @@ -186,6 +186,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ”" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_MODEL_COSTS: '{"providers":{"github-copilot":{"models":{"gpt-5.6-terra":{"cost":{"cache_read":"2e-07","cache_write":"2.5e-06","input":"2e-06","output":"1.2e-05"}}}}}}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"large\",\"name\":\"test-writer\",\"patterns\":[\"github-copilot/*sonnet*\",\"github-copilot/gpt-6*\",\"github-copilot/gpt-5*pro*\",\"github-copilot/gpt-5*\",\"github-copilot/gemini-*pro*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/smoke-claude-sub-agents.lock.yml b/.github/workflows/smoke-claude-sub-agents.lock.yml new file mode 100644 index 00000000000..3a7c4538ff0 --- /dev/null +++ b/.github/workflows/smoke-claude-sub-agents.lock.yml @@ -0,0 +1,1612 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"17bb259908afda18f563200fb0af4ec512da0839459e010a5d6bbec384b2b8c0","body_hash":"387c789d648bf5de762f3561c6a39b9226b0811b9c4e649ed81a115c30912b0b","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.296"}} +# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70","digest":"sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70@sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70","digest":"sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70@sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70","digest":"sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70@sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.36","digest":"sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382","pinned_image":"ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382"},{"image":"ghcr.io/github/github-mcp-server:v2.0.2","digest":"sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796","pinned_image":"ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796"}],"mcp_servers":[{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["create_issue"]}],"threat_detection":{"mode":"disabled"}} +# This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Smoke Claude inline sub-agent registration and pre-start model resolution +# +# Secrets used: +# - ANTHROPIC_API_KEY +# - COPILOT_GITHUB_TOKEN +# - GH_AW_DEFAULT_OTLP_ENDPOINT +# - GH_AW_DEFAULT_OTLP_HEADERS +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +# - actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 +# - actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 +# +# Container images used: +# - ghcr.io/github/gh-aw-firewall/agent:0.28.70@sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70@sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a +# - ghcr.io/github/gh-aw-firewall/squid:0.28.70@sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917 +# - ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2 +# - ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382 +# - ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796 + +name: "Smoke Claude Sub Agents" +on: + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (Reserved for Agentic Workflows)." + required: false + type: string + +# Jobs receive only their explicitly declared permissions. +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + queue: max + +run-name: "Smoke Claude Sub Agents" + +env: + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.GH_AW_DEFAULT_OTLP_ENDPOINT || vars.GH_AW_DEFAULT_OTLP_ENDPOINT }} + OTEL_SERVICE_NAME: gh-aw.smoke-claude-sub-agents + OTEL_RESOURCE_ATTRIBUTES: 'gh-aw.workflow.name=Smoke%20Claude%20Sub%20Agents,gh-aw.repository=${{ github.repository }},gh-aw.run.id=${{ github.run_id }},github.run_id=${{ github.run_id }},gh-aw.engine.id=claude' + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.GH_AW_DEFAULT_OTLP_HEADERS }} + GH_AW_OTLP_ENDPOINTS: '[{"url":"${{ secrets.GH_AW_DEFAULT_OTLP_ENDPOINT || vars.GH_AW_DEFAULT_OTLP_ENDPOINT }}","headers":"${{ secrets.GH_AW_DEFAULT_OTLP_HEADERS }}"}]' + GH_AW_OTLP_IF_MISSING: ignore + +jobs: + activation: + name: activation + needs: admission + if: (!cancelled()) && (needs.admission.result == 'success' || needs.admission.result == 'skipped') + runs-on: ubuntu-slim + # Permissions for the activation job (workflow permissions default to none). + permissions: + actions: read + contents: read + env: + GH_AW_ADMISSION_RESULT: ${{ needs.admission.outputs.result }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + admission-max-ai-credits: ${{ needs.admission.outputs.max-ai-credits }} + admission-max-ai-credits-evals: ${{ needs.admission.outputs.max-ai-credits-evals }} + admission-max-ai-credits-threat-detection: ${{ needs.admission.outputs.max-ai-credits-threat-detection }} + admission-result: ${{ needs.admission.outputs.result }} + aw_context: ${{ steps.generate_aw_info.outputs.aw_context }} + comment_id: "" + comment_repo: "" + daily_ai_credits_estimated: ${{ steps.daily-ai-credits-workflow-guardrail.outputs.daily_ai_credits_estimated || '' }} + daily_ai_credits_exceeded: ${{ steps.daily-ai-credits-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_guardrail_error: ${{ steps.daily-ai-credits-workflow-guardrail.outputs.daily_ai_credits_guardrail_error || '' }} + daily_ai_credits_guardrail_status: ${{ steps.daily-ai-credits-workflow-guardrail.outputs.daily_ai_credits_guardrail_status || '' }} + daily_ai_credits_threshold: ${{ steps.daily-ai-credits-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total: ${{ steps.daily-ai-credits-workflow-guardrail.outputs.daily_ai_credits_total || '' }} + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Checkout actions folder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: github/gh-aw + sparse-checkout: | + actions + fetch-depth: 1 + clean: false + persist-credentials: false + - name: Setup Scripts + id: setup + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.admission.outputs.trace-id }} + safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-claude-sub-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "2.1.296" + GH_AW_INFO_AWF_VERSION: "v0.28.70" + GH_AW_INFO_ENGINE_ID: "claude" + GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }} + - name: Mask OTLP telemetry headers + run: bash "${RUNNER_TEMP}/gh-aw/actions/mask_otlp_headers.sh" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "claude" + GH_AW_INFO_ENGINE_NAME: "Claude Code" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_CLAUDE || vars.GH_AW_DEFAULT_MODEL_CLAUDE || 'agent' }} + GH_AW_INFO_VERSION: "2.1.296" + GH_AW_INFO_AGENT_VERSION: "2.1.296" + GH_AW_INFO_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_INFO_WORKFLOW_ID: ${{ job.workflow_ref }} + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.28.70" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" + GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿงช" + GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_FEATURES: '{"gh-aw-detection":false}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"marker-worker\",\"patterns\":[\"anthropic/*haiku*\"]}]" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'generate_aw_info.cjs')); + await main(core, context); + - name: Restore daily AIC scan observations + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_ADMISSION_RESULT != 'admitted' && env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'restore_aic_scan_cache.cjs')); + await main(); + - name: Check daily workflow token guardrail + id: daily-ai-credits-workflow-guardrail + if: ${{ env.GH_AW_ADMISSION_RESULT != 'admitted' && env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_ID: "smoke-claude-sub-agents" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" + GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'check_daily_aic_workflow_guardrail.cjs')); + await main(); + - name: Publish daily AIC scan observations + if: always() && env.GH_AW_ADMISSION_RESULT != 'admitted' && env.GH_AW_MAX_DAILY_AI_CREDITS != '' + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: aic-usage-scan-v2 + path: /tmp/gh-aw/agentic-workflow-usage-scan-v2.jsonl + overwrite: true + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '3' }} + - name: Validate ANTHROPIC_API_KEY secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Check for OAuth tokens + id: check-oauth-tokens + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + actions/setup + .claude + .codex + .gemini + .pi + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .github" + GH_AW_AGENT_FILES: "AGENTS.md CLAUDE.md" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "smoke-claude-sub-agents.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'check_workflow_timestamp_api.cjs')); + await main(); + - name: Log runtime features + if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" + - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_mcp_transport_prompt.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"file\":\"safe_outputs_auto_create_issue.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}],\"system_item_count\":13}" + GH_AW_EXPR_76DF9333: ${{ github.event.pull_request.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'pull_request' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} + GH_AW_EXPR_77C1A4D2: ${{ github.event.comment.id || fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').comment_id }} + GH_AW_EXPR_7C248226: ${{ github.event.issue.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'issue' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} + GH_AW_EXPR_C19C384F: ${{ github.event.discussion.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'discussion' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: create_issue\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_7C248226__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_C19C384F__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_76DF9333__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_77C1A4D2__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/smoke-claude-sub-agents.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "claude" + GH_AW_SUB_AGENT_DIR: ".claude/agents" + GH_AW_SUB_AGENT_EXT: ".md" + GH_AW_SKILL_DIR: ".claude/skills" + GH_AW_SKILL_EXT: ".md" + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'interpolate_prompt.cjs')); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_76DF9333: ${{ github.event.pull_request.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'pull_request' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} + GH_AW_EXPR_77C1A4D2: ${{ github.event.comment.id || fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').comment_id }} + GH_AW_EXPR_7C248226: ${{ github.event.issue.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'issue' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} + GH_AW_EXPR_C19C384F: ${{ github.event.discussion.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'discussion' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` โ€” run `safeoutputs --help` to see available tools' + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require(path.join(actionsDir, 'substitute_placeholders.cjs')); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_76DF9333: process.env.GH_AW_EXPR_76DF9333, + GH_AW_EXPR_77C1A4D2: process.env.GH_AW_EXPR_77C1A4D2, + GH_AW_EXPR_7C248226: process.env.GH_AW_EXPR_7C248226, + GH_AW_EXPR_C19C384F: process.env.GH_AW_EXPR_C19C384F, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Inventory installed skills + if: success() || failure() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SKILL_DIR: ".claude/skills" + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'skill_inventory.cjs')); + await main(); + - name: Upload info artifact + if: success() || failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: info + path: /tmp/gh-aw/aw_info.json + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ + - name: Upload activation artifact + if: success() || failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/models.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/system.txt + /tmp/gh-aw/aw-prompts/user.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.claude/agents + /tmp/gh-aw/.claude/skills + /tmp/gh-aw/skill_inventory.json + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '1' }} + + admission: + name: admission + if: vars.GH_AW_DEFAULT_ADMISSION_URL != '' + runs-on: ubuntu-slim + # Permissions for the admission job (workflow permissions default to none). + permissions: + contents: read + id-token: write + timeout-minutes: 5 + outputs: + max-ai-credits: ${{ steps.admission.outputs.max-ai-credits }} + max-ai-credits-evals: ${{ steps.admission.outputs.max-ai-credits-evals }} + max-ai-credits-threat-detection: ${{ steps.admission.outputs.max-ai-credits-threat-detection }} + result: ${{ steps.admission.outputs.result }} + trace-id: ${{ steps.admission.outputs.trace-id }} + steps: + - name: Checkout actions folder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: github/gh-aw + sparse-checkout: | + actions + fetch-depth: 1 + clean: false + persist-credentials: false + - name: Setup Scripts + id: setup + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/unknown.lock.yml@${{ github.ref }} + - name: Check workflow admission + id: admission + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_DEFAULT_ADMISSION_URL: ${{ vars.GH_AW_DEFAULT_ADMISSION_URL }} + GH_AW_DEFAULT_ADMISSION_AUDIENCE: ${{ vars.GH_AW_DEFAULT_ADMISSION_AUDIENCE }} + GH_AW_DEFAULT_ADMISSION_ON_OUTAGE: ${{ vars.GH_AW_DEFAULT_ADMISSION_ON_OUTAGE }} + GH_AW_ADMISSION_WORKFLOW_HASH: "17bb259908afda18f563200fb0af4ec512da0839459e010a5d6bbec384b2b8c0:387c789d648bf5de762f3561c6a39b9226b0811b9c4e649ed81a115c30912b0b" + GH_AW_INFO_WORKFLOW_ID: ${{ job.workflow_ref }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TRIGGERING_ACTOR: ${{ github.triggering_actor }} + with: + script: | + await require(require('node:path').join(process.env.RUNNER_TEMP, 'gh-aw', 'actions', 'activation_admission.cjs')).main(core, github, context, exec, io, getOctokit); + + agent: + name: agent + needs: activation + if: > + ((!cancelled()) && needs.activation.result == 'success') && (needs.activation.outputs.daily_ai_credits_exceeded != 'true') + runs-on: ubuntu-latest + # Permissions for the agent job (workflow permissions default to none). + permissions: + contents: read + timeout-minutes: 60 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_PROJECT_UTC: "-08:00" + GH_AW_PR_HEAD_BASE_BRANCH: "" + GH_AW_PR_HEAD_BASE_PR_NUMBER: "" + GH_AW_PR_HEAD_BASE_REF: "" + GH_AW_PR_HEAD_BASE_REPO: "" + GH_AW_PR_HEAD_BASE_SHA: "" + GH_AW_PR_HEAD_REPO: "" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_TARGET_REPO_SLUG: "" + GH_AW_WORKFLOW_ID_SANITIZED: smokeclaudesubagents + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} + aic: ${{ steps.parse-token-usage.outputs.aic }} + ambient_context: ${{ steps.parse-token-usage.outputs.ambient_context }} + checkout_pr_invalidated: ${{ steps.checkout-pr.outputs.checkout_pr_invalidated || 'false' }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} + max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }} + missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }} + model: ${{ needs.activation.outputs.model }} + model_endpoint_mismatch_error: ${{ steps.detect-agent-errors.outputs.model_endpoint_mismatch_error || 'false' }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + shell_expansion_guard_rejected: ${{ steps.detect-agent-errors.outputs.shell_expansion_guard_rejected || 'false' }} + unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }} + steps: + - name: Checkout actions folder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: github/gh-aw + sparse-checkout: | + actions + fetch-depth: 1 + clean: false + persist-credentials: false + - name: Setup Scripts + id: setup + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-claude-sub-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "2.1.296" + GH_AW_INFO_AWF_VERSION: "v0.28.70" + GH_AW_INFO_ENGINE_ID: "claude" + GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }} + - name: Set runtime paths + id: set-runtime-paths + env: + GH_AW_RUNNER_TOOL_CACHE: ${{ runner.tool_cache }} + run: | # zizmor: ignore[github-env] - runner.tool_cache is set by GitHub Actions, not user input. + if [ -z "${RUNNER_TOOL_CACHE:-}" ]; then + echo "RUNNER_TOOL_CACHE=${GH_AW_RUNNER_TOOL_CACHE}" >> "$GITHUB_ENV" + fi + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Mask OTLP telemetry headers + run: bash "${RUNNER_TEMP}/gh-aw/actions/mask_otlp_headers.sh" + - name: Check OTLP telemetry configuration + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_otlp_default_credentials.sh" + - name: Checkout repository (gh-aw default) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Initialize agent execution evidence + run: | + mkdir -p "/tmp/gh-aw" + evidence_tmp="/tmp/gh-aw/agent_execution.json.tmp" + printf '{"version":1,"component":"agent","run_id":%s,"run_attempt":%s,"state":"not_started"}\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" > "$evidence_tmp" + mv "$evidence_tmp" "/tmp/gh-aw/agent_execution.json" + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Download activation artifact + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: activation + path: /tmp/gh-aw + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'checkout_pr_branch.cjs')); + await main(); + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.28.70 --rootless + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code@2.1.296 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const determineAutomaticLockdown = require(path.join(actionsDir, 'determine_automatic_lockdown.cjs')); + await determineAutomaticLockdown(github, context, core); + - name: Restore agent config folders from base branch + if: steps.checkout-pr.outcome == 'success' + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .github" + GH_AW_AGENT_FILES: "AGENTS.md CLAUDE.md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" + - name: Restore inline sub-agents from activation artifact + env: + GH_AW_SUB_AGENT_DIR: ".claude/agents" + GH_AW_SUB_AGENT_EXT: ".md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" + - name: Restore inline skills from activation artifact + env: + GH_AW_SKILL_DIR: ".claude/skills" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.70@sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70@sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a ghcr.io/github/gh-aw-firewall/squid:0.28.70@sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917 ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2 ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382 ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796 + - name: Prepare Safe Outputs Directories + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + - name: Generate Safe Outputs Config + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_FILE_ROOT: "${{ runner.temp }}/gh-aw" + GH_AW_FILE_CONFIG: "{\"files\":[{\"path\":\"safeoutputs/config.json\",\"content_env\":\"GH_AW_SAFE_OUTPUTS_CONFIG\"}]}" + GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-claude-sub-agents\"],\"max\":1,\"title_prefix\":\"[smoke-claude-sub-agents]\"}}" + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'create_files.cjs')); + await main(); + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-claude-sub-agents]\". Labels [\"smoke-claude-sub-agents\"] will be automatically added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "blocked_by": {}, + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000, + "minLength": 20 + }, + "fields": { + "type": "array" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + }, + "collapseData": true + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'generate_safe_outputs_tools.cjs')); + await main(); + - name: Start MCP Gateway + id: start-mcp-gateway + env: + DEBUG: ${{ env.DEBUG || runner.debug == '1' && '*' || '' }} + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} + GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -r "${GITHUB_EVENT_PATH}" ]; then + GH_AW_SAFEOUTPUTS_EVENT_PATH="${RUNNER_TEMP}/gh-aw/safeoutputs/github_event.json" + cp "${GITHUB_EVENT_PATH}" "${GH_AW_SAFEOUTPUTS_EVENT_PATH}" + export GITHUB_EVENT_PATH="${GH_AW_SAFEOUTPUTS_EVENT_PATH}" + fi + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="awmg-mcpg" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_AGENT_ID=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_AGENT_ID}" + export MCP_GATEWAY_AGENT_ID + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" + export GH_AW_PR_HEAD_BASE_BRANCH="${GH_AW_PR_HEAD_BASE_BRANCH:-}" + export GH_AW_PR_HEAD_BASE_SHA="${GH_AW_PR_HEAD_BASE_SHA:-}" + export GH_AW_PR_HEAD_BASE_REPO="${GH_AW_PR_HEAD_BASE_REPO:-}" + export GH_AW_PR_HEAD_BASE_PR_NUMBER="${GH_AW_PR_HEAD_BASE_PR_NUMBER:-}" + export GH_AW_PR_HEAD_BASE_REF="${GH_AW_PR_HEAD_BASE_REF:-}" + export GH_AW_PR_HEAD_REPO="${GH_AW_PR_HEAD_REPO:-}" + export GH_AW_TARGET_REPO_SLUG="${GH_AW_TARGET_REPO_SLUG:-}" + export GH_AW_ENGINE="claude" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_AGENT_ID -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_TARGET_REPO_SLUG -e GH_AW_PR_HEAD_BASE_BRANCH -e GH_AW_PR_HEAD_BASE_SHA -e GH_AW_PR_HEAD_BASE_REPO -e GH_AW_PR_HEAD_BASE_PR_NUMBER -e GH_AW_PR_HEAD_BASE_REF -e GH_AW_PR_HEAD_REPO -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e RUNNER_TOOL_CACHE -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -e GITHUB_AW_OTEL_TRACE_ID -e GITHUB_AW_OTEL_PARENT_SPAN_ID -e OTEL_EXPORTER_OTLP_HEADERS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2' + + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_a11e6c834c3174b8_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:v2.0.2", + "env": { + "GITHUB_FEATURES": "fields_param", + "GITHUB_HOST": "$GITHUB_SERVER_URL", + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "\${DEBUG}", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_TARGET_REPO_SLUG": "\${GH_AW_TARGET_REPO_SLUG}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GH_AW_PR_HEAD_BASE_BRANCH": "\${GH_AW_PR_HEAD_BASE_BRANCH}", + "GH_AW_PR_HEAD_BASE_SHA": "\${GH_AW_PR_HEAD_BASE_SHA}", + "GH_AW_PR_HEAD_BASE_REPO": "\${GH_AW_PR_HEAD_BASE_REPO}", + "GH_AW_PR_HEAD_BASE_PR_NUMBER": "\${GH_AW_PR_HEAD_BASE_PR_NUMBER}", + "GH_AW_PR_HEAD_BASE_REF": "\${GH_AW_PR_HEAD_BASE_REF}", + "GH_AW_PR_HEAD_REPO": "\${GH_AW_PR_HEAD_REPO}", + "GITHUB_EVENT_NAME": "\${GITHUB_EVENT_NAME}", + "GITHUB_EVENT_PATH": "\${GITHUB_EVENT_PATH}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ], + "sink-visibility": "${GH_AW_SINK_VISIBILITY}" + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "agentId": "${MCP_GATEWAY_AGENT_ID}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}", + "startupTimeout": 120, + "opentelemetry": { + "endpoint": "${OTEL_EXPORTER_OTLP_ENDPOINT}", + "traceId": "${GITHUB_AW_OTEL_TRACE_ID}", + "spanId": "${GITHUB_AW_OTEL_PARENT_SPAN_ID}" + } + } + } + GH_AW_MCP_CONFIG_a11e6c834c3174b8_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_AGENT_ID: ${{ steps.start-mcp-gateway.outputs.gateway-agent-id }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io); + const { main } = require(path.join(actionsDir, 'mount_mcp_as_cli.cjs')); + await main(); + - name: Clean credentials + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + bash "${RUNNER_TEMP}/gh-aw/actions/verify_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Log GitHub rate limits at agent start + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { fetchAndLogRateLimit } = require(path.join(actionsDir, 'github_rate_limit_logger.cjs')); + await fetchAndLogRateLimit(github, 'agent_start', true); + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash + # - BashOutput + # - Edit + # - Edit(//tmp/**) + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - MultiEdit + # - NotebookEdit + # - NotebookRead + # - Read + # - Read(//tmp/**) + # - Skill + # - Task + # - TodoWrite + # - Write + # - mcp__github__actions_get + # - mcp__github__actions_list + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_job_logs + # - mcp__github__get_label + # - mcp__github__get_latest_release + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_review_comments + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_release_by_tag + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__issue_read + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issue_types + # - mcp__github__list_issues + # - mcp__github__list_label + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_releases + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_starred_repositories + # - mcp__github__list_tags + # - mcp__github__pull_request_read + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + # - mcp__safeoutputs + timeout-minutes: 10 + run: | + set -o pipefail + gh_aw_exit_code=0 + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; if [ "$gh_aw_exit_code" -ne 0 ]; then echo "::error::Agent execution exited with code $gh_aw_exit_code"; fi' EXIT + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + mkdir -p /tmp/gh-aw && (umask 177 && touch /tmp/gh-aw/claude-debug.log) && touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + if [[ ! "$GH_AW_MAX_AI_CREDITS" =~ ^[0-9]+$ ]]; then + GH_AW_MAX_AI_CREDITS="1000" + fi + if [[ "${GH_AW_ADMISSION_RESULT:-}" == "admitted" && -z "${GH_AW_ADMISSION_MAX_AI_CREDITS:-}" ]]; then + echo "ERR_ADMISSION: Missing required runtime admission allowance." >&2 + exit 1 + fi + + if [[ "${GH_AW_ADMISSION_RESULT:-}" == "admitted" && -n "${GH_AW_ADMISSION_MAX_AI_CREDITS:-}" ]]; then + if [[ ! "$GH_AW_ADMISSION_MAX_AI_CREDITS" =~ ^[1-9][0-9]{0,9}$ ]] || (( GH_AW_ADMISSION_MAX_AI_CREDITS > 2147483647 )); then + echo "ERR_ADMISSION: Invalid runtime admission allowance." >&2 + exit 1 + fi + GH_AW_MAX_AI_CREDITS="$GH_AW_ADMISSION_MAX_AI_CREDITS" + echo "Admission allowance applied (value withheld)." + else + echo "No component admission allowance; retaining existing budget policy (value withheld)." + fi + unset GH_AW_ADMISSION_RESULT GH_AW_ADMISSION_MAX_AI_CREDITS + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.70/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"ppa.launchpad.net\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-6\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-3.7-flash\":[\"copilot/gemini-3.7*flash*\",\"google/gemini-3.7*flash*\",\"gemini/gemini-3.7*flash*\"],\"gemini-3.8-flash\":[\"copilot/gemini-3.8*flash*\",\"google/gemini-3.8*flash*\",\"gemini/gemini-3.8*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"gpt-6\":[\"copilot/gpt-6*\",\"openai/gpt-6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-6\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.70,squid=sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917,agent=sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34,api-proxy=sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a,cli-proxy=sha256:372ac51a5cccffdfe484f3f7be427d2d1c566e8ae550d55bd45d9fd166a154b4\",\"agentTimeout\":10},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + fi + # shellcheck disable=SC1003,SC2016,SC2086 + mkdir -p "/tmp/gh-aw" + evidence_tmp="/tmp/gh-aw/agent_execution.json.tmp" + printf '{"version":1,"component":"agent","run_id":%s,"run_attempt":%s,"state":"started"}\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" > "$evidence_tmp" + mv "$evidence_tmp" "/tmp/gh-aw/agent_execution.json" + export GH_AW_AWF_EXECUTION_COMPONENT="agent" + export GH_AW_AWF_EXECUTION_EVIDENCE_FILE="/tmp/gh-aw/agent_execution.json" + GH_AW_AWF_ENGINE_NAME=claude \ + GH_AW_AWF_HARNESS_MARKER='[claude-harness]' \ + GH_AW_AWF_LOG_FILE=/tmp/gh-aw/agent-stdio.log \ + GH_AW_AWF_ATTEMPT_LOG_NAME=claude \ + bash "${RUNNER_TEMP}/gh-aw/actions/run_awf_with_startup_retries.sh" -- \ + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --tty --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env ANTHROPIC_API_KEY --exclude-env GH_AW_OTLP_ALL_HEADERS --exclude-env GH_AW_OTLP_ENDPOINTS --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_AGENT_ID --exclude-env OTEL_EXPORTER_OTLP_ENDPOINT --exclude-env OTEL_EXPORTER_OTLP_HEADERS --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; GH_AW_TOOL_BINS="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr "\n" ":")"; GH_AW_TOOL_BINS="${GH_AW_TOOL_BINS%:}"; export PATH="$PATH${GH_AW_TOOL_BINS:+:}$GH_AW_TOOL_BINS"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner โ€” check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/claude_harness.cjs claude --print --no-chrome --strict-mcp-config --allowed-tools '\''Bash,BashOutput,Edit,Edit(//tmp/**),ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Read(//tmp/**),Skill,Task,TodoWrite,Write,mcp__github__actions_get,mcp__github__actions_list,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users,mcp__safeoutputs'\'' --disallowed-tools AskUserQuestion,WebFetch,WebSearch --debug-file /tmp/gh-aw/claude-debug.log --verbose --permission-mode dontAsk --output-format stream-json --mcp-config "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_DISABLE_FAST_MODE: 1 + CLAUDE_CODE_MAX_RETRIES: 0 + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_ADMISSION_MAX_AI_CREDITS: ${{ needs.activation.outputs.admission-max-ai-credits }} + GH_AW_ADMISSION_RESULT: ${{ needs.activation.outputs.admission-result }} + GH_AW_LLM_PROVIDER: anthropic + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_MCP_CONFIG: ${{ runner.temp }}/gh-aw/mcp-config/mcp-servers.json + GH_AW_MODEL_AGENT_CLAUDE: ${{ vars.GH_AW_MODEL_AGENT_CLAUDE || vars.GH_AW_DEFAULT_MODEL_CLAUDE || 'claude-sonnet-5' }} + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: dev + GITHUB_AW: true + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + RUNNER_TEMP: ${{ runner.temp }} + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Log GitHub rate limits at agent end + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { fetchAndLogRateLimit } = require(path.join(actionsDir, 'github_rate_limit_logger.cjs')); + await fetchAndLogRateLimit(github, 'agent_end', true); + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + env: + GH_AW_AGENTIC_EXECUTION_OUTCOME: ${{ steps.agentic_execution.outcome }} + GH_AW_ENGINE_STEP_TIMEOUT_MINUTES: 10 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'detect_agent_errors.cjs')); + await main(); + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_AGENT_ID: ${{ steps.start-mcp-gateway.outputs.gateway-agent-id }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + id: redact_secrets + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'redact_secrets.cjs')); + await main(); + env: + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'collect_ndjson_output.cjs')); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'parse_claude_log.cjs')); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'parse_mcp_gateway_log.cjs')); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless + - name: Parse token usage for step summary + if: always() + id: parse-token-usage + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'parse_token_usage.cjs')); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'awf_reflect_summary.cjs')); + await main(); + - name: Generate observability summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'generate_observability_summary.cjs')); + await main(core); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - env: + SMOKE_EXECUTION: ${{ steps.agentic_execution.outcome }} + if: always() + name: Assert declared sub-agent ran on its resolved model + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: "const assert = require(\"node:assert/strict\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst root = \"/tmp/gh-aw\";\nconst worker = \"marker-worker\";\nconst marker = \"GH-AW-CLAUDE-SUBAGENT-MARKER-7F3A\";\nconst { writeUnifiedSession } = require(path.join(process.env.RUNNER_TEMP, \"gh-aw/actions/unified_session.cjs\"));\nwriteUnifiedSession({ rootDir: root, engine: \"claude\" });\nconst readJsonl = file => fs.readFileSync(file, \"utf8\").split(\"\\n\")\n .filter(line => line.trim().startsWith(\"{\"))\n .flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });\nassert.equal(process.env.SMOKE_EXECUTION, \"success\", \"Claude execution failed\");\nconst events = readJsonl(path.join(root, \"usage/aw_session.jsonl\"));\nconst stream = readJsonl(path.join(root, \"agent-stdio.log\"));\n\n// Pre-start resolution of the declared `model: small` worker.\nconst resolution = events.find(event => event.type === \"model.resolution\" && event.data?.engine === \"claude\" && event.data?.role === worker);\nassert.ok(resolution, `${worker} has no model.resolution record`);\nassert.ok([\"resolved\", \"exact\"].includes(resolution.data.status), `${worker} model resolution ${resolution.data.status}`);\nassert.equal(resolution.data.configured_model, \"small\", `${worker} must declare model: small`);\nconst model = resolution.data.resolved_model;\nassert.ok(model && model !== \"small\", `${worker} has no resolved model`);\n\n// The generated definition Claude Code loads carries the identity and the concrete id.\nconst definition = fs.readFileSync(path.join(process.env.GITHUB_WORKSPACE, \".claude/agents\", `${worker}.md`), \"utf8\");\nassert.match(definition, new RegExp(`^name:\\\\s*\"?${worker}\"?\\\\s*$`, \"m\"), `${worker} definition has no native name`);\nassert.ok(definition.includes(`model: ${JSON.stringify(model)}`), `${worker} definition does not carry ${model}`);\n\n// Native initialization registered the worker.\nconst init = stream.find(record => record.type === \"system\" && record.subtype === \"init\");\nassert.ok(init?.agents?.includes(worker), `native initialization did not register ${worker}: ${JSON.stringify(init?.agents)}`);\n\n// The first agent action delegates to the worker, and that delegation succeeds.\nconst toolUses = stream.filter(record => record.type === \"assistant\" && !record.parent_tool_use_id)\n .flatMap(record => record.message?.content || []).filter(item => item.type === \"tool_use\");\nconst first = toolUses[0];\nassert.ok(first && [\"Agent\", \"Task\"].includes(first.name) && first.input?.subagent_type === worker, `first agent action must invoke ${worker}: ${JSON.stringify(first && { name: first.name, input: first.input })}`);\nconst result = stream.filter(record => record.type === \"user\").flatMap(record => record.message?.content || [])\n .find(item => item.type === \"tool_result\" && item.tool_use_id === first.id);\nassert.ok(result && !result.is_error, `${worker} delegation failed: ${JSON.stringify(result?.content)}`);\nassert.ok(JSON.stringify(result.content).includes(marker), `${worker} did not return the marker`);\n\n// The started sub-agent is the worker on the resolved model, with no built-in fallback.\nconst started = events.filter(event => event.type === \"subagent.started\");\nassert.ok(!started.some(event => event.data?.agentType === \"general-purpose\" || event.data?.agentName === \"general-purpose\"), \"fell back to general-purpose\");\nconst invocation = started.find(event => event.data?.toolCallId === first.id);\nassert.ok(invocation && (invocation.data.agentName === worker || invocation.data.agentType === worker), `subagent.started does not name ${worker}`);\nconst activity = events.filter(event => event.agentId === invocation.agentId);\nconst configured = activity.filter(event => event.type === \"subagent.configured\");\nassert.ok(configured.length > 0 && configured.every(event => event.data?.model === model), `${worker} was not configured on ${model}: ${JSON.stringify(configured.map(event => event.data?.model))}`);\nassert.ok(activity.some(event => event.type === \"subagent.completed\"), `${worker} did not complete`);\nassert.ok(!activity.some(event => event.type === \"subagent.failed\"), `${worker} failed`);\n\n// The worker's own messages were served by the resolved model through the proxy.\nconst child = stream.filter(record => record.type === \"assistant\" && record.parent_tool_use_id === first.id);\nassert.ok(child.length > 0 && child.every(record => record.message?.model === model), `${worker} messages were not served by ${model}`);\nconst proxyPaths = [\n \"sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl\",\n \"sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl\",\n \"sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl\",\n];\nconst proxyFile = proxyPaths.map(file => path.join(root, file)).find(file => fs.existsSync(file));\nassert.ok(proxyFile, \"Missing api-proxy token usage evidence\");\nconst served = readJsonl(proxyFile).filter(request => request.model === model && Number(request.status) === 200).length;\nconst childMessages = new Set(child.map(record => record.message?.id)).size;\nassert.ok(served >= childMessages, `${worker} sent ${childMessages} message(s) on ${model}, but the proxy served ${served}`);" + + - name: Upload agent artifacts + if: always() && steps.redact_secrets.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/agent_execution.json + /tmp/gh-aw/agent_execution_exit_code.txt + /tmp/gh-aw/agent-session.jsonl + /tmp/gh-aw/agent-errors.jsonl + /tmp/gh-aw/aw-prompts/user.txt + /tmp/gh-aw/aw-prompts/system.txt + /tmp/gh-aw/skill_inventory.json + /tmp/gh-aw/claude-debug.log + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent/model-resolution.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/otel.jsonl + /tmp/gh-aw/otlp-export-errors.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/awf-resolved-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} + + conclusion: + name: conclusion + needs: + - activation + - admission + - agent + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' || + needs.activation.outputs.secret_verification_result == 'failed' || needs.activation.outputs.daily_ai_credits_exceeded == 'true' || + needs.activation.outputs.daily_ai_credits_guardrail_status == 'structural_error' || needs.activation.outputs.daily_ai_credits_guardrail_status == 'transient_error') + runs-on: ubuntu-slim + # Permissions for the conclusion job (workflow permissions default to none). + permissions: + actions: read + issues: write + pull-requests: read + concurrency: + group: "gh-aw-conclusion-smoke-claude-sub-agents" + cancel-in-progress: false + queue: max + env: + GH_AW_JOB_DISPLAY_NAMES: "{\"activation\":\"activation\",\"admission\":\"admission\",\"agent\":\"agent\",\"conclusion\":\"conclusion\",\"safe_outputs\":\"safe_outputs\"}" + GH_AW_JOB_RESULTS: "{\"activation\":{\"result\":\"${{ needs.activation.result }}\"},\"admission\":{\"result\":\"${{ needs.admission.result }}\"},\"agent\":{\"result\":\"${{ needs.agent.result }}\"},\"safe_outputs\":{\"result\":\"${{ needs.safe_outputs.result }}\"}}" + GH_AW_JOB_TIMING_ELIGIBLE: "{\"activation\":true,\"admission\":true,\"agent\":true,\"conclusion\":true,\"safe_outputs\":true}" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + steps: + - name: Checkout actions folder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: github/gh-aw + sparse-checkout: | + actions + fetch-depth: 1 + clean: false + persist-credentials: false + - name: Setup Scripts + id: setup + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-claude-sub-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "2.1.296" + GH_AW_INFO_AWF_VERSION: "v0.28.70" + GH_AW_INFO_ENGINE_ID: "claude" + GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: "{agent,agent-output-fallback}" + merge-multiple: true + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + if [ -f "/tmp/gh-aw/agent_output.json" ]; then + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + fi + - name: Download Safe Outputs Items Manifest + id: download-safe-outputs-manifest + if: always() + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: safe-outputs-items + merge-multiple: true + path: /tmp/gh-aw/ + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-claude-sub-agents" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "12" + GH_AW_ENGINE_ID: "claude" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_CHECKOUT_PR_INVALIDATED: ${{ needs.agent.outputs.checkout_pr_invalidated }} + GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} + GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_DEFAULT_CHECKOUT_USES_TRIGGER_REF: "true" + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_MODEL_ENDPOINT_MISMATCH_ERROR: ${{ needs.agent.outputs.model_endpoint_mismatch_error }} + GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} + GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }} + GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }} + GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }} + GH_AW_SHELL_EXPANSION_GUARD_REJECTED: ${{ needs.agent.outputs.shell_expansion_guard_rejected }} + GH_AW_ENGINE_API_HOSTS: "api.anthropic.com" + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_GUARDRAIL_STATUS: ${{ needs.activation.outputs.daily_ai_credits_guardrail_status }} + GH_AW_DAILY_AI_CREDITS_GUARDRAIL_ERROR: ${{ needs.activation.outputs.daily_ai_credits_guardrail_error }} + GH_AW_DAILY_AI_CREDITS_TOTAL: ${{ needs.activation.outputs.daily_ai_credits_total }} + GH_AW_DAILY_AI_CREDITS_ESTIMATED: ${{ needs.activation.outputs.daily_ai_credits_estimated }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} + GH_AW_DAILY_AI_CREDITS_CONTINUE_ON_ERROR: "false" + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "10" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'handle_agent_failure.cjs')); + await main(); + - name: Report failed jobs + id: report_failed_jobs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_REPORT_FAILED_JOBS: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'report_failed_jobs.cjs')); + await main(); + - name: Collect usage artifact files + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh" + - name: Generate usage activity summary and unified session + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_ADMISSION_RESULT: ${{ needs.activation.outputs.admission-result }} + GH_AW_ADMISSION_MAX_AI_CREDITS: ${{ needs.activation.outputs.admission-max-ai-credits }} + GH_AW_ADMISSION_MAX_AI_CREDITS_THREAT_DETECTION: ${{ needs.activation.outputs.admission-max-ai-credits-threat-detection }} + GH_AW_ADMISSION_MAX_AI_CREDITS_EVALS: ${{ needs.activation.outputs.admission-max-ai-credits-evals }} + GH_AW_GRADER_MANIFEST_0: '{"version":2,"graders":[]}' + GH_AW_GRADER_MANIFEST_CHUNKS: '1' + GH_AW_JOB_DURATIONS: ${{ steps.report_failed_jobs.outputs.job_durations }} + GH_AW_DAILY_AI_CREDITS_GUARDRAIL_STATUS: ${{ needs.activation.outputs.daily_ai_credits_guardrail_status }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL: ${{ needs.activation.outputs.daily_ai_credits_total }} + GH_AW_DAILY_AI_CREDITS_ESTIMATED: ${{ needs.activation.outputs.daily_ai_credits_estimated }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} + with: + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'generate_usage_artifacts.cjs')); + await main(); + - name: Upload usage artifact + id: upload-usage-artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_session.jsonl + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/graders/grader_manifest.json + /tmp/gh-aw/usage/graders/grader_results.json + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/agent/model-routing.jsonl + /tmp/gh-aw/usage/agent/execution.json + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/detection/execution.json + /tmp/gh-aw/usage/detection/detection_result.json + /tmp/gh-aw/usage/evals/token_usage.jsonl + /tmp/gh-aw/usage/evals/execution.json + /tmp/gh-aw/usage/experiment/state.jsonl + /tmp/gh-aw/usage/experiment/state.json + /tmp/gh-aw/usage/experiment/assignments.json + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} + - name: Wait before retrying usage artifact upload + if: always() && steps.upload-usage-artifact.outcome == 'failure' + run: sleep 10 + - name: Retry upload usage artifact + id: upload-usage-artifact-retry + if: always() && steps.upload-usage-artifact.outcome == 'failure' + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_session.jsonl + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/graders/grader_manifest.json + /tmp/gh-aw/usage/graders/grader_results.json + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/agent/model-routing.jsonl + /tmp/gh-aw/usage/agent/execution.json + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/detection/execution.json + /tmp/gh-aw/usage/detection/detection_result.json + /tmp/gh-aw/usage/evals/token_usage.jsonl + /tmp/gh-aw/usage/evals/execution.json + /tmp/gh-aw/usage/experiment/state.jsonl + /tmp/gh-aw/usage/experiment/state.json + /tmp/gh-aw/usage/experiment/assignments.json + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + overwrite: true + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} + + safe_outputs: + name: safe_outputs + needs: + - activation + - agent + if: (!cancelled()) && needs.agent.result != 'skipped' + runs-on: ubuntu-slim + # Permissions for the safe_outputs job (workflow permissions default to none). + permissions: + issues: write + timeout-minutes: 45 + env: + GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-claude-sub-agents" + GH_AW_ENGINE_ID: "claude" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_PROJECT_UTC: "-08:00" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_EMOJI: "๐Ÿงช" + GH_AW_WORKFLOW_ID: "smoke-claude-sub-agents" + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} + process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} + process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} + steps: + - name: Checkout actions folder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: github/gh-aw + sparse-checkout: | + actions + fetch-depth: 1 + clean: false + persist-credentials: false + - name: Setup Scripts + id: setup + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-claude-sub-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "2.1.296" + GH_AW_INFO_AWF_VERSION: "v0.28.70" + GH_AW_INFO_ENGINE_ID: "claude" + GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }} + - name: Mask OTLP telemetry headers + run: bash "${RUNNER_TEMP}/gh-aw/actions/mask_otlp_headers.sh" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: "{agent,agent-output-fallback}" + merge-multiple: true + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + if [ -f "/tmp/gh-aw/agent_output.json" ]; then + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + fi + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_JOB_CHECK_RUN_ID: ${{ job.check_run_id }} + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_MENTIONS_GITHUB_TOKEN: ${{ github.token }} + GH_AW_ALLOWED_DOMAINS: "api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-claude-sub-agents\"],\"max\":1,\"title_prefix\":\"[smoke-claude-sub-agents]\"}}" + GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit, process.env.GH_AW_GITHUB_TOKEN_SOURCE); + const { main } = require(path.join(actionsDir, 'process_safe_outputs.cjs')); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + /tmp/gh-aw/safe-output-errors.json + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} diff --git a/.github/workflows/smoke-claude-sub-agents.md b/.github/workflows/smoke-claude-sub-agents.md new file mode 100644 index 00000000000..23f755cf009 --- /dev/null +++ b/.github/workflows/smoke-claude-sub-agents.md @@ -0,0 +1,113 @@ +--- +private: true +emoji: "๐Ÿงช" +description: Smoke Claude inline sub-agent registration and pre-start model resolution +on: + workflow_dispatch: +permissions: + contents: read +name: Smoke Claude Sub Agents +strict: true +engine: + id: claude + bare: false +timeout-minutes: 10 +features: + gh-aw-detection: false +sandbox: + agent: + id: awf +post-steps: + - name: Assert declared sub-agent ran on its resolved model + if: always() + uses: actions/github-script@v9.0.0 + env: + SMOKE_EXECUTION: ${{ steps.agentic_execution.outcome }} + with: + script: | + const assert = require("node:assert/strict"); + const fs = require("node:fs"); + const path = require("node:path"); + const root = "/tmp/gh-aw"; + const worker = "marker-worker"; + const marker = "GH-AW-CLAUDE-SUBAGENT-MARKER-7F3A"; + const { writeUnifiedSession } = require(path.join(process.env.RUNNER_TEMP, "gh-aw/actions/unified_session.cjs")); + writeUnifiedSession({ rootDir: root, engine: "claude" }); + const readJsonl = file => fs.readFileSync(file, "utf8").split("\n") + .filter(line => line.trim().startsWith("{")) + .flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } }); + assert.equal(process.env.SMOKE_EXECUTION, "success", "Claude execution failed"); + const events = readJsonl(path.join(root, "usage/aw_session.jsonl")); + const stream = readJsonl(path.join(root, "agent-stdio.log")); + + // Pre-start resolution of the declared `model: small` worker. + const resolution = events.find(event => event.type === "model.resolution" && event.data?.engine === "claude" && event.data?.role === worker); + assert.ok(resolution, `${worker} has no model.resolution record`); + assert.ok(["resolved", "exact"].includes(resolution.data.status), `${worker} model resolution ${resolution.data.status}`); + assert.equal(resolution.data.configured_model, "small", `${worker} must declare model: small`); + const model = resolution.data.resolved_model; + assert.ok(model && model !== "small", `${worker} has no resolved model`); + + // The generated definition Claude Code loads carries the identity and the concrete id. + const definition = fs.readFileSync(path.join(process.env.GITHUB_WORKSPACE, ".claude/agents", `${worker}.md`), "utf8"); + assert.match(definition, new RegExp(`^name:\\s*"?${worker}"?\\s*$`, "m"), `${worker} definition has no native name`); + assert.ok(definition.includes(`model: ${JSON.stringify(model)}`), `${worker} definition does not carry ${model}`); + + // Native initialization registered the worker. + const init = stream.find(record => record.type === "system" && record.subtype === "init"); + assert.ok(init?.agents?.includes(worker), `native initialization did not register ${worker}: ${JSON.stringify(init?.agents)}`); + + // The first agent action delegates to the worker, and that delegation succeeds. + const toolUses = stream.filter(record => record.type === "assistant" && !record.parent_tool_use_id) + .flatMap(record => record.message?.content || []).filter(item => item.type === "tool_use"); + const first = toolUses[0]; + assert.ok(first && ["Agent", "Task"].includes(first.name) && first.input?.subagent_type === worker, `first agent action must invoke ${worker}: ${JSON.stringify(first && { name: first.name, input: first.input })}`); + const result = stream.filter(record => record.type === "user").flatMap(record => record.message?.content || []) + .find(item => item.type === "tool_result" && item.tool_use_id === first.id); + assert.ok(result && !result.is_error, `${worker} delegation failed: ${JSON.stringify(result?.content)}`); + assert.ok(JSON.stringify(result.content).includes(marker), `${worker} did not return the marker`); + + // The started sub-agent is the worker on the resolved model, with no built-in fallback. + const started = events.filter(event => event.type === "subagent.started"); + assert.ok(!started.some(event => event.data?.agentType === "general-purpose" || event.data?.agentName === "general-purpose"), "fell back to general-purpose"); + const invocation = started.find(event => event.data?.toolCallId === first.id); + assert.ok(invocation && (invocation.data.agentName === worker || invocation.data.agentType === worker), `subagent.started does not name ${worker}`); + const activity = events.filter(event => event.agentId === invocation.agentId); + const configured = activity.filter(event => event.type === "subagent.configured"); + assert.ok(configured.length > 0 && configured.every(event => event.data?.model === model), `${worker} was not configured on ${model}: ${JSON.stringify(configured.map(event => event.data?.model))}`); + assert.ok(activity.some(event => event.type === "subagent.completed"), `${worker} did not complete`); + assert.ok(!activity.some(event => event.type === "subagent.failed"), `${worker} failed`); + + // The worker's own messages were served by the resolved model through the proxy. + const child = stream.filter(record => record.type === "assistant" && record.parent_tool_use_id === first.id); + assert.ok(child.length > 0 && child.every(record => record.message?.model === model), `${worker} messages were not served by ${model}`); + const proxyPaths = [ + "sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl", + "sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl", + "sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl", + ]; + const proxyFile = proxyPaths.map(file => path.join(root, file)).find(file => fs.existsSync(file)); + assert.ok(proxyFile, "Missing api-proxy token usage evidence"); + const served = readJsonl(proxyFile).filter(request => request.model === model && Number(request.status) === 200).length; + const childMessages = new Set(child.map(record => record.message?.id)).size; + assert.ok(served >= childMessages, `${worker} sent ${childMessages} message(s) on ${model}, but the proxy served ${served}`); +--- + +# Smoke Test: Claude Inline Sub-Agents + +This is a read-only smoke test. Do not modify any files and do not use any other tools before the delegation below. + +1. Your **first action** must be a single foreground call of the `Agent` tool with `subagent_type` set to `marker-worker` and the prompt `marker?`. Do not run it in the background. +2. Do not use `general-purpose` or any other agent, and do not answer on the worker's behalf. If `marker-worker` is unavailable, stop and report that it is unavailable. +3. Reply with the worker's exact response and nothing else. + +## agent: `marker-worker` +--- +description: Returns a fixed marker for the Claude sub-agent smoke test +model: small +--- +When asked `marker?`, reply with exactly: + +`GH-AW-CLAUDE-SUBAGENT-MARKER-7F3A` + +No extra words, punctuation, formatting, or tool calls. diff --git a/.github/workflows/spec-enforcer.lock.yml b/.github/workflows/spec-enforcer.lock.yml index cf3fc1529cc..8029e85689a 100644 --- a/.github/workflows/spec-enforcer.lock.yml +++ b/.github/workflows/spec-enforcer.lock.yml @@ -185,6 +185,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ“‹" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_MODEL_COSTS: '{"providers":{"openai":{"models":{"gpt-5.6-sol":{"cost":{"cache_read":"4e-07","cache_write":"5e-06","input":"4e-06","output":"2e-05"}}}}}}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"rotation-state-recoverer\",\"patterns\":[\"openai/gpt-5*mini*\",\"openai/gpt-5*nano*\"]},{\"model\":\"small\",\"name\":\"readme-spec-extractor\",\"patterns\":[\"openai/gpt-5*mini*\",\"openai/gpt-5*nano*\"]},{\"model\":\"small\",\"name\":\"test-output-classifier\",\"patterns\":[\"openai/gpt-5*mini*\",\"openai/gpt-5*nano*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/typist.lock.yml b/.github/workflows/typist.lock.yml index a436339fbc0..65bbd95e50d 100644 --- a/.github/workflows/typist.lock.yml +++ b/.github/workflows/typist.lock.yml @@ -218,6 +218,7 @@ jobs: GH_AW_INFO_FRONTMATTER_EMOJI: "โœ๏ธ" GH_AW_COMPILED_STRICT: "true" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' + GH_AW_INFO_SUB_AGENT_MODELS: "[{\"model\":\"small\",\"name\":\"duplicate-type-finder\",\"patterns\":[\"anthropic/*haiku*\"]}]" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 70e3579f488..aae7da2ee20 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -73,6 +73,7 @@ const { isCrashSignalExitCode, crashSignalNameForExitCode } = require("./harness const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs"); const { applyModelFallback, normalizeClaudeModel, normalizeClaudeModelArgs } = require("./model_fallback.cjs"); const { resolveEngineModel } = require("./resolve_model_alias.cjs"); +const { applyClaudeSubagentModels, isDetectionPhase } = require("./engine_subagent_models.cjs"); const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); const { resolveAWFModelRoutingSelection, mapAWFRoutingEffort, recordAWFModelRoutingOutcome, getAWFModelRoutingPolicy, getAWFModelRoutingFailureCode } = require("./awf_model_routing.cjs"); const CLAUDE_ROUTING_POLICY = getAWFModelRoutingPolicy("claude"); @@ -559,6 +560,23 @@ async function main() { process.exit(0); } + // Sub-agent models are resolved on routed runs too, but a pre-existing noop must not + // make startup depend on sub-agent model availability. Threat detection uses no + // sub-agents, so it neither reads, resolves, records nor writes agent files. + if (!isDetectionPhase(process.env.GH_AW_PHASE)) { + const subagents = await applyClaudeSubagentModels({ + reflectData: reflection.reflectData, + refetchReflectData: async () => (await fetchAWFReflect({ logger: log })).reflectData || null, + provider: normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic"), + env: process.env, + logger: log, + }); + if (!subagents.ok) { + removeClaudePlugin(pluginDir); + process.exit(1); + } + } + let lastExitCode = 1; let useContinueOnRetry = false; let continueDisabledPermanently = false; diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index 4d89ac30170..d58da32ae89 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -1555,3 +1555,94 @@ describe("applyClaudeSettingsEnv", () => { expect(applyClaudeSettingsEnv(["--settings={}"], { GH_AW_CLAUDE_SETTINGS: settings })).toEqual(["--settings={}"]); }); }); + +describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => { + const runs = require("./fixtures/claude_codex_subagent_runs.cjs"); + const { writeInlineSubAgents } = require("./extract_inline_sub_agents.cjs"); + const typistAgent = () => fs.readFileSync(path.join(runs.typistAgentsDir, "duplicate-type-finder.md"), "utf8"); + // The stub records the agent files Claude Code would load from the project and whether + // the model-resolution record already existed when the CLI started. + const stubScript = ` + const fs = require("fs"); + const path = require("path"); + const dir = path.join(process.env.GITHUB_WORKSPACE, ".claude", "agents"); + const agents = fs.existsSync(dir) ? Object.fromEntries(fs.readdirSync(dir).map(name => [name, fs.readFileSync(path.join(dir, name), "utf8")])) : {}; + const recordPath = path.join(process.env.GH_AW_TMP_DIR, "agent", "model-resolution.json"); + const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, "utf8")) : []; + fs.appendFileSync(process.env.CLAUDE_HARNESS_STUB_CALLS, JSON.stringify({ agents, records }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "done" }) + "\\n"); + `; + + function runWithStagedAgent({ agentContent, reflectData = runs.typistReflect(), env = {} }) { + const dir = makeHarnessTempDir("claude-subagents-"); + const ostmp = path.join(dir, "ostmp"); + const workspace = path.join(dir, "workspace"); + const recordsDir = path.join(dir, "records"); + // Activation staging plus restore_inline_sub_agents.sh. + writeInlineSubAgents(`# Typist\n\n## agent: \`duplicate-type-finder\`\n${agentContent}`, workspace, path.join(ostmp, "gh-aw"), "claude"); + fs.mkdirSync(path.join(workspace, ".claude/agents"), { recursive: true }); + fs.copyFileSync(path.join(ostmp, "gh-aw/.claude/agents/duplicate-type-finder.md"), path.join(workspace, ".claude/agents/duplicate-type-finder.md")); + try { + const { result, calls } = runHarnessWithStub({ + stubScript, + reflectData, + extraEnv: { + TMPDIR: ostmp, + GITHUB_WORKSPACE: workspace, + GH_AW_ENGINE_CWD: "", + GH_AW_TMP_DIR: recordsDir, + GH_AW_AWF_CONFIG_PATH: path.join(dir, "missing-awf-config.json"), + GH_AW_LLM_PROVIDER: "anthropic", + GH_AW_SAFE_OUTPUTS: "", + GH_AW_HARNESS_MAX_RETRIES: "0", + GH_AW_PHASE: "", + ...env, + }, + }); + const recordPath = path.join(recordsDir, "agent", "model-resolution.json"); + const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, "utf8")) : []; + const workspaceAgent = fs.readFileSync(path.join(workspace, ".claude/agents/duplicate-type-finder.md"), "utf8"); + return { result, calls, records, workspaceAgent }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + it("claude_declared_small_resolves_before_spawn", () => { + const { result, calls, records } = runWithStagedAgent({ agentContent: typistAgent() }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + const loaded = calls[0].agents["duplicate-type-finder.md"]; + expect(loaded).toMatch(/^---\nname: "duplicate-type-finder"\n/); + expect(loaded).toContain('model: "claude-haiku-5-5"'); + const resolved = { engine: "claude", role: "duplicate-type-finder", configured_model: "small", resolved_model: "claude-haiku-5-5", provider: "anthropic", status: "resolved", cause: null }; + expect(calls[0].records).toContainEqual(resolved); + expect(records).toContainEqual(resolved); + }); + + it("claude_declared_dead_or_unknown_model_fails_before_spawn", () => { + // Fixture mutation: the recorded catalogue without claude-haiku-5-5, requested explicitly. + const reflectData = runs.typistReflect(); + reflectData.endpoints.forEach(endpoint => endpoint.models && (endpoint.models = endpoint.models.filter(model => model !== "claude-haiku-5-5"))); + const { result, calls, records, workspaceAgent } = runWithStagedAgent({ agentContent: typistAgent().replace("model: small", "model: claude-haiku-5-5"), reflectData }); + + expect(result.status).toBe(1); + expect(calls).toEqual([]); + expect(result.stderr).toContain("claude sub-agent 'duplicate-type-finder': The requested model is not supported: 'claude-haiku-5-5' is not available in the workflow's live model catalog."); + expect(records).toContainEqual({ engine: "claude", role: "duplicate-type-finder", configured_model: "claude-haiku-5-5", resolved_model: null, provider: "anthropic", status: "failed", cause: "model_unavailable" }); + expect(workspaceAgent).toContain("model: claude-haiku-5-5"); + }); + + it("claude_codex_detection_skips_subagent_resolution (Claude)", () => { + // Fixture mutation: an unknown model that would fail closed outside detection. + const agentContent = typistAgent().replace("model: small", "model: claude-nonexistent-9"); + const { result, calls, records, workspaceAgent } = runWithStagedAgent({ agentContent, env: { GH_AW_PHASE: "detection" } }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(records).toEqual([]); + expect(workspaceAgent).toContain("model: claude-nonexistent-9"); + expect(result.stderr).not.toContain("sub-agent"); + }); +}); diff --git a/actions/setup/js/codex_harness.cjs b/actions/setup/js/codex_harness.cjs index b8a5e51e4ef..fd32bb33e93 100644 --- a/actions/setup/js/codex_harness.cjs +++ b/actions/setup/js/codex_harness.cjs @@ -61,6 +61,7 @@ const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./ const { resolveRetryConfig } = require("./harness_retry_config.cjs"); const { applyModelFallback, injectModelFlagAfterExec, normalizeCodexModel, normalizeCodexModelArgs } = require("./model_fallback.cjs"); const { resolveEngineModel } = require("./resolve_model_alias.cjs"); +const { declareCodexSubagentModels, isDetectionPhase } = require("./engine_subagent_models.cjs"); const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs"); const { calculateWorkingSetFromJSONL } = require("./working_set_metrics.cjs"); const { resolveAWFModelRoutingSelection, mapAWFRoutingEffort, recordAWFModelRoutingOutcome, getAWFModelRoutingPolicy, getAWFModelRoutingFailureCode } = require("./awf_model_routing.cjs"); @@ -966,6 +967,23 @@ async function main() { codexChildEnv[codexModelEnvVar] = resolvedModel; } + // Declare and warn only: Codex does not load the staged Markdown sub-agents, so their + // models are resolved and recorded for audits, never applied and never fatal. Threat + // detection uses no sub-agents. + if (!isDetectionPhase(process.env.GH_AW_PHASE)) { + await declareCodexSubagentModels({ + reflectData, + refetchReflectData: async () => { + if (process.env.AWF_REFLECT_ENABLED !== "1") return null; + const refreshed = await fetchAWFReflect({ logger: log }); + return refreshed.ok && refreshed.reflectData ? refreshed.reflectData : null; + }, + provider: process.env.GH_AW_LLM_PROVIDER || "openai", + env: process.env, + logger: log, + }); + } + // Prompts remain on stdin; use a placeholder in the argument diagnostic. const hadPromptFile = args.includes("--prompt-file"); const safeArgs = hadPromptFile && resolvedArgs.length > 0 ? [...resolvedArgs.slice(0, -1), ""] : resolvedArgs; diff --git a/actions/setup/js/codex_harness.test.cjs b/actions/setup/js/codex_harness.test.cjs index 0d5e4b949f0..9f96f08d0d5 100644 --- a/actions/setup/js/codex_harness.test.cjs +++ b/actions/setup/js/codex_harness.test.cjs @@ -2347,3 +2347,64 @@ process.exit(1);`, }); }); }); + +describe("Codex sub-agent declarations before spawn (github/gh-aw#70667)", () => { + const runs = require("./fixtures/claude_codex_subagent_runs.cjs"); + + function runWithArxivAgents({ reflectData = runs.arxivReflect(), env = {} } = {}) { + const dir = makeHarnessTempDir("codex-subagents-"); + const ostmp = path.join(dir, "ostmp"); + const recordsDir = path.join(dir, "records"); + const staged = path.join(ostmp, "gh-aw", ".codex", "agents"); + fs.mkdirSync(staged, { recursive: true }); + for (const name of fs.readdirSync(runs.arxivAgentsDir)) fs.copyFileSync(path.join(runs.arxivAgentsDir, name), path.join(staged, name)); + const { result, calls } = runHarnessFixture("", { + reflectData, + env: { TMPDIR: ostmp, AWF_REFLECT_ENABLED: "1", GH_AW_LLM_PROVIDER: "openai", GH_AW_TMP_DIR: recordsDir, GH_AW_AWF_CONFIG_PATH: path.join(dir, "missing-awf-config.json"), GH_AW_PHASE: "", ...env }, + }); + const recordPath = path.join(recordsDir, "agent", "model-resolution.json"); + const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, "utf8")) : []; + const stagedAfter = Object.fromEntries(fs.readdirSync(staged).map(name => [name, fs.readFileSync(path.join(staged, name), "utf8")])); + return { result, calls, records, stagedAfter }; + } + + const recordedAgents = () => Object.fromEntries(fs.readdirSync(runs.arxivAgentsDir).map(name => [name, fs.readFileSync(path.join(runs.arxivAgentsDir, name), "utf8")])); + + it("codex_markdown_agents_are_not_loaded_and_warn: records exact resolutions, warns and starts Codex", () => { + const { result, calls, records, stagedAfter } = runWithArxivAgents(); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(result.stderr).toContain("warning: Codex discovers custom agents only from TOML role files (verified for Codex 0.163.0); the 4 workflow-declared Markdown sub-agent(s)"); + expect(records.filter(record => record.role !== "main")).toEqual( + ["opportunity-extractor", "paper-screener", "preliminary-investigator", "relevance-ranker"].map(role => { + const configured = role === "opportunity-extractor" ? "openai/gpt-5.6-sol" : "openai/gpt-5.4-mini"; + return { engine: "codex", role, configured_model: configured, resolved_model: configured.slice("openai/".length), provider: "openai", status: "exact", cause: null }; + }) + ); + expect(stagedAfter).toEqual(recordedAgents()); + }); + + it("codex_dead_model_fails_closed_when_native_agent_is_active: declare-and-warn branch continues", () => { + // Fixture mutation: the recorded catalogue with gpt-5.6-sol removed. + const reflectData = runs.arxivReflect(); + reflectData.endpoints.forEach(endpoint => endpoint.models && (endpoint.models = endpoint.models.filter(model => model !== "gpt-5.6-sol"))); + const { result, calls, records } = runWithArxivAgents({ reflectData }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(records).toContainEqual({ engine: "codex", role: "opportunity-extractor", configured_model: "openai/gpt-5.6-sol", resolved_model: null, provider: "openai", status: "failed", cause: "model_unavailable" }); + expect(result.stderr).toContain("warning: workflow sub-agent 'opportunity-extractor'"); + expect(result.stderr).not.toContain("The requested model is not supported"); + }); + + it("claude_codex_detection_skips_subagent_resolution (Codex)", () => { + const { result, calls, records, stagedAfter } = runWithArxivAgents({ env: { GH_AW_PHASE: "detection" } }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(records.filter(record => record.role !== "main")).toEqual([]); + expect(result.stderr).not.toContain("TOML role files"); + expect(stagedAfter).toEqual(recordedAgents()); + }); +}); diff --git a/actions/setup/js/engine_subagent_models.cjs b/actions/setup/js/engine_subagent_models.cjs new file mode 100644 index 00000000000..0d576c97a7e --- /dev/null +++ b/actions/setup/js/engine_subagent_models.cjs @@ -0,0 +1,408 @@ +// @ts-check +"use strict"; + +/** + * Claude and Codex sub-agent model resolution before the engine CLI starts. + * + * Every declared sub-agent model goes through the shared "resolve configured model" step + * (`resolveEngineModel`) in the engine's provisioned provider scope, then a live check + * against the configured AWF `/reflect` endpoints of that provider. `/reflect` is refreshed + * at most once for the whole batch. Each sub-agent with a model gets a `model.resolution` + * record whose role is the agent name; `inherit` (Claude's native inheritance instruction) + * means the main model and gets no record. + * + * Claude: workflow-declared inline agents fail closed and their generated workspace copy + * gets the concrete id; repository agents are resolved and recorded, but their files are + * never changed โ€” a warning says when the resolved id is not applied. + * + * Codex: the staged Markdown agents are not loaded by Codex (it discovers custom agents only + * from TOML role files), so their models are resolved and recorded for observability and a + * warning says they are not applied; nothing fails closed. + */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { getErrorMessage } = require("./error_helpers.cjs"); +const { normalizeReflectProviderName, REFLECT_PROVIDER_ALIASES } = require("./awf_reflect.cjs"); +const { isModelAvailableInReflectData } = require("./awf_model_routing.cjs"); +const { MODEL_NOT_SUPPORTED_PATTERN } = require("./detect_agent_errors.cjs"); +const { inlineArtifactTarget } = require("./inline_artifact_target.cjs"); +const { getEngineSubAgentTarget } = require("./extract_inline_sub_agents.cjs"); +const { loadModelAliasMap, ModelAliasResolutionError, recordModelResolution, resolveEngineModel, splitModelIdentifier } = require("./resolve_model_alias.cjs"); + +/** + * Determine whether the current run phase is threat detection. + * @param {string | undefined | null} phase + * @returns {boolean} + */ +function isDetectionPhase(phase) { + return ( + String(phase || "") + .trim() + .toLowerCase() === "detection" + ); +} + +/** + * The `/reflect` provider names an engine provider covers. + * @param {string} provider + * @returns {Set} + */ +function reflectProvidersFor(provider) { + const normalized = normalizeReflectProviderName(provider); + if (REFLECT_PROVIDER_ALIASES.github.has(normalized)) return REFLECT_PROVIDER_ALIASES.github; + return Object.prototype.hasOwnProperty.call(REFLECT_PROVIDER_ALIASES, normalized) ? REFLECT_PROVIDER_ALIASES[normalized] : new Set([normalized]); +} + +/** + * Parse the frontmatter of a Markdown agent file. + * @param {string} content + * @returns {Record} + */ +function parseAgentFrontmatter(content) { + const { extractFrontmatterAndBody } = require("./frontmatter_hash_pure.cjs"); + const { parseDocument } = require("./work_queue_yaml.cjs"); + const { frontmatterText } = extractFrontmatterAndBody(content); + /** @type {any} */ + const document = parseDocument(frontmatterText); + if (!document || document.errors.length) throw new Error("cannot parse agent frontmatter"); + const frontmatter = document.toJS() || {}; + if (typeof frontmatter !== "object" || Array.isArray(frontmatter)) throw new Error("agent frontmatter must be a mapping"); + return frontmatter; +} + +/** + * Read the Markdown agent files (`*.md`) in one directory. + * `name` is the trimmed frontmatter `name` string, or null when absent; + * `model` is the trimmed frontmatter `model` string, when declared. + * @param {string} directory + * @param {(msg: string) => void} logger + * @returns {Array<{ fileName: string, path: string, name: string | null, model?: string }>} + */ +function listMarkdownAgentFiles(directory, logger) { + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) logger(`warning: cannot read custom agents in ${directory}: ${getErrorMessage(error)}`); + return []; + } + const agents = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + const agentPath = path.join(directory, entry.name); + try { + const frontmatter = parseAgentFrontmatter(fs.readFileSync(agentPath, "utf8")); + const name = typeof frontmatter.name === "string" && frontmatter.name.trim() ? frontmatter.name.trim() : null; + agents.push({ fileName: entry.name, path: agentPath, name, ...(typeof frontmatter.model === "string" ? { model: frontmatter.model.trim() } : {}) }); + } catch (error) { + logger(`warning: cannot read custom agent ${agentPath}: ${getErrorMessage(error)}`); + } + } + return agents; +} + +/** + * Replace the single top-level frontmatter `model:` scalar of an agent file, including any + * block-scalar continuation lines. + * @param {string} content + * @param {string} model + * @returns {string} + */ +function replaceAgentModel(content, model) { + const { extractFrontmatterAndBody } = require("./frontmatter_hash_pure.cjs"); + const { parseDocument } = require("./work_queue_yaml.cjs"); + const { frontmatterText } = extractFrontmatterAndBody(content); + /** @type {any} */ + const document = parseDocument(frontmatterText); + if (!document || document.errors.length || !document.contents || !Array.isArray(document.contents.items)) { + throw new Error("cannot parse agent frontmatter"); + } + const pairs = document.contents.items.filter(pair => pair?.key?.value === "model"); + if (pairs.length !== 1 || typeof pairs[0].value?.value !== "string" || !Array.isArray(pairs[0].value.range)) { + throw new Error("expected exactly one top-level string 'model:' scalar in the frontmatter"); + } + const frontmatterStart = content.indexOf("\n") + 1; + const start = frontmatterStart + pairs[0].value.range[0]; + const end = frontmatterStart + pairs[0].value.range[1]; + const lineEnding = content.slice(start, end).match(/(?:\r?\n)+$/)?.[0] || ""; + return content.slice(0, start) + JSON.stringify(model) + lineEnding + content.slice(end); +} + +/** + * Why a model is not listed for the engine's providers: `catalog_unavailable` when + * discovery is missing or incomplete for those providers, otherwise `model_unavailable`. + * @param {any} reflectData + * @param {Set} providers + * @returns {"catalog_unavailable" | "model_unavailable"} + */ +function classifyUnlistedModel(reflectData, providers) { + const configured = (Array.isArray(reflectData?.endpoints) ? reflectData.endpoints : []).filter(endpoint => endpoint?.configured === true); + const relevant = configured.filter(endpoint => providers.has(normalizeReflectProviderName(endpoint.provider))); + const incomplete = !reflectData || reflectData.models_fetch_complete === false || relevant.length === 0 || relevant.some(endpoint => !Array.isArray(endpoint.models) || endpoint.models.length === 0); + return incomplete ? "catalog_unavailable" : "model_unavailable"; +} + +/** + * Resolve the models of Claude or Codex sub-agents in one batch. Agents without a model or + * with `inherit` are skipped without a record. A `/` prefix must name the engine's + * provider; another credential route is never opened. The returned `model` is the bare, + * engine-normalized id; model parameters are not carried into agent definitions. + * + * Failures are recorded as `failed` and returned, never thrown. `fatal` agents log the shared + * ModelAliasResolutionError diagnostic; other agents get a warning without it, so a failure + * that does not stop the run is not classified as an unsupported-model run failure. + * + * @param {{ + * engine: "claude" | "codex", + * agents: Array<{ name: string, model?: string, path?: string, fatal: boolean, origin: string }>, + * provider: string, + * normalize: (model: string) => string, + * reflectData: any, + * refetchReflectData?: () => Promise, + * aliasMap?: Record | null, + * nonFatalOutcome: string, + * env?: NodeJS.ProcessEnv, + * logger: (msg: string) => void, + * }} options `nonFatalOutcome` says what happens to a non-fatal agent whose model fails. + * @returns {Promise<{ + * resolutions: Array<{ name: string, configuredModel: string, model: string, status: string }>, + * failures: Array<{ name: string, configuredModel: string, fatal: boolean, failure: InstanceType }>, + * }>} + */ +async function resolveEngineSubagentModels(options) { + const { engine, logger } = options; + const env = options.env || process.env; + const providers = reflectProvidersFor(options.provider); + const withModel = options.agents.filter(agent => typeof agent.model === "string" && agent.model.trim() && agent.model.trim().toLowerCase() !== "inherit"); + /** @type {Awaited>} */ + const outcome = { resolutions: [], failures: [] }; + if (withModel.length === 0) return outcome; + const aliasMap = options.aliasMap !== undefined ? options.aliasMap : loadModelAliasMap({ reflectData: options.reflectData, env }); + let reflectData = options.reflectData; + /** @type {Promise | null} */ + let refresh = null; + const refreshOnce = () => { + refresh ??= (options.refetchReflectData ? options.refetchReflectData() : Promise.resolve(null)).then(refreshed => { + if (refreshed) reflectData = refreshed; + return refreshed || null; + }); + return refresh; + }; + const liveCheck = env.AWF_REFLECT_ENABLED === "1" || Boolean(options.reflectData); + const stripProvider = (/** @type {string} */ model) => { + const { base } = splitModelIdentifier(model); + const slash = base.indexOf("/"); + return slash > 0 && providers.has(normalizeReflectProviderName(base.slice(0, slash))) ? base.slice(slash + 1) : base; + }; + + for (const agent of withModel) { + const configuredModel = String(agent.model).trim(); + const agentLogger = agent.fatal ? logger : (/** @type {string} */ message) => (MODEL_NOT_SUPPORTED_PATTERN.test(message) ? undefined : logger(message)); + const fail = (/** @type {InstanceType} */ failure, /** @type {string} */ detail) => { + recordModelResolution({ engine, role: agent.name, configured_model: configuredModel, resolved_model: null, provider: failure.provider || null, status: "failed", cause: failure.reason }, env); + outcome.failures.push({ name: agent.name, configuredModel, fatal: agent.fatal, failure }); + if (agent.fatal) { + logger(`${engine} sub-agent '${agent.name}': ${failure.message}${detail ? ` ${detail}` : ""} Refusing to start ${engine === "claude" ? "Claude" : "Codex"} with a different sub-agent model.`); + } else { + logger(`warning: ${agent.origin} '${agent.name}' (${agent.path || "unknown file"}) declares model '${configuredModel}', which could not be resolved (cause: ${failure.reason}); ${options.nonFatalOutcome}.`); + } + }; + + const { base } = splitModelIdentifier(configuredModel); + const slash = base.indexOf("/"); + const prefix = slash > 0 ? base.slice(0, slash) : ""; + if (prefix && ![...reflectProvidersFor(prefix)].some(name => providers.has(name))) { + fail(new ModelAliasResolutionError(configuredModel, "model_unavailable", { provider: prefix }), `Sub-agent model '${configuredModel}' names provider '${prefix}', but ${engine} is provisioned for '${options.provider}'.`); + continue; + } + + let result; + try { + result = await resolveEngineModel({ + engine, + role: agent.name, + configuredModel, + aliasMap, + reflectData, + provider: options.provider, + refetchReflectData: refreshOnce, + logPrefix: `${engine} sub-agent '${agent.name}' model alias resolution`, + normalize: resolution => stripProvider(options.normalize(resolution.resolvedModel)), + env, + logger: agentLogger, + }); + } catch (error) { + if (error instanceof ModelAliasResolutionError) { + fail(error, ""); + continue; + } + fail(new ModelAliasResolutionError(configuredModel, "model_unavailable", { provider: options.provider }), getErrorMessage(error)); + continue; + } + const model = result.engineModel; + const resolvedNote = model !== configuredModel ? ` (resolved to '${model}')` : ""; + if (liveCheck && !isModelAvailableInReflectData(model, reflectData, providers)) { + const initialReason = classifyUnlistedModel(reflectData, providers); + const refreshed = await refreshOnce(); + if (!refreshed || !isModelAvailableInReflectData(model, refreshed, providers)) { + const reason = initialReason === "model_unavailable" || !refreshed ? initialReason : classifyUnlistedModel(refreshed, providers); + fail(new ModelAliasResolutionError(configuredModel, reason, { provider: result.provider || options.provider }), `Sub-agent model '${configuredModel}'${resolvedNote} is not listed on the configured awf-reflect endpoints.`); + continue; + } + } + const { params } = splitModelIdentifier(configuredModel); + if ([...params.keys()].length > 0) logger(`${engine} sub-agent '${agent.name}': model parameters in '${configuredModel}' are not applied to the agent definition`); + logger(`${engine} sub-agent '${agent.name}': model '${configuredModel}' -> '${model}'`); + outcome.resolutions.push({ name: agent.name, configuredModel, model, status: result.status }); + } + return outcome; +} + +/** + * Claude: resolve the models of the sub-agents Claude Code loads from the project before it + * starts. Workflow-declared inline agents are the files staged by the activation job in + * `/.claude/agents`; their generated workspace copy (restored by + * `restore_inline_sub_agents.sh`) is rewritten with the resolved concrete id. When one of them + * cannot be resolved or written, `ok` is false and Claude must not start. Repository agents + * in `/.claude/agents` are resolved and recorded, but their files are left + * unchanged: Claude Code ranks project agents above user agents, so no file-based override + * exists without editing tracked content. Agent files without a `name` are skipped because + * Claude Code does not register them. + * + * @param {{ + * reflectData: any, + * refetchReflectData?: () => Promise, + * provider: string, + * workspaceDir?: string, + * workingDirectory?: string, + * agentsBaseDir?: string, + * aliasMap?: Record | null, + * env?: NodeJS.ProcessEnv, + * logger: (msg: string) => void, + * }} options + * @returns {Promise<{ ok: boolean, resolutions: Array<{ name: string, configuredModel: string, model: string, status: string }> }>} + */ +async function applyClaudeSubagentModels(options) { + const { logger } = options; + const env = options.env || process.env; + const { normalizeClaudeModel } = require("./model_fallback.cjs"); + const workspaceDir = options.workspaceDir || env.GITHUB_WORKSPACE || process.cwd(); + const workingDirectory = options.workingDirectory || path.resolve(workspaceDir, env.GH_AW_ENGINE_CWD || "."); + const agentsBaseDir = options.agentsBaseDir || path.join(os.tmpdir(), "gh-aw"); + const target = inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget("claude")); + const declared = listMarkdownAgentFiles(path.resolve(agentsBaseDir, target.dir), logger).map(agent => ({ + ...agent, + name: agent.name || agent.fileName.slice(0, -".md".length), + workspacePath: path.resolve(workspaceDir, target.dir, agent.fileName), + })); + const declaredPaths = new Set(declared.map(agent => agent.workspacePath)); + const declaredNames = new Set(declared.map(agent => agent.name)); + const repository = []; + for (const agent of listMarkdownAgentFiles(path.resolve(workingDirectory, ".claude", "agents"), logger)) { + if (declaredPaths.has(path.resolve(agent.path))) continue; + if (!agent.name) { + if (agent.model) logger(`warning: repository agent ${agent.path} has no frontmatter 'name', so Claude Code does not register it; its model '${agent.model}' is not resolved.`); + continue; + } + if (declaredNames.has(agent.name)) continue; + repository.push({ ...agent, name: agent.name }); + } + const { resolutions, failures } = await resolveEngineSubagentModels({ + engine: "claude", + agents: [ + ...declared.map(agent => ({ name: agent.name, model: agent.model, path: agent.path, fatal: true, origin: "workflow sub-agent" })), + ...repository.map(agent => ({ name: agent.name, model: agent.model, path: agent.path, fatal: false, origin: "repository agent" })), + ], + provider: options.provider, + normalize: model => normalizeClaudeModel(model, options.provider, env, { reflectData: options.reflectData, logger }), + reflectData: options.reflectData, + refetchReflectData: options.refetchReflectData, + aliasMap: options.aliasMap, + nonFatalOutcome: "Claude Code uses the agent file unchanged", + env, + logger, + }); + if (failures.some(failure => failure.fatal)) return { ok: false, resolutions }; + + for (const resolution of resolutions) { + const agent = declared.find(candidate => candidate.name === resolution.name); + if (!agent) { + const repositoryAgent = repository.find(candidate => candidate.name === resolution.name); + if (resolution.model !== resolution.configuredModel) { + logger( + `warning: repository agent '${resolution.name}' (${repositoryAgent?.path || "unknown file"}) declares model '${resolution.configuredModel}', resolved to '${resolution.model}', but the resolved id is not applied: the agent file is left unchanged and Claude Code receives '${resolution.configuredModel}'.` + ); + } + continue; + } + try { + const staged = fs.readFileSync(agent.path, "utf8"); + const content = resolution.model === resolution.configuredModel ? staged : replaceAgentModel(staged, resolution.model); + const existing = fs.existsSync(agent.workspacePath) ? fs.readFileSync(agent.workspacePath, "utf8") : null; + if (existing !== content) { + fs.mkdirSync(path.dirname(agent.workspacePath), { recursive: true }); + fs.writeFileSync(agent.workspacePath, content); + logger(`claude sub-agent '${resolution.name}': wrote model '${resolution.model}' to ${agent.workspacePath}`); + } + } catch (error) { + logger(`claude sub-agent '${resolution.name}': cannot write the generated agent copy ${agent.workspacePath}: ${getErrorMessage(error)}. Refusing to start Claude with an unresolved sub-agent model.`); + return { ok: false, resolutions }; + } + } + return { ok: true, resolutions }; +} + +/** + * Codex: declare and warn only. Codex discovers custom agents only from TOML role files + * (verified for Codex 0.163.0), so the workflow-declared Markdown agents staged in + * `/.codex/agents` are not loaded and their models are not applied. Their + * models are still resolved and recorded so audits see the declarations; a failure is a + * warning and never stops Codex. + * + * @param {{ + * reflectData: any, + * refetchReflectData?: () => Promise, + * provider: string, + * agentsBaseDir?: string, + * aliasMap?: Record | null, + * env?: NodeJS.ProcessEnv, + * logger: (msg: string) => void, + * }} options + * @returns {Promise<{ agents: string[], resolutions: Array<{ name: string, configuredModel: string, model: string, status: string }>, failures: Array<{ name: string }> }>} + */ +async function declareCodexSubagentModels(options) { + const { logger } = options; + const env = options.env || process.env; + const { normalizeCodexModel } = require("./model_fallback.cjs"); + const agentsBaseDir = options.agentsBaseDir || path.join(os.tmpdir(), "gh-aw"); + const directory = path.resolve(agentsBaseDir, inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget("codex")).dir); + const agents = listMarkdownAgentFiles(directory, logger).map(agent => ({ ...agent, name: agent.name || agent.fileName.slice(0, -".md".length) })); + if (agents.length === 0) return { agents: [], resolutions: [], failures: [] }; + logger( + `warning: Codex discovers custom agents only from TOML role files (verified for Codex 0.163.0); the ${agents.length} workflow-declared Markdown sub-agent(s) in ${directory} (${agents.map(agent => agent.name).join(", ")}) are not loaded by Codex, and their declared models are recorded but not applied.` + ); + const { resolutions, failures } = await resolveEngineSubagentModels({ + engine: "codex", + agents: agents.map(agent => ({ name: agent.name, model: agent.model, path: agent.path, fatal: false, origin: "workflow sub-agent" })), + provider: options.provider, + normalize: model => normalizeCodexModel(model, options.provider, { env, logger }), + reflectData: options.reflectData, + refetchReflectData: options.refetchReflectData, + aliasMap: options.aliasMap, + nonFatalOutcome: "Codex does not load the Markdown agent, so the run continues", + env, + logger, + }); + return { agents: agents.map(agent => agent.name), resolutions, failures }; +} + +module.exports = { + applyClaudeSubagentModels, + declareCodexSubagentModels, + isDetectionPhase, + listMarkdownAgentFiles, + replaceAgentModel, + resolveEngineSubagentModels, +}; diff --git a/actions/setup/js/engine_subagent_models.test.cjs b/actions/setup/js/engine_subagent_models.test.cjs new file mode 100644 index 00000000000..0706c0fcf48 --- /dev/null +++ b/actions/setup/js/engine_subagent_models.test.cjs @@ -0,0 +1,301 @@ +import { afterEach, describe, it, expect } from "vitest"; +import { createRequire } from "module"; +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const require = createRequire(import.meta.url); +global.core = { info: () => {}, warning: () => {}, error: () => {}, setFailed: () => {} }; +const { applyClaudeSubagentModels, declareCodexSubagentModels, isDetectionPhase, replaceAgentModel } = require("./engine_subagent_models.cjs"); +const { extractInlineSubAgents, writeInlineSubAgents } = require("./extract_inline_sub_agents.cjs"); +const { extractFrontmatterAndBody } = require("./frontmatter_hash_pure.cjs"); +const { parseDocument } = require("./work_queue_yaml.cjs"); +const runs = require("./fixtures/claude_codex_subagent_runs.cjs"); + +const repoRoot = path.resolve(path.dirname(require.resolve("./engine_subagent_models.cjs")), "../../.."); +const tempDirs = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +const frontmatterOf = content => parseDocument(extractFrontmatterAndBody(content).frontmatterText).toJS() || {}; +const sha = content => crypto.createHash("sha256").update(content).digest("hex"); + +/** + * A run layout: the activation artifact under `/gh-aw`, restored into the workspace as + * restore_inline_sub_agents.sh does, and `GH_AW_TMP_DIR` for model-resolution records. + */ +function layout() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "engine-subagent-models-")); + tempDirs.push(dir); + const agentsBaseDir = path.join(dir, "gh-aw"); + const workspaceDir = path.join(dir, "workspace"); + const env = { GH_AW_TMP_DIR: path.join(dir, "records"), GH_AW_AWF_CONFIG_PATH: path.join(dir, "missing-awf-config.json"), AWF_REFLECT_ENABLED: "1" }; + const records = () => { + const file = path.join(env.GH_AW_TMP_DIR, "agent", "model-resolution.json"); + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : []; + }; + const logs = []; + const logger = message => logs.push(message); + return { dir, agentsBaseDir, workspaceDir, env, records, logs, logger }; +} + +/** Stage inline agents through the activation writer and restore them into the workspace. */ +function stageInline(run, engine, agents) { + const markdown = ["# Workflow", "", "Main prompt.", ...Object.entries(agents).flatMap(([name, content]) => ["", `## agent: \`${name}\``, content.trimEnd()])].join("\n"); + writeInlineSubAgents(markdown, run.workspaceDir, run.agentsBaseDir, engine); + const dir = engine === "claude" ? ".claude/agents" : ".codex/agents"; + fs.mkdirSync(path.join(run.workspaceDir, dir), { recursive: true }); + for (const name of fs.readdirSync(path.join(run.agentsBaseDir, dir))) fs.copyFileSync(path.join(run.agentsBaseDir, dir, name), path.join(run.workspaceDir, dir, name)); +} + +const typistAgent = () => fs.readFileSync(path.join(runs.typistAgentsDir, "duplicate-type-finder.md"), "utf8"); +const arxivAgents = () => Object.fromEntries(fs.readdirSync(runs.arxivAgentsDir).map(name => [name.slice(0, -".md".length), fs.readFileSync(path.join(runs.arxivAgentsDir, name), "utf8")])); + +describe("Claude sub-agent models (Typist run 39415087125)", () => { + it("claude_declared_small_resolves_before_spawn", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent() }); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + expect(outcome.resolutions).toEqual([{ name: "duplicate-type-finder", configuredModel: "small", model: "claude-haiku-5-5", status: "resolved" }]); + const workspaceCopy = fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/duplicate-type-finder.md"), "utf8"); + expect(frontmatterOf(workspaceCopy)).toMatchObject({ name: runs.typistAgentToolUse.input.subagent_type, model: "claude-haiku-5-5" }); + expect(extractFrontmatterAndBody(workspaceCopy).markdown).toBe(extractFrontmatterAndBody(typistAgent()).markdown); + expect(frontmatterOf(fs.readFileSync(path.join(run.agentsBaseDir, ".claude/agents/duplicate-type-finder.md"), "utf8")).model).toBe("small"); + expect(run.records()).toEqual([{ engine: "claude", role: "duplicate-type-finder", configured_model: "small", resolved_model: "claude-haiku-5-5", provider: "anthropic", status: "resolved", cause: null }]); + }); + + it.each([ + // Fixture mutation: the recorded catalogue with claude-haiku-5-5 removed. + ["a requested id absent from the catalogue", "claude-haiku-5-5", reflect => reflect.endpoints.forEach(endpoint => endpoint.models && (endpoint.models = endpoint.models.filter(model => model !== "claude-haiku-5-5")))], + // Fixture mutation: an injected unknown selector. + ["an unknown selector", "claude-nonexistent-9", () => {}], + ])("claude_declared_dead_or_unknown_model_fails_before_spawn: %s", async (_name, model, mutate) => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent().replace("model: small", `model: ${model}`) }); + const reflectData = runs.typistReflect(); + mutate(reflectData); + let refreshes = 0; + const before = fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/duplicate-type-finder.md"), "utf8"); + + const outcome = await applyClaudeSubagentModels({ + reflectData, + refetchReflectData: async () => (refreshes++, reflectData), + provider: "anthropic", + workspaceDir: run.workspaceDir, + agentsBaseDir: run.agentsBaseDir, + env: run.env, + logger: run.logger, + }); + + expect(outcome.ok).toBe(false); + expect(refreshes).toBe(1); + expect(run.records()).toEqual([{ engine: "claude", role: "duplicate-type-finder", configured_model: model, resolved_model: null, provider: "anthropic", status: "failed", cause: "model_unavailable" }]); + expect(run.logs).toContainEqual(expect.stringContaining(`claude sub-agent 'duplicate-type-finder': The requested model is not supported: '${model}' is not available in the workflow's live model catalog.`)); + expect(run.logs).toContainEqual(expect.stringContaining("Refusing to start Claude with a different sub-agent model.")); + expect(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/duplicate-type-finder.md"), "utf8")).toBe(before); + }); + + it("records catalog_unavailable when the provider's catalogue is missing", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent().replace("model: small", "model: claude-haiku-5-5") }); + // Fixture mutation: model discovery incomplete with no Anthropic models. + const reflectData = runs.typistReflect(); + reflectData.models_fetch_complete = false; + reflectData.endpoints.forEach(endpoint => (endpoint.models = null)); + + const outcome = await applyClaudeSubagentModels({ reflectData, provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(false); + expect(run.records()).toEqual([expect.objectContaining({ role: "duplicate-type-finder", status: "failed", cause: "catalog_unavailable" })]); + }); + + it("claude_inherit_uses_main_without_subagent_record", async () => { + const run = layout(); + const source = fs.readFileSync(path.join(repoRoot, ".github/workflows/agent-persona-explorer.md"), "utf8"); + const persona = extractInlineSubAgents(extractFrontmatterAndBody(source).markdown).agents.find(agent => agent.name === "persona-evaluator"); + expect(frontmatterOf(persona.content).model).toBe("inherit"); + stageInline(run, "claude", { "persona-evaluator": persona.content }); + const before = fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/persona-evaluator.md"), "utf8"); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome).toEqual({ ok: true, resolutions: [] }); + expect(run.records()).toEqual([]); + expect(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/persona-evaluator.md"), "utf8")).toBe(before); + expect(frontmatterOf(before).model).toBe("inherit"); + }); + + it("claude_native_aliases_use_shared_resolution", async () => { + const run = layout(); + const agent = model => typistAgent().replace("model: small", `model: ${model}`); + stageInline(run, "claude", { "haiku-worker": agent("haiku"), "sonnet-worker": agent("sonnet"), "opus-worker": agent("opus") }); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + expect(Object.fromEntries(outcome.resolutions.map(resolution => [resolution.name, [resolution.model, resolution.status]]))).toEqual({ + "haiku-worker": ["claude-haiku-5-5", "resolved"], + "opus-worker": ["claude-opus-5-5", "resolved"], + "sonnet-worker": ["claude-sonnet-5-5", "resolved"], + }); + for (const name of ["haiku-worker", "sonnet-worker", "opus-worker"]) { + const record = run.records().find(entry => entry.role === name); + expect(record).toMatchObject({ engine: "claude", status: "resolved", provider: "anthropic" }); + expect(frontmatterOf(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents", `${name}.md`), "utf8")).model).toBe(record.resolved_model); + } + }); + + it("repository_unresolvable_model_warns_records_and_continues", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent() }); + // Fixture mutation: a repository agent with an unknown model. + const repositoryAgent = "---\nname: repo-reviewer\ndescription: Reviews code\nmodel: claude-nonexistent-9\ntools: Read, Grep\n---\nReview.\n"; + fs.writeFileSync(path.join(run.workspaceDir, ".claude/agents/repo-reviewer.md"), repositoryAgent); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + expect(sha(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/repo-reviewer.md"), "utf8"))).toBe(sha(repositoryAgent)); + expect(run.records()).toContainEqual({ engine: "claude", role: "repo-reviewer", configured_model: "claude-nonexistent-9", resolved_model: null, provider: "anthropic", status: "failed", cause: "model_unavailable" }); + expect(run.logs).toContainEqual(expect.stringContaining("warning: repository agent 'repo-reviewer'")); + expect(run.logs.join("\n")).not.toMatch(/The requested model is not supported/); + }); + + it("claude_repository_override_respects_native_precedence", async () => { + const run = layout(); + const home = path.join(run.dir, "home"); + // Claude Code ranks project agents above user agents, so a user-level copy cannot override + // a project agent; no tested session-local override exists. The project file stays as is. + const repositoryAgent = "---\nname: repo-reviewer\ndescription: Reviews code\nmodel: small\ntools: Read, Grep\npermissionMode: plan\n---\nReview.\n"; + fs.mkdirSync(path.join(run.workspaceDir, ".claude/agents"), { recursive: true }); + fs.writeFileSync(path.join(run.workspaceDir, ".claude/agents/repo-reviewer.md"), repositoryAgent); + + const outcome = await applyClaudeSubagentModels({ + reflectData: runs.typistReflect(), + provider: "anthropic", + workspaceDir: run.workspaceDir, + agentsBaseDir: run.agentsBaseDir, + env: { ...run.env, HOME: home }, + logger: run.logger, + }); + + expect(outcome.ok).toBe(true); + expect(outcome.resolutions).toEqual([{ name: "repo-reviewer", configuredModel: "small", model: "claude-haiku-5-5", status: "resolved" }]); + expect(sha(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/repo-reviewer.md"), "utf8"))).toBe(sha(repositoryAgent)); + expect(fs.existsSync(path.join(home, ".claude"))).toBe(false); + expect(run.records()).toEqual([expect.objectContaining({ role: "repo-reviewer", resolved_model: "claude-haiku-5-5", status: "resolved" })]); + expect(run.logs).toContainEqual(expect.stringContaining("resolved to 'claude-haiku-5-5', but the resolved id is not applied")); + }); + + it("skips repository agents without a name, which Claude Code does not register", async () => { + const run = layout(); + fs.mkdirSync(path.join(run.workspaceDir, ".claude/agents"), { recursive: true }); + fs.writeFileSync(path.join(run.workspaceDir, ".claude/agents/unnamed.md"), "---\nmodel: claude-nonexistent-9\n---\nBody\n"); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome).toEqual({ ok: true, resolutions: [] }); + expect(run.records()).toEqual([]); + expect(run.logs).toContainEqual(expect.stringContaining("has no frontmatter 'name'")); + }); + + it("rejects a provider prefix the engine is not provisioned for", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent().replace("model: small", "model: openai/gpt-5.4-mini") }); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(false); + expect(run.records()).toEqual([expect.objectContaining({ role: "duplicate-type-finder", status: "failed", cause: "model_unavailable", provider: "openai" })]); + }); + + it("replaces only the model scalar", () => { + expect(replaceAgentModel('---\nname: "a"\nmodel: small\ntools: Read\n---\nBody model: small\n', "claude-haiku-5-5")).toBe('---\nname: "a"\nmodel: "claude-haiku-5-5"\ntools: Read\n---\nBody model: small\n'); + }); +}); + +describe("Codex sub-agent models (daily-arxiv-researcher run 39394965127)", () => { + it("codex_live_prefixed_model_is_exact", async () => { + const run = layout(); + stageInline(run, "codex", arxivAgents()); + + const outcome = await declareCodexSubagentModels({ reflectData: runs.arxivReflect(), provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.failures).toEqual([]); + expect(outcome.resolutions.map(resolution => [resolution.name, resolution.configuredModel, resolution.model, resolution.status])).toEqual([ + ["opportunity-extractor", "openai/gpt-5.6-sol", "gpt-5.6-sol", "exact"], + ["paper-screener", "openai/gpt-5.4-mini", "gpt-5.4-mini", "exact"], + ["preliminary-investigator", "openai/gpt-5.4-mini", "gpt-5.4-mini", "exact"], + ["relevance-ranker", "openai/gpt-5.4-mini", "gpt-5.4-mini", "exact"], + ]); + // Same record fields as the run's recorded main-role model.resolution. + for (const record of run.records()) { + expect(Object.keys(record)).toEqual(Object.keys(runs.arxivMainModelResolution.data)); + expect(record).toMatchObject({ engine: "codex", provider: runs.arxivMainModelResolution.data.provider, status: "exact", cause: null }); + expect(record.configured_model).toMatch(/^openai\//); + } + expect(run.records().map(record => record.role)).toEqual(["opportunity-extractor", "paper-screener", "preliminary-investigator", "relevance-ranker"]); + }); + + it("codex_dead_model_fails_closed_when_native_agent_is_active: declare-and-warn branch (Markdown agents are not native)", async () => { + const run = layout(); + stageInline(run, "codex", arxivAgents()); + // Fixture mutation: the recorded catalogue with gpt-5.6-sol removed. + const reflectData = runs.arxivReflect(); + reflectData.endpoints.forEach(endpoint => endpoint.models && (endpoint.models = endpoint.models.filter(model => model !== "gpt-5.6-sol"))); + + const outcome = await declareCodexSubagentModels({ reflectData, refetchReflectData: async () => reflectData, provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.failures.map(failure => failure.name)).toEqual(["opportunity-extractor"]); + expect(run.records()).toContainEqual({ engine: "codex", role: "opportunity-extractor", configured_model: "openai/gpt-5.6-sol", resolved_model: null, provider: "openai", status: "failed", cause: "model_unavailable" }); + expect(run.logs).toContainEqual(expect.stringContaining("warning: workflow sub-agent 'opportunity-extractor'")); + expect(run.logs.join("\n")).not.toMatch(/The requested model is not supported|Refusing to start/); + }); + + it("codex_markdown_agents_are_not_loaded_and_warn", async () => { + const run = layout(); + stageInline(run, "codex", arxivAgents()); + const staged = Object.fromEntries(fs.readdirSync(path.join(run.agentsBaseDir, ".codex/agents")).map(name => [name, fs.readFileSync(path.join(run.agentsBaseDir, ".codex/agents", name), "utf8")])); + // The staged files are the recorded Markdown (Codex 0.163.0 discovers `.toml` only). + expect(Object.keys(staged).every(name => name.endsWith(".md"))).toBe(true); + expect(staged).toEqual(Object.fromEntries(Object.entries(arxivAgents()).map(([name, content]) => [`${name}.md`, content.endsWith("\n") ? content : `${content}\n`]))); + + const outcome = await declareCodexSubagentModels({ reflectData: runs.arxivReflect(), provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.agents).toEqual(["opportunity-extractor", "paper-screener", "preliminary-investigator", "relevance-ranker"]); + expect(run.logs).toContainEqual( + expect.stringMatching( + /^warning: Codex discovers custom agents only from TOML role files \(verified for Codex 0\.163\.0\); the 4 workflow-declared Markdown sub-agent\(s\) .* are not loaded by Codex, and their declared models are recorded but not applied\.$/ + ) + ); + for (const [name, content] of Object.entries(staged)) { + expect(fs.readFileSync(path.join(run.agentsBaseDir, ".codex/agents", name), "utf8")).toBe(content); + expect(fs.readFileSync(path.join(run.workspaceDir, ".codex/agents", name), "utf8")).toBe(content); + } + expect(fs.readdirSync(path.join(run.workspaceDir, ".codex/agents")).some(name => name.endsWith(".toml"))).toBe(false); + }); + + it("does nothing without staged Codex agents", async () => { + const run = layout(); + const outcome = await declareCodexSubagentModels({ reflectData: runs.arxivReflect(), provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + expect(outcome).toEqual({ agents: [], resolutions: [], failures: [] }); + expect(run.logs).toEqual([]); + }); +}); + +describe("isDetectionPhase", () => { + it.each([ + ["detection", true], + [" Detection ", true], + ["agent", false], + ["", false], + [undefined, false], + ])("%s -> %s", (phase, expected) => { + expect(isDetectionPhase(phase)).toBe(expected); + }); +}); diff --git a/actions/setup/js/extract_inline_sub_agents.cjs b/actions/setup/js/extract_inline_sub_agents.cjs index 3c6d3010a50..190807f9ee2 100644 --- a/actions/setup/js/extract_inline_sub_agents.cjs +++ b/actions/setup/js/extract_inline_sub_agents.cjs @@ -131,6 +131,25 @@ function resolveSubAgentExperimentReferences(agentName, content, env = process.e return content.slice(0, start) + resolved + content.slice(start + frontmatter.length); } +/** + * Claude Code skips a project agent file without a frontmatter `name`, so the + * generated Claude copy gets the declared block name when the author omitted it. + * An authored `name` is kept. + * + * @param {string} agentName - Declared sub-agent name from the `## agent:` marker. + * @param {string} content - Agent block content (frontmatter + prompt). + * @returns {string} Content with a top-level `name:` in its frontmatter. + */ +function ensureClaudeSubAgentName(agentName, content) { + const nameLine = `name: ${JSON.stringify(agentName)}`; + const match = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*\r?(?=\n|$)/.exec(content); + if (!match) return `---\n${nameLine}\n---\n${content}`; + const frontmatter = match[1] ?? ""; + if (/^name[ \t]*:/m.test(frontmatter)) return content; + const start = content.indexOf("\n") + 1; + return content.slice(0, start) + nameLine + "\n" + content.slice(start); +} + /** * Extracts inline sub-agents from markdown content. * @@ -383,7 +402,8 @@ function writeInlineSubAgents(content, workspaceDir, agentsBaseDir, engineId) { for (const agent of agents) { const agentPath = path.join(agentsDir, agent.name + ext); - const filteredContent = resolveSubAgentExperimentReferences(agent.name, preserveSubAgentFrontmatter(agent.content)); + let filteredContent = resolveSubAgentExperimentReferences(agent.name, preserveSubAgentFrontmatter(agent.content)); + if ((engineId || "").toLowerCase() === "claude") filteredContent = ensureClaudeSubAgentName(agent.name, filteredContent); const agentContent = filteredContent.endsWith("\n") ? filteredContent : filteredContent + "\n"; try { fs.writeFileSync(agentPath, agentContent, "utf8"); @@ -461,6 +481,7 @@ module.exports = { getEngineSubAgentTarget, preserveSubAgentFrontmatter, resolveSubAgentExperimentReferences, + ensureClaudeSubAgentName, closeUnterminatedSubAgentMarkers, loadCopilotSDKCustomAgents, }; diff --git a/actions/setup/js/extract_inline_sub_agents.test.cjs b/actions/setup/js/extract_inline_sub_agents.test.cjs index edb5bd34969..650fde63cac 100644 --- a/actions/setup/js/extract_inline_sub_agents.test.cjs +++ b/actions/setup/js/extract_inline_sub_agents.test.cjs @@ -13,7 +13,16 @@ global.core = { setFailed: () => {}, }; -const { extractInlineSubAgents, writeInlineSubAgents, preserveSubAgentFrontmatter, resolveSubAgentExperimentReferences, closeUnterminatedSubAgentMarkers, loadCopilotSDKCustomAgents } = require("./extract_inline_sub_agents.cjs"); +const { + extractInlineSubAgents, + writeInlineSubAgents, + preserveSubAgentFrontmatter, + resolveSubAgentExperimentReferences, + closeUnterminatedSubAgentMarkers, + loadCopilotSDKCustomAgents, + ensureClaudeSubAgentName, +} = require("./extract_inline_sub_agents.cjs"); +const { typistAgentsDir, typistAgentToolUse, typistAgentToolResult, typistInit } = require("./fixtures/claude_codex_subagent_runs.cjs"); // Helper: returns a ## agent: `name` start marker line. const agentMarker = name => `## agent: \`${name}\``; @@ -435,3 +444,57 @@ describe("resolveSubAgentExperimentReferences", () => { expect(() => resolveSubAgentExperimentReferences("reader", "---\nmodel: ${{ experiments.subagent_model }}\n---\nBody", { GH_AW_EXPERIMENTS_SUBAGENT_MODEL: value })).toThrow("is not a plain model or alias value"); }); }); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Claude staged copies must be native-loadable (github/gh-aw#70667) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Claude staged sub-agent identity", () => { + let tmpDir; + const parseFrontmatter = content => { + const { extractFrontmatterAndBody } = require("./frontmatter_hash_pure.cjs"); + const { parseDocument } = require("./work_queue_yaml.cjs"); + return parseDocument(extractFrontmatterAndBody(content).frontmatterText).toJS() || {}; + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(process.cwd(), ".inline-agents-claude-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // Typist run 39415087125 staged this file without `name`; Claude Code 2.1.296 did not + // register it (init agents) and rejected the named delegation. + it("claude_missing_name_fixture_is_registered", () => { + const fixturePath = path.join(typistAgentsDir, "duplicate-type-finder.md"); + const recorded = fs.readFileSync(fixturePath, "utf8"); + expect(parseFrontmatter(recorded).name).toBeUndefined(); + expect(typistInit.agents).not.toContain(typistAgentToolUse.input.subagent_type); + expect(typistAgentToolResult.content).toContain(`Agent type '${typistAgentToolUse.input.subagent_type}' not found`); + + const workflow = `# Typist\n\nAnalyze types.\n\n${agentMarker("duplicate-type-finder")}\n${recorded}`; + writeInlineSubAgents(workflow, tmpDir, tmpDir, "claude"); + + const written = fs.readFileSync(path.join(tmpDir, ".claude", "agents", "duplicate-type-finder.md"), "utf8"); + const frontmatter = parseFrontmatter(written); + expect(frontmatter.name).toBe(typistAgentToolUse.input.subagent_type); + expect(frontmatter.description).toBe(parseFrontmatter(recorded).description); + expect(frontmatter.model).toBe("small"); + expect(written).toBe(recorded.replace("---\n", '---\nname: "duplicate-type-finder"\n')); + expect(fs.readFileSync(fixturePath, "utf8")).toBe(recorded); + }); + + it("keeps an authored name and leaves other engines' copies unchanged", () => { + expect(ensureClaudeSubAgentName("persona-evaluator", "---\nname: persona-evaluator\nmodel: inherit\n---\nBody\n")).toBe("---\nname: persona-evaluator\nmodel: inherit\n---\nBody\n"); + expect(ensureClaudeSubAgentName("helper", "Body only\n")).toBe('---\nname: "helper"\n---\nBody only\n'); + expect(ensureClaudeSubAgentName("helper", "---\n---\nBody\n")).toBe('---\nname: "helper"\n---\nBody\n'); + + const workflow = `Main.\n\n${agentMarker("helper")}\n---\nmodel: small\n---\nHelp.`; + writeInlineSubAgents(workflow, tmpDir, tmpDir, "codex"); + writeInlineSubAgents(workflow, tmpDir, tmpDir, "copilot"); + expect(fs.readFileSync(path.join(tmpDir, ".codex", "agents", "helper.md"), "utf8")).toBe("---\nmodel: small\n---\nHelp.\n"); + expect(fs.readFileSync(path.join(tmpDir, ".github", "agents", "helper.agent.md"), "utf8")).toBe("---\nmodel: small\n---\nHelp.\n"); + }); +}); diff --git a/actions/setup/js/fixtures/awf-reflect-claude-typist-39415087125.json b/actions/setup/js/fixtures/awf-reflect-claude-typist-39415087125.json new file mode 100644 index 00000000000..94246e74ea3 --- /dev/null +++ b/actions/setup/js/fixtures/awf-reflect-claude-typist-39415087125.json @@ -0,0 +1,79 @@ +{ + "endpoints": [ + { + "provider": "openai", + "port": 10000, + "base_url": "http://api-proxy:10000", + "configured": false, + "models": null, + "models_url": "http://api-proxy:10000/v1/models" + }, + { + "provider": "anthropic", + "port": 10001, + "base_url": "http://api-proxy:10001", + "configured": true, + "models": [ + "claude-haiku-4-5-20251001", + "claude-haiku-5-5", + "claude-opus-4-5-20251101", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-opus-5-5", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-sonnet-5-5" + ], + "models_url": "http://api-proxy:10001/v1/models" + }, + { + "provider": "copilot", + "port": 10002, + "base_url": "http://api-proxy:10002", + "configured": false, + "models": null, + "models_url": "http://api-proxy:10002/models" + }, + { + "provider": "gemini", + "port": 10003, + "base_url": "http://api-proxy:10003", + "configured": false, + "models": null, + "models_url": "http://api-proxy:10003/v1beta/models" + }, + { + "provider": "vertex", + "port": 10004, + "base_url": "http://api-proxy:10004", + "configured": false, + "models": null, + "models_url": null + } + ], + "models_fetch_complete": true, + "model_aliases": { + "agent": ["sonnet-6x", "gpt-6", "gpt-5.4", "gpt-5.5", "gpt-5.6", "gpt-5.3", "gemini-pro", "any"], + "any": ["copilot/*", "anthropic/*", "openai/*", "google/*", "gemini/*"], + "auto": ["copilot/auto", "large"], + "claude": ["agent"], + "codex": ["agent"], + "copilot": ["agent"], + "detection": ["small"], + "evals": ["small"], + "gemini": ["agent"], + "haiku": ["copilot/*haiku*", "anthropic/*haiku*"], + "large": ["sonnet", "gpt-6", "gpt-5-pro", "gpt-5", "gemini-pro"], + "mini": ["haiku", "gpt-5-mini", "gpt-5-nano", "gemini-flash-lite"], + "opus": ["copilot/*opus*", "anthropic/*opus*"], + "opusplan": ["opus?effort=high"], + "small": ["mini"], + "small-agent": ["haiku", "gpt-5-mini", "gemini-flash"], + "sonnet": ["copilot/*sonnet*", "anthropic/*sonnet*"], + "sonnet-6x": ["copilot/*sonnet-4.5*", "copilot/*sonnet-4.6*", "copilot/*sonnet-5*", "copilot/*sonnet-4-5-*", "anthropic/*sonnet-4-5-*", "copilot/*sonnet-4-6*", "anthropic/*sonnet-4-6*", "anthropic/*sonnet-5*"], + "summarization": ["haiku", "gpt-5-mini", "gemini-flash-lite", "mini"] + } +} diff --git a/actions/setup/js/fixtures/awf-reflect-codex-arxiv-39394965127.json b/actions/setup/js/fixtures/awf-reflect-codex-arxiv-39394965127.json new file mode 100644 index 00000000000..96b25d18639 --- /dev/null +++ b/actions/setup/js/fixtures/awf-reflect-codex-arxiv-39394965127.json @@ -0,0 +1,213 @@ +{ + "endpoints": [ + { + "provider": "openai", + "port": 10000, + "base_url": "http://api-proxy:10000", + "configured": true, + "models": [ + "babbage-002", + "chat-latest", + "chatgpt-image-latest", + "davinci-002", + "gpt-3.5-turbo", + "gpt-3.5-turbo-0125", + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-16k", + "gpt-3.5-turbo-instruct", + "gpt-3.5-turbo-instruct-0914", + "gpt-4.1", + "gpt-4.1-2025-04-14", + "gpt-4.1-mini", + "gpt-4.1-mini-2025-04-14", + "gpt-4.1-nano", + "gpt-4.1-nano-2025-04-14", + "gpt-4o", + "gpt-4o-2024-05-13", + "gpt-4o-2024-08-06", + "gpt-4o-2024-11-20", + "gpt-4o-mini", + "gpt-4o-mini-2024-07-18", + "gpt-4o-mini-search-preview", + "gpt-4o-mini-search-preview-2025-03-11", + "gpt-4o-mini-transcribe", + "gpt-4o-mini-transcribe-2025-03-20", + "gpt-4o-mini-transcribe-2025-12-15", + "gpt-4o-mini-tts", + "gpt-4o-mini-tts-2025-03-20", + "gpt-4o-mini-tts-2025-12-15", + "gpt-4o-search-preview", + "gpt-4o-search-preview-2025-03-11", + "gpt-4o-transcribe", + "gpt-4o-transcribe-diarize", + "gpt-5", + "gpt-5-2025-08-07", + "gpt-5-chat-latest", + "gpt-5-codex", + "gpt-5-mini", + "gpt-5-mini-2025-08-07", + "gpt-5-nano", + "gpt-5-nano-2025-08-07", + "gpt-5-pro", + "gpt-5-pro-2025-10-06", + "gpt-5-search-api", + "gpt-5-search-api-2025-10-14", + "gpt-5.1", + "gpt-5.1-2025-11-13", + "gpt-5.1-chat-latest", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2", + "gpt-5.2-2025-12-11", + "gpt-5.2-chat-latest", + "gpt-5.2-codex", + "gpt-5.2-pro", + "gpt-5.2-pro-2025-12-11", + "gpt-5.3-chat-latest", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-2026-03-05", + "gpt-5.4-mini", + "gpt-5.4-mini-2026-03-17", + "gpt-5.4-nano", + "gpt-5.4-nano-2026-03-17", + "gpt-5.4-pro", + "gpt-5.4-pro-2026-03-05", + "gpt-5.5", + "gpt-5.5-2026-04-23", + "gpt-5.5-pro", + "gpt-5.5-pro-2026-04-23", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-6-astra", + "gpt-6-luna", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-audio", + "gpt-audio-1.5", + "gpt-audio-2025-08-28", + "gpt-audio-mini", + "gpt-audio-mini-2025-10-06", + "gpt-audio-mini-2025-12-15", + "gpt-image-1", + "gpt-image-1-mini", + "gpt-image-1.5", + "gpt-image-2", + "gpt-image-2-2026-04-21", + "gpt-image-2.5-flare", + "gpt-image-2.5-flare-2026-09-08", + "gpt-image-2.5-sunburst", + "gpt-image-2.5-sunburst-2026-09-08", + "gpt-live-1", + "gpt-live-transcribe", + "gpt-realtime", + "gpt-realtime-1.5", + "gpt-realtime-2", + "gpt-realtime-2.1", + "gpt-realtime-2.1-mini", + "gpt-realtime-2025-08-28", + "gpt-realtime-mini", + "gpt-realtime-mini-2025-12-15", + "gpt-realtime-translate", + "gpt-realtime-whisper", + "gpt-transcribe", + "o1", + "o1-2024-12-17", + "o1-pro", + "o1-pro-2025-03-19", + "o3", + "o3-2025-04-16", + "o3-deep-research", + "o3-deep-research-2025-06-26", + "o3-mini", + "o3-mini-2025-01-31", + "o3-pro", + "o3-pro-2025-06-10", + "o4-mini", + "o4-mini-2025-04-16", + "o4-mini-deep-research", + "o4-mini-deep-research-2025-06-26", + "omni-moderation-2024-09-26", + "omni-moderation-latest", + "sora-2", + "sora-2-pro", + "text-embedding-3-large", + "text-embedding-3-small", + "text-embedding-ada-002", + "tts-1", + "tts-1-1106", + "tts-1-hd", + "tts-1-hd-1106", + "whisper-1" + ], + "models_url": "http://api-proxy:10000/v1/models" + }, + { + "provider": "anthropic", + "port": 10001, + "base_url": "http://api-proxy:10001", + "configured": false, + "models": null, + "models_url": "http://api-proxy:10001/v1/models" + }, + { + "provider": "copilot", + "port": 10002, + "base_url": "http://api-proxy:10002", + "configured": false, + "models": null, + "models_url": "http://api-proxy:10002/models" + }, + { + "provider": "gemini", + "port": 10003, + "base_url": "http://api-proxy:10003", + "configured": false, + "models": null, + "models_url": "http://api-proxy:10003/v1beta/models" + }, + { + "provider": "vertex", + "port": 10004, + "base_url": "http://api-proxy:10004", + "configured": false, + "models": null, + "models_url": null + } + ], + "models_fetch_complete": true, + "model_aliases": { + "agent": ["sonnet-6x", "gpt-6", "gpt-5.4", "gpt-5.5", "gpt-5.6", "gpt-5.3", "gemini-pro", "any"], + "any": ["copilot/*", "anthropic/*", "openai/*", "google/*", "gemini/*"], + "auto": ["copilot/auto", "large"], + "claude": ["agent"], + "codex": ["agent"], + "coding": ["copilot/gpt-5*codex*", "openai/gpt-5*codex*", "gpt-5-codex", "kimi"], + "copilot": ["agent"], + "deep-research": ["copilot/deep-research*", "copilot/o3-deep-research*", "copilot/o4-mini-deep-research*", "google/deep-research*", "gemini/deep-research*", "openai/o3-deep-research*", "openai/o4-mini-deep-research*"], + "detection": ["small"], + "evals": ["small"], + "gemini": ["agent"], + "gpt-5": ["copilot/gpt-5*", "openai/gpt-5*"], + "gpt-5-codex": ["copilot/gpt-5*codex*", "openai/gpt-5*codex*"], + "gpt-5-mini": ["copilot/gpt-5*mini*", "openai/gpt-5*mini*"], + "gpt-5-nano": ["copilot/gpt-5*nano*", "openai/gpt-5*nano*"], + "gpt-5-pro": ["copilot/gpt-5*pro*", "openai/gpt-5*pro*"], + "gpt-5.1": ["copilot/gpt-5.1*", "openai/gpt-5.1*"], + "gpt-5.2": ["copilot/gpt-5.2*", "openai/gpt-5.2*"], + "gpt-5.3": ["copilot/gpt-5.3*", "openai/gpt-5.3*"], + "gpt-5.4": ["copilot/gpt-5.4*", "openai/gpt-5.4*"], + "gpt-5.5": ["copilot/gpt-5.5*", "openai/gpt-5.5*"], + "gpt-5.6": ["copilot/gpt-5.6*", "openai/gpt-5.6*"], + "gpt-6": ["copilot/gpt-6*", "openai/gpt-6*"], + "image-generation": ["copilot/gpt-image*", "openai/gpt-image*", "openai/chatgpt-image*", "copilot/gemini-*image*", "google/gemini-*image*", "gemini/gemini-*image*", "google/imagen*"], + "large": ["sonnet", "gpt-6", "gpt-5-pro", "gpt-5", "gemini-pro"], + "mini": ["haiku", "gpt-5-mini", "gpt-5-nano", "gemini-flash-lite"], + "reasoning": ["copilot/o1*", "copilot/o3*", "copilot/o4*", "openai/o1*", "openai/o3*", "openai/o4*"], + "small": ["mini"], + "small-agent": ["haiku", "gpt-5-mini", "gemini-flash"], + "summarization": ["haiku", "gpt-5-mini", "gemini-flash-lite", "mini"] + } +} diff --git a/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/opportunity-extractor.md b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/opportunity-extractor.md new file mode 100644 index 00000000000..85177c0befe --- /dev/null +++ b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/opportunity-extractor.md @@ -0,0 +1,23 @@ +--- +description: Extracts a specific actionable improvement for gh-aw from a relevant arXiv paper +model: openai/gpt-5.6-sol +--- + +Extract one specific actionable improvement for GitHub Agentic Workflows (gh-aw) from an arXiv paper. + +gh-aw components (Go compiler + TypeScript runtime): +- **Workflow compiler**: parses markdown frontmatter + body into GitHub Actions YAML +- **Safe-outputs**: typed write operations (create-issue, create-discussion, add-comment, create-pull-request, upload-artifact) +- **Network firewall (AWF)**: blocks outbound domains not in `network.allowed` +- **Token optimization**: DataOps pre-steps, gh-proxy, cache-memory, sub-agent fan-out, prompt caching +- **Sub-agents**: inline agents invoked by the orchestrator, with model aliases (small/large) +- **Memory**: cache-memory (ephemeral, up to 90 days), repo-memory (Git branch, indefinite), comment-memory (issue/PR comment) +- **Engines**: Claude, Copilot, Gemini, Codex โ€” pluggable via `engine:` field +- **Imports**: shared workflow components, skill files, MCP server configs + +Input: full paper JSON object. + +Identify the single most actionable improvement the paper suggests for gh-aw โ€” a new feature, optimization, design pattern, or new workflow type. + +Output: exactly one line of valid JSON โ€” no other text: +`{"opportunity": "concise one-sentence action", "area": "token-optimization|safe-outputs|workflow-compilation|multi-agent|prompt-engineering|network|security|other", "effort": "low|medium|high", "rationale": "2-3 sentences naming the paper mechanism and the specific gh-aw component it improves"}` diff --git a/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/paper-screener.md b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/paper-screener.md new file mode 100644 index 00000000000..83286272f51 --- /dev/null +++ b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/paper-screener.md @@ -0,0 +1,27 @@ +--- +description: Fast relevance screening of arXiv paper abstracts for GitHub Agentic Workflows +model: openai/gpt-5.4-mini +--- + +Screen an arXiv paper abstract for relevance to GitHub Agentic Workflows (gh-aw). + +gh-aw compiles markdown workflow files into GitHub Actions YAML, uses AI agents as workflow engines, manages tool access, enforces network firewalls, routes writes through typed safe-outputs, optimizes tokens, and supports multi-agent orchestration. + +**Relevant** (any of): +- Agentic AI systems, multi-agent coordination, LLM agent workflows +- Prompt engineering, context management, or caching for LLM agents +- Token efficiency, structured output generation from LLMs +- AI-driven CI/CD, automated code review, workflow automation +- Security in agentic systems: sandboxing, tool access control, policy enforcement +- Orchestration patterns for decomposing tasks across AI agents + +**Not relevant**: +- Pure mathematical theory with no LLM/agent application +- Hardware, systems, or networking research unrelated to AI +- Computer vision, speech, or domain-specific scientific tasks +- Medical, biological, or physical science applications + +Input: `{"title": "...", "abstract": "...", "preliminary_note": "..."}` as a JSON string. Consider the preliminary note when present; do not reject a paper solely because the abstract is weak when the note identifies a relevant technique. + +Output: exactly one line of valid JSON โ€” no other text: +`{"relevant": true, "reason": "one sentence"}` or `{"relevant": false, "reason": "one sentence"}` diff --git a/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/preliminary-investigator.md b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/preliminary-investigator.md new file mode 100644 index 00000000000..692d1c15f7c --- /dev/null +++ b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/preliminary-investigator.md @@ -0,0 +1,15 @@ +--- +description: Extracts a concise evidence-based note from an arXiv paper PDF +model: openai/gpt-5.4-mini +--- + +Investigate an arXiv paper beyond its abstract before relevance screening. + +Input: a full paper JSON object containing `id`, `title`, and `url`. + +1. Download `https://arxiv.org/pdf/` to `/tmp/gh-aw/agent/arxiv/.pdf` with `curl`. If the download fails, return `{"pdf_fetched": false, "preliminary_note": ""}`. +2. Use the `kreuzberg` MCP server's `extract_bytes` tool with the PDF's base64 content to extract its text. If extraction fails or produces no text, return `{"pdf_fetched": false, "preliminary_note": ""}`. +3. Read only enough extracted text to identify techniques, mechanisms, evaluation results, or implementation details that could apply to gh-aw but are absent from the abstract. + +Output exactly one line of valid JSON โ€” no other text: +`{"pdf_fetched": true, "preliminary_note": "one or two concise sentences"}` diff --git a/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/relevance-ranker.md b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/relevance-ranker.md new file mode 100644 index 00000000000..aabd2c5efe4 --- /dev/null +++ b/actions/setup/js/fixtures/claude-codex-sub-agents/arxiv-39394965127/.codex/agents/relevance-ranker.md @@ -0,0 +1,20 @@ +--- +description: Scores a relevant arXiv paper by actionability for GitHub Agentic Workflows +model: openai/gpt-5.4-mini +--- + +Score a relevant arXiv paper by how actionable it is for GitHub Agentic Workflows (gh-aw). + +gh-aw compiles markdown workflows into GitHub Actions YAML with pluggable AI engines, safe-outputs typed writes, network firewall, token optimization, sub-agents, cache-memory, repo-memory, and multi-agent orchestration. + +Score 1โ€“5: +- 5: describes a new technique, pattern, or mechanism directly applicable to a specific gh-aw component +- 4: strong connection to gh-aw but requires adaptation +- 3: loosely related; possible indirect improvement +- 2: tangential; only marginally relevant to gh-aw +- 1: relevant to AI/agents generally but no clear gh-aw application + +Input: `{"title": "...", "abstract": "...", "preliminary_note": "..."}` as a JSON string. Consider the preliminary note when present. + +Output: exactly one line of valid JSON โ€” no other text: +`{"score": <1-5>, "reason": "one sentence"}` diff --git a/actions/setup/js/fixtures/claude-codex-sub-agents/typist-39415087125/.claude/agents/duplicate-type-finder.md b/actions/setup/js/fixtures/claude-codex-sub-agents/typist-39415087125/.claude/agents/duplicate-type-finder.md new file mode 100644 index 00000000000..2127f48b9e9 --- /dev/null +++ b/actions/setup/js/fixtures/claude-codex-sub-agents/typist-39415087125/.claude/agents/duplicate-type-finder.md @@ -0,0 +1,42 @@ +--- +description: Collects all Go type definitions under pkg/ and groups them into exact, near, and semantic duplicate clusters +model: small +--- +You are a Go type-definition analyst. Collect all type definitions from non-test Go source files under `pkg/` and group them into duplicate clusters. + +**Step 1 โ€” Discover source files**: +```bash +find pkg -name "*.go" ! -name "*_test.go" -type f | sort +``` + +**Step 2 โ€” Extract type definitions**: +For each file discovered, use Serena's `get_symbols_overview` to extract type definitions (structs, interfaces, type aliases). For each type, record: +- `file`: file path relative to repo root +- `package`: Go package name +- `type_name`: name of the type +- `fields`: for structs, list each field as `"name type"` (e.g. `"Timeout int"`); empty list for interfaces and aliases + +**Step 3 โ€” Cluster duplicates**: +Group collected types using these rules: +- **Exact duplicates**: same `type_name` appears in two or more different packages +- **Near duplicates**: names differ only by common synonyms (`Config`/`Configuration`, `Opts`/`Options`, `Info`/`Data`, `Result`/`Response`), OR two struct types share โ‰ฅ80% of their field name+type pairs (count matching `"name type"` strings divided by the size of the larger field list) +- **Semantic duplicates**: same apparent purpose inferred from name and fields, but different structure; use this category only when exact/near do not apply + +Only include a cluster when it has two or more occurrences. Omit types with no near-duplicates. + +Return ONLY a JSON object: +```json +{ + "total_files": 0, + "total_types": 0, + "duplicate_clusters": [ + { + "cluster_type": "exact|near|semantic", + "type_name": "", + "occurrences": [ + {"file": "", "package": "", "type_name": "", "fields": []} + ] + } + ] +} +``` diff --git a/actions/setup/js/fixtures/claude_codex_subagent_runs.cjs b/actions/setup/js/fixtures/claude_codex_subagent_runs.cjs new file mode 100644 index 00000000000..8b676ebd068 --- /dev/null +++ b/actions/setup/js/fixtures/claude_codex_subagent_runs.cjs @@ -0,0 +1,99 @@ +// @ts-check +"use strict"; + +/** + * Recorded artifacts for the Claude/Codex sub-agent model tests (github/gh-aw#70667). + * + * Typist, https://github.com/github/gh-aw/actions/runs/39415087125 (Claude Code 2.1.296): + * - claude-codex-sub-agents/typist-39415087125/.claude/agents/duplicate-type-finder.md is the + * activation artifact's staged agent, unchanged (no `name`, `model: small`). + * - awf-reflect-claude-typist-39415087125.json is the agent artifact's + * sandbox/firewall/awf-reflect.json reduced to endpoint provider/port/base_url/configured/ + * models/models_url, `models_fetch_complete` and the same-run `model_aliases`. + * - The records below come from agent-stdio.log (init and the failed `Agent` call) and + * usage/aw_session.jsonl (the built-in general-purpose delegation that followed). Only the + * fields used here are kept; the delegated prompt is truncated. + * + * daily-arxiv-researcher, https://github.com/github/gh-aw/actions/runs/39394965127 (Codex 0.163.0): + * - claude-codex-sub-agents/arxiv-39394965127/.codex/agents/*.md are the activation artifact's + * four staged agents, unchanged. + * - awf-reflect-codex-arxiv-39394965127.json is reduced the same way. + * - The run's only model.resolution record (role `main`) is below; it has no subagent records. + */ + +const path = require("path"); + +const TYPIST_RUN_ID = "39415087125"; +const ARXIV_RUN_ID = "39394965127"; +const typistAgentsDir = path.join(__dirname, "claude-codex-sub-agents", `typist-${TYPIST_RUN_ID}`, ".claude", "agents"); +const arxivAgentsDir = path.join(__dirname, "claude-codex-sub-agents", `arxiv-${ARXIV_RUN_ID}`, ".codex", "agents"); + +/** agent-stdio.log line 123: native initialization, custom agents not registered. */ +const typistInit = { + type: "system", + subtype: "init", + cwd: "/home/runner/work/gh-aw/gh-aw", + model: "claude-sonnet-5", + claude_code_version: "2.1.296", + agents: ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], +}; + +/** agent-stdio.log line 151: the parent's first named delegation (prompt truncated). */ +const typistAgentToolUse = { + type: "tool_use", + id: "toolu_011gZG7sPRerwjpFjDVKyUsb", + name: "Agent", + input: { description: "Find duplicate Go type definitions", subagent_type: "duplicate-type-finder", run_in_background: true, prompt: "Repository: /home/runner/work/gh-aw/gh-aw (github/gh-aw). Follow your system ins" }, +}; + +/** agent-stdio.log line 152: the delegation failed because the agent was not registered. */ +const typistAgentToolResult = { + type: "tool_result", + content: "Agent type 'duplicate-type-finder' not found. Available agents: claude, Explore, general-purpose, Plan, statusline-setup", + is_error: true, + tool_use_id: "toolu_011gZG7sPRerwjpFjDVKyUsb", +}; + +/** usage/aw_session.jsonl lines 247 and 249: the built-in fallback delegation (provenance omitted). */ +const typistFallbackSubagentStarted = { + type: "subagent.started", + agentId: "a14bc56b8185b2efb", + data: { toolCallId: "toolu_01C3hnT2mvz1njAfWxMJHg65", agentName: "general-purpose", agentType: "general-purpose", executionMode: "background", spawnDepth: 1, sessionId: "2139bbfd-8795-42c5-8f19-af8314bc98f9", parentToolUseId: null }, +}; +const typistFallbackSubagentConfigured = { + type: "subagent.configured", + timestamp: "2026-10-20T11:30:55.661Z", + agentId: "a14bc56b8185b2efb", + data: { model: "claude-sonnet-5", sessionId: "2139bbfd-8795-42c5-8f19-af8314bc98f9", parentToolUseId: null }, +}; + +/** usage/aw_session.jsonl line 831 (provenance omitted). */ +const arxivMainModelResolution = { + type: "model.resolution", + data: { engine: "codex", role: "main", configured_model: "gpt-5.6-sol", resolved_model: "gpt-5.6-sol", provider: "openai", status: "exact", cause: null }, +}; + +/** @returns {any} A fresh copy of the reduced Typist /reflect. */ +function typistReflect() { + return structuredClone(require("./awf-reflect-claude-typist-39415087125.json")); +} + +/** @returns {any} A fresh copy of the reduced arXiv /reflect. */ +function arxivReflect() { + return structuredClone(require("./awf-reflect-codex-arxiv-39394965127.json")); +} + +module.exports = { + ARXIV_RUN_ID, + arxivAgentsDir, + arxivMainModelResolution, + arxivReflect, + TYPIST_RUN_ID, + typistAgentsDir, + typistAgentToolResult, + typistAgentToolUse, + typistFallbackSubagentConfigured, + typistFallbackSubagentStarted, + typistInit, + typistReflect, +}; diff --git a/actions/setup/js/generate_aw_info.test.cjs b/actions/setup/js/generate_aw_info.test.cjs index c2a596b101c..84bed9f395d 100644 --- a/actions/setup/js/generate_aw_info.test.cjs +++ b/actions/setup/js/generate_aw_info.test.cjs @@ -60,6 +60,21 @@ describe("generate_aw_info.cjs", () => { expect(info.skills).toBeUndefined(); }); + // Compiled GH_AW_INFO_SUB_AGENT_MODELS of typist (Claude) and daily-arxiv-researcher (Codex, + // whose run 39394965127 never delegated), as written to their lock files. + it.each([ + ["claude", '[{"model":"small","name":"duplicate-type-finder","patterns":["anthropic/*haiku*"]}]'], + [ + "codex", + '[{"model":"openai/gpt-5.4-mini","name":"preliminary-investigator","patterns":["openai/gpt-5.4-mini"]},{"model":"openai/gpt-5.4-mini","name":"paper-screener","patterns":["openai/gpt-5.4-mini"]},{"model":"openai/gpt-5.4-mini","name":"relevance-ranker","patterns":["openai/gpt-5.4-mini"]},{"model":"openai/gpt-5.6-sol","name":"opportunity-extractor","patterns":["openai/gpt-5.6-sol"]}]', + ], + ])("claude_codex_aw_info_contains_declared_subagent_models (%s)", async (_engine, declarations) => { + process.env.GH_AW_INFO_SUB_AGENT_MODELS = declarations; + await main(mockCore, mockContext); + const info = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(info.sub_agent_models).toEqual(JSON.parse(declarations)); + }); + beforeEach(async () => { vi.clearAllMocks(); // Keep compiler-fixed runner paths in memory; never write to the host's /tmp. diff --git a/actions/setup/js/smoke_claude_sub_agents.test.cjs b/actions/setup/js/smoke_claude_sub_agents.test.cjs new file mode 100644 index 00000000000..f247978e1be --- /dev/null +++ b/actions/setup/js/smoke_claude_sub_agents.test.cjs @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import { describe, test, expect } from "vitest"; + +const require = createRequire(import.meta.url); +const runs = require("./fixtures/claude_codex_subagent_runs.cjs"); +const workflow = fs.readFileSync(new URL("../../../.github/workflows/smoke-claude-sub-agents.md", import.meta.url), "utf8"); +const script = workflow + .split(" script: |\n")[1] + .split("\n---")[0] + .replace(/^ /gm, ""); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const MARKER = "GH-AW-CLAUDE-SUBAGENT-MARKER-7F3A"; +const clone = value => JSON.parse(JSON.stringify(value)); + +/** + * Passing evidence derived from the Typist run 39415087125 record shapes (fixture mutation: + * the worker is registered, the first `Agent` call names it in the foreground, and the + * started/configured/completed records and child messages belong to it on the resolved id). + * No historical run produced these values. + */ +function passingEvidence() { + const model = "claude-haiku-5-5"; + const toolUse = { ...clone(runs.typistAgentToolUse), input: { description: "Return marker", subagent_type: "marker-worker", prompt: "marker?" } }; + const started = clone(runs.typistFallbackSubagentStarted); + Object.assign(started.data, { toolCallId: toolUse.id, agentName: "marker-worker", agentType: "marker-worker", executionMode: "foreground" }); + const configured = clone(runs.typistFallbackSubagentConfigured); + configured.data.model = model; + return { + model, + events: [ + { type: "model.resolution", data: { engine: "claude", role: "marker-worker", configured_model: "small", resolved_model: model, provider: "anthropic", status: "resolved", cause: null } }, + started, + configured, + { type: "subagent.completed", agentId: started.agentId, data: { toolCallId: toolUse.id, durationMs: 1200, totalTokens: 50, totalToolCalls: 0 } }, + ], + stream: [ + { ...clone(runs.typistInit), agents: [...runs.typistInit.agents, "marker-worker"] }, + { type: "assistant", parent_tool_use_id: null, message: { id: "msg_parent", model: "claude-sonnet-5", content: [toolUse] } }, + { type: "assistant", parent_tool_use_id: toolUse.id, message: { id: "msg_child", model, content: [{ type: "text", text: MARKER }] } }, + { type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: toolUse.id, content: [{ type: "text", text: MARKER }] }] } }, + ], + requests: [ + { provider: "anthropic", model: "claude-sonnet-5", path: "/v1/messages?beta=true", status: 200 }, + { provider: "anthropic", model, path: "/v1/messages?beta=true", status: 200 }, + ], + definition: `---\nname: "marker-worker"\ndescription: Returns a fixed marker\nmodel: "${model}"\n---\nReply.\n`, + }; +} + +async function assertEvidence({ events, stream, requests, definition }) { + const files = { + "aw_session.jsonl": events, + "agent-stdio.log": stream, + "token-usage.jsonl": requests, + }; + const mockRequire = name => { + if (name.endsWith("unified_session.cjs")) return { writeUnifiedSession() {} }; + if (name === "node:fs") { + return { + existsSync: () => true, + readFileSync: file => { + if (file.endsWith("marker-worker.md")) return definition; + const key = Object.keys(files).find(suffix => file.endsWith(suffix)); + return files[key].map(record => JSON.stringify(record)).join("\n"); + }, + }; + } + return require(name); + }; + await new AsyncFunction("require", "process", script)(mockRequire, { + env: { RUNNER_TEMP: "/tmp", GITHUB_WORKSPACE: "/workspace", SMOKE_EXECUTION: "success" }, + }); +} + +describe("claude_small_subagent_smoke_runs_resolved_model_first (post-step checks)", () => { + test("accepts the declared worker invoked first on its resolved model", async () => { + await expect(assertEvidence(passingEvidence())).resolves.toBeUndefined(); + }); + + test("rejects the recorded Typist registration failure and general-purpose fallback", async () => { + // Recorded Typist records: init without the custom agent, a failed named delegation and + // a built-in general-purpose sub-agent on the main model. + const data = passingEvidence(); + data.stream[0] = clone(runs.typistInit); + await expect(assertEvidence(data)).rejects.toThrow("native initialization did not register marker-worker"); + + const fallback = passingEvidence(); + fallback.events.splice(1, 0, clone(runs.typistFallbackSubagentStarted)); + await expect(assertEvidence(fallback)).rejects.toThrow("fell back to general-purpose"); + + const failed = passingEvidence(); + failed.stream[3].message.content[0] = { ...clone(runs.typistAgentToolResult), tool_use_id: failed.stream[1].message.content[0].id }; + await expect(assertEvidence(failed)).rejects.toThrow("marker-worker delegation failed"); + }); + + test("rejects a resolution record without the worker being invoked first", async () => { + const data = passingEvidence(); + data.stream.splice(1, 0, { type: "assistant", parent_tool_use_id: null, message: { id: "msg_read", content: [{ type: "tool_use", id: "toolu_read", name: "Read", input: { file_path: "README.md" } }] } }); + await expect(assertEvidence(data)).rejects.toThrow("first agent action must invoke marker-worker"); + + const missing = passingEvidence(); + missing.events.shift(); + await expect(assertEvidence(missing)).rejects.toThrow("marker-worker has no model.resolution record"); + }); + + test("rejects a worker configured, served or staged on another model", async () => { + const configured = passingEvidence(); + configured.events[2].data.model = "claude-sonnet-5"; + await expect(assertEvidence(configured)).rejects.toThrow("was not configured on claude-haiku-5-5"); + + const child = passingEvidence(); + child.stream[2].message.model = "claude-sonnet-5"; + await expect(assertEvidence(child)).rejects.toThrow("messages were not served by claude-haiku-5-5"); + + // Unrelated Haiku traffic is not attribution: the proxy must serve the worker's messages. + const proxy = passingEvidence(); + proxy.requests = proxy.requests.filter(request => request.model !== proxy.model); + await expect(assertEvidence(proxy)).rejects.toThrow("but the proxy served 0"); + + const staged = passingEvidence(); + staged.definition = "---\ndescription: Returns a fixed marker\nmodel: small\n---\nReply.\n"; + await expect(assertEvidence(staged)).rejects.toThrow("definition has no native name"); + }); +}); diff --git a/pkg/workflow/claude_codex_subagent_declarations_test.go b/pkg/workflow/claude_codex_subagent_declarations_test.go new file mode 100644 index 00000000000..01f05c9a960 --- /dev/null +++ b/pkg/workflow/claude_codex_subagent_declarations_test.go @@ -0,0 +1,110 @@ +//go:build !integration + +package workflow + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type subagentModelDeclaration struct { + Name string `json:"name"` + Model string `json:"model"` + Patterns []string `json:"patterns"` +} + +// compileSubagentModelDeclarations compiles a workflow and returns the generate_aw_info +// GH_AW_INFO_SUB_AGENT_MODELS declarations, or nil when the variable is absent. +func compileSubagentModelDeclarations(t *testing.T, content string) []subagentModelDeclaration { + t.Helper() + source := filepath.Join(t.TempDir(), "workflow.md") + require.NoError(t, os.WriteFile(source, []byte(content), 0600)) + compiler := NewCompiler() + data, err := compiler.ParseWorkflowFile(source) + require.NoError(t, err) + generated, err := compiler.CompileToYAML(data, source) + require.NoError(t, err) + var workflow struct { + Jobs map[string]struct { + Steps []struct { + ID string `yaml:"id"` + Env map[string]string `yaml:"env"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + require.NoError(t, yaml.Unmarshal([]byte(generated), &workflow)) + for _, step := range workflow.Jobs["activation"].Steps { + if step.ID != "generate_aw_info" { + continue + } + encoded, ok := step.Env["GH_AW_INFO_SUB_AGENT_MODELS"] + if !ok { + return nil + } + var declarations []subagentModelDeclaration + require.NoError(t, json.Unmarshal([]byte(encoded), &declarations)) + return declarations + } + t.Fatal("generate_aw_info step must exist") + return nil +} + +// TestClaudeCodexAwInfoContainsDeclaredSubagentModels covers +// claude_codex_aw_info_contains_declared_subagent_models with the inline declarations of +// typist (Claude, `small`), agent-persona-explorer (Claude, `inherit`) and +// daily-arxiv-researcher (Codex, provider-prefixed models; its run 39394965127 never delegated). +func TestClaudeCodexAwInfoContainsDeclaredSubagentModels(t *testing.T) { + const frontmatter = "---\non: workflow_dispatch\nstrict: false\npermissions:\n contents: read\n" + tests := []struct { + name string + body string + want []subagentModelDeclaration + }{ + { + name: "claude small uses anthropic patterns", + body: frontmatter + "engine: claude\n---\n# Work\nUse duplicate-type-finder.\n\n" + + "## agent: `duplicate-type-finder`\n---\ndescription: Collects all Go type definitions under pkg/ and groups them into exact, near, and semantic duplicate clusters\nmodel: small\n---\nYou are a Go type-definition analyst.\n", + want: []subagentModelDeclaration{{Name: "duplicate-type-finder", Model: "small", Patterns: []string{"anthropic/*haiku*"}}}, + }, + { + name: "claude on the copilot provider uses github-copilot patterns", + body: frontmatter + "engine: claude\nmodel: copilot/claude-sonnet-4.6\n---\n# Work\nUse duplicate-type-finder.\n\n" + + "## agent: `duplicate-type-finder`\n---\ndescription: Collects all Go type definitions\nmodel: small\n---\nYou are a Go type-definition analyst.\n", + want: []subagentModelDeclaration{{Name: "duplicate-type-finder", Model: "small", Patterns: []string{"github-copilot/*haiku*", "github-copilot/gpt-5*mini*", "github-copilot/gpt-5*nano*", "github-copilot/gemini-*flash*lite*"}}}, + }, + { + name: "claude inherit declares no patterns", + body: frontmatter + "engine: claude\n---\n# Work\nUse persona-evaluator.\n\n" + + "## agent: `persona-evaluator`\n---\nname: persona-evaluator\ndescription: Evaluates workflow scenarios using the repository's agentic-workflows authoring guidance\nmodel: inherit\n---\nYou are acting as the agentic-workflows custom agent.\n", + want: []subagentModelDeclaration{{Name: "persona-evaluator", Model: "inherit", Patterns: []string{}}}, + }, + { + name: "codex provider-prefixed models", + body: frontmatter + "engine: codex\nmodel: openai/gpt-5.6-sol\n---\n# Work\nScreen papers.\n\n" + + "## agent: `paper-screener`\n---\ndescription: Fast relevance screening of arXiv paper abstracts for GitHub Agentic Workflows\nmodel: openai/gpt-5.4-mini\n---\nScreen an arXiv paper abstract.\n\n" + + "## agent: `opportunity-extractor`\n---\ndescription: Extracts a specific actionable improvement for gh-aw from a relevant arXiv paper\nmodel: openai/gpt-5.6-sol\n---\nExtract one improvement.\n", + want: []subagentModelDeclaration{ + {Name: "paper-screener", Model: "openai/gpt-5.4-mini", Patterns: []string{"openai/gpt-5.4-mini"}}, + {Name: "opportunity-extractor", Model: "openai/gpt-5.6-sol", Patterns: []string{"openai/gpt-5.6-sol"}}, + }, + }, + { + name: "codex alias keeps only openai patterns", + body: frontmatter + "engine: codex\n---\n# Work\nScreen papers.\n\n" + + "## agent: `paper-screener`\n---\ndescription: Screens papers\nmodel: small\n---\nScreen.\n", + want: []subagentModelDeclaration{{Name: "paper-screener", Model: "small", Patterns: []string{"openai/gpt-5*mini*", "openai/gpt-5*nano*"}}}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := compileSubagentModelDeclarations(t, test.body) + assert.Equal(t, test.want, got) + }) + } +} diff --git a/pkg/workflow/compiler_yaml_step_lifecycle.go b/pkg/workflow/compiler_yaml_step_lifecycle.go index 5f03b85a439..f2076c7d629 100644 --- a/pkg/workflow/compiler_yaml_step_lifecycle.go +++ b/pkg/workflow/compiler_yaml_step_lifecycle.go @@ -266,23 +266,30 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat } func writeSubagentModelDeclarations(yaml *strings.Builder, data *WorkflowData, engineID string) { - if len(data.SubAgentModels) == 0 || (engineID != "pi" && engineID != "copilot") { + if len(data.SubAgentModels) == 0 || (engineID != "pi" && engineID != "copilot" && engineID != "claude" && engineID != "codex") { return } declarations := make([]map[string]any, 0, len(data.SubAgentModels)) provider := "github-copilot" - if engineID == "pi" { + switch engineID { + case "pi": provider = piSubagentProvider(piConfiguredProvider(data)) if provider == "copilot" { provider = "github-copilot" } + case "claude", "codex": + provider = claudeCodexSubagentProvider(data, engineID) } for _, agent := range data.SubAgentModels { model := RewriteExperimentsReferenceForDownstreamJobs(agent.Model, data.Experiments) model = RewriteActivationOutputsToLocalStepOutputs(model, data.Experiments) + patterns := expandSubagentModelPatterns(agent.Model, data, provider) + if engineID == "claude" || engineID == "codex" { + patterns = providerScopedSubagentPatterns(agent.Model, patterns, provider) + } declarations = append(declarations, map[string]any{ "name": agent.Name, "model": model, - "patterns": expandSubagentModelPatterns(agent.Model, data, provider), + "patterns": patterns, }) } encoded, err := json.Marshal(declarations) @@ -292,6 +299,36 @@ func writeSubagentModelDeclarations(yaml *strings.Builder, data *WorkflowData, e yaml.WriteString(formatYAMLEnv(" ", "GH_AW_INFO_SUB_AGENT_MODELS", string(encoded))) } +// claudeCodexSubagentProvider returns the pattern provider for Claude and Codex sub-agent +// declarations: the provider the engine is provisioned for, since their sub-agents never +// open another credential route. +func claudeCodexSubagentProvider(data *WorkflowData, engineID string) string { + defaultProvider := LLMProviderAnthropic + if engineID == "codex" { + defaultProvider = LLMProviderOpenAI + } + provider := string(resolveEngineLLMProviderFromModel(data, defaultProvider)) + if provider == string(LLMProviderGitHub) || provider == "copilot" { + return "github-copilot" + } + return provider +} + +// providerScopedSubagentPatterns keeps only the patterns of the engine's provider. Claude's +// `inherit` is an inheritance instruction, not a model, so it declares no patterns. +func providerScopedSubagentPatterns(model string, patterns []string, provider string) []string { + scoped := []string{} + if strings.EqualFold(strings.TrimSpace(model), "inherit") { + return scoped + } + for _, pattern := range patterns { + if prefix, _, ok := strings.Cut(pattern, "/"); ok && prefix == provider { + scoped = append(scoped, pattern) + } + } + return scoped +} + func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *WorkflowData) error { //nolint:largefunc // Existing artifact collection keeps related output paths and ordering together. // Copy the raw safe-output NDJSON to a /tmp/gh-aw/ path so it can be included in the // unified agent artifact together with all other /tmp/gh-aw/ outputs. From 03e4ed57109edd0dda712cdca78be27a6bb4fa3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:29:59 +0000 Subject: [PATCH 3/5] Add changeset for Claude/Codex sub-agent model resolution Co-authored-by: SivaKesava1 <11771739+SivaKesava1@users.noreply.github.com> --- .changeset/claude-codex-subagent-model-resolution.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/claude-codex-subagent-model-resolution.md diff --git a/.changeset/claude-codex-subagent-model-resolution.md b/.changeset/claude-codex-subagent-model-resolution.md new file mode 100644 index 00000000000..d9259e50184 --- /dev/null +++ b/.changeset/claude-codex-subagent-model-resolution.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Claude inline sub-agents are now registered by Claude Code: their generated agent files get the required `name`. Claude and Codex resolve and record each declared sub-agent model before the engine starts. A workflow-declared Claude sub-agent whose model is unavailable stops the run before Claude starts. Codex warns that it does not load the Markdown sub-agents. `aw_info.sub_agent_models` now lists Claude and Codex declarations. From 1f97c55df9bd89c7050837305ee3f887d03a1053 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:04:21 +0000 Subject: [PATCH 4/5] Fail closed on malformed staged Claude sub-agents, tolerate /reflect refresh failures, guard harness call site Co-authored-by: SivaKesava1 <11771739+SivaKesava1@users.noreply.github.com> --- .../smoke-claude-sub-agents.lock.yml | 76 +++++++++++++++++-- actions/setup/js/claude_harness.cjs | 20 +++-- actions/setup/js/claude_harness.test.cjs | 38 ++++++++++ actions/setup/js/engine_subagent_models.cjs | 48 +++++++++--- .../setup/js/engine_subagent_models.test.cjs | 56 ++++++++++++++ .../setup/js/extract_inline_sub_agents.cjs | 7 +- .../js/extract_inline_sub_agents.test.cjs | 11 +++ pkg/workflow/compiler_yaml_step_lifecycle.go | 4 +- 8 files changed, 235 insertions(+), 25 deletions(-) diff --git a/.github/workflows/smoke-claude-sub-agents.lock.yml b/.github/workflows/smoke-claude-sub-agents.lock.yml index 3a7c4538ff0..7c08d332683 100644 --- a/.github/workflows/smoke-claude-sub-agents.lock.yml +++ b/.github/workflows/smoke-claude-sub-agents.lock.yml @@ -758,7 +758,7 @@ jobs: export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_AGENT_ID -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_TARGET_REPO_SLUG -e GH_AW_PR_HEAD_BASE_BRANCH -e GH_AW_PR_HEAD_BASE_SHA -e GH_AW_PR_HEAD_BASE_REPO -e GH_AW_PR_HEAD_BASE_PR_NUMBER -e GH_AW_PR_HEAD_BASE_REF -e GH_AW_PR_HEAD_REPO -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e RUNNER_TOOL_CACHE -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -e GITHUB_AW_OTEL_TRACE_ID -e GITHUB_AW_OTEL_PARENT_SPAN_ID -e OTEL_EXPORTER_OTLP_HEADERS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2' GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_a11e6c834c3174b8_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_3474fdb8118d2bfc_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -778,7 +778,7 @@ jobs: } }, "safeoutputs": { - "container": "ghcr.io/github/gh-aw-node", + "container": "ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382", "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], "args": ["-w", "\${GITHUB_WORKSPACE}"], "entrypoint": "sh", @@ -832,7 +832,7 @@ jobs: } } } - GH_AW_MCP_CONFIG_a11e6c834c3174b8_EOF + GH_AW_MCP_CONFIG_3474fdb8118d2bfc_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -1256,10 +1256,14 @@ jobs: cancel-in-progress: false queue: max env: - GH_AW_JOB_DISPLAY_NAMES: "{\"activation\":\"activation\",\"admission\":\"admission\",\"agent\":\"agent\",\"conclusion\":\"conclusion\",\"safe_outputs\":\"safe_outputs\"}" + GH_AW_JOB_DISPLAY_NAMES: "{\"activation\":\"activation\",\"admission\":\"admission\",\"agent\":\"agent\",\"conclusion\":\"conclusion\",\"conclusion_admission\":\"conclusion_admission\",\"safe_outputs\":\"safe_outputs\"}" GH_AW_JOB_RESULTS: "{\"activation\":{\"result\":\"${{ needs.activation.result }}\"},\"admission\":{\"result\":\"${{ needs.admission.result }}\"},\"agent\":{\"result\":\"${{ needs.agent.result }}\"},\"safe_outputs\":{\"result\":\"${{ needs.safe_outputs.result }}\"}}" - GH_AW_JOB_TIMING_ELIGIBLE: "{\"activation\":true,\"admission\":true,\"agent\":true,\"conclusion\":true,\"safe_outputs\":true}" + GH_AW_JOB_TIMING_ELIGIBLE: "{\"activation\":true,\"admission\":true,\"agent\":true,\"conclusion\":true,\"conclusion_admission\":true,\"safe_outputs\":true}" GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + admission_compact_session: ${{ steps.admission-conclusion-metadata.outputs.compact_session }} + usage_artifact_id: ${{ steps.upload-usage-artifact.outputs.artifact-id }} + usage_artifact_retry_id: ${{ steps.upload-usage-artifact-retry.outputs.artifact-id }} steps: - name: Checkout actions folder uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1415,6 +1419,17 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require(path.join(actionsDir, 'generate_usage_artifacts.cjs')); await main(); + - name: Prepare admission conclusion metadata + id: admission-conclusion-metadata + if: always() && vars.GH_AW_DEFAULT_ADMISSION_URL != '' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const path = require('node:path'); + const { readCompactUnifiedSession } = require(path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions', 'compact_unified_session.cjs')); + const compact = readCompactUnifiedSession('/tmp/gh-aw/usage/aw_session.jsonl'); + core.setOutput('compact_session', JSON.stringify({ version: compact.version, session: compact.session, included_events: compact.includedEvents, omitted_events: compact.omittedEvents, oversized_lines: compact.oversizedLines, scan_truncated: compact.scanTruncated, truncated: compact.truncated })); - name: Upload usage artifact id: upload-usage-artifact if: always() @@ -1484,6 +1499,57 @@ jobs: overwrite: true retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} + conclusion_admission: + name: conclusion_admission + needs: conclusion + if: always() && needs.conclusion.result != 'skipped' && vars.GH_AW_DEFAULT_ADMISSION_URL != '' + runs-on: ubuntu-slim + # Permissions for the conclusion_admission job (workflow permissions default to none). + permissions: + contents: read + id-token: write + timeout-minutes: 5 + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + steps: + - name: Checkout actions folder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: github/gh-aw + sparse-checkout: | + actions + fetch-depth: 1 + clean: false + persist-credentials: false + - name: Setup Scripts + id: setup + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/unknown.lock.yml@${{ github.ref }} + - name: Report admission conclusion + if: always() && vars.GH_AW_DEFAULT_ADMISSION_URL != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_DEFAULT_ADMISSION_URL: ${{ vars.GH_AW_DEFAULT_ADMISSION_URL }} + GH_AW_DEFAULT_ADMISSION_AUDIENCE: ${{ vars.GH_AW_DEFAULT_ADMISSION_AUDIENCE }} + GH_AW_DEFAULT_ADMISSION_ON_OUTAGE: ${{ vars.GH_AW_DEFAULT_ADMISSION_ON_OUTAGE }} + GH_AW_ADMISSION_WORKFLOW_HASH: "17bb259908afda18f563200fb0af4ec512da0839459e010a5d6bbec384b2b8c0:387c789d648bf5de762f3561c6a39b9226b0811b9c4e649ed81a115c30912b0b" + GH_AW_INFO_WORKFLOW_ID: ${{ job.workflow_ref }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TRIGGERING_ACTOR: ${{ github.triggering_actor }} + GH_AW_ADMISSION_COMPACT_SESSION: ${{ needs.conclusion.outputs.admission_compact_session }} + GH_AW_USAGE_ARTIFACT_ID: ${{ needs.conclusion.outputs.usage_artifact_id }} + GH_AW_USAGE_ARTIFACT_RETRY_ID: ${{ needs.conclusion.outputs.usage_artifact_retry_id }} + with: + script: | + const { setupGlobals } = require(require('node:path').join(process.env.RUNNER_TEMP, 'gh-aw', 'actions', 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + await require(require('node:path').join(process.env.RUNNER_TEMP, 'gh-aw', 'actions', 'conclusion_admission.cjs')).main(core); + safe_outputs: name: safe_outputs needs: diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index aae7da2ee20..2e8a6c4e06b 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -564,13 +564,19 @@ async function main() { // make startup depend on sub-agent model availability. Threat detection uses no // sub-agents, so it neither reads, resolves, records nor writes agent files. if (!isDetectionPhase(process.env.GH_AW_PHASE)) { - const subagents = await applyClaudeSubagentModels({ - reflectData: reflection.reflectData, - refetchReflectData: async () => (await fetchAWFReflect({ logger: log })).reflectData || null, - provider: normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic"), - env: process.env, - logger: log, - }); + let subagents; + try { + subagents = await applyClaudeSubagentModels({ + reflectData: reflection.reflectData, + refetchReflectData: async () => (await fetchAWFReflect({ logger: log })).reflectData || null, + provider: normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic"), + env: process.env, + logger: log, + }); + } catch (error) { + log(`fatal: unexpected error while resolving Claude sub-agent models: ${getErrorMessage(error)}. Refusing to start Claude with unresolved sub-agent models.`); + subagents = { ok: false }; + } if (!subagents.ok) { removeClaudePlugin(pluginDir); process.exit(1); diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index d58da32ae89..7dd003625f3 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -1634,6 +1634,44 @@ describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => { expect(workspaceAgent).toContain("model: claude-haiku-5-5"); }); + it("fails closed before spawn when a staged workflow sub-agent file cannot be parsed", () => { + // Fixture mutation: malformed frontmatter in the staged inline agent. + const { result, calls, records } = runWithStagedAgent({ agentContent: "---\nname: [unterminated\nmodel: small\n---\nBody.\n" }); + + expect(result.status).toBe(1); + expect(calls).toEqual([]); + expect(records).toEqual([]); + expect(result.stderr).toContain("cannot read or parse the staged workflow sub-agent"); + expect(result.stderr).toContain("Refusing to start Claude"); + }); + + it("cleans up and exits with a clear message when sub-agent resolution throws unexpectedly", () => { + const dir = makeHarnessTempDir("claude-subagents-throw-"); + try { + const cleanupLog = path.join(dir, "cleanup.log"); + const preload = path.join(dir, "throwing-subagents.cjs"); + fs.writeFileSync( + preload, + ` + const fs = require("fs"); + require(${JSON.stringify(require.resolve("./engine_subagent_models.cjs"))}).applyClaudeSubagentModels = async () => { throw new Error("injected sub-agent failure"); }; + const runtime = require(${JSON.stringify(require.resolve("./claude_runtime.cjs"))}); + const remove = runtime.removeClaudePlugin; + runtime.removeClaudePlugin = pluginDir => { fs.appendFileSync(${JSON.stringify(cleanupLog)}, "removed\\n"); return remove(pluginDir); }; + `, + "utf8" + ); + const { result, calls } = runWithStagedAgent({ agentContent: typistAgent(), env: { NODE_OPTIONS: `--require=${JSON.stringify(preload)}` } }); + + expect(result.status).toBe(1); + expect(calls).toEqual([]); + expect(result.stderr).toContain("fatal: unexpected error while resolving Claude sub-agent models: injected sub-agent failure"); + expect(fs.readFileSync(cleanupLog, "utf8")).toBe("removed\n"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("claude_codex_detection_skips_subagent_resolution (Claude)", () => { // Fixture mutation: an unknown model that would fail closed outside detection. const agentContent = typistAgent().replace("model: small", "model: claude-nonexistent-9"); diff --git a/actions/setup/js/engine_subagent_models.cjs b/actions/setup/js/engine_subagent_models.cjs index 0d576c97a7e..8b642b92ef1 100644 --- a/actions/setup/js/engine_subagent_models.cjs +++ b/actions/setup/js/engine_subagent_models.cjs @@ -76,16 +76,21 @@ function parseAgentFrontmatter(content) { * Read the Markdown agent files (`*.md`) in one directory. * `name` is the trimmed frontmatter `name` string, or null when absent; * `model` is the trimmed frontmatter `model` string, when declared. + * A directory or file that cannot be read or parsed is skipped with a warning, unless + * `onInvalid` is given: it then receives the path and error instead, so the caller can + * fail closed. * @param {string} directory * @param {(msg: string) => void} logger + * @param {{ onInvalid?: (filePath: string, error: unknown) => void }} [options] * @returns {Array<{ fileName: string, path: string, name: string | null, model?: string }>} */ -function listMarkdownAgentFiles(directory, logger) { +function listMarkdownAgentFiles(directory, logger, options = {}) { + const invalid = options.onInvalid || ((/** @type {string} */ filePath, /** @type {unknown} */ error) => logger(`warning: cannot read custom agent ${filePath}: ${getErrorMessage(error)}`)); let entries; try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) logger(`warning: cannot read custom agents in ${directory}: ${getErrorMessage(error)}`); + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) invalid(directory, error); return []; } const agents = []; @@ -97,7 +102,7 @@ function listMarkdownAgentFiles(directory, logger) { const name = typeof frontmatter.name === "string" && frontmatter.name.trim() ? frontmatter.name.trim() : null; agents.push({ fileName: entry.name, path: agentPath, name, ...(typeof frontmatter.model === "string" ? { model: frontmatter.model.trim() } : {}) }); } catch (error) { - logger(`warning: cannot read custom agent ${agentPath}: ${getErrorMessage(error)}`); + invalid(agentPath, error); } } return agents; @@ -184,10 +189,20 @@ async function resolveEngineSubagentModels(options) { /** @type {Promise | null} */ let refresh = null; const refreshOnce = () => { - refresh ??= (options.refetchReflectData ? options.refetchReflectData() : Promise.resolve(null)).then(refreshed => { - if (refreshed) reflectData = refreshed; - return refreshed || null; - }); + // A failed refresh counts as no refreshed catalogue, so every agent is still resolved, + // recorded and classified (catalog_unavailable when the catalogue stays incomplete). + refresh ??= Promise.resolve() + .then(() => (options.refetchReflectData ? options.refetchReflectData() : null)) + .then( + refreshed => { + if (refreshed) reflectData = refreshed; + return refreshed || null; + }, + error => { + logger(`warning: ${engine} sub-agent model resolution: cannot refresh the awf-reflect catalogue: ${getErrorMessage(error)}`); + return null; + } + ); return refresh; }; const liveCheck = env.AWF_REFLECT_ENABLED === "1" || Boolean(options.reflectData); @@ -247,7 +262,9 @@ async function resolveEngineSubagentModels(options) { const initialReason = classifyUnlistedModel(reflectData, providers); const refreshed = await refreshOnce(); if (!refreshed || !isModelAvailableInReflectData(model, refreshed, providers)) { - const reason = initialReason === "model_unavailable" || !refreshed ? initialReason : classifyUnlistedModel(refreshed, providers); + // A complete catalogue without the model already proves model_unavailable; only an + // incomplete one is reclassified, and only when a refreshed catalogue exists. + const reason = initialReason === "catalog_unavailable" && refreshed ? classifyUnlistedModel(refreshed, providers) : initialReason; fail(new ModelAliasResolutionError(configuredModel, reason, { provider: result.provider || options.provider }), `Sub-agent model '${configuredModel}'${resolvedNote} is not listed on the configured awf-reflect endpoints.`); continue; } @@ -265,7 +282,7 @@ async function resolveEngineSubagentModels(options) { * starts. Workflow-declared inline agents are the files staged by the activation job in * `/.claude/agents`; their generated workspace copy (restored by * `restore_inline_sub_agents.sh`) is rewritten with the resolved concrete id. When one of them - * cannot be resolved or written, `ok` is false and Claude must not start. Repository agents + * cannot be read, parsed, resolved or written, `ok` is false and Claude must not start. Repository agents * in `/.claude/agents` are resolved and recorded, but their files are left * unchanged: Claude Code ranks project agents above user agents, so no file-based override * exists without editing tracked content. Agent files without a `name` are skipped because @@ -292,11 +309,22 @@ async function applyClaudeSubagentModels(options) { const workingDirectory = options.workingDirectory || path.resolve(workspaceDir, env.GH_AW_ENGINE_CWD || "."); const agentsBaseDir = options.agentsBaseDir || path.join(os.tmpdir(), "gh-aw"); const target = inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget("claude")); - const declared = listMarkdownAgentFiles(path.resolve(agentsBaseDir, target.dir), logger).map(agent => ({ + /** @type {string[]} */ + const invalidDeclared = []; + const declared = listMarkdownAgentFiles(path.resolve(agentsBaseDir, target.dir), logger, { + onInvalid: (filePath, error) => { + invalidDeclared.push(filePath); + logger(`claude sub-agent file ${filePath}: cannot read or parse the staged workflow sub-agent: ${getErrorMessage(error)}.`); + }, + }).map(agent => ({ ...agent, name: agent.name || agent.fileName.slice(0, -".md".length), workspacePath: path.resolve(workspaceDir, target.dir, agent.fileName), })); + if (invalidDeclared.length > 0) { + logger(`Refusing to start Claude: ${invalidDeclared.length} workflow-declared sub-agent file(s) cannot be read or parsed, so their models cannot be resolved.`); + return { ok: false, resolutions: [] }; + } const declaredPaths = new Set(declared.map(agent => agent.workspacePath)); const declaredNames = new Set(declared.map(agent => agent.name)); const repository = []; diff --git a/actions/setup/js/engine_subagent_models.test.cjs b/actions/setup/js/engine_subagent_models.test.cjs index 0706c0fcf48..859e4aca2cb 100644 --- a/actions/setup/js/engine_subagent_models.test.cjs +++ b/actions/setup/js/engine_subagent_models.test.cjs @@ -114,6 +114,62 @@ describe("Claude sub-agent models (Typist run 39415087125)", () => { expect(run.records()).toEqual([expect.objectContaining({ role: "duplicate-type-finder", status: "failed", cause: "catalog_unavailable" })]); }); + it("records every agent as catalog_unavailable when the /reflect refresh rejects", async () => { + const run = layout(); + stageInline(run, "claude", { "first-worker": typistAgent().replace("model: small", "model: claude-haiku-5-5"), "second-worker": typistAgent().replace("model: small", "model: claude-sonnet-5-5") }); + // Fixture mutation: model discovery incomplete, and the refresh fails. + const reflectData = runs.typistReflect(); + reflectData.models_fetch_complete = false; + reflectData.endpoints.forEach(endpoint => (endpoint.models = null)); + let refreshes = 0; + + const outcome = await applyClaudeSubagentModels({ + reflectData, + refetchReflectData: async () => { + refreshes++; + throw new Error("connect ECONNREFUSED"); + }, + provider: "anthropic", + workspaceDir: run.workspaceDir, + agentsBaseDir: run.agentsBaseDir, + env: run.env, + logger: run.logger, + }); + + expect(outcome.ok).toBe(false); + expect(refreshes).toBe(1); + expect(run.records()).toEqual([expect.objectContaining({ role: "first-worker", status: "failed", cause: "catalog_unavailable" }), expect.objectContaining({ role: "second-worker", status: "failed", cause: "catalog_unavailable" })]); + expect(run.logs).toContainEqual(expect.stringContaining("cannot refresh the awf-reflect catalogue: connect ECONNREFUSED")); + }); + + it("fails closed when a staged workflow sub-agent file cannot be parsed", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent() }); + // Fixture mutation: a second staged agent with malformed frontmatter. + fs.writeFileSync(path.join(run.agentsBaseDir, ".claude/agents/broken-worker.md"), "---\nname: [unterminated\nmodel: small\n---\nBody.\n"); + const before = fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/duplicate-type-finder.md"), "utf8"); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome).toEqual({ ok: false, resolutions: [] }); + expect(run.logs).toContainEqual(expect.stringContaining(`claude sub-agent file ${path.join(run.agentsBaseDir, ".claude/agents/broken-worker.md")}: cannot read or parse the staged workflow sub-agent`)); + expect(run.logs).toContainEqual(expect.stringContaining("Refusing to start Claude: 1 workflow-declared sub-agent file(s) cannot be read or parsed")); + expect(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/duplicate-type-finder.md"), "utf8")).toBe(before); + }); + + it("keeps a malformed repository agent file lenient", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent() }); + // Fixture mutation: a repository agent with malformed frontmatter. + const repositoryPath = path.join(run.workspaceDir, ".claude/agents/broken-repo.md"); + fs.writeFileSync(repositoryPath, "---\nname: [unterminated\n---\nBody.\n"); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + expect(run.logs).toContainEqual(expect.stringContaining(`warning: cannot read custom agent ${repositoryPath}`)); + }); + it("claude_inherit_uses_main_without_subagent_record", async () => { const run = layout(); const source = fs.readFileSync(path.join(repoRoot, ".github/workflows/agent-persona-explorer.md"), "utf8"); diff --git a/actions/setup/js/extract_inline_sub_agents.cjs b/actions/setup/js/extract_inline_sub_agents.cjs index 190807f9ee2..0c202ae0fac 100644 --- a/actions/setup/js/extract_inline_sub_agents.cjs +++ b/actions/setup/js/extract_inline_sub_agents.cjs @@ -390,7 +390,10 @@ function writeInlineSubAgents(content, workspaceDir, agentsBaseDir, engineId) { } const baseDir = agentsBaseDir || workspaceDir; - const { dir, ext } = inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget(engineId)); + // Normalise once so the target directory and the Claude `name:` injection always agree: + // every id that stages into .claude/agents also gets the name Claude Code registers by. + const normalizedEngineId = (engineId || "").trim().toLowerCase(); + const { dir, ext } = inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget(normalizedEngineId)); const agentsDir = path.join(baseDir, dir); core.info(`[extractInlineSubAgents] Engine: "${engineId || "(default)"}" โ†’ dir="${dir}" ext="${ext}"`); core.info(`[extractInlineSubAgents] Writing ${agents.length} sub-agent(s) to: ${agentsDir}`); @@ -403,7 +406,7 @@ function writeInlineSubAgents(content, workspaceDir, agentsBaseDir, engineId) { for (const agent of agents) { const agentPath = path.join(agentsDir, agent.name + ext); let filteredContent = resolveSubAgentExperimentReferences(agent.name, preserveSubAgentFrontmatter(agent.content)); - if ((engineId || "").toLowerCase() === "claude") filteredContent = ensureClaudeSubAgentName(agent.name, filteredContent); + if (normalizedEngineId === "claude") filteredContent = ensureClaudeSubAgentName(agent.name, filteredContent); const agentContent = filteredContent.endsWith("\n") ? filteredContent : filteredContent + "\n"; try { fs.writeFileSync(agentPath, agentContent, "utf8"); diff --git a/actions/setup/js/extract_inline_sub_agents.test.cjs b/actions/setup/js/extract_inline_sub_agents.test.cjs index 650fde63cac..4ddc448ed3d 100644 --- a/actions/setup/js/extract_inline_sub_agents.test.cjs +++ b/actions/setup/js/extract_inline_sub_agents.test.cjs @@ -497,4 +497,15 @@ describe("Claude staged sub-agent identity", () => { expect(fs.readFileSync(path.join(tmpDir, ".codex", "agents", "helper.md"), "utf8")).toBe("---\nmodel: small\n---\nHelp.\n"); expect(fs.readFileSync(path.join(tmpDir, ".github", "agents", "helper.agent.md"), "utf8")).toBe("---\nmodel: small\n---\nHelp.\n"); }); + + it("injects the name for any engine id that stages into .claude/agents", () => { + // interpolate_prompt.cjs passes GH_AW_ENGINE_ID, which the compiler emits as the canonical + // "claude"; differently cased or padded ids still stage and register the same way. + const workflow = `Main.\n\n${agentMarker("helper")}\n---\nmodel: small\n---\nHelp.`; + for (const engineId of ["Claude", " CLAUDE "]) { + const dir = fs.mkdtempSync(path.join(tmpDir, "engine-")); + writeInlineSubAgents(workflow, dir, dir, engineId); + expect(fs.readFileSync(path.join(dir, ".claude", "agents", "helper.md"), "utf8")).toBe('---\nname: "helper"\nmodel: small\n---\nHelp.\n'); + } + }); }); diff --git a/pkg/workflow/compiler_yaml_step_lifecycle.go b/pkg/workflow/compiler_yaml_step_lifecycle.go index f2076c7d629..51e585d55c8 100644 --- a/pkg/workflow/compiler_yaml_step_lifecycle.go +++ b/pkg/workflow/compiler_yaml_step_lifecycle.go @@ -307,8 +307,10 @@ func claudeCodexSubagentProvider(data *WorkflowData, engineID string) string { if engineID == "codex" { defaultProvider = LLMProviderOpenAI } + // resolveEngineLLMProviderFromModel returns a normalized LLMProvider (aliases such as + // "copilot" already map to LLMProviderGitHub), whose pattern provider is "github-copilot". provider := string(resolveEngineLLMProviderFromModel(data, defaultProvider)) - if provider == string(LLMProviderGitHub) || provider == "copilot" { + if provider == string(LLMProviderGitHub) { return "github-copilot" } return provider From f55f6612c12d7020c3b20f010b2014bd3f9d5367 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:19:54 +0000 Subject: [PATCH 5/5] Fix deferred sub-agent review threads: read-only smoke, reserved main, bare plugin agents, YAML name key, provider-prefixed aliases, inherited audit match Co-authored-by: SivaKesava1 <11771739+SivaKesava1@users.noreply.github.com> --- .../smoke-claude-sub-agents.lock.yml | 226 +++++++++++++----- .github/workflows/smoke-claude-sub-agents.md | 17 +- actions/setup/js/claude_harness.cjs | 1 + actions/setup/js/claude_harness.test.cjs | 94 ++++++++ actions/setup/js/codex_harness.cjs | 10 +- actions/setup/js/codex_harness.test.cjs | 20 ++ actions/setup/js/engine_subagent_models.cjs | 114 ++++++++- .../setup/js/engine_subagent_models.test.cjs | 90 ++++++- .../setup/js/extract_inline_sub_agents.cjs | 25 +- .../js/extract_inline_sub_agents.test.cjs | 20 ++ .../setup/js/smoke_claude_sub_agents.test.cjs | 17 ++ pkg/cli/token_usage_declared_subagents.go | 70 ++++-- .../token_usage_declared_subagents_test.go | 47 ++++ ...claude_codex_subagent_declarations_test.go | 28 +++ pkg/workflow/compiler_yaml_step_lifecycle.go | 18 +- pkg/workflow/model_expression_patterns.go | 10 +- 16 files changed, 710 insertions(+), 97 deletions(-) diff --git a/.github/workflows/smoke-claude-sub-agents.lock.yml b/.github/workflows/smoke-claude-sub-agents.lock.yml index 7c08d332683..e3d0aff6996 100644 --- a/.github/workflows/smoke-claude-sub-agents.lock.yml +++ b/.github/workflows/smoke-claude-sub-agents.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"17bb259908afda18f563200fb0af4ec512da0839459e010a5d6bbec384b2b8c0","body_hash":"387c789d648bf5de762f3561c6a39b9226b0811b9c4e649ed81a115c30912b0b","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.296"}} -# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70","digest":"sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70@sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70","digest":"sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70@sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70","digest":"sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70@sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.36","digest":"sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382","pinned_image":"ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382"},{"image":"ghcr.io/github/github-mcp-server:v2.0.2","digest":"sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796","pinned_image":"ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796"}],"mcp_servers":[{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["create_issue"]}],"threat_detection":{"mode":"disabled"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"461d3e3d6c351c6b191f7c4c540c2dadcb4c6fe176b625bf42f766982cb2ba61","body_hash":"d5a5dbbabd70166e4c251b716ef6c51d8f2ee70ab7e78904f9a0903714fff754","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.296"}} +# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70","digest":"sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.70@sha256:b15ab48cbbab02904eb9bbd359a398db59690eebbea1e6934d47ed2bb407dd34"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70","digest":"sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.70@sha256:9ac8f9c7294e892c916de3da9d2588dfd8cd4931e5c39b00143266d7ed9fd52a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70","digest":"sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.70@sha256:b5bc53f4becfd96e926e63c83831f1b3c9512f713c83161d2b8c9b7204b3e917"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.36","digest":"sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.36@sha256:b4d2856018959c798b3e3f81ae4ab5b275e06354e123e1f7971de931f2f450f2"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382","pinned_image":"ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382"},{"image":"ghcr.io/github/github-mcp-server:v2.0.2","digest":"sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796","pinned_image":"ghcr.io/github/github-mcp-server:v2.0.2@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796"}],"mcp_servers":[{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["missing_data","missing_tool","noop","report_incomplete"]}],"threat_detection":{"mode":"disabled"}} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -276,7 +276,7 @@ jobs: GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl - GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_mcp_transport_prompt.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"file\":\"safe_outputs_auto_create_issue.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}],\"system_item_count\":13}" + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_mcp_transport_prompt.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}],\"system_item_count\":12}" GH_AW_EXPR_76DF9333: ${{ github.event.pull_request.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'pull_request' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} GH_AW_EXPR_77C1A4D2: ${{ github.event.comment.id || fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').comment_id }} GH_AW_EXPR_7C248226: ${{ github.event.issue.number || (fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_type == 'issue' && fromJSON(steps.generate_aw_info.outputs.aw_context || '{}').item_number) }} @@ -286,7 +286,7 @@ jobs: GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_PROMPT_CONTENT_0000: "\n" - GH_AW_PROMPT_CONTENT_0001: "\nTools: create_issue\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: missing_tool, missing_data, noop(max:2)\n" GH_AW_PROMPT_CONTENT_0002: "\n" GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_7C248226__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_C19C384F__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_76DF9333__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_77C1A4D2__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" GH_AW_PROMPT_CONTENT_0004: "\n" @@ -449,7 +449,7 @@ jobs: GH_AW_DEFAULT_ADMISSION_URL: ${{ vars.GH_AW_DEFAULT_ADMISSION_URL }} GH_AW_DEFAULT_ADMISSION_AUDIENCE: ${{ vars.GH_AW_DEFAULT_ADMISSION_AUDIENCE }} GH_AW_DEFAULT_ADMISSION_ON_OUTAGE: ${{ vars.GH_AW_DEFAULT_ADMISSION_ON_OUTAGE }} - GH_AW_ADMISSION_WORKFLOW_HASH: "17bb259908afda18f563200fb0af4ec512da0839459e010a5d6bbec384b2b8c0:387c789d648bf5de762f3561c6a39b9226b0811b9c4e649ed81a115c30912b0b" + GH_AW_ADMISSION_WORKFLOW_HASH: "461d3e3d6c351c6b191f7c4c540c2dadcb4c6fe176b625bf42f766982cb2ba61:d5a5dbbabd70166e4c251b716ef6c51d8f2ee70ab7e78904f9a0903714fff754" GH_AW_INFO_WORKFLOW_ID: ${{ job.workflow_ref }} GITHUB_ACTOR: ${{ github.actor }} GITHUB_TRIGGERING_ACTOR: ${{ github.triggering_actor }} @@ -641,7 +641,7 @@ jobs: env: GH_AW_FILE_ROOT: "${{ runner.temp }}/gh-aw" GH_AW_FILE_CONFIG: "{\"files\":[{\"path\":\"safeoutputs/config.json\",\"content_env\":\"GH_AW_SAFE_OUTPUTS_CONFIG\"}]}" - GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-claude-sub-agents\"],\"max\":1,\"title_prefix\":\"[smoke-claude-sub-agents]\"}}" + GH_AW_SAFE_OUTPUTS_CONFIG: "{\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":2,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}" with: script: | const path = require('path'); @@ -654,52 +654,84 @@ jobs: env: GH_AW_TOOLS_META_JSON: | { - "description_suffixes": { - "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-claude-sub-agents]\". Labels [\"smoke-claude-sub-agents\"] will be automatically added." - }, + "description_suffixes": {}, "repo_params": {}, "dynamic_tools": [] } GH_AW_VALIDATION_JSON: | { - "create_issue": { - "defaultMax": 1, + "missing_data": { + "defaultMax": 20, "fields": { - "blocked_by": {}, - "body": { - "required": true, + "alternatives": { "type": "string", "sanitize": true, - "maxLength": 65000, - "minLength": 20 + "maxLength": 256 }, - "fields": { - "type": "array" + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 }, - "parent": { - "issueOrPRNumber": true + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 }, - "repo": { + "reason": { + "required": true, "type": "string", + "sanitize": true, "maxLength": 256 }, - "temporary_id": { - "type": "string" + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 2, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 }, - "title": { + "reason": { "required": true, "type": "string", "sanitize": true, - "maxLength": 128 + "maxLength": 1024 } - }, - "collapseData": true + } } } uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1198,6 +1230,31 @@ jobs: with: script: "const assert = require(\"node:assert/strict\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst root = \"/tmp/gh-aw\";\nconst worker = \"marker-worker\";\nconst marker = \"GH-AW-CLAUDE-SUBAGENT-MARKER-7F3A\";\nconst { writeUnifiedSession } = require(path.join(process.env.RUNNER_TEMP, \"gh-aw/actions/unified_session.cjs\"));\nwriteUnifiedSession({ rootDir: root, engine: \"claude\" });\nconst readJsonl = file => fs.readFileSync(file, \"utf8\").split(\"\\n\")\n .filter(line => line.trim().startsWith(\"{\"))\n .flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });\nassert.equal(process.env.SMOKE_EXECUTION, \"success\", \"Claude execution failed\");\nconst events = readJsonl(path.join(root, \"usage/aw_session.jsonl\"));\nconst stream = readJsonl(path.join(root, \"agent-stdio.log\"));\n\n// Pre-start resolution of the declared `model: small` worker.\nconst resolution = events.find(event => event.type === \"model.resolution\" && event.data?.engine === \"claude\" && event.data?.role === worker);\nassert.ok(resolution, `${worker} has no model.resolution record`);\nassert.ok([\"resolved\", \"exact\"].includes(resolution.data.status), `${worker} model resolution ${resolution.data.status}`);\nassert.equal(resolution.data.configured_model, \"small\", `${worker} must declare model: small`);\nconst model = resolution.data.resolved_model;\nassert.ok(model && model !== \"small\", `${worker} has no resolved model`);\n\n// The generated definition Claude Code loads carries the identity and the concrete id.\nconst definition = fs.readFileSync(path.join(process.env.GITHUB_WORKSPACE, \".claude/agents\", `${worker}.md`), \"utf8\");\nassert.match(definition, new RegExp(`^name:\\\\s*\"?${worker}\"?\\\\s*$`, \"m\"), `${worker} definition has no native name`);\nassert.ok(definition.includes(`model: ${JSON.stringify(model)}`), `${worker} definition does not carry ${model}`);\n\n// Native initialization registered the worker.\nconst init = stream.find(record => record.type === \"system\" && record.subtype === \"init\");\nassert.ok(init?.agents?.includes(worker), `native initialization did not register ${worker}: ${JSON.stringify(init?.agents)}`);\n\n// The first agent action delegates to the worker, and that delegation succeeds.\nconst toolUses = stream.filter(record => record.type === \"assistant\" && !record.parent_tool_use_id)\n .flatMap(record => record.message?.content || []).filter(item => item.type === \"tool_use\");\nconst first = toolUses[0];\nassert.ok(first && [\"Agent\", \"Task\"].includes(first.name) && first.input?.subagent_type === worker, `first agent action must invoke ${worker}: ${JSON.stringify(first && { name: first.name, input: first.input })}`);\nconst result = stream.filter(record => record.type === \"user\").flatMap(record => record.message?.content || [])\n .find(item => item.type === \"tool_result\" && item.tool_use_id === first.id);\nassert.ok(result && !result.is_error, `${worker} delegation failed: ${JSON.stringify(result?.content)}`);\nassert.ok(JSON.stringify(result.content).includes(marker), `${worker} did not return the marker`);\n\n// The started sub-agent is the worker on the resolved model, with no built-in fallback.\nconst started = events.filter(event => event.type === \"subagent.started\");\nassert.ok(!started.some(event => event.data?.agentType === \"general-purpose\" || event.data?.agentName === \"general-purpose\"), \"fell back to general-purpose\");\nconst invocation = started.find(event => event.data?.toolCallId === first.id);\nassert.ok(invocation && (invocation.data.agentName === worker || invocation.data.agentType === worker), `subagent.started does not name ${worker}`);\nconst activity = events.filter(event => event.agentId === invocation.agentId);\nconst configured = activity.filter(event => event.type === \"subagent.configured\");\nassert.ok(configured.length > 0 && configured.every(event => event.data?.model === model), `${worker} was not configured on ${model}: ${JSON.stringify(configured.map(event => event.data?.model))}`);\nassert.ok(activity.some(event => event.type === \"subagent.completed\"), `${worker} did not complete`);\nassert.ok(!activity.some(event => event.type === \"subagent.failed\"), `${worker} failed`);\n\n// The worker's own messages were served by the resolved model through the proxy.\nconst child = stream.filter(record => record.type === \"assistant\" && record.parent_tool_use_id === first.id);\nassert.ok(child.length > 0 && child.every(record => record.message?.model === model), `${worker} messages were not served by ${model}`);\nconst proxyPaths = [\n \"sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl\",\n \"sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl\",\n \"sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl\",\n];\nconst proxyFile = proxyPaths.map(file => path.join(root, file)).find(file => fs.existsSync(file));\nassert.ok(proxyFile, \"Missing api-proxy token usage evidence\");\nconst served = readJsonl(proxyFile).filter(request => request.model === model && Number(request.status) === 200).length;\nconst childMessages = new Set(child.map(record => record.message?.id)).size;\nassert.ok(served >= childMessages, `${worker} sent ${childMessages} message(s) on ${model}, but the proxy served ${served}`);" + # Small dedicated copy of the agent output so safe-output processing + # survives a failed or timed-out upload of the larger agent artifact + - name: Upload agent output fallback artifact + if: always() && steps.redact_secrets.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: agent-output-fallback + path: | + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_execution.json + /tmp/gh-aw/agent_execution_exit_code.txt + /tmp/gh-aw/agent_usage.jsonl + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent/model-endpoint-mismatch.json + /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl + /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/model-routing.jsonl + /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl + /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/model-routing.jsonl + /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl + /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/model-routing.jsonl + /tmp/gh-aw/awf-resolved-config.json + if-no-files-found: ignore + retention-days: ${{ vars.GH_AW_DEFAULT_ARTIFACT_RETENTION_DAYS || '0' }} - name: Upload agent artifacts if: always() && steps.redact_secrets.outcome == 'success' continue-on-error: true @@ -1249,8 +1306,6 @@ jobs: # Permissions for the conclusion job (workflow permissions default to none). permissions: actions: read - issues: write - pull-requests: read concurrency: group: "gh-aw-conclusion-smoke-claude-sub-agents" cancel-in-progress: false @@ -1262,6 +1317,11 @@ jobs: GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: admission_compact_session: ${{ steps.admission-conclusion-metadata.outputs.compact_session }} + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_comment_body: ${{ steps.noop.outputs.noop_comment_body }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} usage_artifact_id: ${{ steps.upload-usage-artifact.outputs.artifact-id }} usage_artifact_retry_id: ${{ steps.upload-usage-artifact-retry.outputs.artifact-id }} steps: @@ -1315,6 +1375,71 @@ jobs: pattern: safe-outputs-items merge-multiple: true path: /tmp/gh-aw/ + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "2" + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" + GH_AW_ENGINE_ID: "claude" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "false" + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_WORKFLOW_ID: "smoke-claude-sub-agents" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'handle_noop_message.cjs')); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "false" + GH_AW_MISSING_TOOL_TITLE_PREFIX: "[missing tool]" + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" + GH_AW_ENGINE_ID: "claude" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'missing_tool.cjs')); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "false" + GH_AW_REPORT_INCOMPLETE_TITLE_PREFIX: "[incomplete]" + GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" + GH_AW_ENGINE_ID: "claude" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const path = require('path'); + const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); + const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(path.join(actionsDir, 'report_incomplete_handler.cjs')); + await main(); - name: Handle agent failure id: handle_agent_failure if: always() @@ -1326,7 +1451,7 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "smoke-claude-sub-agents" - GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "12" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0" GH_AW_ENGINE_ID: "claude" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} @@ -1358,7 +1483,7 @@ jobs: GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} GH_AW_DAILY_AI_CREDITS_CONTINUE_ON_ERROR: "false" GH_AW_GROUP_REPORTS: "false" - GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_FAILURE_REPORT_AS_ISSUE: "false" GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" GH_AW_TIMEOUT_MINUTES: "10" @@ -1371,25 +1496,6 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require(path.join(actionsDir, 'handle_agent_failure.cjs')); await main(); - - name: Report failed jobs - id: report_failed_jobs - if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Smoke Claude Sub Agents" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-claude-sub-agents.md" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_REPORT_FAILED_JOBS: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const path = require('path'); - const actionsDir = path.join(process.env.RUNNER_TEMP, 'gh-aw', 'actions'); - const { setupGlobals } = require(path.join(actionsDir, 'setup_globals.cjs')); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require(path.join(actionsDir, 'report_failed_jobs.cjs')); - await main(); - name: Collect usage artifact files if: always() continue-on-error: true @@ -1405,7 +1511,6 @@ jobs: GH_AW_ADMISSION_MAX_AI_CREDITS_EVALS: ${{ needs.activation.outputs.admission-max-ai-credits-evals }} GH_AW_GRADER_MANIFEST_0: '{"version":2,"graders":[]}' GH_AW_GRADER_MANIFEST_CHUNKS: '1' - GH_AW_JOB_DURATIONS: ${{ steps.report_failed_jobs.outputs.job_durations }} GH_AW_DAILY_AI_CREDITS_GUARDRAIL_STATUS: ${{ needs.activation.outputs.daily_ai_credits_guardrail_status }} GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} GH_AW_DAILY_AI_CREDITS_TOTAL: ${{ needs.activation.outputs.daily_ai_credits_total }} @@ -1537,7 +1642,7 @@ jobs: GH_AW_DEFAULT_ADMISSION_URL: ${{ vars.GH_AW_DEFAULT_ADMISSION_URL }} GH_AW_DEFAULT_ADMISSION_AUDIENCE: ${{ vars.GH_AW_DEFAULT_ADMISSION_AUDIENCE }} GH_AW_DEFAULT_ADMISSION_ON_OUTAGE: ${{ vars.GH_AW_DEFAULT_ADMISSION_ON_OUTAGE }} - GH_AW_ADMISSION_WORKFLOW_HASH: "17bb259908afda18f563200fb0af4ec512da0839459e010a5d6bbec384b2b8c0:387c789d648bf5de762f3561c6a39b9226b0811b9c4e649ed81a115c30912b0b" + GH_AW_ADMISSION_WORKFLOW_HASH: "461d3e3d6c351c6b191f7c4c540c2dadcb4c6fe176b625bf42f766982cb2ba61:d5a5dbbabd70166e4c251b716ef6c51d8f2ee70ab7e78904f9a0903714fff754" GH_AW_INFO_WORKFLOW_ID: ${{ job.workflow_ref }} GITHUB_ACTOR: ${{ github.actor }} GITHUB_TRIGGERING_ACTOR: ${{ github.triggering_actor }} @@ -1558,8 +1663,7 @@ jobs: if: (!cancelled()) && needs.agent.result != 'skipped' runs-on: ubuntu-slim # Permissions for the safe_outputs job (workflow permissions default to none). - permissions: - issues: write + permissions: {} timeout-minutes: 45 env: GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} @@ -1579,8 +1683,6 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} - created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} @@ -1654,7 +1756,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-claude-sub-agents\"],\"max\":1,\"title_prefix\":\"[smoke-claude-sub-agents]\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":2,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/smoke-claude-sub-agents.md b/.github/workflows/smoke-claude-sub-agents.md index 23f755cf009..7026903a694 100644 --- a/.github/workflows/smoke-claude-sub-agents.md +++ b/.github/workflows/smoke-claude-sub-agents.md @@ -17,6 +17,19 @@ features: sandbox: agent: id: awf +safe-outputs: + create-issue: false + threat-detection: false + report-failure-as-issue: false + report-failed-jobs: false + noop: + report-as-issue: false + missing-tool: + create-issue: false + missing-data: + create-issue: false + report-incomplete: + create-issue: false post-steps: - name: Assert declared sub-agent ran on its resolved model if: always() @@ -98,8 +111,8 @@ post-steps: This is a read-only smoke test. Do not modify any files and do not use any other tools before the delegation below. 1. Your **first action** must be a single foreground call of the `Agent` tool with `subagent_type` set to `marker-worker` and the prompt `marker?`. Do not run it in the background. -2. Do not use `general-purpose` or any other agent, and do not answer on the worker's behalf. If `marker-worker` is unavailable, stop and report that it is unavailable. -3. Reply with the worker's exact response and nothing else. +2. Do not use `general-purpose` or any other agent, and do not answer on the worker's behalf. If `marker-worker` is unavailable, call the `noop` safe output with a message saying it is unavailable, then stop. +3. Finish by calling the `noop` safe output once, with the worker's exact response as the message. Do not publish anything else. ## agent: `marker-worker` --- diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 2e8a6c4e06b..76949b88d35 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -570,6 +570,7 @@ async function main() { reflectData: reflection.reflectData, refetchReflectData: async () => (await fetchAWFReflect({ logger: log })).reflectData || null, provider: normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic"), + pluginDir, env: process.env, logger: log, }); diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index 7dd003625f3..da77dc8ced4 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -1672,6 +1672,100 @@ describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => { } }); + it("fails closed before spawn for a workflow-declared sub-agent named main", () => { + const dir = makeHarnessTempDir("claude-subagents-main-"); + const ostmp = path.join(dir, "ostmp"); + const workspace = path.join(dir, "workspace"); + const recordsDir = path.join(dir, "records"); + try { + writeInlineSubAgents(`# Typist\n\n## agent: \`main\`\n${typistAgent()}`, workspace, path.join(ostmp, "gh-aw"), "claude"); + const { result, calls } = runHarnessWithStub({ + stubScript, + reflectData: runs.typistReflect(), + extraEnv: { + TMPDIR: ostmp, + GITHUB_WORKSPACE: workspace, + GH_AW_ENGINE_CWD: "", + GH_AW_TMP_DIR: recordsDir, + GH_AW_AWF_CONFIG_PATH: path.join(dir, "missing-awf-config.json"), + GH_AW_LLM_PROVIDER: "anthropic", + GH_AW_SAFE_OUTPUTS: "", + GH_AW_HARNESS_MAX_RETRIES: "0", + GH_AW_PHASE: "", + }, + }); + + expect(result.status).toBe(1); + expect(calls).toEqual([]); + expect(result.stderr).toContain("the sub-agent name 'main' is reserved for the Claude main model's resolution record"); + const recordPath = path.join(recordsDir, "agent", "model-resolution.json"); + const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, "utf8")) : []; + expect(records.filter(record => record.configured_model === "small")).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("bare mode loads the resolved definition through the plugin's agents path", () => { + const dir = makeHarnessTempDir("claude-subagents-bare-"); + const ostmp = path.join(dir, "ostmp"); + const workspace = path.join(dir, "workspace"); + try { + // The managed directory is /tmp/gh-aw/.claude in a run; point the real plugin builder at this run's staging. + const preload = path.join(dir, "bare-plugin.cjs"); + fs.writeFileSync( + preload, + ` + const runtime = require(${JSON.stringify(require.resolve("./claude_runtime.cjs"))}); + const build = runtime.claudeBareCapabilities; + runtime.claudeBareCapabilities = args => build(args, ${JSON.stringify(path.join(ostmp, "gh-aw", ".claude"))}, ${JSON.stringify(path.join(ostmp, "agent"))}); + `, + "utf8" + ); + writeInlineSubAgents(`# Typist\n\n## agent: \`duplicate-type-finder\`\n${typistAgent()}`, workspace, path.join(ostmp, "gh-aw"), "claude"); + const staged = fs.readFileSync(path.join(ostmp, "gh-aw/.claude/agents/duplicate-type-finder.md"), "utf8"); + const bareStub = ` + const fs = require("fs"); + const path = require("path"); + const args = process.argv.slice(2); + const agentsDir = path.join(args[args.indexOf("--plugin-dir") + 1], "agents"); + const agents = Object.fromEntries(fs.readdirSync(agentsDir).map(name => [name, fs.readFileSync(path.join(agentsDir, name), "utf8")])); + fs.appendFileSync(process.env.CLAUDE_HARNESS_STUB_CALLS, JSON.stringify({ args, agents, symlink: fs.lstatSync(agentsDir).isSymbolicLink() }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "done" }) + "\\n"); + `; + const { result, calls } = runHarnessWithStub({ + stubScript: bareStub, + extraArgs: ["--bare"], + reflectData: runs.typistReflect(), + extraEnv: { + NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, + TMPDIR: ostmp, + GITHUB_WORKSPACE: workspace, + GH_AW_ENGINE_CWD: "", + GH_AW_TMP_DIR: path.join(dir, "records"), + GH_AW_AWF_CONFIG_PATH: path.join(dir, "missing-awf-config.json"), + GH_AW_LLM_PROVIDER: "anthropic", + GH_AW_SAFE_OUTPUTS: "", + GH_AW_HARNESS_MAX_RETRIES: "0", + GH_AW_PHASE: "", + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0].args).toContain("--plugin-dir"); + expect(calls[0].symlink).toBe(false); + const loaded = calls[0].agents["duplicate-type-finder.md"]; + expect(loaded).toMatch(/^---\nname: "duplicate-type-finder"\n/); + expect(loaded).toContain('model: "claude-haiku-5-5"'); + expect(fs.readFileSync(path.join(ostmp, "gh-aw/.claude/agents/duplicate-type-finder.md"), "utf8")).toBe(staged); + expect(staged).toContain("model: small"); + expect(fs.readdirSync(path.join(ostmp, "agent"))).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("claude_codex_detection_skips_subagent_resolution (Claude)", () => { // Fixture mutation: an unknown model that would fail closed outside detection. const agentContent = typistAgent().replace("model: small", "model: claude-nonexistent-9"); diff --git a/actions/setup/js/codex_harness.cjs b/actions/setup/js/codex_harness.cjs index fd32bb33e93..a4129871ce3 100644 --- a/actions/setup/js/codex_harness.cjs +++ b/actions/setup/js/codex_harness.cjs @@ -968,10 +968,10 @@ async function main() { } // Declare and warn only: Codex does not load the staged Markdown sub-agents, so their - // models are resolved and recorded for audits, never applied and never fatal. Threat - // detection uses no sub-agents. + // models are resolved and recorded for audits, never applied and never fatal. Only a + // sub-agent using the reserved name `main` stops Codex. Threat detection uses no sub-agents. if (!isDetectionPhase(process.env.GH_AW_PHASE)) { - await declareCodexSubagentModels({ + const subagents = await declareCodexSubagentModels({ reflectData, refetchReflectData: async () => { if (process.env.AWF_REFLECT_ENABLED !== "1") return null; @@ -982,6 +982,10 @@ async function main() { env: process.env, logger: log, }); + if (subagents && subagents.ok === false) { + process.exit(1); + return; + } } // Prompts remain on stdin; use a placeholder in the argument diagnostic. diff --git a/actions/setup/js/codex_harness.test.cjs b/actions/setup/js/codex_harness.test.cjs index 9f96f08d0d5..5d73c16c7e6 100644 --- a/actions/setup/js/codex_harness.test.cjs +++ b/actions/setup/js/codex_harness.test.cjs @@ -2398,6 +2398,26 @@ describe("Codex sub-agent declarations before spawn (github/gh-aw#70667)", () => expect(result.stderr).not.toContain("The requested model is not supported"); }); + it("fails closed before spawn for a workflow-declared sub-agent named main", () => { + const dir = makeHarnessTempDir("codex-subagents-main-"); + const ostmp = path.join(dir, "ostmp"); + const recordsDir = path.join(dir, "records"); + const staged = path.join(ostmp, "gh-aw", ".codex", "agents"); + fs.mkdirSync(staged, { recursive: true }); + fs.copyFileSync(path.join(runs.arxivAgentsDir, "paper-screener.md"), path.join(staged, "main.md")); + const { result, calls } = runHarnessFixture("", { + reflectData: runs.arxivReflect(), + env: { TMPDIR: ostmp, AWF_REFLECT_ENABLED: "1", GH_AW_LLM_PROVIDER: "openai", GH_AW_TMP_DIR: recordsDir, GH_AW_AWF_CONFIG_PATH: path.join(dir, "missing-awf-config.json"), GH_AW_PHASE: "" }, + }); + + expect(result.status).toBe(1); + expect(calls).toEqual([]); + expect(result.stderr).toContain("the sub-agent name 'main' is reserved for the Codex main model's resolution record"); + const recordPath = path.join(recordsDir, "agent", "model-resolution.json"); + const records = fs.existsSync(recordPath) ? JSON.parse(fs.readFileSync(recordPath, "utf8")) : []; + expect(records.filter(record => record.engine === "codex" && record.role === "main").every(record => record.configured_model !== "openai/gpt-5.4-mini")).toBe(true); + }); + it("claude_codex_detection_skips_subagent_resolution (Codex)", () => { const { result, calls, records, stagedAfter } = runWithArxivAgents({ env: { GH_AW_PHASE: "detection" } }); diff --git a/actions/setup/js/engine_subagent_models.cjs b/actions/setup/js/engine_subagent_models.cjs index 8b642b92ef1..2571a2dbd34 100644 --- a/actions/setup/js/engine_subagent_models.cjs +++ b/actions/setup/js/engine_subagent_models.cjs @@ -17,7 +17,11 @@ * * Codex: the staged Markdown agents are not loaded by Codex (it discovers custom agents only * from TOML role files), so their models are resolved and recorded for observability and a - * warning says they are not applied; nothing fails closed. + * warning says they are not applied; only a reserved name fails closed. + * + * The sub-agent name `main` is reserved for both engines: it is the role of the main model's + * `model.resolution` record, which a sub-agent record with the same role would replace. A + * workflow-declared `main` stops the engine; a repository `main` is skipped with a warning. */ const fs = require("fs"); @@ -31,6 +35,25 @@ const { inlineArtifactTarget } = require("./inline_artifact_target.cjs"); const { getEngineSubAgentTarget } = require("./extract_inline_sub_agents.cjs"); const { loadModelAliasMap, ModelAliasResolutionError, recordModelResolution, resolveEngineModel, splitModelIdentifier } = require("./resolve_model_alias.cjs"); +/** The `model.resolution` role of the engine's main model; no sub-agent may use it. */ +const RESERVED_SUBAGENT_NAME = "main"; + +/** + * Log why workflow-declared sub-agents with the reserved name stop the engine. + * @param {"claude" | "codex"} engine + * @param {Array<{ path: string }>} agents + * @param {(msg: string) => void} logger + * @returns {boolean} true when at least one agent uses the reserved name + */ +function rejectReservedDeclaredNames(engine, agents, logger) { + for (const agent of agents) { + logger( + `${engine} sub-agent file ${agent.path}: the sub-agent name '${RESERVED_SUBAGENT_NAME}' is reserved for the ${engine === "claude" ? "Claude" : "Codex"} main model's resolution record. Rename the agent in the workflow. Refusing to start ${engine === "claude" ? "Claude" : "Codex"}.` + ); + } + return agents.length > 0; +} + /** * Determine whether the current run phase is threat detection. * @param {string | undefined | null} phase @@ -288,10 +311,16 @@ async function resolveEngineSubagentModels(options) { * exists without editing tracked content. Agent files without a `name` are skipped because * Claude Code does not register them. * + * With `engine.bare: true`, Claude loads the workflow agents through the temporary plugin in + * `pluginDir`, whose `agents` entry links to the staged directory. That link is replaced by a + * run-local copy of the staged definitions carrying the resolved ids, so the staged files and + * repository files stay unchanged and the copy is removed with the plugin. + * * @param {{ * reflectData: any, * refetchReflectData?: () => Promise, * provider: string, + * pluginDir?: string, * workspaceDir?: string, * workingDirectory?: string, * agentsBaseDir?: string, @@ -311,7 +340,8 @@ async function applyClaudeSubagentModels(options) { const target = inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget("claude")); /** @type {string[]} */ const invalidDeclared = []; - const declared = listMarkdownAgentFiles(path.resolve(agentsBaseDir, target.dir), logger, { + const stagedDir = path.resolve(agentsBaseDir, target.dir); + const declared = listMarkdownAgentFiles(stagedDir, logger, { onInvalid: (filePath, error) => { invalidDeclared.push(filePath); logger(`claude sub-agent file ${filePath}: cannot read or parse the staged workflow sub-agent: ${getErrorMessage(error)}.`); @@ -325,6 +355,15 @@ async function applyClaudeSubagentModels(options) { logger(`Refusing to start Claude: ${invalidDeclared.length} workflow-declared sub-agent file(s) cannot be read or parsed, so their models cannot be resolved.`); return { ok: false, resolutions: [] }; } + if ( + rejectReservedDeclaredNames( + "claude", + declared.filter(agent => agent.name === RESERVED_SUBAGENT_NAME), + logger + ) + ) { + return { ok: false, resolutions: [] }; + } const declaredPaths = new Set(declared.map(agent => agent.workspacePath)); const declaredNames = new Set(declared.map(agent => agent.name)); const repository = []; @@ -334,6 +373,10 @@ async function applyClaudeSubagentModels(options) { if (agent.model) logger(`warning: repository agent ${agent.path} has no frontmatter 'name', so Claude Code does not register it; its model '${agent.model}' is not resolved.`); continue; } + if (agent.name === RESERVED_SUBAGENT_NAME) { + logger(`warning: repository agent ${agent.path} uses the reserved name '${RESERVED_SUBAGENT_NAME}' (the role of the Claude main model's resolution record); its model is not resolved or recorded.`); + continue; + } if (declaredNames.has(agent.name)) continue; repository.push({ ...agent, name: agent.name }); } @@ -354,6 +397,8 @@ async function applyClaudeSubagentModels(options) { }); if (failures.some(failure => failure.fatal)) return { ok: false, resolutions }; + /** @type {Map} */ + const resolvedDefinitions = new Map(); for (const resolution of resolutions) { const agent = declared.find(candidate => candidate.name === resolution.name); if (!agent) { @@ -368,6 +413,7 @@ async function applyClaudeSubagentModels(options) { try { const staged = fs.readFileSync(agent.path, "utf8"); const content = resolution.model === resolution.configuredModel ? staged : replaceAgentModel(staged, resolution.model); + resolvedDefinitions.set(agent.fileName, content); const existing = fs.existsSync(agent.workspacePath) ? fs.readFileSync(agent.workspacePath, "utf8") : null; if (existing !== content) { fs.mkdirSync(path.dirname(agent.workspacePath), { recursive: true }); @@ -379,15 +425,60 @@ async function applyClaudeSubagentModels(options) { return { ok: false, resolutions }; } } + if (options.pluginDir) { + try { + writePluginAgents(options.pluginDir, stagedDir, resolvedDefinitions, logger); + } catch (error) { + logger(`claude sub-agents: cannot write the resolved agent definitions into the bare-mode plugin ${options.pluginDir}: ${getErrorMessage(error)}. Refusing to start Claude with unresolved sub-agent models.`); + return { ok: false, resolutions }; + } + } return { ok: true, resolutions }; } +/** + * Replace the bare-mode plugin's `agents` link to the staged directory with a run-local copy + * of the staged definitions, using the resolved content where a model was resolved. Nothing + * happens when the plugin has no `agents` entry. + * @param {string} pluginDir + * @param {string} stagedDir + * @param {Map} resolvedDefinitions file name -> resolved definition + * @param {(msg: string) => void} logger + */ +function writePluginAgents(pluginDir, stagedDir, resolvedDefinitions, logger) { + const pluginAgents = path.join(pluginDir, "agents"); + try { + fs.lstatSync(pluginAgents); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return; + throw error; + } + /** @type {string | undefined} */ + let copy; + try { + copy = fs.mkdtempSync(path.join(pluginDir, ".agents-")); + fs.cpSync(stagedDir, copy, { recursive: true, dereference: true }); + for (const [fileName, content] of resolvedDefinitions) fs.writeFileSync(path.join(copy, fileName), content, { mode: 0o600 }); + fs.rmSync(pluginAgents, { recursive: true, force: true }); + fs.renameSync(copy, pluginAgents); + } catch (error) { + try { + if (copy) fs.rmSync(copy, { recursive: true, force: true }); + } catch (cleanupError) { + logger(`warning: cannot remove the partial bare-mode agents copy ${copy}: ${getErrorMessage(cleanupError)}; the plugin directory is removed with the plugin.`); + } + throw error; + } + logger(`claude sub-agents: the bare-mode plugin loads a run-local copy of the resolved definitions from ${pluginAgents}`); +} + /** * Codex: declare and warn only. Codex discovers custom agents only from TOML role files * (verified for Codex 0.163.0), so the workflow-declared Markdown agents staged in * `/.codex/agents` are not loaded and their models are not applied. Their - * models are still resolved and recorded so audits see the declarations; a failure is a - * warning and never stops Codex. + * models are still resolved and recorded so audits see the declarations; a model failure is + * a warning and never stops Codex. An agent named `main` makes `ok` false, because its + * record would replace the main model's record; Codex must then not start. * * @param {{ * reflectData: any, @@ -398,7 +489,7 @@ async function applyClaudeSubagentModels(options) { * env?: NodeJS.ProcessEnv, * logger: (msg: string) => void, * }} options - * @returns {Promise<{ agents: string[], resolutions: Array<{ name: string, configuredModel: string, model: string, status: string }>, failures: Array<{ name: string }> }>} + * @returns {Promise<{ ok: boolean, agents: string[], resolutions: Array<{ name: string, configuredModel: string, model: string, status: string }>, failures: Array<{ name: string }> }>} */ async function declareCodexSubagentModels(options) { const { logger } = options; @@ -407,7 +498,16 @@ async function declareCodexSubagentModels(options) { const agentsBaseDir = options.agentsBaseDir || path.join(os.tmpdir(), "gh-aw"); const directory = path.resolve(agentsBaseDir, inlineArtifactTarget("SUB_AGENT", getEngineSubAgentTarget("codex")).dir); const agents = listMarkdownAgentFiles(directory, logger).map(agent => ({ ...agent, name: agent.name || agent.fileName.slice(0, -".md".length) })); - if (agents.length === 0) return { agents: [], resolutions: [], failures: [] }; + if (agents.length === 0) return { ok: true, agents: [], resolutions: [], failures: [] }; + if ( + rejectReservedDeclaredNames( + "codex", + agents.filter(agent => agent.name === RESERVED_SUBAGENT_NAME), + logger + ) + ) { + return { ok: false, agents: agents.map(agent => agent.name), resolutions: [], failures: [] }; + } logger( `warning: Codex discovers custom agents only from TOML role files (verified for Codex 0.163.0); the ${agents.length} workflow-declared Markdown sub-agent(s) in ${directory} (${agents.map(agent => agent.name).join(", ")}) are not loaded by Codex, and their declared models are recorded but not applied.` ); @@ -423,7 +523,7 @@ async function declareCodexSubagentModels(options) { env, logger, }); - return { agents: agents.map(agent => agent.name), resolutions, failures }; + return { ok: true, agents: agents.map(agent => agent.name), resolutions, failures }; } module.exports = { diff --git a/actions/setup/js/engine_subagent_models.test.cjs b/actions/setup/js/engine_subagent_models.test.cjs index 859e4aca2cb..58c11b5bc96 100644 --- a/actions/setup/js/engine_subagent_models.test.cjs +++ b/actions/setup/js/engine_subagent_models.test.cjs @@ -9,8 +9,10 @@ const require = createRequire(import.meta.url); global.core = { info: () => {}, warning: () => {}, error: () => {}, setFailed: () => {} }; const { applyClaudeSubagentModels, declareCodexSubagentModels, isDetectionPhase, replaceAgentModel } = require("./engine_subagent_models.cjs"); const { extractInlineSubAgents, writeInlineSubAgents } = require("./extract_inline_sub_agents.cjs"); +const { claudeBareCapabilities, removeClaudePlugin } = require("./claude_runtime.cjs"); const { extractFrontmatterAndBody } = require("./frontmatter_hash_pure.cjs"); const { parseDocument } = require("./work_queue_yaml.cjs"); +const { recordModelResolution } = require("./resolve_model_alias.cjs"); const runs = require("./fixtures/claude_codex_subagent_runs.cjs"); const repoRoot = path.resolve(path.dirname(require.resolve("./engine_subagent_models.cjs")), "../../.."); @@ -270,6 +272,68 @@ describe("Claude sub-agent models (Typist run 39415087125)", () => { expect(run.records()).toEqual([expect.objectContaining({ role: "duplicate-type-finder", status: "failed", cause: "model_unavailable", provider: "openai" })]); }); + it("gives the bare-mode plugin a run-local copy of the resolved definitions", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent(), "persona-evaluator": "---\ndescription: Evaluates\nmodel: inherit\n---\nEvaluate.\n" }); + const managedDir = path.join(run.agentsBaseDir, ".claude"); + const stagedPath = path.join(managedDir, "agents/duplicate-type-finder.md"); + const staged = fs.readFileSync(stagedPath, "utf8"); + const repositoryAgent = "---\nname: repo-reviewer\nmodel: small\n---\nReview.\n"; + fs.writeFileSync(path.join(run.workspaceDir, ".claude/agents/repo-reviewer.md"), repositoryAgent); + // The plugin Claude loads with --bare, as the harness prepares it. + const { args, pluginDir } = claudeBareCapabilities(["--bare"], managedDir, path.join(run.dir, "agent")); + expect(args.slice(-2)).toEqual(["--plugin-dir", pluginDir]); + expect(fs.realpathSync(path.join(pluginDir, "agents"))).toBe(fs.realpathSync(path.join(managedDir, "agents"))); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", pluginDir, workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + const pluginAgents = path.join(pluginDir, "agents"); + expect(fs.lstatSync(pluginAgents).isSymbolicLink()).toBe(false); + expect(fs.readdirSync(pluginAgents).sort()).toEqual(["duplicate-type-finder.md", "persona-evaluator.md"]); + const loaded = fs.readFileSync(path.join(pluginAgents, "duplicate-type-finder.md"), "utf8"); + expect(frontmatterOf(loaded)).toMatchObject({ name: "duplicate-type-finder", model: "claude-haiku-5-5" }); + expect(loaded).toBe(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/duplicate-type-finder.md"), "utf8")); + expect(frontmatterOf(fs.readFileSync(path.join(pluginAgents, "persona-evaluator.md"), "utf8"))).toMatchObject({ name: "persona-evaluator", model: "inherit" }); + // Staged and repository files are unchanged; removing the plugin removes the copy only. + expect(fs.readFileSync(stagedPath, "utf8")).toBe(staged); + expect(frontmatterOf(staged).model).toBe("small"); + expect(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/repo-reviewer.md"), "utf8")).toBe(repositoryAgent); + removeClaudePlugin(pluginDir); + expect(fs.existsSync(pluginDir)).toBe(false); + expect(fs.readFileSync(stagedPath, "utf8")).toBe(staged); + }); + + it("fails closed for a workflow-declared sub-agent named main", async () => { + const run = layout(); + stageInline(run, "claude", { main: typistAgent(), "duplicate-type-finder": typistAgent() }); + const before = fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/main.md"), "utf8"); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome).toEqual({ ok: false, resolutions: [] }); + expect(run.records()).toEqual([]); + expect(run.logs).toContainEqual(expect.stringContaining("the sub-agent name 'main' is reserved for the Claude main model's resolution record. Rename the agent in the workflow. Refusing to start Claude.")); + expect(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/main.md"), "utf8")).toBe(before); + }); + + it("skips a repository agent named main, so the main and worker records both survive", async () => { + const run = layout(); + stageInline(run, "claude", { "duplicate-type-finder": typistAgent() }); + const repositoryAgent = "---\nname: main\nmodel: claude-nonexistent-9\n---\nShadow.\n"; + fs.writeFileSync(path.join(run.workspaceDir, ".claude/agents/main-agent.md"), repositoryAgent); + const mainRecord = { engine: "claude", role: "main", configured_model: "sonnet", resolved_model: "claude-sonnet-5-5", provider: "anthropic", status: "resolved", cause: null }; + recordModelResolution(mainRecord, run.env); + + const outcome = await applyClaudeSubagentModels({ reflectData: runs.typistReflect(), provider: "anthropic", workspaceDir: run.workspaceDir, agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + expect(outcome.resolutions.map(resolution => resolution.name)).toEqual(["duplicate-type-finder"]); + expect(run.records()).toEqual([mainRecord, { engine: "claude", role: "duplicate-type-finder", configured_model: "small", resolved_model: "claude-haiku-5-5", provider: "anthropic", status: "resolved", cause: null }]); + expect(run.logs).toContainEqual(expect.stringContaining("warning: repository agent " + path.join(run.workspaceDir, ".claude/agents/main-agent.md") + " uses the reserved name 'main'")); + expect(fs.readFileSync(path.join(run.workspaceDir, ".claude/agents/main-agent.md"), "utf8")).toBe(repositoryAgent); + }); + it("replaces only the model scalar", () => { expect(replaceAgentModel('---\nname: "a"\nmodel: small\ntools: Read\n---\nBody model: small\n', "claude-haiku-5-5")).toBe('---\nname: "a"\nmodel: "claude-haiku-5-5"\ntools: Read\n---\nBody model: small\n'); }); @@ -336,10 +400,34 @@ describe("Codex sub-agent models (daily-arxiv-researcher run 39394965127)", () = expect(fs.readdirSync(path.join(run.workspaceDir, ".codex/agents")).some(name => name.endsWith(".toml"))).toBe(false); }); + it("fails closed for a workflow-declared sub-agent named main and keeps the main record", async () => { + const run = layout(); + const agents = arxivAgents(); + stageInline(run, "codex", { main: agents["paper-screener"], "paper-screener": agents["paper-screener"] }); + recordModelResolution(runs.arxivMainModelResolution.data, run.env); + + const outcome = await declareCodexSubagentModels({ reflectData: runs.arxivReflect(), provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome).toEqual({ ok: false, agents: ["main", "paper-screener"], resolutions: [], failures: [] }); + expect(run.records()).toEqual([runs.arxivMainModelResolution.data]); + expect(run.logs).toContainEqual(expect.stringContaining("the sub-agent name 'main' is reserved for the Codex main model's resolution record. Rename the agent in the workflow. Refusing to start Codex.")); + }); + + it("records the worker next to the main record", async () => { + const run = layout(); + stageInline(run, "codex", { "paper-screener": arxivAgents()["paper-screener"] }); + recordModelResolution(runs.arxivMainModelResolution.data, run.env); + + const outcome = await declareCodexSubagentModels({ reflectData: runs.arxivReflect(), provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); + + expect(outcome.ok).toBe(true); + expect(run.records()).toEqual([runs.arxivMainModelResolution.data, { engine: "codex", role: "paper-screener", configured_model: "openai/gpt-5.4-mini", resolved_model: "gpt-5.4-mini", provider: "openai", status: "exact", cause: null }]); + }); + it("does nothing without staged Codex agents", async () => { const run = layout(); const outcome = await declareCodexSubagentModels({ reflectData: runs.arxivReflect(), provider: "openai", agentsBaseDir: run.agentsBaseDir, env: run.env, logger: run.logger }); - expect(outcome).toEqual({ agents: [], resolutions: [], failures: [] }); + expect(outcome).toEqual({ ok: true, agents: [], resolutions: [], failures: [] }); expect(run.logs).toEqual([]); }); }); diff --git a/actions/setup/js/extract_inline_sub_agents.cjs b/actions/setup/js/extract_inline_sub_agents.cjs index 0c202ae0fac..ad44970c965 100644 --- a/actions/setup/js/extract_inline_sub_agents.cjs +++ b/actions/setup/js/extract_inline_sub_agents.cjs @@ -134,20 +134,35 @@ function resolveSubAgentExperimentReferences(agentName, content, env = process.e /** * Claude Code skips a project agent file without a frontmatter `name`, so the * generated Claude copy gets the declared block name when the author omitted it. - * An authored `name` is kept. + * An authored `name` (detected by the YAML parser, so quoted keys count) is kept. + * A flow mapping gets the key inside its braces, so mapping styles are never mixed. + * Frontmatter that does not parse as a mapping is left unchanged; the model + * resolution step then rejects it before Claude starts. * * @param {string} agentName - Declared sub-agent name from the `## agent:` marker. * @param {string} content - Agent block content (frontmatter + prompt). * @returns {string} Content with a top-level `name:` in its frontmatter. */ function ensureClaudeSubAgentName(agentName, content) { - const nameLine = `name: ${JSON.stringify(agentName)}`; + const { parseDocument } = require("./work_queue_yaml.cjs"); + const nameEntry = `name: ${JSON.stringify(agentName)}`; const match = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*\r?(?=\n|$)/.exec(content); - if (!match) return `---\n${nameLine}\n---\n${content}`; + if (!match) return `---\n${nameEntry}\n---\n${content}`; const frontmatter = match[1] ?? ""; - if (/^name[ \t]*:/m.test(frontmatter)) return content; const start = content.indexOf("\n") + 1; - return content.slice(0, start) + nameLine + "\n" + content.slice(start); + /** @type {any} */ + const document = parseDocument(frontmatter); + if (!document || document.errors.length) return content; + const map = document.contents; + if (map === null || map === undefined) return content.slice(0, start) + nameEntry + "\n" + content.slice(start); + if (!Array.isArray(map.items) || !Array.isArray(map.range)) return content; + if (map.items.some((/** @type {any} */ pair) => pair?.key?.value === "name")) return content; + const position = start + map.range[0]; + if (map.flow) { + return content.slice(0, position + 1) + nameEntry + (map.items.length > 0 ? ", " : "") + content.slice(position + 1); + } + const indent = content.slice(content.lastIndexOf("\n", position - 1) + 1, position); + return content.slice(0, position) + nameEntry + "\n" + indent + content.slice(position); } /** diff --git a/actions/setup/js/extract_inline_sub_agents.test.cjs b/actions/setup/js/extract_inline_sub_agents.test.cjs index 4ddc448ed3d..65836bae4dd 100644 --- a/actions/setup/js/extract_inline_sub_agents.test.cjs +++ b/actions/setup/js/extract_inline_sub_agents.test.cjs @@ -498,6 +498,26 @@ describe("Claude staged sub-agent identity", () => { expect(fs.readFileSync(path.join(tmpDir, ".github", "agents", "helper.agent.md"), "utf8")).toBe("---\nmodel: small\n---\nHelp.\n"); }); + it("detects a quoted authored name key and keeps it", () => { + const workflow = `Main.\n\n${agentMarker("reviewer")}\n---\n"name": reviewer\nmodel: small\n---\nReview.`; + writeInlineSubAgents(workflow, tmpDir, tmpDir, "claude"); + + const written = fs.readFileSync(path.join(tmpDir, ".claude", "agents", "reviewer.md"), "utf8"); + expect(written).toBe('---\n"name": reviewer\nmodel: small\n---\nReview.\n'); + expect(parseFrontmatter(written)).toEqual({ name: "reviewer", model: "small" }); + }); + + it("inserts the name inside flow-mapping frontmatter without mixing mapping styles", () => { + const workflow = `Main.\n\n${agentMarker("marker-worker")}\n---\n{description: Returns a marker, model: small}\n---\nMarker.\n\n${agentMarker("empty-flow")}\n---\n{ }\n---\nEmpty.`; + writeInlineSubAgents(workflow, tmpDir, tmpDir, "claude"); + + const written = fs.readFileSync(path.join(tmpDir, ".claude", "agents", "marker-worker.md"), "utf8"); + expect(written).toBe('---\n{name: "marker-worker", description: Returns a marker, model: small}\n---\nMarker.\n'); + expect(parseFrontmatter(written)).toEqual({ name: "marker-worker", description: "Returns a marker", model: "small" }); + const empty = fs.readFileSync(path.join(tmpDir, ".claude", "agents", "empty-flow.md"), "utf8"); + expect(parseFrontmatter(empty)).toEqual({ name: "empty-flow" }); + }); + it("injects the name for any engine id that stages into .claude/agents", () => { // interpolate_prompt.cjs passes GH_AW_ENGINE_ID, which the compiler emits as the canonical // "claude"; differently cased or padded ids still stage and register the same way. diff --git a/actions/setup/js/smoke_claude_sub_agents.test.cjs b/actions/setup/js/smoke_claude_sub_agents.test.cjs index f247978e1be..863fea588b6 100644 --- a/actions/setup/js/smoke_claude_sub_agents.test.cjs +++ b/actions/setup/js/smoke_claude_sub_agents.test.cjs @@ -123,3 +123,20 @@ describe("claude_small_subagent_smoke_runs_resolved_model_first (post-step check await expect(assertEvidence(staged)).rejects.toThrow("definition has no native name"); }); }); + +describe("smoke-claude-sub-agents is read-only", () => { + const lock = fs.readFileSync(new URL("../../../.github/workflows/smoke-claude-sub-agents.lock.yml", import.meta.url), "utf8"); + + test("the compiled lock grants no issues: write and exposes no issue-creating safe output", () => { + expect(lock).not.toMatch(/^\s*issues:\s*write\s*$/m); + expect(lock).not.toMatch(/create_issue|create_report_incomplete_issue/); + expect(lock).toContain('GH_AW_FAILURE_REPORT_AS_ISSUE: "false"'); + }); + + test("the agent finishes through a non-publishing noop", () => { + const manifest = JSON.parse(lock.split("\n")[1].replace(/^# gh-aw-manifest: /, "")); + expect(manifest.mcp_servers.find(server => server.name === "safeoutputs").tools).toContain("noop"); + expect(lock).toContain('GH_AW_NOOP_REPORT_AS_ISSUE: "false"'); + expect(workflow).toContain("Finish by calling the `noop` safe output once"); + }); +}); diff --git a/pkg/cli/token_usage_declared_subagents.go b/pkg/cli/token_usage_declared_subagents.go index 154e639f07b..a9fe31bc8f3 100644 --- a/pkg/cli/token_usage_declared_subagents.go +++ b/pkg/cli/token_usage_declared_subagents.go @@ -14,32 +14,36 @@ type declaredSubagentModel struct { Patterns []string `json:"patterns"` } -func readDeclaredSubagentModels(runDir string, summary *TokenUsageSummary) []declaredSubagentModel { +// readDeclaredSubagentModels returns the declared sub-agent models and the main model +// recorded in aw_info.json. +func readDeclaredSubagentModels(runDir string, summary *TokenUsageSummary) ([]declaredSubagentModel, string) { infoPath := findAwInfoPath(runDir) if infoPath == "" { - return nil + return nil, "" } content, err := os.ReadFile(infoPath) if err != nil { addTokenUsageWarning(summary, "cannot read declared sub-agent models: "+err.Error()) - return nil + return nil, "" } var info struct { + Model string `json:"model"` Models []declaredSubagentModel `json:"sub_agent_models"` } if err := json.Unmarshal(content, &info); err != nil { addTokenUsageWarning(summary, "cannot parse declared sub-agent models: "+err.Error()) - return nil + return nil, "" } - return info.Models + return info.Models, strings.TrimSpace(info.Model) } func augmentDeclaredSubagentModels(runDir string, summary *TokenUsageSummary) { - models := readDeclaredSubagentModels(runDir, summary) + models, mainModel := readDeclaredSubagentModels(runDir, summary) if len(models) == 0 { return } resolver := newModelIdentityResolver(runDir) + parentModels := effectiveParentModels(summary, mainModel) observedModels := subagentObservedModels(summary) observedNames := make([]string, 0, len(observedModels)) for name := range observedModels { @@ -47,15 +51,49 @@ func augmentDeclaredSubagentModels(runDir string, summary *TokenUsageSummary) { } slices.Sort(observedNames) for _, model := range models { - row, sessionObserved := declaredSessionModelRow(model, summary.SubagentModelRequests, resolver, observedNames) + row, sessionObserved := declaredSessionModelRow(model, summary.SubagentModelRequests, resolver, observedNames, parentModels) if !sessionObserved { - row = declaredTrafficModelRow(model, observedModels, observedNames, resolver) + row = declaredTrafficModelRow(model, observedModels, observedNames, resolver, parentModels) } summary.DeclaredSubagentModels = append(summary.DeclaredSubagentModels, row) } } -func declaredSessionModelRow(model declaredSubagentModel, requests []SubagentModelRequest, resolver *modelIdentityResolver, observedNames []string) (SubagentModelRequest, bool) { +// effectiveParentModels returns the models the main agent ran on, which an inherited +// sub-agent model uses: the main agent's served session models when the session records +// them, otherwise the main model from aw_info.json. +func effectiveParentModels(summary *TokenUsageSummary, mainModel string) []string { + var models []string + for _, agent := range summary.AgentUsage { + if agent.AgentType != "main" { + continue + } + for _, usage := range agent.Models { + if usage.Requests > 0 { + models = appendUnique(models, usage.Model) + } + } + for _, served := range agent.ServedModels { + models = appendUnique(models, served) + } + } + if len(models) == 0 && mainModel != "" { + models = append(models, mainModel) + } + return models +} + +// isInheritedSubagentModel reports whether a declared model is an inheritance instruction +// (Claude's `inherit`), which means the sub-agent runs on the parent model. +func isInheritedSubagentModel(model string) bool { + switch strings.ToLower(strings.TrimSpace(model)) { + case "inherit", "inherited": + return true + } + return false +} + +func declaredSessionModelRow(model declaredSubagentModel, requests []SubagentModelRequest, resolver *modelIdentityResolver, observedNames []string, parentModels []string) (SubagentModelRequest, bool) { row := SubagentModelRequest{AgentName: model.Name, RequestedModel: model.Model} for _, request := range requests { if !strings.EqualFold(request.AgentName, model.Name) { @@ -76,7 +114,7 @@ func declaredSessionModelRow(model declaredSubagentModel, requests []SubagentMod row.ServedModels = appendUnique(row.ServedModels, served) } row.ServedModels = appendUnique(row.ServedModels, request.EffectiveModel) - if declaredModelMatches(model, request.EffectiveModel, "", resolver) { + if declaredModelMatches(model, request.EffectiveModel, "", resolver, parentModels) { row.EffectiveModel = request.EffectiveModel row.ResolvedModel = firstNonEmptyModel(request.ResolvedModel, resolver.resolve(request.EffectiveModel, "", observedNames)) } @@ -107,14 +145,14 @@ func combineSubagentEffort(current, next string) string { return current } -func declaredTrafficModelRow(model declaredSubagentModel, observedModels map[string]*ModelTokenUsage, observedNames []string, resolver *modelIdentityResolver) SubagentModelRequest { +func declaredTrafficModelRow(model declaredSubagentModel, observedModels map[string]*ModelTokenUsage, observedNames []string, resolver *modelIdentityResolver, parentModels []string) SubagentModelRequest { row := SubagentModelRequest{AgentName: model.Name, RequestedModel: model.Model} for _, observed := range observedNames { usage := observedModels[observed] if usage == nil || usage.Requests == 0 { continue } - if declaredModelMatches(model, observed, usage.Provider, resolver) { + if declaredModelMatches(model, observed, usage.Provider, resolver, parentModels) { row.EffectiveModel = normalizeModelIdentity(observed) row.ResolvedModel = row.EffectiveModel row.ServedModels = []string{observed} @@ -130,9 +168,13 @@ func declaredTrafficModelRow(model declaredSubagentModel, observedModels map[str return row } -func declaredModelMatches(model declaredSubagentModel, observed, provider string, resolver *modelIdentityResolver) bool { +// declaredModelMatches reports whether an observed model satisfies a declaration. An +// inherited declaration matches the effective parent models. +func declaredModelMatches(model declaredSubagentModel, observed, provider string, resolver *modelIdentityResolver, parentModels []string) bool { patterns := model.Patterns - if len(patterns) == 0 { + if isInheritedSubagentModel(model.Model) { + patterns = parentModels + } else if len(patterns) == 0 { patterns = []string{model.Model} } for _, pattern := range patterns { diff --git a/pkg/cli/token_usage_declared_subagents_test.go b/pkg/cli/token_usage_declared_subagents_test.go index 6a71887a2cd..1411f2aa9ab 100644 --- a/pkg/cli/token_usage_declared_subagents_test.go +++ b/pkg/cli/token_usage_declared_subagents_test.go @@ -458,3 +458,50 @@ func TestDeclaredSubagentModelsExcludeDetectionUsage(t *testing.T) { require.Len(t, summary.DeclaredSubagentModels, 1) require.Equal(t, modelMismatchReasonModelNotObserved, summary.DeclaredSubagentModels[0].ReasonCode) } + +func TestDeclaredInheritedSubagentModelMatchesParentModel(t *testing.T) { + for _, test := range []struct { + name string + declared string + mainModel string + mainUsage []AgentUsageBreakdown + effective string + reason string + }{ + {"inherit matches the aw_info main model", "inherit", "claude-sonnet-4.5", nil, "claude-sonnet-4-5-20250929", ""}, + {"inherited matches the aw_info main alias", "inherited", "sonnet", nil, "claude-sonnet-4.5", ""}, + {"inherit matches the main agent's served model", "inherit", "", []AgentUsageBreakdown{{AgentName: "main", AgentType: "main", Models: []AgentModelUsage{{Model: "claude-opus-4.5", Requests: 3}}}}, "claude-opus-4.5", ""}, + {"inherit on another model is not observed", "inherit", "claude-sonnet-4.5", nil, "claude-haiku-4.5", modelMismatchReasonModelNotObserved}, + {"inherit without a parent model is not observed", "inherit", "", nil, "claude-sonnet-4.5", modelMismatchReasonModelNotObserved}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + info := fmt.Sprintf(`{"model":%q,"sub_agent_models":[{"name":"persona-evaluator","model":%q,"patterns":[]}]}`, test.mainModel, test.declared) + require.NoError(t, os.WriteFile(filepath.Join(dir, "aw_info.json"), []byte(info), 0600)) + summary := &TokenUsageSummary{ + ByModel: map[string]*ModelTokenUsage{test.effective: {Provider: "anthropic", Requests: 2}}, + AgentUsage: test.mainUsage, + SubagentModelRequests: []SubagentModelRequest{{ + AgentName: "persona-evaluator", RequestedModel: test.declared, EffectiveModel: test.effective, + InvocationCount: 1, CompletedCount: 1, + }}, + } + + augmentDeclaredSubagentModels(dir, summary) + + require.Len(t, summary.DeclaredSubagentModels, 1) + row := summary.DeclaredSubagentModels[0] + require.Equal(t, test.reason, row.ReasonCode) + require.Equal(t, 1, row.CompletedCount) + findings := generateSubagentModelFindings(summary) + if test.reason == "" { + require.Equal(t, test.effective, row.EffectiveModel) + require.Empty(t, findings, "a successful inherited invocation is not a missing declared model") + } else { + require.Empty(t, row.EffectiveModel) + require.Len(t, findings, 1) + require.Equal(t, AuditFindingSubagentModelNotObserved, findings[0].Code) + } + }) + } +} diff --git a/pkg/workflow/claude_codex_subagent_declarations_test.go b/pkg/workflow/claude_codex_subagent_declarations_test.go index 01f05c9a960..a2825ba2377 100644 --- a/pkg/workflow/claude_codex_subagent_declarations_test.go +++ b/pkg/workflow/claude_codex_subagent_declarations_test.go @@ -100,6 +100,34 @@ func TestClaudeCodexAwInfoContainsDeclaredSubagentModels(t *testing.T) { "## agent: `paper-screener`\n---\ndescription: Screens papers\nmodel: small\n---\nScreen.\n", want: []subagentModelDeclaration{{Name: "paper-screener", Model: "small", Patterns: []string{"openai/gpt-5*mini*", "openai/gpt-5*nano*"}}}, }, + { + name: "claude provider-qualified alias expands the alias", + body: frontmatter + "engine: claude\nmodels:\n fast: [anthropic/*haiku*]\n---\n# Work\nUse fast-worker.\n\n" + + "## agent: `fast-worker`\n---\ndescription: Works fast\nmodel: anthropic/fast\n---\nWork.\n\n" + + "## agent: `small-worker`\n---\ndescription: Works small\nmodel: anthropic/small\n---\nWork.\n", + want: []subagentModelDeclaration{ + {Name: "fast-worker", Model: "anthropic/fast", Patterns: []string{"anthropic/*haiku*"}}, + {Name: "small-worker", Model: "anthropic/small", Patterns: []string{"anthropic/*haiku*"}}, + }, + }, + { + name: "claude on the copilot provider expands a copilot-qualified alias", + body: frontmatter + "engine: claude\nmodel: copilot/claude-sonnet-4.6\n---\n# Work\nUse duplicate-type-finder.\n\n" + + "## agent: `duplicate-type-finder`\n---\ndescription: Collects all Go type definitions\nmodel: copilot/small\n---\nYou are a Go type-definition analyst.\n", + want: []subagentModelDeclaration{{Name: "duplicate-type-finder", Model: "copilot/small", Patterns: []string{"github-copilot/*haiku*", "github-copilot/gpt-5*mini*", "github-copilot/gpt-5*nano*", "github-copilot/gemini-*flash*lite*"}}}, + }, + { + name: "codex provider-qualified alias expands the alias", + body: frontmatter + "engine: codex\n---\n# Work\nScreen papers.\n\n" + + "## agent: `paper-screener`\n---\ndescription: Screens papers\nmodel: openai/small\n---\nScreen.\n", + want: []subagentModelDeclaration{{Name: "paper-screener", Model: "openai/small", Patterns: []string{"openai/gpt-5*mini*", "openai/gpt-5*nano*"}}}, + }, + { + name: "another provider's prefix is not stripped", + body: frontmatter + "engine: codex\n---\n# Work\nScreen papers.\n\n" + + "## agent: `paper-screener`\n---\ndescription: Screens papers\nmodel: anthropic/small\n---\nScreen.\n", + want: []subagentModelDeclaration{{Name: "paper-screener", Model: "anthropic/small", Patterns: []string{}}}, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/pkg/workflow/compiler_yaml_step_lifecycle.go b/pkg/workflow/compiler_yaml_step_lifecycle.go index 51e585d55c8..0fdc1c87b43 100644 --- a/pkg/workflow/compiler_yaml_step_lifecycle.go +++ b/pkg/workflow/compiler_yaml_step_lifecycle.go @@ -283,9 +283,14 @@ func writeSubagentModelDeclarations(yaml *strings.Builder, data *WorkflowData, e for _, agent := range data.SubAgentModels { model := RewriteExperimentsReferenceForDownstreamJobs(agent.Model, data.Experiments) model = RewriteActivationOutputsToLocalStepOutputs(model, data.Experiments) - patterns := expandSubagentModelPatterns(agent.Model, data, provider) + var patterns []string if engineID == "claude" || engineID == "codex" { + patterns = expandSubagentModelPatternsWith(agent.Model, data, provider, func(model string) string { + return stripSubagentProviderPrefix(model, provider) + }) patterns = providerScopedSubagentPatterns(agent.Model, patterns, provider) + } else { + patterns = expandSubagentModelPatterns(agent.Model, data, provider) } declarations = append(declarations, map[string]any{ "name": agent.Name, "model": model, @@ -316,6 +321,17 @@ func claudeCodexSubagentProvider(data *WorkflowData, engineID string) string { return provider } +// stripSubagentProviderPrefix removes a `/` prefix that names the engine's provider, +// as the runtime does before alias lookup, so `anthropic/fast` expands the `fast` alias. +// Another provider's prefix is kept; its patterns are then dropped by provider scoping. +func stripSubagentProviderPrefix(model, provider string) string { + prefix, rest, ok := strings.Cut(strings.TrimSpace(model), "/") + if !ok || prefix == "" || rest == "" || strings.Contains(rest, "/") || normalizeLLMProvider(prefix) != normalizeLLMProvider(provider) { + return model + } + return rest +} + // providerScopedSubagentPatterns keeps only the patterns of the engine's provider. Claude's // `inherit` is an inheritance instruction, not a model, so it declares no patterns. func providerScopedSubagentPatterns(model string, patterns []string, provider string) []string { diff --git a/pkg/workflow/model_expression_patterns.go b/pkg/workflow/model_expression_patterns.go index 4c492a1bcab..79bd5fe0418 100644 --- a/pkg/workflow/model_expression_patterns.go +++ b/pkg/workflow/model_expression_patterns.go @@ -10,16 +10,22 @@ import ( // audit metadata and request admission. These are possible models, not proof that // the selected variant was honored. func expandSubagentModelPatterns(request string, data *WorkflowData, provider string) []string { + return expandSubagentModelPatternsWith(request, data, provider, func(model string) string { return model }) +} + +// expandSubagentModelPatternsWith expands like expandSubagentModelPatterns after applying +// normalize to the literal request or to each experiment candidate. +func expandSubagentModelPatternsWith(request string, data *WorkflowData, provider string, normalize func(string) string) []string { model, _, _ := strings.Cut(request, "?") if !isExpression(model) { - return expandModelPatterns(request, data.ModelMappings, provider) + return expandModelPatterns(normalize(request), data.ModelMappings, provider) } value := stripExpressionWrapper(model) var patterns []string for _, prefix := range []string{"experiments.", activationOutputsPrefix, pickExperimentOutputsPrefix} { if name, ok := strings.CutPrefix(value, prefix); ok { for _, candidate := range data.Experiments[name] { - for _, pattern := range expandModelPatterns(candidate, data.ModelMappings, provider) { + for _, pattern := range expandModelPatterns(normalize(candidate), data.ModelMappings, provider) { if !slices.Contains(patterns, pattern) { patterns = append(patterns, pattern) }