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.
diff --git a/.github/workflows/ab-testing-advisor.lock.yml b/.github/workflows/ab-testing-advisor.lock.yml
index 873c84e268f..9189c35a893 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 4452cdb74a6..4ad2a204abb 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 b2513ce533e..37bc85c1e8d 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 63081a71013..3bc213bed3e 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 87b447d208a..e929947ddf0 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 57ffd55063a..114e90a7e10 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 8a0d5e370fe..3d75d7dfc13 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 4a94bfbf8f6..b12b37470c3 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 cd16c5e290d..93d76f967fd 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 fd6c166aa58..41869c14d54 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 3323905eb94..1a09c756e06 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 629713db5a1..631c813a5de 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 61f4b52125d..f4201af6479 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..e3d0aff6996
--- /dev/null
+++ b/.github/workflows/smoke-claude-sub-agents.lock.yml
@@ -0,0 +1,1780 @@
+# 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
+#
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ 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\"},{\"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) }}
+ 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: 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"
+ 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: "461d3e3d6c351c6b191f7c4c540c2dadcb4c6fe176b625bf42f766982cb2ba61:d5a5dbbabd70166e4c251b716ef6c51d8f2ee70ab7e78904f9a0903714fff754"
+ 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: "{\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":2,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
+ 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": {},
+ "repo_params": {},
+ "dynamic_tools": []
+ }
+ GH_AW_VALIDATION_JSON: |
+ {
+ "missing_data": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "context": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "data_type": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ },
+ "reason": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ }
+ }
+ },
+ "missing_tool": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 512
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "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
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 1024
+ }
+ }
+ }
+ }
+ 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_3474fdb8118d2bfc_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@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",
+ "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_3474fdb8118d2bfc_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}`);"
+
+ # 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
+ 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
+ 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\",\"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,\"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 }}
+ 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:
+ - 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: 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()
+ 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: "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 }}
+ 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: "false"
+ 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: 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_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: 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()
+ 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' }}
+
+ 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: "461d3e3d6c351c6b191f7c4c540c2dadcb4c6fe176b625bf42f766982cb2ba61:d5a5dbbabd70166e4c251b716ef6c51d8f2ee70ab7e78904f9a0903714fff754"
+ 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:
+ - activation
+ - agent
+ if: (!cancelled()) && needs.agent.result != 'skipped'
+ runs-on: ubuntu-slim
+ # Permissions for the safe_outputs job (workflow permissions default to none).
+ permissions: {}
+ 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 }}
+ 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: "{\"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 }}
+ 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..7026903a694
--- /dev/null
+++ b/.github/workflows/smoke-claude-sub-agents.md
@@ -0,0 +1,126 @@
+---
+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
+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()
+ 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, 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`
+---
+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 aa82079f5f2..93791a20a88 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 dcde49dec89..fb1c10a39a7 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..76949b88d35 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,30 @@ 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)) {
+ 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"),
+ pluginDir,
+ 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);
+ }
+ }
+
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..da77dc8ced4 100644
--- a/actions/setup/js/claude_harness.test.cjs
+++ b/actions/setup/js/claude_harness.test.cjs
@@ -1555,3 +1555,226 @@ 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("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("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");
+ 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..a4129871ce3 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,27 @@ 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. Only a
+ // sub-agent using the reserved name `main` stops Codex. Threat detection uses no sub-agents.
+ if (!isDetectionPhase(process.env.GH_AW_PHASE)) {
+ const subagents = 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,
+ });
+ if (subagents && subagents.ok === false) {
+ process.exit(1);
+ return;
+ }
+ }
+
// 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..5d73c16c7e6 100644
--- a/actions/setup/js/codex_harness.test.cjs
+++ b/actions/setup/js/codex_harness.test.cjs
@@ -2347,3 +2347,84 @@ 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("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" } });
+
+ 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..2571a2dbd34
--- /dev/null
+++ b/actions/setup/js/engine_subagent_models.cjs
@@ -0,0 +1,536 @@
+// @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; 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");
+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");
+
+/** 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
+ * @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.
+ * 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, 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")) invalid(directory, 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) {
+ invalid(agentPath, 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 = () => {
+ // 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);
+ 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)) {
+ // 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;
+ }
+ }
+ 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 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
+ * 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,
+ * 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"));
+ /** @type {string[]} */
+ const invalidDeclared = [];
+ 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)}.`);
+ },
+ }).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: [] };
+ }
+ 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 = [];
+ 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 (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 });
+ }
+ 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 };
+
+ /** @type {Map} */
+ const resolvedDefinitions = new Map();
+ 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);
+ 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 });
+ 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 };
+ }
+ }
+ 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 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,
+ * refetchReflectData?: () => Promise,
+ * provider: string,
+ * agentsBaseDir?: string,
+ * aliasMap?: Record | null,
+ * env?: NodeJS.ProcessEnv,
+ * logger: (msg: string) => void,
+ * }} options
+ * @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;
+ 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 { 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.`
+ );
+ 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 { ok: true, 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..58c11b5bc96
--- /dev/null
+++ b/actions/setup/js/engine_subagent_models.test.cjs
@@ -0,0 +1,445 @@
+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 { 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")), "../../..");
+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("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");
+ 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("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');
+ });
+});
+
+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("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({ ok: true, 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..ad44970c965 100644
--- a/actions/setup/js/extract_inline_sub_agents.cjs
+++ b/actions/setup/js/extract_inline_sub_agents.cjs
@@ -131,6 +131,40 @@ 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` (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 { 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${nameEntry}\n---\n${content}`;
+ const frontmatter = match[1] ?? "";
+ const start = content.indexOf("\n") + 1;
+ /** @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);
+}
+
/**
* Extracts inline sub-agents from markdown content.
*
@@ -371,7 +405,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}`);
@@ -383,7 +420,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 (normalizedEngineId === "claude") filteredContent = ensureClaudeSubAgentName(agent.name, filteredContent);
const agentContent = filteredContent.endsWith("\n") ? filteredContent : filteredContent + "\n";
try {
fs.writeFileSync(agentPath, agentContent, "utf8");
@@ -461,6 +499,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..65836bae4dd 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,88 @@ 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");
+ });
+
+ 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.
+ 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/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..863fea588b6
--- /dev/null
+++ b/actions/setup/js/smoke_claude_sub_agents.test.cjs
@@ -0,0 +1,142 @@
+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");
+ });
+});
+
+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
new file mode 100644
index 00000000000..a2825ba2377
--- /dev/null
+++ b/pkg/workflow/claude_codex_subagent_declarations_test.go
@@ -0,0 +1,138 @@
+//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*"}}},
+ },
+ {
+ 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) {
+ 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..0fdc1c87b43 100644
--- a/pkg/workflow/compiler_yaml_step_lifecycle.go
+++ b/pkg/workflow/compiler_yaml_step_lifecycle.go
@@ -266,23 +266,35 @@ 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)
+ 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,
- "patterns": expandSubagentModelPatterns(agent.Model, data, provider),
+ "patterns": patterns,
})
}
encoded, err := json.Marshal(declarations)
@@ -292,6 +304,49 @@ 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
+ }
+ // 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) {
+ return "github-copilot"
+ }
+ 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 {
+ 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.
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)
}