From 3cfa81d7c7ad5e1f67b9a025abf744050d1a8484 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 11 Sep 2026 05:54:48 +0000
Subject: [PATCH 1/3] Initial plan
From ad66d55b1e98bb398bba771b093edb6d242e2db5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 11 Sep 2026 06:10:00 +0000
Subject: [PATCH 2/3] Stage Jira smoke issue creation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/smoke-issues.lock.yml | 40 +-
.github/workflows/smoke-issues.md | 14 +-
.../gh-aw_pydantic.md | 575 ++++++++++++++++++
3 files changed, 588 insertions(+), 41 deletions(-)
create mode 100644 pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md
diff --git a/.github/workflows/smoke-issues.lock.yml b/.github/workflows/smoke-issues.lock.yml
index e31d0e603ee..bc40f4cdd48 100644
--- a/.github/workflows/smoke-issues.lock.yml
+++ b/.github/workflows/smoke-issues.lock.yml
@@ -1,5 +1,5 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0a110a0c6347d3c3079b13fd2be8be89fe94333cd2ec8c1d76f557d360b845ad","body_hash":"8b1f00fc00aca4ac89a72c3817ea763c5f153ba05378410608a30060a2f1caed","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.83"}}
-# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","JIRA_API_TOKEN","JIRA_USER_EMAIL","LINEAR_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.15","digest":"sha256:9f13ae19ceac89554ad7b2d07b68135f40664721f811cb4df05b0eb372af3a5d","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.15@sha256:9f13ae19ceac89554ad7b2d07b68135f40664721f811cb4df05b0eb372af3a5d"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.15","digest":"sha256:0410a07cd39dfd086ad2f4f1a22c36fff293696a4675c7e2e46a90754131122c","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.15@sha256:0410a07cd39dfd086ad2f4f1a22c36fff293696a4675c7e2e46a90754131122c"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.15","digest":"sha256:0006cecbfcc0363afb00a306e30e8991b02cc1a52b77be2f6616000c01cb9161","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.15@sha256:0006cecbfcc0363afb00a306e30e8991b02cc1a52b77be2f6616000c01cb9161"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.20","digest":"sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0daa8971fa4732b647150cb6524a6b0804b68d5d24f6f58b5dd1af23bd63fb23","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0daa8971fa4732b647150cb6524a6b0804b68d5d24f6f58b5dd1af23bd63fb23"},{"image":"ghcr.io/github/github-mcp-server:v1.12.1","digest":"sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560","pinned_image":"ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"}],"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":["jira_create_issue","linear_create_issue","missing_data","missing_tool","noop"]}]}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"5929b5dc1b4ce60ad7b0eca8a6b4939e25ac557b42a67e7f4ac0865e272e6366","body_hash":"7b19b6cb9c9d3182e3cded9380e582276bac1ea29e13ccdfc3cc8f548fe11188","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.83"}}
+# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","JIRA_API_TOKEN","JIRA_USER_EMAIL"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.15","digest":"sha256:9f13ae19ceac89554ad7b2d07b68135f40664721f811cb4df05b0eb372af3a5d","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.15@sha256:9f13ae19ceac89554ad7b2d07b68135f40664721f811cb4df05b0eb372af3a5d"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.15","digest":"sha256:0410a07cd39dfd086ad2f4f1a22c36fff293696a4675c7e2e46a90754131122c","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.15@sha256:0410a07cd39dfd086ad2f4f1a22c36fff293696a4675c7e2e46a90754131122c"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.15","digest":"sha256:0006cecbfcc0363afb00a306e30e8991b02cc1a52b77be2f6616000c01cb9161","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.15@sha256:0006cecbfcc0363afb00a306e30e8991b02cc1a52b77be2f6616000c01cb9161"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.20","digest":"sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0daa8971fa4732b647150cb6524a6b0804b68d5d24f6f58b5dd1af23bd63fb23","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0daa8971fa4732b647150cb6524a6b0804b68d5d24f6f58b5dd1af23bd63fb23"},{"image":"ghcr.io/github/github-mcp-server:v1.12.1","digest":"sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560","pinned_image":"ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"}],"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":["jira_create_issue","missing_data","missing_tool","noop"]}]}
# 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
#
# ___ _ _
@@ -23,7 +23,7 @@
#
# For more information: https://github.github.com/gh-aw/introduction/overview/
#
-# Create a daily haiku issue in Linear and Jira through safe outputs
+# Create a daily haiku issue in Jira through safe outputs
#
# Secrets used:
# - COPILOT_GITHUB_TOKEN
@@ -33,7 +33,6 @@
# - GITHUB_TOKEN
# - JIRA_API_TOKEN
# - JIRA_USER_EMAIL
-# - LINEAR_API_KEY
#
# Custom actions used:
# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -273,7 +272,7 @@ jobs:
GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
GH_AW_PROMPT_CONTENT_0000: "\n"
- GH_AW_PROMPT_CONTENT_0001: "\nTools: linear_create_issue, jira_create_issue, missing_tool, missing_data, noop\n"
+ GH_AW_PROMPT_CONTENT_0001: "\nTools: jira_create_issue, missing_tool, missing_data, noop\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_802A9F6A__\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_1A3A194A__\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_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\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"
@@ -545,7 +544,7 @@ jobs:
env:
GH_AW_FILE_ROOT: "${{ runner.temp }}/gh-aw"
GH_AW_FILE_CONFIG: "{\"files\":[{\"path\":\"safeoutputs/config.json\",\"content_env\":\"GH_AW_SAFE_OUTPUTS_CONFIG\"}]}"
- GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_report_incomplete_issue\":{},\"jira_create_issue\":{\"max\":1},\"linear_create_issue\":{\"max\":1,\"project_id\":\"810f57a7e383\",\"team_id\":\"${{ vars.LINEAR_TEAM_ID }}\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
+ GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_report_incomplete_issue\":{},\"jira_create_issue\":{\"max\":1,\"staged\":true},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
with:
script: |
const path = require('path');
@@ -600,26 +599,6 @@ jobs:
}
}
},
- "linear_create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000,
- "minLength": 20,
- "rejectIfOversized": true
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128,
- "rejectIfOversized": true
- }
- }
- },
"missing_data": {
"defaultMax": 20,
"fields": {
@@ -1528,7 +1507,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
WORKFLOW_NAME: "Smoke Issues"
- WORKFLOW_DESCRIPTION: "Create a daily haiku issue in Linear and Jira through safe outputs"
+ WORKFLOW_DESCRIPTION: "Create a daily haiku issue in Jira through safe outputs"
HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
GH_AW_DETECTION_CONTINUE_ON_ERROR: "true"
GH_AW_DETECTION_SKIP_PROMPT_SUMMARY: "true"
@@ -1596,7 +1575,7 @@ jobs:
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) || '' }}
WORKFLOW_NAME: "Smoke Issues"
- WORKFLOW_DESCRIPTION: "Create a daily haiku issue in Linear and Jira through safe outputs"
+ WORKFLOW_DESCRIPTION: "Create a daily haiku issue in Jira through safe outputs"
HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
GH_AW_DETECTION_CONTINUE_ON_ERROR: "true"
run: |
@@ -1795,15 +1774,12 @@ jobs:
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_BASE_URL: https://pelidehalleux.atlassian.net
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
- GH_AW_LINEAR_TOKEN: ${{ secrets.LINEAR_API_KEY }}
- LINEAR_PROJECT_ID: ${{ vars.LINEAR_PROJECT_ID }}
- LINEAR_TEAM_ID: ${{ vars.LINEAR_TEAM_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_ALLOWED_DOMAINS: "api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_report_incomplete_issue\":{},\"jira_create_issue\":{\"max\":1},\"linear_create_issue\":{\"max\":1,\"project_id\":\"810f57a7e383\",\"team_id\":\"${{ vars.LINEAR_TEAM_ID }}\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
+ GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_report_incomplete_issue\":{},\"jira_create_issue\":{\"max\":1,\"staged\":true},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
diff --git a/.github/workflows/smoke-issues.md b/.github/workflows/smoke-issues.md
index 8c04f524c8d..330e292ff83 100644
--- a/.github/workflows/smoke-issues.md
+++ b/.github/workflows/smoke-issues.md
@@ -2,7 +2,7 @@
private: true
emoji: "๐งช"
name: Smoke Issues
-description: Create a daily haiku issue in Linear and Jira through safe outputs
+description: Create a daily haiku issue in Jira through safe outputs
on:
schedule: daily
workflow_dispatch:
@@ -11,12 +11,9 @@ permissions:
actions: read
engine: copilot
safe-outputs:
- linear-create-issue:
- team-id: ${{ vars.LINEAR_TEAM_ID }}
- project-id: "810f57a7e383"
- max: 1
jira-create-issue:
max: 1
+ staged: true
timeout-minutes: 5
---
@@ -24,12 +21,11 @@ timeout-minutes: 5
Generate one original haiku about code, automation, or workflows using a 5-7-5 syllable pattern.
-Create exactly two issues containing the same haiku and the workflow run URL:
+Create exactly one issue containing the haiku and the workflow run URL:
-1. Use `linear_create_issue` to create one issue in the configured Linear project.
-2. Use `jira_create_issue` to create one `Task` in Jira project `KAN`.
+Use `jira_create_issue` to create one `Task` in Jira project `KAN`.
-Use `Smoke Issues โ ${{ github.run_id }}` as both issue titles. Include this run URL in both issue bodies:
+Use `Smoke Issues โ ${{ github.run_id }}` as the issue title. Include this run URL in the issue body:
`${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`.
Do not create GitHub issues or use any other write tools.
diff --git a/pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md b/pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md
new file mode 100644
index 00000000000..2438433bbff
--- /dev/null
+++ b/pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md
@@ -0,0 +1,575 @@
+---
+runtimes:
+ python:
+ version: "3.12"
+pre-agent-steps:
+ - name: Preinstall Pydantic AI coder agent
+ run: |
+ # This step runs on the host runner with the checkout as its working
+ # directory, before the AWF sandbox exists. -P keeps that directory off
+ # sys.path, so a repo-local pip.py or pydantic_ai_harness/ cannot be
+ # imported in place of the installed packages.
+ #
+ # 2.36.0 is the first pydantic-ai-slim release carrying `pai --mcp-config`,
+ # which is how the gateway's MCP servers reach the agent.
+ #
+ # The anthropic extra is what an `anthropic/` model runs on: that backend of
+ # the api-proxy serves the Messages API, not Chat Completions.
+ python3 -P -m pip install --quiet --user --disable-pip-version-check "pydantic-ai-harness[cli]==$GH_AW_ENGINE_VERSION" "pydantic-ai-slim[anthropic,openai,mcp]>=2.36.0"
+ "$HOME/.local/bin/pai" --version
+ python3 -P -c "from pydantic_ai_harness import Coder"
+engine:
+ id: pydantic-ai
+ version: "0.26.0"
+ display-name: Pydantic AI
+ description: Pydantic AI CLI (pai) running the pydantic-ai-harness coder agent with MCP tool support
+ experimental: true
+ mcp: true
+ provider:
+ name: github
+ behaviors:
+ secret-strategy: universal-llm-consumer
+ # Repository paths gh-aw treats as this engine's configuration: it protects them
+ # from pull-request modification and derives the inline sub-agent and skill
+ # directories from the first prefix (.pydantic-ai/agents, .pydantic-ai/skills).
+ # The engine itself writes nothing into the checkout.
+ manifest:
+ files:
+ - AGENTS.md
+ path-prefixes:
+ - .pydantic-ai/
+ network:
+ defaults:
+ - host.docker.internal
+ - github.com
+ - raw.githubusercontent.com
+ - api.github.com
+ - objects.githubusercontent.com
+ - pypi.org
+ - files.pythonhosted.org
+ provider-domains:
+ copilot: api.githubcopilot.com
+ anthropic: api.anthropic.com
+ openai: api.openai.com
+ codex: api.openai.com
+ execution:
+ command-name: pai
+ step-name: Execute Pydantic AI CLI
+ model-env-var: PAI_MODEL
+ write-timestamp: true
+ provider-env-mode: universal-llm-consumer
+ harness-script: |
+ const { spawnSync } = require("child_process");
+ const { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } = require("fs");
+ const { homedir, tmpdir } = require("os");
+ const { join } = require("path");
+ const { fetchAWFReflect, resolveProviderEndpointFromReflect, deriveBaseUrlFromModelsURL } = require("./awf_reflect.cjs");
+
+ // gh-aw passes `execution.command-name` (or a workflow's `engine.command`)
+ // first, then `execution.args`. The name is not spawned -- the CLI is started
+ // by the interpreter that owns the install, see LAUNCHER below -- so only the
+ // arguments after it are forwarded.
+ const commandArgs = process.argv.slice(3);
+ const log = message => process.stderr.write(`[pydantic-ai] ${message}\n`);
+
+ // `pai -a` takes one target, either an import path or a JSON/YAML agent
+ // spec, and the spec format resolves capability names through a closed
+ // registry that the harness capabilities are not part of, so the coder
+ // composition cannot be expressed as a spec. It is written as a Python
+ // module instead: `Coder()` supplies the filesystem, shell, planning and
+ // sub-agent tools.
+ //
+ // The gateway's MCP servers are deliberately not part of the module.
+ // `pai --mcp-config` reads the same Claude-shaped config file through the
+ // same `pydantic_ai.mcp.load_mcp_toolsets`, `${VAR}` expansion included, so
+ // routing them through the CLI is what lets a `PAI_AGENT` agent receive
+ // them on identical terms.
+ const AGENT_MODULE = `from pydantic_ai import Agent
+ from pydantic_ai_harness import Coder
+
+ agent = Agent(name="coder", capabilities=[Coder()])
+ `;
+ const DEFAULT_AGENT = "gh_aw_agent:agent";
+
+ // The CLI runs inside the interpreter that owns the install rather than as a
+ // separate `pai` process, so the agent module is imported once, in the process
+ // that runs it. Three things follow from that.
+ //
+ // `pai` reduces any failed `-a` load to one line naming the target, so an
+ // agent that raises on import would reach the step log without its traceback.
+ // The import here happens before the CLI starts, and an unhandled exception is
+ // the step's failure, traceback included.
+ //
+ // `pydantic_ai._cli.load_agent` prepends the working directory -- the checkout
+ // -- to sys.path before resolving the target, ahead of PYTHONPATH. A
+ // repository file named `gh_aw_agent.py` would therefore be loaded in place of
+ // the generated module. Importing the target first settles which file the name
+ // means, because an import of a module already in sys.modules does not search
+ // the path again.
+ //
+ // A separate preflight process could do neither: it would import the module in
+ // one interpreter and leave the CLI to import it again in another, running any
+ // module-level work in the agent twice.
+ //
+ // The residual is `load_agent`'s insert itself: everything the agent imports
+ // after that point still sees the checkout first on sys.path. That is the
+ // CLI's documented behavior for its own users, and not something this file
+ // can change from the outside.
+ //
+ // `-P` keeps the `-c` invocation from putting the working directory on
+ // sys.path on its own account; PYTHONPATH below is what makes the agent
+ // importable. A spec file, and the dotted `module.attribute` form the CLI also
+ // accepts, are left to the CLI as before.
+ const LAUNCHER = `import runpy
+ import sys
+
+ target, *cli_args = sys.argv[1:]
+ module, separator, attribute = target.rpartition(":")
+ if separator and not target.lower().endswith((".yml", ".yaml", ".json")):
+ import importlib
+
+ from pydantic_ai import Agent
+
+ loaded = getattr(importlib.import_module(module), attribute)
+ if not isinstance(loaded, Agent):
+ raise TypeError(f"{target} is {type(loaded).__name__}, not pydantic_ai.Agent")
+
+ sys.argv = ["pai", *cli_args]
+ runpy.run_module("pydantic_ai", run_name="__main__", alter_sys=True)
+ `;
+
+ const main = async () => {
+ const workspace = process.env.GITHUB_WORKSPACE;
+ if (!workspace) throw new Error("GITHUB_WORKSPACE is required");
+ const promptFile = process.env.GH_AW_PROMPT;
+ if (!promptFile) throw new Error("GH_AW_PROMPT is required");
+
+ // Neither the generated module nor the gateway's MCP config is written into
+ // the checkout. A file committed at a path the engine reads is
+ // repository-controlled input to a process that runs with the gateway's
+ // credentials: an `mcp.json` there can name a stdio server for the CLI to
+ // spawn, and a package there shadows an installed one for the whole run. The
+ // module goes to a private directory created inside the sandbox; the config
+ // adapter writes on the host into the `${RUNNER_TEMP}/gh-aw` tree that the
+ // agent step mounts read-only, where gh-aw's own Claude and Codex converters
+ // write theirs.
+ //
+ // `PAI_AGENT` runs an agent the repository defines, in whichever form
+ // `pai -a` accepts. The generated module is not written in that case:
+ // nothing would load it, and a stale copy on disk is worse than none.
+ const configuredAgent = process.env.PAI_AGENT;
+ const agentTarget = configuredAgent || DEFAULT_AGENT;
+ const moduleDir = configuredAgent ? "" : mkdtempSync(join(tmpdir(), "gh-aw-pydantic-ai-"));
+ if (moduleDir) {
+ const agentModulePath = join(moduleDir, "gh_aw_agent.py");
+ writeFileSync(agentModulePath, AGENT_MODULE, { mode: 0o600 });
+ chmodSync(agentModulePath, 0o600);
+ }
+
+ const env = { ...process.env };
+ // `pip install --user` puts `pai` here. The runner tool cache that holds
+ // `uv` and the interpreter's own bin directory is under /opt, which the
+ // sandbox exposes read-only, but the home directory is where the CLI and
+ // its user site-packages actually live.
+ //
+ // Which interpreter owns those user site-packages matters: only the one
+ // that ran the pre-agent `pip install --user` can import them, and the
+ // sandbox prelude prepends every `bin` directory under the runner tool
+ // cache โ which caches several Python versions โ so a bare `python3`
+ // there resolves by `find` order rather than to the installing
+ // interpreter. `actions/setup-python` names that one in `pythonLocation`;
+ // putting its `bin` on PATH also gives the agent's own shell tool a
+ // `python3` that can see the installed packages.
+ const pythonBin = process.env.pythonLocation ? join(process.env.pythonLocation, "bin") : "";
+ const python = pythonBin ? join(pythonBin, "python3") : "python3";
+ env.PATH = [join(homedir(), ".local", "bin"), pythonBin, process.env.PATH || ""].filter(Boolean).join(":");
+ // The module is reached through PYTHONPATH rather than by importing it as a
+ // package, and prepending keeps a caller-supplied PYTHONPATH usable.
+ //
+ // The checkout itself joins the path only under `PAI_AGENT`. That is the
+ // opt-in: it makes repository code importable, which is the whole point
+ // of running your own agent, and it is exactly what `-P` on the install
+ // step keeps off the path for the default composition.
+ env.PYTHONPATH = [moduleDir, configuredAgent ? workspace : "", process.env.PYTHONPATH || ""].filter(Boolean).join(":");
+ delete env.COPILOT_GITHUB_TOKEN;
+
+ const provider = process.env.GH_AW_LLM_PROVIDER;
+ const configuredBaseUrl = process.env.PAI_BASE_URL;
+
+ // `pai` sends the model name verbatim, minus the provider marker that
+ // selects one of its clients, so the bare model ID reaches the api-proxy โ
+ // which steers to the configured provider by the port it is reached on, not
+ // by a prefix in the model name: Copilot rejects `copilot/` with
+ // `model_not_supported`.
+ // Only the first segment is the provider. Stripping greedily would eat an
+ // org namespace out of ids like `meta-llama/Llama-3.1`, so this mirrors the
+ // `SplitN(model, "/", 2)` gh-aw itself uses to read the provider off.
+ if (!env.PAI_MODEL) throw new Error("PAI_MODEL is required");
+ const modelProvider = env.PAI_MODEL.split("/", 1)[0].trim().toLowerCase();
+ const requestedModel = env.PAI_MODEL.replace(/^[^/]*\//, "");
+ // The api-proxy's Anthropic backend forwards the request path to
+ // api.anthropic.com unchanged and rewrites Messages-shaped bodies; it does
+ // not translate Chat Completions into Messages. So `anthropic/` is addressed
+ // with the Messages API: `anthropic:` on `-m`, and ANTHROPIC_BASE_URL for
+ // the endpoint. The Copilot and Codex backends are OpenAI-shaped and stay on
+ // Chat Completions, and `PAI_BASE_URL` names a Chat Completions endpoint by
+ // definition, so it keeps every provider there too.
+ const useMessagesAPI = !configuredBaseUrl && modelProvider === "anthropic";
+ // The dotted-alias rewrite describes the api-proxy's Copilot backend,
+ // which publishes Copilot's Claude models under dotted IDs. Every other
+ // destination โ the anthropic and openai backends, or an endpoint named
+ // by PAI_BASE_URL โ gets the id the workflow wrote: a model actually
+ // called `claude-sonnet-4-5` there has to arrive as that.
+ const model = !configuredBaseUrl && modelProvider === "copilot"
+ ? requestedModel.replace(/^(claude-(?:haiku|sonnet|opus)-\d+)-(\d+)$/, "$1.$2")
+ : requestedModel;
+
+ // `PAI_BASE_URL` points the engine at an OpenAI-compatible endpoint of the
+ // workflow's choosing instead of the AWF api-proxy. Two constraints shape
+ // it.
+ //
+ // It has to be a variable of this definition's own, because AWF sets the
+ // backend's own base URL variable on this step itself (OPENAI_BASE_URL, or
+ // ANTHROPIC_BASE_URL for the anthropic backend), pointing at the api-proxy
+ // on host.docker.internal whenever the firewall is enabled, so its presence
+ // cannot carry the workflow's intent, and reading it as intent is what
+ // made the pre-#52843 definition pick the wrong endpoint.
+ //
+ // There is deliberately no matching key knob. gh-aw excludes any
+ // `engine.env` value holding a secret from the agent sandbox
+ // (`awf --exclude-env`), so a credential cannot be delivered here at all
+ // and the API key below stays the placeholder. The endpoint therefore
+ // has to accept that placeholder, or be fronted by something upstream of
+ // the agent that adds the real credential.
+ let baseUrl = configuredBaseUrl || (useMessagesAPI ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL);
+ if (!configuredBaseUrl) {
+ // Only /reflect discovery needs the provider: it selects which of the
+ // api-proxy's configured endpoints to use. A caller-supplied base URL
+ // names the endpoint outright, so demanding a provider alongside it
+ // would reject a complete configuration.
+ if (!provider) throw new Error("GH_AW_LLM_PROVIDER is required");
+ if (process.env.AWF_REFLECT_ENABLED === "1") {
+ const result = await fetchAWFReflect({ logger: log });
+ if (!result.ok || !result.reflectData) {
+ throw new Error(`Unable to discover the Pydantic AI LLM endpoint from /reflect: ${result.reason || "empty response"}`);
+ }
+ const endpoint = resolveProviderEndpointFromReflect({
+ provider,
+ reflectData: result.reflectData,
+ logger: log,
+ });
+ if (!endpoint?.baseUrl) {
+ throw new Error(`No configured /reflect endpoint found for provider ${provider}`);
+ }
+ baseUrl = endpoint.baseUrl;
+ const reflectedEndpoint = result.reflectData.endpoints?.find(
+ entry => entry?.configured === true && entry.provider === endpoint.endpointProvider
+ );
+ if (!useMessagesAPI && typeof reflectedEndpoint?.models_url === "string") {
+ // `endpoint.baseUrl` is the models-listing origin, while the
+ // OpenAI-compatible client posts to `/chat/completions`, so the
+ // path prefix carried by models_url (`/v1` on some providers) has to
+ // come along โ and this helper applies the same api-proxy ->
+ // host.docker.internal rewrite.
+ //
+ // The Anthropic client keeps the origin instead: it appends
+ // `/v1/messages` itself, so carrying the prefix over would post to
+ // `/v1/v1/messages`.
+ baseUrl = deriveBaseUrlFromModelsURL(reflectedEndpoint.models_url);
+ }
+ }
+ }
+ if (!baseUrl) {
+ throw new Error(
+ `Pydantic AI requires AWF endpoint discovery, PAI_BASE_URL or ${useMessagesAPI ? "ANTHROPIC_BASE_URL" : "OPENAI_BASE_URL"}`
+ );
+ }
+ // The AWF api-proxy injects the real upstream credentials and ignores the
+ // inbound key, but neither client constructs itself without one. Setting it
+ // also replaces whatever key this step inherited, so the agent process holds
+ // the placeholder rather than a provider credential.
+ if (useMessagesAPI) {
+ env.ANTHROPIC_BASE_URL = baseUrl;
+ env.ANTHROPIC_API_KEY = "awf-anthropic-proxy";
+ } else {
+ env.OPENAI_BASE_URL = baseUrl;
+ env.OPENAI_API_KEY = "awf-copilot-proxy";
+ }
+
+ // `-m` is always passed: the composed agent carries no model, and without
+ // the flag `pai` silently falls back to its own `openai:gpt-5` default,
+ // billing a model the workflow never asked for. gh-aw validates
+ // `provider/model` at compile time, so PAI_MODEL is set for every compiled
+ // workflow, and the throw above covers any other invocation.
+ //
+ // An explicit `-m` also replaces the model a loaded agent declares, so a
+ // `PAI_AGENT` agent runs on the workflow's `engine.model` whatever it was
+ // constructed with. That is what routes it through the endpoint above.
+ const cliArgs = [...commandArgs, "-a", agentTarget];
+ // The config adapter writes this file only for a workflow that configures
+ // MCP tools, and `--mcp-config` fails on a path that is not there, so its
+ // absence has to mean "no servers" rather than an error. The
+ // `RUNNER_TEMP || "/tmp"` fallback is the one gh-aw's own converters use, and
+ // the adapter resolves this path by the same expression.
+ const mcpConfig = join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "mcp-config", "mcp-servers.json");
+ if (existsSync(mcpConfig)) cliArgs.push("--mcp-config", mcpConfig);
+ cliArgs.push("-m", `${useMessagesAPI ? "anthropic" : "openai-chat"}:${model}`, readFileSync(promptFile, "utf8"));
+ log(
+ `provider=${configuredBaseUrl ? "(PAI_BASE_URL)" : provider} model=${model} baseUrl=${baseUrl}` +
+ (configuredAgent ? ` agent=${configuredAgent}` : "")
+ );
+ // The target is passed twice on purpose: once for LAUNCHER, which imports it
+ // and hands the CLI a module already in sys.modules, and once as the `-a`
+ // the CLI parses for itself.
+ const result = spawnSync(python, ["-P", "-c", LAUNCHER, agentTarget, ...cliArgs], { cwd: workspace, env, stdio: "inherit" });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ const error = new Error(`Pydantic AI execution failed with exit code ${result.status ?? "unknown"}`);
+ // Surface the child's own status so the step fails with the same code.
+ error.exitCode = typeof result.status === "number" && result.status !== 0 ? result.status : 1;
+ throw error;
+ }
+ };
+
+ main().catch(error => {
+ log(error instanceof Error ? error.message : String(error));
+ process.exitCode = typeof error?.exitCode === "number" && error.exitCode !== 0 ? error.exitCode : 1;
+ });
+ mcp:
+ config-path: ${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json
+ config-adapter: |
+ // Renders the MCP gateway's configuration as the Claude-style
+ // `mcpServers` document that `pydantic_ai.mcp.load_mcp_toolsets` reads,
+ // which the harness script hands to `pai --mcp-config`. Only HTTP entries
+ // are carried: `load_mcp_toolsets` can host stdio
+ // servers too, but the gateway already fronts every configured server
+ // over HTTP, and CLI-mounted servers are excluded because the agent
+ // reaches those as executables on PATH instead.
+ const fs = require("fs");
+ const path = require("path");
+
+ const requireEnvVar = name => {
+ const value = process.env[name];
+ if (!value) throw new Error(`${name} environment variable is required`);
+ return value;
+ };
+
+ const gatewayOutputPath = requireEnvVar("MCP_GATEWAY_OUTPUT");
+ const gatewayDomain = process.env.MCP_GATEWAY_DOMAIN || "host.docker.internal";
+ const gatewayPort = requireEnvVar("MCP_GATEWAY_PORT");
+ const gatewayURL = `http://${gatewayDomain}:${gatewayPort}`;
+
+ let cliServers;
+ try {
+ cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]"));
+ } catch (error) {
+ throw new Error(`Failed to parse GH_AW_MCP_CLI_SERVERS: ${error instanceof Error ? error.message : String(error)}`);
+ }
+
+ const gatewayOutput = JSON.parse(fs.readFileSync(gatewayOutputPath, "utf8"));
+ const rawServers = gatewayOutput.mcpServers;
+ const servers = rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {};
+
+ const mcpServers = {};
+ for (const [name, entry] of Object.entries(servers)) {
+ if (cliServers.has(name) || !entry || typeof entry !== "object") continue;
+ if (typeof entry.url !== "string") {
+ console.log(`Skipping MCP server ${name}: the Pydantic AI engine only supports HTTP MCP servers`);
+ continue;
+ }
+ const server = { url: entry.url.replace(/^http:\/\/[^/]+\/mcp\//, `${gatewayURL}/mcp/`) };
+ if (entry.headers && typeof entry.headers === "object") server.headers = entry.headers;
+ mcpServers[name] = server;
+ }
+
+ // This script runs on the host runner, in the Start MCP Gateway step, so it
+ // writes where that step already created a directory and where the agent step
+ // mounts `${RUNNER_TEMP}/gh-aw` read-only -- the same file the built-in Claude
+ // converter produces, which is also the path gh-aw's log redaction scans for
+ // the gateway bearer token. The harness script resolves it by the same
+ // expression. Keeping it out of the checkout is what stops a committed
+ // `mcp.json` from reaching `pai --mcp-config`; see the harness script.
+ const configPath = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "mcp-config", "mcp-servers.json");
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2), { mode: 0o600 });
+ fs.chmodSync(configPath, 0o600);
+ console.log(`Wrote ${Object.keys(mcpServers).length} MCP server(s) to ${configPath}`);
+ log-parser: |
+ function parseLog(logContent) {
+ const lines = logContent.split("\n");
+ const logEntries = [];
+ const mcpFailures = [];
+ let maxTurnsHit = false;
+ const AWF_INFRA_RE = /^\[(INFO|WARN|SUCCESS|ERROR|entrypoint|health-check|pydantic-ai)\]|^ (?:Container|Network|Volume) |^Process exiting with code:/;
+ let inputTokens = 0;
+ let outputTokens = 0;
+ let toolCallIndex = 0;
+ let turnCount = 0;
+ let pendingText = [];
+
+ function flushText() {
+ if (pendingText.length === 0) return;
+ const text = pendingText.join("\n").trim();
+ if (text) {
+ logEntries.push({ type: "assistant", message: { content: [{ type: "text", text }] } });
+ turnCount++;
+ }
+ pendingText = [];
+ }
+
+ logEntries.push({ type: "system", subtype: "init", model: null, session_id: null });
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ if (AWF_INFRA_RE.test(line)) continue;
+ if (/max.?turns|maximum.*turns.*reached|turn limit/i.test(line)) maxTurnsHit = true;
+ if (/MCP server .* failed|MCP.*connection.*error|Failed to connect to MCP/i.test(line)) {
+ const serverMatch = line.match(/MCP server ['"]?([^\s'"]+)['"]?/i);
+ mcpFailures.push(serverMatch ? serverMatch[1] : line.trim());
+ }
+
+ let parsed = null;
+ try {
+ if (line.trim().startsWith("{")) parsed = JSON.parse(line.trim());
+ } catch (e) { /* not JSON */ }
+
+ if (parsed) {
+ if (parsed.input_tokens) inputTokens += parsed.input_tokens;
+ if (parsed.output_tokens) outputTokens += parsed.output_tokens;
+ const entryType = parsed.type != null ? String(parsed.type) : "log";
+ const msg = parsed.msg || parsed.message || parsed.content || "";
+
+ if (/tool[._]call|tool[._]use/i.test(entryType)) {
+ flushText();
+ const toolId = `pai_tool_${toolCallIndex++}`;
+ const toolName = parsed.tool || parsed.name || entryType;
+ logEntries.push({ type: "assistant", message: { content: [{ type: "tool_use", id: toolId, name: toolName, input: {} }] } });
+ logEntries.push({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: toolId, content: msg }] } });
+ } else if (msg) {
+ pendingText.push(msg);
+ } else if (!parsed.input_tokens && !parsed.output_tokens) {
+ // A JSON line carrying none of the text fields is still assistant output --
+ // a reply that is bare JSON, say -- so it is kept as written. A usage record
+ // is not: its numbers were just added to the totals.
+ pendingText.push(line.trim());
+ }
+ } else {
+ pendingText.push(line.trim());
+ }
+ }
+ flushText();
+
+ const usage = {};
+ if (inputTokens) usage.input_tokens = inputTokens;
+ if (outputTokens) usage.output_tokens = outputTokens;
+ logEntries.push({ type: "result", num_turns: turnCount, usage });
+ const parts = [`**Turns:** ${turnCount}`, `**Tool calls:** ${toolCallIndex}`];
+ if (inputTokens || outputTokens) parts.push(`**Tokens:** ${((inputTokens ?? 0) + (outputTokens ?? 0)).toLocaleString()}`);
+ if (mcpFailures.length) parts.push(`**MCP failures:** ${mcpFailures.length}`);
+ if (maxTurnsHit) parts.push("**Max turns reached**");
+ return { markdown: parts.join(" ยท "), logEntries, mcpFailures, maxTurnsHit };
+ }
+---
+
+
From f917648b51e9bb69856db2c6962451b1d12a49e0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 11 Sep 2026 06:11:13 +0000
Subject: [PATCH 3/3] Remove generated import cache
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../gh-aw_pydantic.md | 575 ------------------
1 file changed, 575 deletions(-)
delete mode 100644 pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md
diff --git a/pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md b/pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md
deleted file mode 100644
index 2438433bbff..00000000000
--- a/pkg/workflow/.github/aw/imports/pydantic/pydantic-ai-harness/8e863b5b88c9e41e638f0dc416b8946135b584b7/gh-aw_pydantic.md
+++ /dev/null
@@ -1,575 +0,0 @@
----
-runtimes:
- python:
- version: "3.12"
-pre-agent-steps:
- - name: Preinstall Pydantic AI coder agent
- run: |
- # This step runs on the host runner with the checkout as its working
- # directory, before the AWF sandbox exists. -P keeps that directory off
- # sys.path, so a repo-local pip.py or pydantic_ai_harness/ cannot be
- # imported in place of the installed packages.
- #
- # 2.36.0 is the first pydantic-ai-slim release carrying `pai --mcp-config`,
- # which is how the gateway's MCP servers reach the agent.
- #
- # The anthropic extra is what an `anthropic/` model runs on: that backend of
- # the api-proxy serves the Messages API, not Chat Completions.
- python3 -P -m pip install --quiet --user --disable-pip-version-check "pydantic-ai-harness[cli]==$GH_AW_ENGINE_VERSION" "pydantic-ai-slim[anthropic,openai,mcp]>=2.36.0"
- "$HOME/.local/bin/pai" --version
- python3 -P -c "from pydantic_ai_harness import Coder"
-engine:
- id: pydantic-ai
- version: "0.26.0"
- display-name: Pydantic AI
- description: Pydantic AI CLI (pai) running the pydantic-ai-harness coder agent with MCP tool support
- experimental: true
- mcp: true
- provider:
- name: github
- behaviors:
- secret-strategy: universal-llm-consumer
- # Repository paths gh-aw treats as this engine's configuration: it protects them
- # from pull-request modification and derives the inline sub-agent and skill
- # directories from the first prefix (.pydantic-ai/agents, .pydantic-ai/skills).
- # The engine itself writes nothing into the checkout.
- manifest:
- files:
- - AGENTS.md
- path-prefixes:
- - .pydantic-ai/
- network:
- defaults:
- - host.docker.internal
- - github.com
- - raw.githubusercontent.com
- - api.github.com
- - objects.githubusercontent.com
- - pypi.org
- - files.pythonhosted.org
- provider-domains:
- copilot: api.githubcopilot.com
- anthropic: api.anthropic.com
- openai: api.openai.com
- codex: api.openai.com
- execution:
- command-name: pai
- step-name: Execute Pydantic AI CLI
- model-env-var: PAI_MODEL
- write-timestamp: true
- provider-env-mode: universal-llm-consumer
- harness-script: |
- const { spawnSync } = require("child_process");
- const { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } = require("fs");
- const { homedir, tmpdir } = require("os");
- const { join } = require("path");
- const { fetchAWFReflect, resolveProviderEndpointFromReflect, deriveBaseUrlFromModelsURL } = require("./awf_reflect.cjs");
-
- // gh-aw passes `execution.command-name` (or a workflow's `engine.command`)
- // first, then `execution.args`. The name is not spawned -- the CLI is started
- // by the interpreter that owns the install, see LAUNCHER below -- so only the
- // arguments after it are forwarded.
- const commandArgs = process.argv.slice(3);
- const log = message => process.stderr.write(`[pydantic-ai] ${message}\n`);
-
- // `pai -a` takes one target, either an import path or a JSON/YAML agent
- // spec, and the spec format resolves capability names through a closed
- // registry that the harness capabilities are not part of, so the coder
- // composition cannot be expressed as a spec. It is written as a Python
- // module instead: `Coder()` supplies the filesystem, shell, planning and
- // sub-agent tools.
- //
- // The gateway's MCP servers are deliberately not part of the module.
- // `pai --mcp-config` reads the same Claude-shaped config file through the
- // same `pydantic_ai.mcp.load_mcp_toolsets`, `${VAR}` expansion included, so
- // routing them through the CLI is what lets a `PAI_AGENT` agent receive
- // them on identical terms.
- const AGENT_MODULE = `from pydantic_ai import Agent
- from pydantic_ai_harness import Coder
-
- agent = Agent(name="coder", capabilities=[Coder()])
- `;
- const DEFAULT_AGENT = "gh_aw_agent:agent";
-
- // The CLI runs inside the interpreter that owns the install rather than as a
- // separate `pai` process, so the agent module is imported once, in the process
- // that runs it. Three things follow from that.
- //
- // `pai` reduces any failed `-a` load to one line naming the target, so an
- // agent that raises on import would reach the step log without its traceback.
- // The import here happens before the CLI starts, and an unhandled exception is
- // the step's failure, traceback included.
- //
- // `pydantic_ai._cli.load_agent` prepends the working directory -- the checkout
- // -- to sys.path before resolving the target, ahead of PYTHONPATH. A
- // repository file named `gh_aw_agent.py` would therefore be loaded in place of
- // the generated module. Importing the target first settles which file the name
- // means, because an import of a module already in sys.modules does not search
- // the path again.
- //
- // A separate preflight process could do neither: it would import the module in
- // one interpreter and leave the CLI to import it again in another, running any
- // module-level work in the agent twice.
- //
- // The residual is `load_agent`'s insert itself: everything the agent imports
- // after that point still sees the checkout first on sys.path. That is the
- // CLI's documented behavior for its own users, and not something this file
- // can change from the outside.
- //
- // `-P` keeps the `-c` invocation from putting the working directory on
- // sys.path on its own account; PYTHONPATH below is what makes the agent
- // importable. A spec file, and the dotted `module.attribute` form the CLI also
- // accepts, are left to the CLI as before.
- const LAUNCHER = `import runpy
- import sys
-
- target, *cli_args = sys.argv[1:]
- module, separator, attribute = target.rpartition(":")
- if separator and not target.lower().endswith((".yml", ".yaml", ".json")):
- import importlib
-
- from pydantic_ai import Agent
-
- loaded = getattr(importlib.import_module(module), attribute)
- if not isinstance(loaded, Agent):
- raise TypeError(f"{target} is {type(loaded).__name__}, not pydantic_ai.Agent")
-
- sys.argv = ["pai", *cli_args]
- runpy.run_module("pydantic_ai", run_name="__main__", alter_sys=True)
- `;
-
- const main = async () => {
- const workspace = process.env.GITHUB_WORKSPACE;
- if (!workspace) throw new Error("GITHUB_WORKSPACE is required");
- const promptFile = process.env.GH_AW_PROMPT;
- if (!promptFile) throw new Error("GH_AW_PROMPT is required");
-
- // Neither the generated module nor the gateway's MCP config is written into
- // the checkout. A file committed at a path the engine reads is
- // repository-controlled input to a process that runs with the gateway's
- // credentials: an `mcp.json` there can name a stdio server for the CLI to
- // spawn, and a package there shadows an installed one for the whole run. The
- // module goes to a private directory created inside the sandbox; the config
- // adapter writes on the host into the `${RUNNER_TEMP}/gh-aw` tree that the
- // agent step mounts read-only, where gh-aw's own Claude and Codex converters
- // write theirs.
- //
- // `PAI_AGENT` runs an agent the repository defines, in whichever form
- // `pai -a` accepts. The generated module is not written in that case:
- // nothing would load it, and a stale copy on disk is worse than none.
- const configuredAgent = process.env.PAI_AGENT;
- const agentTarget = configuredAgent || DEFAULT_AGENT;
- const moduleDir = configuredAgent ? "" : mkdtempSync(join(tmpdir(), "gh-aw-pydantic-ai-"));
- if (moduleDir) {
- const agentModulePath = join(moduleDir, "gh_aw_agent.py");
- writeFileSync(agentModulePath, AGENT_MODULE, { mode: 0o600 });
- chmodSync(agentModulePath, 0o600);
- }
-
- const env = { ...process.env };
- // `pip install --user` puts `pai` here. The runner tool cache that holds
- // `uv` and the interpreter's own bin directory is under /opt, which the
- // sandbox exposes read-only, but the home directory is where the CLI and
- // its user site-packages actually live.
- //
- // Which interpreter owns those user site-packages matters: only the one
- // that ran the pre-agent `pip install --user` can import them, and the
- // sandbox prelude prepends every `bin` directory under the runner tool
- // cache โ which caches several Python versions โ so a bare `python3`
- // there resolves by `find` order rather than to the installing
- // interpreter. `actions/setup-python` names that one in `pythonLocation`;
- // putting its `bin` on PATH also gives the agent's own shell tool a
- // `python3` that can see the installed packages.
- const pythonBin = process.env.pythonLocation ? join(process.env.pythonLocation, "bin") : "";
- const python = pythonBin ? join(pythonBin, "python3") : "python3";
- env.PATH = [join(homedir(), ".local", "bin"), pythonBin, process.env.PATH || ""].filter(Boolean).join(":");
- // The module is reached through PYTHONPATH rather than by importing it as a
- // package, and prepending keeps a caller-supplied PYTHONPATH usable.
- //
- // The checkout itself joins the path only under `PAI_AGENT`. That is the
- // opt-in: it makes repository code importable, which is the whole point
- // of running your own agent, and it is exactly what `-P` on the install
- // step keeps off the path for the default composition.
- env.PYTHONPATH = [moduleDir, configuredAgent ? workspace : "", process.env.PYTHONPATH || ""].filter(Boolean).join(":");
- delete env.COPILOT_GITHUB_TOKEN;
-
- const provider = process.env.GH_AW_LLM_PROVIDER;
- const configuredBaseUrl = process.env.PAI_BASE_URL;
-
- // `pai` sends the model name verbatim, minus the provider marker that
- // selects one of its clients, so the bare model ID reaches the api-proxy โ
- // which steers to the configured provider by the port it is reached on, not
- // by a prefix in the model name: Copilot rejects `copilot/` with
- // `model_not_supported`.
- // Only the first segment is the provider. Stripping greedily would eat an
- // org namespace out of ids like `meta-llama/Llama-3.1`, so this mirrors the
- // `SplitN(model, "/", 2)` gh-aw itself uses to read the provider off.
- if (!env.PAI_MODEL) throw new Error("PAI_MODEL is required");
- const modelProvider = env.PAI_MODEL.split("/", 1)[0].trim().toLowerCase();
- const requestedModel = env.PAI_MODEL.replace(/^[^/]*\//, "");
- // The api-proxy's Anthropic backend forwards the request path to
- // api.anthropic.com unchanged and rewrites Messages-shaped bodies; it does
- // not translate Chat Completions into Messages. So `anthropic/` is addressed
- // with the Messages API: `anthropic:` on `-m`, and ANTHROPIC_BASE_URL for
- // the endpoint. The Copilot and Codex backends are OpenAI-shaped and stay on
- // Chat Completions, and `PAI_BASE_URL` names a Chat Completions endpoint by
- // definition, so it keeps every provider there too.
- const useMessagesAPI = !configuredBaseUrl && modelProvider === "anthropic";
- // The dotted-alias rewrite describes the api-proxy's Copilot backend,
- // which publishes Copilot's Claude models under dotted IDs. Every other
- // destination โ the anthropic and openai backends, or an endpoint named
- // by PAI_BASE_URL โ gets the id the workflow wrote: a model actually
- // called `claude-sonnet-4-5` there has to arrive as that.
- const model = !configuredBaseUrl && modelProvider === "copilot"
- ? requestedModel.replace(/^(claude-(?:haiku|sonnet|opus)-\d+)-(\d+)$/, "$1.$2")
- : requestedModel;
-
- // `PAI_BASE_URL` points the engine at an OpenAI-compatible endpoint of the
- // workflow's choosing instead of the AWF api-proxy. Two constraints shape
- // it.
- //
- // It has to be a variable of this definition's own, because AWF sets the
- // backend's own base URL variable on this step itself (OPENAI_BASE_URL, or
- // ANTHROPIC_BASE_URL for the anthropic backend), pointing at the api-proxy
- // on host.docker.internal whenever the firewall is enabled, so its presence
- // cannot carry the workflow's intent, and reading it as intent is what
- // made the pre-#52843 definition pick the wrong endpoint.
- //
- // There is deliberately no matching key knob. gh-aw excludes any
- // `engine.env` value holding a secret from the agent sandbox
- // (`awf --exclude-env`), so a credential cannot be delivered here at all
- // and the API key below stays the placeholder. The endpoint therefore
- // has to accept that placeholder, or be fronted by something upstream of
- // the agent that adds the real credential.
- let baseUrl = configuredBaseUrl || (useMessagesAPI ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL);
- if (!configuredBaseUrl) {
- // Only /reflect discovery needs the provider: it selects which of the
- // api-proxy's configured endpoints to use. A caller-supplied base URL
- // names the endpoint outright, so demanding a provider alongside it
- // would reject a complete configuration.
- if (!provider) throw new Error("GH_AW_LLM_PROVIDER is required");
- if (process.env.AWF_REFLECT_ENABLED === "1") {
- const result = await fetchAWFReflect({ logger: log });
- if (!result.ok || !result.reflectData) {
- throw new Error(`Unable to discover the Pydantic AI LLM endpoint from /reflect: ${result.reason || "empty response"}`);
- }
- const endpoint = resolveProviderEndpointFromReflect({
- provider,
- reflectData: result.reflectData,
- logger: log,
- });
- if (!endpoint?.baseUrl) {
- throw new Error(`No configured /reflect endpoint found for provider ${provider}`);
- }
- baseUrl = endpoint.baseUrl;
- const reflectedEndpoint = result.reflectData.endpoints?.find(
- entry => entry?.configured === true && entry.provider === endpoint.endpointProvider
- );
- if (!useMessagesAPI && typeof reflectedEndpoint?.models_url === "string") {
- // `endpoint.baseUrl` is the models-listing origin, while the
- // OpenAI-compatible client posts to `/chat/completions`, so the
- // path prefix carried by models_url (`/v1` on some providers) has to
- // come along โ and this helper applies the same api-proxy ->
- // host.docker.internal rewrite.
- //
- // The Anthropic client keeps the origin instead: it appends
- // `/v1/messages` itself, so carrying the prefix over would post to
- // `/v1/v1/messages`.
- baseUrl = deriveBaseUrlFromModelsURL(reflectedEndpoint.models_url);
- }
- }
- }
- if (!baseUrl) {
- throw new Error(
- `Pydantic AI requires AWF endpoint discovery, PAI_BASE_URL or ${useMessagesAPI ? "ANTHROPIC_BASE_URL" : "OPENAI_BASE_URL"}`
- );
- }
- // The AWF api-proxy injects the real upstream credentials and ignores the
- // inbound key, but neither client constructs itself without one. Setting it
- // also replaces whatever key this step inherited, so the agent process holds
- // the placeholder rather than a provider credential.
- if (useMessagesAPI) {
- env.ANTHROPIC_BASE_URL = baseUrl;
- env.ANTHROPIC_API_KEY = "awf-anthropic-proxy";
- } else {
- env.OPENAI_BASE_URL = baseUrl;
- env.OPENAI_API_KEY = "awf-copilot-proxy";
- }
-
- // `-m` is always passed: the composed agent carries no model, and without
- // the flag `pai` silently falls back to its own `openai:gpt-5` default,
- // billing a model the workflow never asked for. gh-aw validates
- // `provider/model` at compile time, so PAI_MODEL is set for every compiled
- // workflow, and the throw above covers any other invocation.
- //
- // An explicit `-m` also replaces the model a loaded agent declares, so a
- // `PAI_AGENT` agent runs on the workflow's `engine.model` whatever it was
- // constructed with. That is what routes it through the endpoint above.
- const cliArgs = [...commandArgs, "-a", agentTarget];
- // The config adapter writes this file only for a workflow that configures
- // MCP tools, and `--mcp-config` fails on a path that is not there, so its
- // absence has to mean "no servers" rather than an error. The
- // `RUNNER_TEMP || "/tmp"` fallback is the one gh-aw's own converters use, and
- // the adapter resolves this path by the same expression.
- const mcpConfig = join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "mcp-config", "mcp-servers.json");
- if (existsSync(mcpConfig)) cliArgs.push("--mcp-config", mcpConfig);
- cliArgs.push("-m", `${useMessagesAPI ? "anthropic" : "openai-chat"}:${model}`, readFileSync(promptFile, "utf8"));
- log(
- `provider=${configuredBaseUrl ? "(PAI_BASE_URL)" : provider} model=${model} baseUrl=${baseUrl}` +
- (configuredAgent ? ` agent=${configuredAgent}` : "")
- );
- // The target is passed twice on purpose: once for LAUNCHER, which imports it
- // and hands the CLI a module already in sys.modules, and once as the `-a`
- // the CLI parses for itself.
- const result = spawnSync(python, ["-P", "-c", LAUNCHER, agentTarget, ...cliArgs], { cwd: workspace, env, stdio: "inherit" });
- if (result.error) throw result.error;
- if (result.status !== 0) {
- const error = new Error(`Pydantic AI execution failed with exit code ${result.status ?? "unknown"}`);
- // Surface the child's own status so the step fails with the same code.
- error.exitCode = typeof result.status === "number" && result.status !== 0 ? result.status : 1;
- throw error;
- }
- };
-
- main().catch(error => {
- log(error instanceof Error ? error.message : String(error));
- process.exitCode = typeof error?.exitCode === "number" && error.exitCode !== 0 ? error.exitCode : 1;
- });
- mcp:
- config-path: ${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json
- config-adapter: |
- // Renders the MCP gateway's configuration as the Claude-style
- // `mcpServers` document that `pydantic_ai.mcp.load_mcp_toolsets` reads,
- // which the harness script hands to `pai --mcp-config`. Only HTTP entries
- // are carried: `load_mcp_toolsets` can host stdio
- // servers too, but the gateway already fronts every configured server
- // over HTTP, and CLI-mounted servers are excluded because the agent
- // reaches those as executables on PATH instead.
- const fs = require("fs");
- const path = require("path");
-
- const requireEnvVar = name => {
- const value = process.env[name];
- if (!value) throw new Error(`${name} environment variable is required`);
- return value;
- };
-
- const gatewayOutputPath = requireEnvVar("MCP_GATEWAY_OUTPUT");
- const gatewayDomain = process.env.MCP_GATEWAY_DOMAIN || "host.docker.internal";
- const gatewayPort = requireEnvVar("MCP_GATEWAY_PORT");
- const gatewayURL = `http://${gatewayDomain}:${gatewayPort}`;
-
- let cliServers;
- try {
- cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]"));
- } catch (error) {
- throw new Error(`Failed to parse GH_AW_MCP_CLI_SERVERS: ${error instanceof Error ? error.message : String(error)}`);
- }
-
- const gatewayOutput = JSON.parse(fs.readFileSync(gatewayOutputPath, "utf8"));
- const rawServers = gatewayOutput.mcpServers;
- const servers = rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {};
-
- const mcpServers = {};
- for (const [name, entry] of Object.entries(servers)) {
- if (cliServers.has(name) || !entry || typeof entry !== "object") continue;
- if (typeof entry.url !== "string") {
- console.log(`Skipping MCP server ${name}: the Pydantic AI engine only supports HTTP MCP servers`);
- continue;
- }
- const server = { url: entry.url.replace(/^http:\/\/[^/]+\/mcp\//, `${gatewayURL}/mcp/`) };
- if (entry.headers && typeof entry.headers === "object") server.headers = entry.headers;
- mcpServers[name] = server;
- }
-
- // This script runs on the host runner, in the Start MCP Gateway step, so it
- // writes where that step already created a directory and where the agent step
- // mounts `${RUNNER_TEMP}/gh-aw` read-only -- the same file the built-in Claude
- // converter produces, which is also the path gh-aw's log redaction scans for
- // the gateway bearer token. The harness script resolves it by the same
- // expression. Keeping it out of the checkout is what stops a committed
- // `mcp.json` from reaching `pai --mcp-config`; see the harness script.
- const configPath = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "mcp-config", "mcp-servers.json");
- fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
- fs.writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2), { mode: 0o600 });
- fs.chmodSync(configPath, 0o600);
- console.log(`Wrote ${Object.keys(mcpServers).length} MCP server(s) to ${configPath}`);
- log-parser: |
- function parseLog(logContent) {
- const lines = logContent.split("\n");
- const logEntries = [];
- const mcpFailures = [];
- let maxTurnsHit = false;
- const AWF_INFRA_RE = /^\[(INFO|WARN|SUCCESS|ERROR|entrypoint|health-check|pydantic-ai)\]|^ (?:Container|Network|Volume) |^Process exiting with code:/;
- let inputTokens = 0;
- let outputTokens = 0;
- let toolCallIndex = 0;
- let turnCount = 0;
- let pendingText = [];
-
- function flushText() {
- if (pendingText.length === 0) return;
- const text = pendingText.join("\n").trim();
- if (text) {
- logEntries.push({ type: "assistant", message: { content: [{ type: "text", text }] } });
- turnCount++;
- }
- pendingText = [];
- }
-
- logEntries.push({ type: "system", subtype: "init", model: null, session_id: null });
-
- for (const line of lines) {
- if (!line.trim()) continue;
- if (AWF_INFRA_RE.test(line)) continue;
- if (/max.?turns|maximum.*turns.*reached|turn limit/i.test(line)) maxTurnsHit = true;
- if (/MCP server .* failed|MCP.*connection.*error|Failed to connect to MCP/i.test(line)) {
- const serverMatch = line.match(/MCP server ['"]?([^\s'"]+)['"]?/i);
- mcpFailures.push(serverMatch ? serverMatch[1] : line.trim());
- }
-
- let parsed = null;
- try {
- if (line.trim().startsWith("{")) parsed = JSON.parse(line.trim());
- } catch (e) { /* not JSON */ }
-
- if (parsed) {
- if (parsed.input_tokens) inputTokens += parsed.input_tokens;
- if (parsed.output_tokens) outputTokens += parsed.output_tokens;
- const entryType = parsed.type != null ? String(parsed.type) : "log";
- const msg = parsed.msg || parsed.message || parsed.content || "";
-
- if (/tool[._]call|tool[._]use/i.test(entryType)) {
- flushText();
- const toolId = `pai_tool_${toolCallIndex++}`;
- const toolName = parsed.tool || parsed.name || entryType;
- logEntries.push({ type: "assistant", message: { content: [{ type: "tool_use", id: toolId, name: toolName, input: {} }] } });
- logEntries.push({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: toolId, content: msg }] } });
- } else if (msg) {
- pendingText.push(msg);
- } else if (!parsed.input_tokens && !parsed.output_tokens) {
- // A JSON line carrying none of the text fields is still assistant output --
- // a reply that is bare JSON, say -- so it is kept as written. A usage record
- // is not: its numbers were just added to the totals.
- pendingText.push(line.trim());
- }
- } else {
- pendingText.push(line.trim());
- }
- }
- flushText();
-
- const usage = {};
- if (inputTokens) usage.input_tokens = inputTokens;
- if (outputTokens) usage.output_tokens = outputTokens;
- logEntries.push({ type: "result", num_turns: turnCount, usage });
- const parts = [`**Turns:** ${turnCount}`, `**Tool calls:** ${toolCallIndex}`];
- if (inputTokens || outputTokens) parts.push(`**Tokens:** ${((inputTokens ?? 0) + (outputTokens ?? 0)).toLocaleString()}`);
- if (mcpFailures.length) parts.push(`**MCP failures:** ${mcpFailures.length}`);
- if (maxTurnsHit) parts.push("**Max turns reached**");
- return { markdown: parts.join(" ยท "), logEntries, mcpFailures, maxTurnsHit };
- }
----
-
-