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 }; - } ---- - -