diff --git a/.github/workflows/showcase_promote.yml b/.github/workflows/showcase_promote.yml index b39f7415aa4..d2f822fa696 100644 --- a/.github/workflows/showcase_promote.yml +++ b/.github/workflows/showcase_promote.yml @@ -272,6 +272,16 @@ jobs: environment: railway permissions: contents: read + outputs: + # Distinguishes a job that actually PROBED prod (`success`) from one that + # SKIPPED probing because nothing promoted (`skipped`). The GitHub job + # `result` is `success` in BOTH cases (the skip path exits 0), so the + # notify job must read THIS output — not `needs.verify-prod.result` — to + # avoid reporting a misleading `verify-prod=success` when prod was never + # touched. A real probe failure / contract violation exits non-zero, so + # the job `result` becomes `failure` and this output is never written + # (notify falls back to the job result for that case). + status: ${{ steps.verify.outputs.status }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -282,6 +292,7 @@ jobs: - working-directory: showcase/scripts run: npm ci - name: Run verify-deploy --env prod + id: verify working-directory: showcase/scripts env: RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} @@ -318,11 +329,20 @@ jobs: exit 1 fi echo "::notice::succeeded_csv is empty (no services promoted, or promote did not run); skipping prod verification. The run is red via the promote job result if anything failed." + # Record that prod was SKIPPED, not verified. The job still exits 0 + # (its `result` is `success`), so the notify job reads this `status` + # output to render `verify-prod=skipped` instead of a misleading + # `verify-prod=success`. + echo "status=skipped" >> "$GITHUB_OUTPUT" exit 0 fi # Run from showcase/scripts (where `npm ci` installed tsx) so npx uses - # the local install instead of network-fetching it. + # the local install instead of network-fetching it. A non-zero exit + # here fails the step (job `result` = failure) and `status` is never + # written, so notify falls back to the job result. npx tsx verify-deploy.ts --env prod --services "$SERVICES_CSV" + # Prod was actually probed and passed. + echo "status=success" >> "$GITHUB_OUTPUT" notify: # Slack #oss-alerts only. Never #engr (engr is sacred — release alerts only). @@ -348,6 +368,9 @@ jobs: # service failed). # verify-prod = verifies the succeeded subset. # run success = PROMOTE == success AND PROD == success. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Compute state id: state env: @@ -355,10 +378,22 @@ jobs: PRE: ${{ needs.verify-staging-precondition.result }} PROMOTE: ${{ needs.promote.result }} PROD: ${{ needs.verify-prod.result }} + # The verify-prod job's own status output: `success` (prod actually + # probed + passed) or `skipped` (nothing promoted, so prod was never + # touched). Empty when the job failed/cancelled or never wrote it. + PROD_STATUS: ${{ needs.verify-prod.outputs.status }} CSV: ${{ needs.resolve-targets.outputs.services_csv }} INPUT: ${{ inputs.service }} run: | set -euo pipefail + # Displayed verify-prod value for the Slack line. The job `result` is + # `success` for BOTH a real pass AND the empty-CSV skip (which exits + # 0), so a bare `result` would read `verify-prod=success` even when + # prod was never probed. The mapping (prefer the job's `status` output + # when it ran cleanly, else fall back to the raw result) lives in the + # bats-tested showcase/scripts/verify-prod-display.sh so it can't + # drift from its test (see __tests__/verify-prod-display.bats). + PROD_DISPLAY="$(PROD="$PROD" PROD_STATUS="$PROD_STATUS" showcase/scripts/verify-prod-display.sh)" if [ "$INPUT" = "__select_a_service__" ]; then # Deliberate no-op abort: a human clicked Run without picking a # service. resolve-targets exits 1 and the downstream jobs skip. @@ -387,6 +422,7 @@ jobs: echo "state=$STATE" echo "icon=$ICON" echo "csv=$CSV" + echo "prod_display=$PROD_DISPLAY" } >> "$GITHUB_OUTPUT" - name: Post to #oss-alerts if: steps.state.outputs.state == 'failure' && inputs.service != '__select_a_service__' && env.SLACK_WEBHOOK != '' @@ -408,7 +444,7 @@ jobs: needs.resolve-targets.result, needs.verify-staging-precondition.result, needs.promote.result, - needs.verify-prod.result, + steps.state.outputs.prod_display, github.server_url, github.repository, github.run_id, diff --git a/.github/workflows/showcase_validate.yml b/.github/workflows/showcase_validate.yml index 18e562c379b..16a0d001a70 100644 --- a/.github/workflows/showcase_validate.yml +++ b/.github/workflows/showcase_validate.yml @@ -550,9 +550,9 @@ jobs: sudo apt-get update sudo apt-get install -y bats - - name: Shellcheck promote-fleet.sh + - name: Shellcheck promote workflow scripts # shellcheck is preinstalled on ubuntu-latest. - run: shellcheck showcase/scripts/promote-fleet.sh + run: shellcheck showcase/scripts/promote-fleet.sh showcase/scripts/verify-prod-display.sh - name: Run bats suite run: bats showcase/scripts/__tests__/ diff --git a/.npmrc b/.npmrc index abe072f55eb..fae840d4007 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,5 @@ minimum-release-age=1440 minimum-release-age-exclude[]=@ag-ui/langgraph minimum-release-age-exclude[]=@ag-ui/a2ui-middleware +minimum-release-age-exclude[]=@ag-ui/a2ui-toolkit block-exotic-subdeps=true diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e6c7d096b12..1b9a918f454 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -77,11 +77,11 @@ "attw": "attw --pack . --profile node16" }, "dependencies": { - "@ag-ui/a2ui-middleware": "0.0.6", + "@ag-ui/a2ui-middleware": "0.0.8", "@ag-ui/client": "0.0.53", "@ag-ui/core": "0.0.53", "@ag-ui/encoder": "0.0.53", - "@ag-ui/langgraph": "0.0.37", + "@ag-ui/langgraph": "0.0.39", "@ag-ui/mcp-apps-middleware": "0.0.3", "@ag-ui/mcp-middleware": "0.0.1", "@ai-sdk/anthropic": "^3.0.49", diff --git a/packages/sdk-js/package.json b/packages/sdk-js/package.json index 2c6925dd944..ccaec370578 100644 --- a/packages/sdk-js/package.json +++ b/packages/sdk-js/package.json @@ -59,7 +59,7 @@ "attw": "attw --pack . --profile node16" }, "dependencies": { - "@ag-ui/langgraph": "0.0.37", + "@ag-ui/langgraph": "0.0.39", "@copilotkit/shared": "workspace:*" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d731bcf081..418358a626c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,7 +591,7 @@ importers: version: 0.1.13 eslint-config-next: specifier: ^15.0.2 - version: 15.4.4(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) + version: 15.4.4(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) flowtoken: specifier: ^1.0.40 version: 1.0.40(@types/react@19.1.8)(react@19.2.3) @@ -2688,8 +2688,8 @@ importers: packages/runtime: dependencies: '@ag-ui/a2ui-middleware': - specifier: 0.0.6 - version: 0.0.6(@ag-ui/client@0.0.53)(rxjs@7.8.1) + specifier: 0.0.8 + version: 0.0.8(@ag-ui/client@0.0.53)(rxjs@7.8.1) '@ag-ui/client': specifier: 0.0.53 version: 0.0.53 @@ -2700,8 +2700,8 @@ importers: specifier: 0.0.53 version: 0.0.53 '@ag-ui/langgraph': - specifier: 0.0.37 - version: 0.0.37(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76)) + specifier: 0.0.39 + version: 0.0.39(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76)) '@ag-ui/mcp-apps-middleware': specifier: 0.0.3 version: 0.0.3(@ag-ui/client@0.0.53)(@cfworker/json-schema@4.1.1)(zod@3.25.76) @@ -2975,8 +2975,8 @@ importers: packages/sdk-js: dependencies: '@ag-ui/langgraph': - specifier: 0.0.37 - version: 0.0.37(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.54)(@opentelemetry/api@1.9.0)(openai@6.35.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76)) + specifier: 0.0.39 + version: 0.0.39(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.54)(@opentelemetry/api@1.9.0)(openai@6.35.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76)) '@copilotkit/shared': specifier: workspace:* version: link:../shared @@ -3496,14 +3496,14 @@ packages: '@ag-ui/client': '>=0.0.42' '@ag-ui/core': '>=0.0.42' - '@ag-ui/a2ui-middleware@0.0.6': - resolution: {integrity: sha512-LAv6Prh399WgGIbjnkd9Qw0/9SuyjVh6Hatkbs5IjO7zVeipY6fMyVunBN52j4AnJANRnk4qbSqpG/HOcGMaGw==} + '@ag-ui/a2ui-middleware@0.0.8': + resolution: {integrity: sha512-YXabOMyNekshHWLc63fD166ndy/zOXp+UWbx1alYoGRhO2y2uZJzOlPLvBAkFY4PF3Lng78ByG4mNpxJlSLDvw==} peerDependencies: '@ag-ui/client': '>=0.0.40' rxjs: 7.8.1 - '@ag-ui/a2ui-toolkit@0.0.1-alpha.3': - resolution: {integrity: sha512-9U4DtwJ6rHO4vn4ixYVnRJGrO7u07phT/AjgsHymLf4cvPw57PNZACc4y6eTtayG0IcySNqRGW/wE+qjlXzgzw==} + '@ag-ui/a2ui-toolkit@0.0.2': + resolution: {integrity: sha512-HFphlNxBxGSQfvxlI2LCQValSMDUTh3MAsaFMgYlF8sQXgCrXNiLJ70+Dz3uyOv4y/rfqdFafvlo1GKQtEVIVA==} '@ag-ui/client@0.0.36': resolution: {integrity: sha512-1Ey2KqK9KQpRJcnJvKPfVyLiTK4+CLBQZ085oJvr6T1nznw224j0KyzXNJ7cRjXeEGnuafmXTgpU+xEbN3xuYQ==} @@ -3553,8 +3553,8 @@ packages: '@ag-ui/langgraph@0.0.11': resolution: {integrity: sha512-3xUkaOelnpQ5tbsbuoOTin71tTgWEN0GDZBjGs/7xAwly2Dn4fahbBAoscXullO/pH9kTGGgbuJ0rWDUgo6fKQ==} - '@ag-ui/langgraph@0.0.37': - resolution: {integrity: sha512-N/u2axTbnvd9MLIzHX1T7YE90X6zTEuTEI3yEud4ywIjBov5qdgA3MqhCqfcgjeJnKKp78AvcMCQ5zMk6aiPkA==} + '@ag-ui/langgraph@0.0.39': + resolution: {integrity: sha512-+pFw49I9liEt8omTFFiie2YdtRFodjnWQTgN0Vxgo2XdC68xtyUy6I68D0QlZJE2Yy29oEx377vvkrNkL2AplA==} peerDependencies: '@ag-ui/client': '>=0.0.42' '@ag-ui/core': '>=0.0.42' @@ -24232,13 +24232,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@ag-ui/a2ui-middleware@0.0.6(@ag-ui/client@0.0.53)(rxjs@7.8.1)': + '@ag-ui/a2ui-middleware@0.0.8(@ag-ui/client@0.0.53)(rxjs@7.8.1)': dependencies: + '@ag-ui/a2ui-toolkit': 0.0.2 '@ag-ui/client': 0.0.53 clarinet: 0.12.6 rxjs: 7.8.1 - '@ag-ui/a2ui-toolkit@0.0.1-alpha.3': {} + '@ag-ui/a2ui-toolkit@0.0.2': {} '@ag-ui/client@0.0.36': dependencies: @@ -24357,9 +24358,9 @@ snapshots: - react-dom - ws - '@ag-ui/langgraph@0.0.37(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76))': + '@ag-ui/langgraph@0.0.39(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76))': dependencies: - '@ag-ui/a2ui-toolkit': 0.0.1-alpha.3 + '@ag-ui/a2ui-toolkit': 0.0.2 '@ag-ui/client': 0.0.53 '@ag-ui/core': 0.0.53 '@langchain/core': 1.1.42(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))(ws@8.19.0) @@ -24379,9 +24380,9 @@ snapshots: - ws - zod-to-json-schema - '@ag-ui/langgraph@0.0.37(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.54)(@opentelemetry/api@1.9.0)(openai@6.35.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76))': + '@ag-ui/langgraph@0.0.39(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.54)(@opentelemetry/api@1.9.0)(openai@6.35.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.1(zod@3.25.76))': dependencies: - '@ag-ui/a2ui-toolkit': 0.0.1-alpha.3 + '@ag-ui/a2ui-toolkit': 0.0.2 '@ag-ui/client': 0.0.53 '@ag-ui/core': 0.0.54 '@langchain/core': 1.1.42(@opentelemetry/api@1.9.0)(openai@6.35.0(ws@8.19.0)(zod@3.25.76))(ws@8.19.0) @@ -28324,6 +28325,11 @@ snapshots: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': + dependencies: + eslint: 9.39.2(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -36125,16 +36131,16 @@ snapshots: '@types/node': 20.19.27 optional: true - '@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) '@typescript-eslint/scope-manager': 7.2.0 - '@typescript-eslint/type-utils': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) - '@typescript-eslint/utils': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/type-utils': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) + '@typescript-eslint/utils': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) '@typescript-eslint/visitor-keys': 7.2.0 debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -36193,14 +36199,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2)': + '@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 7.2.0 '@typescript-eslint/types': 7.2.0 '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 7.2.0 debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -36278,12 +36284,12 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2)': + '@typescript-eslint/type-utils@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2)': dependencies: '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.9.2) - '@typescript-eslint/utils': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/utils': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.9.2) optionalDependencies: typescript: 5.9.2 @@ -36375,15 +36381,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2)': + '@typescript-eslint/utils@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 7.2.0 '@typescript-eslint/types': 7.2.0 '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.9.2) - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -40188,19 +40194,19 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@15.4.4(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2): + eslint-config-next@15.4.4(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2): dependencies: '@next/eslint-plugin-next': 15.4.4 '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 7.2.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) - '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) - eslint: 9.39.2(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 7.2.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) + '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.7.0)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.7.0)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.7.0)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -40251,29 +40257,29 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) - eslint: 9.39.2(jiti@2.7.0) + '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color @@ -40287,7 +40293,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -40296,9 +40302,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -40310,7 +40316,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/parser': 7.2.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -40343,6 +40349,25 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@1.21.7)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.2(jiti@1.21.7) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 10.2.4 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.7.0)): dependencies: aria-query: 5.3.2 @@ -40362,9 +40387,9 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@2.7.0)): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@1.21.7)): dependencies: - eslint: 9.39.2(jiti@2.7.0) + eslint: 9.39.2(jiti@1.21.7) eslint-plugin-react-hooks@7.1.1(eslint@9.39.2(jiti@2.7.0)): dependencies: @@ -40377,6 +40402,28 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.2(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 10.2.4 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.7.0)): dependencies: array-includes: 3.1.9 @@ -40489,6 +40536,47 @@ snapshots: transitivePeerDependencies: - supports-color + eslint@9.39.2(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.2(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) diff --git a/sdk-python/poetry.lock b/sdk-python/poetry.lock index cf81b623510..051526366ef 100644 --- a/sdk-python/poetry.lock +++ b/sdk-python/poetry.lock @@ -1,18 +1,31 @@ # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.2" +description = "Framework-agnostic helpers for building A2UI subagent tools — op builders, prompt assembly, history walkers, and request/envelope orchestration shared across framework adapters." +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "ag_ui_a2ui_toolkit-0.0.2-py3-none-any.whl", hash = "sha256:028e497dfa2c9ca716143248dee14712d5c1055615a1bd91efa95f85e0a467ef"}, + {file = "ag_ui_a2ui_toolkit-0.0.2.tar.gz", hash = "sha256:5908fa7a9cf474fa26d8821ac15b135bdca2c1cfddce0b7c580c6382d1f0bfd9"}, +] + [[package]] name = "ag-ui-langgraph" -version = "0.0.35" +version = "0.0.40" description = "Implementation of the AG-UI protocol for LangGraph." optional = false python-versions = "<3.15,>=3.10" groups = ["main"] files = [ - {file = "ag_ui_langgraph-0.0.35-py3-none-any.whl", hash = "sha256:49c7de8e230bd3e55afcb8ff62be438f2b33f6acfb2803d3bda8d5214d5870dc"}, - {file = "ag_ui_langgraph-0.0.35.tar.gz", hash = "sha256:34ee82e30bf110e36d0bc0819e733d70452d1e1069ee26cb08f3483645e9e5f4"}, + {file = "ag_ui_langgraph-0.0.40-py3-none-any.whl", hash = "sha256:ee5e06a47ea267c082f746c9cdab4b0ac2dcc4f8e3a26d750d6dd82d0a0a07f1"}, + {file = "ag_ui_langgraph-0.0.40.tar.gz", hash = "sha256:11b009920b79fe97162f53777ecd7860c8a0999479d55f5d85d8da979d5be28f"}, ] [package.dependencies] +ag-ui-a2ui-toolkit = ">=0.0.2" ag-ui-protocol = ">=0.1.15" fastapi = {version = ">=0.115.12", optional = true, markers = "extra == \"fastapi\""} langchain = ">=1.2.0" @@ -5629,4 +5642,4 @@ crewai = ["crewai"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.15" -content-hash = "956fd2b7aec97554ee44f7a9f9969ba400e3e252a35cd2f7824986b0c9c118c1" +content-hash = "63e8a9a538d3c4dafa497f509516c818f51ace66477ce4d1fd2cc273a357976f" diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index ab47863dbc2..c525c79da8f 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -31,7 +31,7 @@ python = ">=3.10,<3.15" langgraph = { version = ">=0.3.25,<2" } langchain = { version = ">=0.3.0" } crewai = { version = ">=0.118.0", optional = true, python = ">=3.10,<3.14" } -ag-ui-langgraph = { version = ">=0.0.38", extras = ["fastapi"] } +ag-ui-langgraph = { version = ">=0.0.40", extras = ["fastapi"] } ag-ui-protocol = ">=0.1.15" fastapi = ">=0.115.0,<1.0.0" partialjson = "^0.0.8" diff --git a/showcase/bin/railway b/showcase/bin/railway index 09d006fc8b7..e4ca13f1de8 100755 --- a/showcase/bin/railway +++ b/showcase/bin/railway @@ -674,11 +674,29 @@ module Railway end # 3. For each service, fetch its serviceInstance for this env. - # Some services may not exist in this env (returns nil); skip them. + # Some services may not exist in this env. Railway signals that + # two different ways for the SAME underlying condition: + # (a) the field resolves to null (`serviceInstance` => nil), or + # (b) the query THROWS `GraphQL: ServiceInstance not found` + # (a half-deleted service that still appears in the project + # service list but has no instance in this env). + # Both mean "this service has no instance here" — skip it and keep + # going. We deliberately scope the rescue to ONLY that message so + # every other GraphQL failure (auth, rate-limit, schema drift, + # transient 5xx surfaced as a GraphQL error) still propagates + # fail-loud. Otherwise a single odd service aborts the whole + # snapshot — and with it the whole promote — before any preflight + # runs (see run 27144525566). services = [] service_nodes.each do |node| - instance_data = gql.query(SERVICE_INSTANCE_QUERY, - serviceId: node["id"], envId: env_id) + begin + instance_data = gql.query(SERVICE_INSTANCE_QUERY, + serviceId: node["id"], envId: env_id) + rescue GraphQL::Error => e + raise unless e.message.include?("ServiceInstance not found") + warn "skipping service #{node['name'].inspect} (#{node['id']}): no serviceInstance in this env (#{e.message})" + next + end inst = instance_data["serviceInstance"] next if inst.nil? @@ -1183,9 +1201,10 @@ module Railway # (1) check_expected_prod_domains diffs the fleet-wide # EXPECTED_DOMAINS against the union of custom_domains # in `prod["services"]`. Narrowed prod carries at most - # one service's domains → the other ~4 fleet hosts look - # "missing" → spurious WARN → run_with_preflight_only - # refuses (workflow doesn't pass --confirm-divergence). + # one service's domains → the remaining fleet hosts (the + # other 4 of the 5 public prod hosts) look "missing" → + # spurious WARN → run_with_preflight_only refuses + # (workflow doesn't pass --confirm-divergence). # (2) check_service_set_parity is a fleet-shape invariant # (no env has services the other lacks); evaluating it # on the narrowed pair makes it tautological when both @@ -1295,7 +1314,7 @@ module Railway # produce the same verdict regardless of whether this run is # full-fleet or restricted to a single service. See `run` for # the fleet_* rationale. - findings.concat(check_service_set_parity(fleet_staging, fleet_prod)) + findings.concat(check_service_set_parity(fleet_staging, fleet_prod, target: options[:service])) findings.concat(check_critical_env_key_parity(target_staging, target_prod)) findings.concat(check_expected_prod_domains(fleet_prod)) @@ -1611,12 +1630,29 @@ module Railway :other end - def check_service_set_parity(staging, prod) + def check_service_set_parity(staging, prod, target: nil) findings = [] s_names = (staging["services"] || []).map { |s| s["name"] }.sort p_names = (prod["services"] || []).map { |s| s["name"] }.sort - findings << "REFUSE: services in staging not in prod: #{(s_names - p_names).join(', ')}" unless (s_names - p_names).empty? - findings << "REFUSE: services in prod not in staging: #{(p_names - s_names).join(', ')}" unless (p_names - s_names).empty? + staging_only = s_names - p_names + prod_only = p_names - s_names + # For a single-service promote, the ONLY parity violation that must + # REFUSE is one involving the TARGET itself — in BOTH directions: + # - target in staging but not prod (the silent-rc=0 footgun: + # narrowed prod would skip the absent target with no mutation); + # - target in prod but not staging (the mirror case). + # Unrelated services that exist in only one env (harness-workers, + # starter-* demos staging-only; a deprecated prod-only service) are + # legitimately single-env and are not this promote's concern, so we + # scope BOTH arms to the target. Full-fleet promotes (target nil) + # retain full-strictness parity on both arms — there, any env-shape + # asymmetry across the whole fleet is a genuine REFUSE. + if target + staging_only = staging_only & [target] + prod_only = prod_only & [target] + end + findings << "REFUSE: services in staging not in prod: #{staging_only.join(', ')}" unless staging_only.empty? + findings << "REFUSE: services in prod not in staging: #{prod_only.join(', ')}" unless prod_only.empty? findings end diff --git a/showcase/bin/spec/test_promote_single_service_fleet_invariants.rb b/showcase/bin/spec/test_promote_single_service_fleet_invariants.rb index 06655404b89..a56d2382f0d 100644 --- a/showcase/bin/spec/test_promote_single_service_fleet_invariants.rb +++ b/showcase/bin/spec/test_promote_single_service_fleet_invariants.rb @@ -1,30 +1,44 @@ # frozen_string_literal: true -# bin/railway promote — fleet-scoped invariants must evaluate against the -# FULL un-narrowed snapshots, not the single-service narrowed view. +# bin/railway promote — fleet-scoped invariants must read the FULL +# un-narrowed snapshots, while set-parity is target-scoped for a +# single-service promote. # -# Background: the per-service `promote ` feature narrows BOTH -# @staging_snapshot and @prod_snapshot to one service before preflight. Two -# fleet-scoped checks were incorrectly co-narrowed: +# Background: the per-service `promote ` feature narrows the +# target_*/per-service views to one service for the P1..P3/P6 and +# critical-env-key checks, but the FLEET-SHAPE checks +# (check_expected_prod_domains, check_service_set_parity) read the FULL +# un-narrowed fleet_* snapshots — they describe the env, not the promote +# target. (#5322 removed the earlier co-narrowing of these checks; do NOT +# reintroduce snapshot narrowing for fleet-scoped checks.) +# +# Mechanism under test: # # (1) check_expected_prod_domains compares the FLEET-WIDE public-host set # (EXPECTED_DOMAINS[PRODUCTION_ENV_ID]) against the union of -# custom_domains across `prod["services"]`. Narrowed prod has ~1 -# service → ~4 fleet hosts look "missing" → spurious WARN. The real -# workflow (.github/workflows/showcase_promote.yml) does NOT pass -# --confirm-divergence, so promote refuses on its first iteration and -# `set -e` aborts the loop. Healthy fleet, no real divergence, -# blocked. +# custom_domains across the FULL prod snapshot. The fixture's full +# prod fleet carries all 5 public hosts; a narrowed single-service +# view of the target owns 0 of them, so reading the narrowed view +# would make all 5 look "missing" → spurious WARN → and because the +# real workflow (.github/workflows/showcase_promote.yml) does NOT +# pass --confirm-divergence, promote would refuse. Reading the FULL +# fleet avoids that. # -# (2) check_service_set_parity diffs staging vs prod service names. After -# narrowing both to the same single name, the diff is always empty — -# the invariant is dead. Worse: if the target is in staging but -# ABSENT from prod, execute_promotion's `next unless find_service` -# silently skips and returns rc=0. Operator sees "success" while -# prod was untouched. +# (2) check_service_set_parity reads the FULL un-narrowed fleet_staging / +# fleet_prod snapshots and applies `& [target]` scoping to BOTH the +# staging-only and prod-only arms when a single service is targeted. +# So a single-service promote REFUSEs only on parity violations +# involving the TARGET itself, and tolerates unrelated single-env +# services (staging-only harness-workers / starter-* demos, or a +# deprecated prod-only service). Full-fleet promotes (target nil) +# keep both arms at full fleet strictness. # -# These tests pin the fix contract: fleet-scoped checks read the FULL -# snapshots; per-service mutation reads the narrowed snapshots. +# Red-green coverage for THIS PR is provided by the two tolerance tests: +# - test_single_service_promote_tolerates_unrelated_staging_only_services +# - test_single_service_promote_tolerates_unrelated_prod_only_service +# The test_healthy_* and test_target_absent_* tests are #5322 regression +# guards (they pin that fleet-scoped checks read the FULL snapshots and +# that an absent target still fails loud), not red-green for this change. require_relative "spec_helper" require "stringio" @@ -96,17 +110,6 @@ def manifest_exists(_ref); :exists; end def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end end - # Public hosts as published by EXPECTED_DOMAINS for the production env. - # These are the SSOT-derived fleet-wide hosts; pulled here verbatim so - # the fixture's full prod snapshot legitimately satisfies the check. - FLEET_PUBLIC_PROD_HOSTS = [ - "dashboard.showcase.copilotkit.ai", - "docs.copilotkit.ai", - "dojo.showcase.copilotkit.ai", - "hooks.showcase.copilotkit.ai", - "showcase.copilotkit.ai", - ].freeze - def make_staging_service(name) { "name" => name, "service_id" => "svc-#{name}", @@ -133,10 +136,24 @@ def make_prod_service(name, custom_domains: []) # EXPECTED_DOMAINS[PRODUCTION_ENV_ID] set, so a healthy fleet must NOT # produce a domain WARN. Two of those services ("aimock", "harness") # also exist in staging — promote will target one of them. - def install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "aimock") + # + # `staging_only_extras:` injects services into the STAGING fleet that + # have NO prod counterpart — modeling the live staging-only services + # (harness-workers + the 12 starter-* demos). They land in the full + # (pre-narrow) staging snapshot, exactly where the fleet-scoped + # set-parity check reads. They are legitimately staging-only and a + # single-service promote must tolerate them. + # + # `prod_only_extras:` is the mirror: it injects services into the FULL + # prod snapshot that have NO staging counterpart — modeling a + # deprecated/prod-only service. They land in the full (pre-narrow) prod + # snapshot where the prod-only arm of set-parity reads. They are + # legitimately prod-only and a single-service promote must tolerate them. + def install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "aimock", + staging_only_extras: [], prod_only_extras: []) # Five "domain-bearing" services so the prod fleet legitimately - # carries all FLEET_PUBLIC_PROD_HOSTS, even when promote is - # narrowed to a service that doesn't itself own any public host. + # carries all 5 public prod hosts, even when promote is narrowed to + # a service that doesn't itself own any public host. domain_services_prod = [ make_prod_service("dashboard", custom_domains: ["dashboard.showcase.copilotkit.ai"]), make_prod_service("docs", custom_domains: ["docs.copilotkit.ai"]), @@ -153,8 +170,15 @@ def install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "a # Staging mirrors prod's service NAMES (set parity) — every prod # service has a corresponding staging entry. (For BUG #2, target is - # in staging but absent from prod by design.) - staging_services = (domain_services_prod.map { |s| s["name"] } + [target]).uniq.map { |n| make_staging_service(n) } + # in staging but absent from prod by design.) `prod_only_extras` are + # deliberately NOT mirrored into staging, so they land in the full + # prod snapshot with no staging counterpart. + staging_names = (domain_services_prod.map { |s| s["name"] } + [target] + staging_only_extras).uniq + staging_services = staging_names.map { |n| make_staging_service(n) } + + # Inject prod-only extras into the FULL prod snapshot AFTER deriving + # staging_names, so they have no staging counterpart. + prod_only_extras.each { |n| domain_services_prod << make_prod_service(n) } cmd.instance_variable_set(:@staging_snapshot, { "services" => staging_services }) cmd.instance_variable_set(:@prod_snapshot, { "services" => domain_services_prod }) @@ -195,11 +219,12 @@ def with_fast_sleeper Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original) end - # ── BUG #1: healthy fleet, single-service promote, NO --confirm-divergence. - # Today (red): narrowed prod has 1 service whose custom_domains do not - # include the 5 fleet public hosts → WARN → run_with_preflight_only - # refuses (rc=1) because options[:confirm_divergence] is false. - # After fix (green): rc=0, target pinned, siblings untouched. + # ── #5322 regression guard: healthy fleet, single-service promote, NO + # --confirm-divergence must succeed. check_expected_prod_domains reads the + # FULL prod snapshot (all 5 public hosts present), so no phantom domain + # WARN is synthesized and the promote proceeds: rc=0, target pinned, + # siblings untouched. (This pins that fleet-scoped checks read the full + # snapshot; it is a guard, not red-green for the current parity change.) def test_healthy_fleet_single_service_promote_without_confirm_divergence_succeeds gql = FakeGQLBenign.new ghcr = FakeGHCR.new(resolve_map: { @@ -236,20 +261,15 @@ def test_healthy_fleet_single_service_promote_without_confirm_divergence_succeed "dashboard must not be pinned when only aimock was promoted" end - # ── BUG #2: target exists in staging but is ABSENT from prod. - # Today (red): both snapshots get narrowed; service_set_parity (narrowed) - # diffs [target] vs [] but the test fixture wouldn't have a prod entry - # at all after narrowing... wait — narrowing prod yields an EMPTY - # services list (no match). check_service_set_parity then diffs - # ["aimock"] vs [] → finds REFUSE "services in staging not in prod". - # BUT: the per-service feature's narrowing also narrows staging to - # [target], so the diff against an empty prod *should* surface the - # REFUSE. Verify the failure mode is the silent-skip path: - # execute_promotion's `next unless find_service` for absent prod. - # - # Either way, the contract is: if the targeted service is in staging - # but ABSENT from prod, promote must FAIL LOUD (nonzero rc + clear - # message), never silently rc=0 with no pin mutations. + # ── #5322 regression guard: target in staging but ABSENT from prod must + # FAIL LOUD. check_service_set_parity reads the FULL un-narrowed + # fleet_staging / fleet_prod and applies `& [target]` scoping: the target + # is in the staging-only set, so the staging-not-in-prod REFUSE survives + # scoping and surfaces (never a silent rc=0 with no pin). To prove the + # check truly reads the FULL fleet AND applies target-scoping (not merely + # a trivially-narrowed pair), the fixture also carries an UNRELATED + # staging-only sibling: it must be IGNORED while the target REFUSE fires — + # the REFUSE names ONLY the target. def test_target_absent_from_prod_fails_loud gql = FakeGQLBenign.new ghcr = FakeGHCR.new(resolve_map: { @@ -257,10 +277,14 @@ def test_target_absent_from_prod_fails_loud }) cmd = build_cmd(["aimock"]) # prod_includes_target=false → "aimock" exists in staging but NOT - # in the full prod snapshot. The fleet otherwise mirrors itself - # (no spurious set-parity issues), so the REFUSE we observe is - # specifically the staging-only-target case. - install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: false, target: "aimock") + # in the full prod snapshot. An unrelated staging-only sibling + # ("harness-workers") is injected too: the full fleet thus has TWO + # staging-only names, but target-scoping must REFUSE on ONLY the + # target. This distinguishes the fix from the buggy full-vs-narrowed + # behavior — a check that ignored target-scoping would also name the + # sibling. + install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: false, target: "aimock", + staging_only_extras: %w[harness-workers]) rc = nil out, _ = with_fast_sleeper { capture_io { rc = cmd.run } } @@ -271,11 +295,108 @@ def test_target_absent_from_prod_fails_loud "Got rc=#{rc.inspect}; out=\n#{out}" # The error message must be informative — surface the parity - # violation rather than a generic / opaque failure. - assert_match(/REFUSE: services in staging not in prod/, out, - "must surface a clear REFUSE explaining target is missing from prod") + # violation rather than a generic / opaque failure. AND it must name + # ONLY the target: target-scoping means the unrelated staging-only + # sibling is excluded from the REFUSE even though it is in the FULL + # fleet's staging-only set. (A check that read the full fleet without + # target-scoping would also list "harness-workers" here.) + assert_match(/REFUSE: services in staging not in prod: aimock\b/, out, + "must surface a clear REFUSE naming the target missing from prod") + refute_match(/harness-workers/, out, + "target-scoping must exclude unrelated staging-only siblings from " \ + "the REFUSE — only the target should be named") assert_empty gql.pinned_services, "no pin mutations may be issued when target is absent from prod" end + + # ── Single-service promote must TOLERATE unrelated staging-only services. + # The live staging fleet legitimately carries services with no prod + # counterpart — harness-workers (SSOT-modeled staging-only) and the 12 + # starter-* demos (not in SSOT). For a single-service promote of a + # healthy target (present in both staging and prod) these are irrelevant. + # + # Red-green for the STAGING-only arm of the target-scoping fix (#5324): + # before scoping, check_service_set_parity diffs the FULL staging fleet vs + # the FULL prod fleet and REFUSEs because the extras are "in staging not + # in prod" — blocking an otherwise-clean single-service promote. After + # the fix, the staging-only REFUSE is target-scoped, so the unrelated + # extras are ignored; rc=0, target pinned, no REFUSE. + def test_single_service_promote_tolerates_unrelated_staging_only_services + gql = FakeGQLBenign.new + ghcr = FakeGHCR.new(resolve_map: { + "ghcr.io/copilotkit/docs:latest" => "sha256:NEW_DOCS", + }) + cmd = build_cmd(["docs"]) + # Target "docs" is present in BOTH staging and prod (the domain- + # bearing prod fixture already includes a "docs" service, mirrored + # into staging). Inject unrelated staging-only siblings. + install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "docs", + staging_only_extras: %w[harness-workers starter-adk starter-langgraph-python]) + + rc = nil + out, _ = with_fast_sleeper { capture_io { rc = cmd.run } } + + assert_equal 0, rc, + "a single-service promote of a healthy target must NOT be refused " \ + "because UNRELATED staging-only services (harness-workers, " \ + "starter-* demos) exist in staging but not prod. " \ + "Got rc=#{rc.inspect}; out=\n#{out}" + + refute_match(/REFUSE: services in staging not in prod/, out, + "staging-only services unrelated to the promote target must not " \ + "trigger the set-parity REFUSE on a single-service promote") + + pinned = gql.pinned_services + assert_equal 1, pinned.size, + "exactly one service should be promoted (target only); got #{pinned.inspect}" + sid, image = pinned.first + assert_equal "prod-docs", sid + assert_equal "ghcr.io/copilotkit/docs@sha256:NEW_DOCS", image + end + + # ── Single-service promote must TOLERATE unrelated prod-only services. + # This is the MIRROR of the staging-only tolerance test and the red-green + # for the PROD-only arm of the target-scoping fix (#5324). A prod-only + # service (e.g. a deprecated "harness-legacy" still present in prod but + # removed from staging) has no bearing on a single-service promote of an + # unrelated healthy target. + # + # Red (before this PR's source change): the prod-only arm of + # check_service_set_parity was computed over the FULL fleet + # (`(p_names - s_names)`) with NO target scoping, so ANY prod-only service + # REFUSEs EVERY single-service promote — "services in prod not in staging" + # fires and rc=1. Green (after): the prod-only arm is target-scoped too, + # so the unrelated prod-only service is ignored; rc=0, target pinned, no + # "in prod not in staging" REFUSE. + def test_single_service_promote_tolerates_unrelated_prod_only_service + gql = FakeGQLBenign.new + ghcr = FakeGHCR.new(resolve_map: { + "ghcr.io/copilotkit/docs:latest" => "sha256:NEW_DOCS", + }) + cmd = build_cmd(["docs"]) + # Target "docs" is present in BOTH staging and prod. Inject an + # unrelated prod-only sibling (no staging counterpart). + install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "docs", + prod_only_extras: %w[harness-legacy]) + + rc = nil + out, _ = with_fast_sleeper { capture_io { rc = cmd.run } } + + assert_equal 0, rc, + "a single-service promote of a healthy target must NOT be refused " \ + "because an UNRELATED prod-only service (harness-legacy) exists in " \ + "prod but not staging. Got rc=#{rc.inspect}; out=\n#{out}" + + refute_match(/REFUSE: services in prod not in staging/, out, + "prod-only services unrelated to the promote target must not " \ + "trigger the set-parity REFUSE on a single-service promote") + + pinned = gql.pinned_services + assert_equal 1, pinned.size, + "exactly one service should be promoted (target only); got #{pinned.inspect}" + sid, image = pinned.first + assert_equal "prod-docs", sid + assert_equal "ghcr.io/copilotkit/docs@sha256:NEW_DOCS", image + end end diff --git a/showcase/bin/spec/test_snapshot_graphql.rb b/showcase/bin/spec/test_snapshot_graphql.rb index 492bacaf335..1a24535fc65 100644 --- a/showcase/bin/spec/test_snapshot_graphql.rb +++ b/showcase/bin/spec/test_snapshot_graphql.rb @@ -245,4 +245,65 @@ def test_deploymentRollback_mutation_has_no_selection_set_because_it_returns_boo Railway::RollbackCommand::ROLLBACK_MUTATION, "deploymentRollback returns Boolean; no selection set allowed.") end + + def test_build_snapshot_skips_service_whose_instance_throws_not_found_instead_of_aborting + # Regression: run 27144525566. A single odd/half-deleted service in the + # project list threw `GraphQL: ServiceInstance not found` from the + # per-service serviceInstance query. The only guard was `next if + # inst.nil?` — it handled a NULL result but not a THROWN error, so the + # error bubbled to Railway.run's top-level `rescue GraphQL::Error` and + # aborted the ENTIRE promote (opaque exit 2) before any preflight ran. + # + # build_snapshot must tolerate a thrown per-service `ServiceInstance not + # found` the same way it tolerates nil: skip that one service and keep + # going, so the healthy services still produce a usable snapshot. + fake = FakeGQL.new( + "ProjectServices" => services_list_response, + "EnvVariables" => env_vars_response_with_per_service_keys, + "ServiceInstance" => lambda do |vars| + if vars[:serviceId] == "svc-aimock" + # Half-deleted / instance-less service: Railway throws this + # exact GraphQL error rather than returning null. + raise Railway::GraphQL::Error, "GraphQL: ServiceInstance not found" + else + service_instance_response( + image: "ghcr.io/copilotkit/showcase-shell@sha256:beef1234", + domains: [], + ) + end + end, + ) + + cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"]) + cmd.instance_variable_set(:@gql, fake) + + snap = cmd.build_snapshot(Railway::PRODUCTION_ENV_ID) + + # The thrown service is skipped; the healthy one survives. + names = snap["services"].map { |s| s["name"] } + assert_equal ["shell"], names, "the instance-less service must be skipped, not abort the snapshot" + shell = snap["services"].find { |s| s["name"] == "shell" } + assert_equal "ghcr.io/copilotkit/showcase-shell@sha256:beef1234", shell["image"] + end + + def test_build_snapshot_does_not_swallow_unrelated_graphql_errors + # The per-service tolerance MUST be narrow: only a `ServiceInstance not + # found` error for an individual service is skippable. Any OTHER GraphQL + # failure (auth, rate-limit, schema drift, transient 5xx surfaced as a + # GraphQL error) must still propagate fail-loud — never silently hidden. + fake = FakeGQL.new( + "ProjectServices" => services_list_response, + "EnvVariables" => env_vars_response_with_per_service_keys, + "ServiceInstance" => lambda do |_vars| + raise Railway::GraphQL::Error, "GraphQL: Not Authorized" + end, + ) + + cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"]) + cmd.instance_variable_set(:@gql, fake) + + assert_raises(Railway::GraphQL::Error) do + cmd.build_snapshot(Railway::PRODUCTION_ENV_ID) + end + end end diff --git a/showcase/bin/spec/test_snapshot_ivar_lint.rb b/showcase/bin/spec/test_snapshot_ivar_lint.rb index fcfddb84d9c..144c0709903 100644 --- a/showcase/bin/spec/test_snapshot_ivar_lint.rb +++ b/showcase/bin/spec/test_snapshot_ivar_lint.rb @@ -54,33 +54,33 @@ class SnapshotIvarLintTest < Minitest::Test # prose (do not perform a read) ALLOWED_LINES = [ # `run` — capture full-fleet view before optional narrowing. - '1202:@full_staging_snapshot = @staging_snapshot', - '1203:@full_prod_snapshot = @prod_snapshot', + '1221:@full_staging_snapshot = @staging_snapshot', + '1222:@full_prod_snapshot = @prod_snapshot', # Doc comment above narrow_snapshots_to_single_service!. - '1211:# Narrow @staging_snapshot and @prod_snapshot to only the named', + '1230:# Narrow @staging_snapshot and @prod_snapshot to only the named', # narrow_snapshots_to_single_service! — the WRITE site. - '1219:staging_match = (@staging_snapshot["services"] || []).select { |s| s["name"] == name }', - '1224:@staging_snapshot = @staging_snapshot.merge("services" => staging_match)', - '1225:prod_match = (@prod_snapshot["services"] || []).select { |s| s["name"] == name }', - '1226:@prod_snapshot = @prod_snapshot.merge("services" => prod_match)', + '1238:staging_match = (@staging_snapshot["services"] || []).select { |s| s["name"] == name }', + '1243:@staging_snapshot = @staging_snapshot.merge("services" => staging_match)', + '1244:prod_match = (@prod_snapshot["services"] || []).select { |s| s["name"] == name }', + '1245:@prod_snapshot = @prod_snapshot.merge("services" => prod_match)', # Doc comment above capture_snapshots. - '1230:# @staging_snapshot / @prod_snapshot directly.', + '1249:# @staging_snapshot / @prod_snapshot directly.', # capture_snapshots — single test-seam assignment site. - '1232:@staging_snapshot ||= SnapshotCommand.new(["--env", "staging", "--dry-run"]).build_snapshot(STAGING_ENV_ID)', - '1233:@prod_snapshot ||= SnapshotCommand.new(["--env", "production", "--dry-run"]).build_snapshot(PRODUCTION_ENV_ID)', + '1251:@staging_snapshot ||= SnapshotCommand.new(["--env", "staging", "--dry-run"]).build_snapshot(STAGING_ENV_ID)', + '1252:@prod_snapshot ||= SnapshotCommand.new(["--env", "production", "--dry-run"]).build_snapshot(PRODUCTION_ENV_ID)', # Doc comment above the accessor block (explains test seam). - '1257:# promote tests stub @staging_snapshot/@prod_snapshot directly', + '1276:# promote tests stub @staging_snapshot/@prod_snapshot directly', # The four accessor bodies — the ONLY sanctioned reads. - '1269:@full_staging_snapshot || @staging_snapshot', - '1273:@full_prod_snapshot || @prod_snapshot', - '1277:@staging_snapshot', - '1281:@prod_snapshot', + '1288:@full_staging_snapshot || @staging_snapshot', + '1292:@full_prod_snapshot || @prod_snapshot', + '1296:@staging_snapshot', + '1300:@prod_snapshot', ].freeze def setup diff --git a/showcase/integrations/ag2/PARITY_NOTES.md b/showcase/integrations/ag2/PARITY_NOTES.md index 6a7ba14f31e..69ba38ae642 100644 --- a/showcase/integrations/ag2/PARITY_NOTES.md +++ b/showcase/integrations/ag2/PARITY_NOTES.md @@ -16,7 +16,7 @@ all pointing to the same HTTP backend. - `chat-customization-css` — scoped CSS theming of built-in classes - `headless-simple` — bespoke chat built on `useAgent` / `useComponent` - `readonly-state-agent-context` — `useAgentContext` read-only context -- `reasoning-default-render` — built-in `CopilotChatReasoningMessage` (no custom slot) +- `reasoning-default` — built-in `CopilotChatReasoningMessage` (no custom slot) - `tool-rendering-default-catchall` — `useDefaultRenderTool()` (built-in card) - `tool-rendering-custom-catchall` — single branded wildcard renderer - `frontend-tools` — `useFrontendTool` with sync handler (change_background) @@ -69,16 +69,14 @@ the runtime middleware config doesn't leak into other cells. `openGenerativeUI: { agents: [...] }` so the runtime middleware converts streamed `generateSandboxedUi` tool calls into `open-generative-ui` activity events. -- `agentic-chat-reasoning`, `tool-rendering-reasoning-chain` — Frontend +- `reasoning-custom`, `tool-rendering-reasoning-chain` — Frontend ports of the LangGraph reasoning cells. The custom `reasoningMessage` - slot is wired exactly as in the canonical reference. Backend caveat: - AG2's `ConversableAgent` does not natively emit AG-UI - `REASONING_MESSAGE_*` events the way LangGraph's `deepagents` does, - so the reasoning slot may render empty on every turn until a future - AG2 release adds reasoning emission. The tool chain + slot is wired exactly as in the canonical reference. The tool chain (`tool-rendering-reasoning-chain` backend at `src/agents/tool_rendering_reasoning_chain.py`, mounted at `/tool-rendering-reasoning-chain/`) still exercises end-to-end. + **Reasoning channel does NOT light up — confirmed framework-bridge + limitation, not a fixture bug.** See the dedicated section below. ### Batch 2 — Dedicated AG2 sub-apps @@ -152,3 +150,99 @@ strictly "missing primitive" skips: - `interrupt-headless` — same underlying primitive as `gen-ui-interrupt`. Marked `not_supported_features`; stub page points at `hitl-in-app` / `frontend-tools-async`. + +## Reasoning channel — framework-bridge limitation (verified) + +Applies to `reasoning-custom`, `tool-rendering-reasoning-chain`, +and `reasoning-default`. The custom/built-in `reasoningMessage` +slot is wired correctly, but the AG-UI reasoning channel never lights up +because **AG2's `AGUIStream` bridge cannot emit `REASONING_MESSAGE_*` +events** — it has no reasoning data to emit. This is the same class of +gap as pydantic-ai, not a fixture or wiring bug. Do NOT attempt to fix +it by hacking the aimock fixtures. + +Verified against `ag2==0.13.3` / `autogen 0.13.3` (the version pinned by +`requirements.txt`, `ag2[openai,ag-ui]>=0.9.0`). + +### What AGUIStream actually emits + +`autogen.ag_ui.adapter` (the `AGUIStream` / `run_stream` implementation) +imports and emits only this fixed set of AG-UI event types: + +- `RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR` +- `STATE_SNAPSHOT` +- `TEXT_MESSAGE_START` / `_CONTENT` / `_END` / `_CHUNK` +- `TOOL_CALL_START` / `_ARGS` / `_CHUNK` / `_END` / `_RESULT` + +There is **no** `REASONING_MESSAGE_*` import and **no** `THINKING_*` +import anywhere in the adapter. So the question "does it emit +`REASONING_MESSAGE_*`, `THINKING_*`, or nothing?" resolves to **nothing** +— the reasoning channel is entirely absent from the bridge. (Note: even +if it emitted `THINKING_*`, that would be a dead end — `@ag-ui/client` +0.0.52 drops `THINKING_*`; only `REASONING_MESSAGE_*` with +`role:"reasoning"` reaches the UI.) + +### Why a custom-synth interceptor is NOT feasible + +The agno / claude-sdk-python pattern (synthesize `REASONING_MESSAGE_*` +from the model's native reasoning channel — agno reads +`RunContentEvent.reasoning_content`; claude-sdk-python reads Anthropic's +Messages-API `thinking_delta`, never chat-completions +`delta.reasoning_content`) cannot be applied here, because the reasoning +data never survives into any layer the bridge can see: + +1. `AGUIStream` exposes an `event_interceptors` hook, but interceptors + receive `ServiceResponse` objects (`autogen.agentchat.remote.protocol`). + `ServiceResponse` has exactly four fields — `message`, `context`, + `input_required`, `streaming_text` — and **no reasoning field**. +2. Upstream of that, `AgentService` (`agent_service.py`) builds its + streaming text from an `AsyncIOQueueStream` whose `send()` only + captures `StreamEvent.content.content` (visible text). The final + reply comes from `a_generate_oai_reply`, which returns a plain OAI + message (content + tool_calls). +3. Upstream of _that_, autogen's OpenAI chat-completions client + (`autogen/oai/client.py`) reads only `choice.delta.content` and + `choice.delta.tool_calls` from each streaming chunk. + `choice.delta.reasoning_content` is **never read** in the + chat-completions path — it is silently dropped at ingestion. (Only the + separate `responses_v2` / Responses-API client surfaces reasoning via + `response.reasoning`, and that path does not flow through `AGUIStream` + either.) + +Empirical confirmation: an OpenAI-compatible endpoint that streams +`delta.reasoning_content` (exactly the channel aimock's `reasoning` +fixture field drives) + `delta.content`, driven through a real +`ConversableAgent` + `AGUIStream`, produces: + +``` +RUN_STARTED: 1 +TEXT_MESSAGE_START: 1 +TEXT_MESSAGE_CONTENT: 3 +TEXT_MESSAGE_END: 1 +RUN_FINISHED: 1 +REASONING_MESSAGE_START: 0 ← reasoning channel never fires +``` + +and the assembled reply is just the visible string — the +`reasoning_content` is gone. There is therefore no reasoning data for a +custom interceptor to synthesize from; manufacturing reasoning text would +be a demo fabrication, which we explicitly do not do. + +### What a real fix requires (upstream, in AG2) + +A genuine fix must add reasoning support inside autogen itself, end to +end: + +1. `autogen/oai/client.py` streaming consumer must read + `choice.delta.reasoning_content` and accumulate it alongside content. +2. A reasoning carrier must be threaded through `StreamEvent` → + `AsyncIOQueueStream` → `AgentService`, and `ServiceResponse` must gain + a reasoning field (or a dedicated streaming reasoning chunk type). +3. `autogen/ag_ui/adapter.py::run_stream` must import and emit + `REASONING_MESSAGE_START` / `_CONTENT` / `_END` (role `"reasoning"`) + when reasoning deltas arrive — analogous to its existing + `TEXT_MESSAGE_*` handling. + +Until AG2 ships that, the showcase reasoning slot for AG2 demos will +render empty/skeletal. The cells remain valuable for exercising the slot +plumbing and (for `tool-rendering-reasoning-chain`) the multi-tool chain. diff --git a/showcase/integrations/agno/manifest.yaml b/showcase/integrations/agno/manifest.yaml index 770671b9176..0928e859304 100644 --- a/showcase/integrations/agno/manifest.yaml +++ b/showcase/integrations/agno/manifest.yaml @@ -51,8 +51,8 @@ features: - byoc-json-render - tool-rendering-default-catchall - tool-rendering-custom-catchall - - agentic-chat-reasoning - - reasoning-default-render + - reasoning-custom + - reasoning-default - tool-rendering-reasoning-chain - headless-complete - auth @@ -302,6 +302,33 @@ demos: - src/app/demos/frontend-tools-async/page.tsx - src/app/demos/frontend-tools-async/notes-card.tsx - src/app/api/copilotkit/route.ts + - id: reasoning-custom + name: Agentic Chat (Reasoning) + description: Visible reasoning / thinking chain rendered via a custom + reasoningMessage slot on the reasoning-enabled Agno agent + tags: + - chat-ui + - reasoning + route: /demos/reasoning-custom + animated_preview_url: + highlight: + - src/agents/reasoning_agent.py + - src/app/demos/reasoning-custom/page.tsx + - src/app/demos/reasoning-custom/reasoning-block.tsx + - src/app/api/copilotkit/route.ts + - id: reasoning-default + name: Reasoning (Default Render) + description: Zero-config rendering of the agent's reasoning chain via + CopilotKit's built-in reasoning message + tags: + - chat-ui + - reasoning + route: /demos/reasoning-default + animated_preview_url: + highlight: + - src/agents/reasoning_agent.py + - src/app/demos/reasoning-default/page.tsx + - src/app/api/copilotkit/route.ts - id: tool-rendering-reasoning-chain name: Tool Rendering (Reasoning Chain) description: Reasoning card + sequential tool-call renderers (per-tool + diff --git a/showcase/integrations/agno/src/agent_server.py b/showcase/integrations/agno/src/agent_server.py index 563f2f812ff..74ba8b920b8 100644 --- a/showcase/integrations/agno/src/agent_server.py +++ b/showcase/integrations/agno/src/agent_server.py @@ -512,23 +512,62 @@ async def _gen(): # # This custom handler: # 1. Runs the agent with reasoning=False (single LLM call) -# 2. Collects the streamed text content -# 3. Parses ... tags from the text -# 4. Emits REASONING_MESSAGE_* events for the reasoning block +# 2. Captures the model's NATIVE reasoning channel — OpenAI-compatible +# chat-completions stream a `delta.reasoning_content` field which Agno's +# OpenAIChat model surfaces on each `RunContentEvent.reasoning_content`. +# That is the channel aimock fixtures populate (via their `reasoning` +# field), and it is what reasoning models emit in production. Agno's +# AGUI stream mapper (`async_stream_agno_response_as_agui_events`) DROPS +# that channel entirely, so we tee the raw stream to accumulate it. +# 3. Collects the streamed text content (the answer) +# 4. Emits REASONING_MESSAGE_* events for the reasoning block — preferring +# the native channel, then falling back to ... +# tag parsing of the text for no-native-reasoning fixtures # 5. Emits TEXT_MESSAGE_* events for the answer # -# If no tags are found, the entire response is emitted as a -# text message (graceful fallback for aimock fixtures that return plain -# text containing reasoning keywords). +# Mirrors the claude-sdk-python /reasoning handler, which forwards Anthropic +# `thinking`/`thinking_delta` blocks as REASONING_MESSAGE_* directly. The +# emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*, +# which @ag-ui/client silently drops. import re +from agno.run.agent import RunEvent + _REASONING_PATTERN = re.compile( r"(.*?)", re.DOTALL | re.IGNORECASE, ) +async def _tee_native_reasoning( + # Carries Agno run events (RunContentEvent etc.) plus the terminal + # RunOutput — NOT AG-UI's BaseEvent. Annotated ``Any`` because Agno's + # concrete union (``RunOutputEvent | RunOutput``) does not line up with + # the ``RunOutputEvent | TeamRunOutputEvent`` the downstream AGUI mapper + # consumes, and a precise annotation here only moves the mismatch. + response_stream: AsyncIterator[Any], + reasoning_sink: dict, +) -> AsyncIterator[Any]: + """Pass an Agno run stream through verbatim, accumulating the model's + native ``reasoning_content`` channel into ``reasoning_sink["text"]``. + + OpenAI-compatible providers (and aimock fixtures' ``reasoning`` field) + stream reasoning as ``delta.reasoning_content`` deltas, which Agno's + ``OpenAIChat`` model surfaces on each ``RunContentEvent.reasoning_content``. + Agno's AGUI stream mapper ignores that field, so we capture it here while + forwarding every chunk untouched to the downstream mapper. No chunk is + consumed or altered — the mapper still produces TEXT/TOOL events exactly + as before; we only read the reasoning side-channel. + """ + async for chunk in response_stream: + if getattr(chunk, "event", None) == RunEvent.run_content: + delta = getattr(chunk, "reasoning_content", None) + if delta: + reasoning_sink["text"] = reasoning_sink.get("text", "") + delta + yield chunk + + async def _run_reasoning_agent( agent: Union[Agent, RemoteAgent], run_input: RunAgentInput ) -> AsyncIterator[BaseEvent]: @@ -567,8 +606,17 @@ async def _run_reasoning_agent( full_text = "" tool_events: list[BaseEvent] = [] + # Tee the raw Agno stream to capture the model's native reasoning + # channel (`RunContentEvent.reasoning_content`) — the channel aimock + # fixtures populate and that the AGUI mapper drops. Accumulated here, + # preferred over -tag parsing below. + native_reasoning: dict = {} + async for event in async_stream_agno_response_as_agui_events( - response_stream=response_stream, # type: ignore[arg-type] + response_stream=_tee_native_reasoning( + response_stream, + native_reasoning, + ), thread_id=thread_id, run_id=run_id, ): @@ -597,27 +645,41 @@ async def _run_reasoning_agent( tool_events.append(event) # Skip text start/end — we'll re-emit with reasoning split - # Parse ... tags - match = _REASONING_PATTERN.search(full_text) - - if match: - reasoning_text = match.group(1).strip() - answer_text = ( - full_text[: match.start()] + full_text[match.end() :] - ).strip() + native_reasoning_text = (native_reasoning.get("text") or "").strip() + + if native_reasoning_text: + # Native reasoning channel present (reasoning model / aimock + # `reasoning` fixture field). This is the production path and the + # gold-standard parity channel — use it directly. The answer is + # the streamed text minus any stray tags (defensive: + # a native-reasoning fixture shouldn't also embed tags, but strip + # them so they never leak into the visible answer bubble). + reasoning_text = native_reasoning_text + answer_text = _REASONING_PATTERN.sub("", full_text).strip() else: - # Fallback: check for "Reasoning:" prefix pattern (aimock fixtures) - lower = full_text.lower() - if lower.startswith("reasoning:") or lower.startswith("reasoning step"): - # Treat the whole text as containing reasoning — emit as - # reasoning message so the ReasoningBlock renders, then - # re-emit as a text message so CopilotKit's conversation - # view has an assistant bubble the D5 probe can read. - reasoning_text = full_text.strip() - answer_text = full_text.strip() + # Fallback: parse ... tags from the text + # (no-native-reasoning fixtures / non-reasoning models). + match = _REASONING_PATTERN.search(full_text) + + if match: + reasoning_text = match.group(1).strip() + answer_text = ( + full_text[: match.start()] + full_text[match.end() :] + ).strip() else: - reasoning_text = "" - answer_text = full_text.strip() + # Fallback: check for "Reasoning:" prefix pattern (aimock + # fixtures) + lower = full_text.lower() + if lower.startswith("reasoning:") or lower.startswith("reasoning step"): + # Treat the whole text as containing reasoning — emit as + # reasoning message so the ReasoningBlock renders, then + # re-emit as a text message so CopilotKit's conversation + # view has an assistant bubble the D5 probe can read. + reasoning_text = full_text.strip() + answer_text = full_text.strip() + else: + reasoning_text = "" + answer_text = full_text.strip() # Emit reasoning message if we have reasoning content if reasoning_text: diff --git a/showcase/integrations/agno/src/agents/reasoning_agent.py b/showcase/integrations/agno/src/agents/reasoning_agent.py index bd8d51c7ad5..257260ecd19 100644 --- a/showcase/integrations/agno/src/agents/reasoning_agent.py +++ b/showcase/integrations/agno/src/agents/reasoning_agent.py @@ -1,18 +1,22 @@ """Reasoning-capable Agno agent for the reasoning family of demos. Backs three showcase cells: - - agentic-chat-reasoning (custom amber ReasoningBlock slot) - - reasoning-default-render (CopilotKit's built-in reasoning card) + - reasoning-custom (custom amber ReasoningBlock slot) + - reasoning-default (CopilotKit's built-in reasoning card) - tool-rendering-reasoning-chain (reasoning + sequential tool calls) Mirrors `showcase/integrations/langgraph-python/src/agents/reasoning_agent.py` (shared across the three reasoning demos there). Uses reasoning=False with a custom AGUI handler in agent_server.py that -synthesizes REASONING_MESSAGE_* AG-UI events from ... -XML tags in the model output. This avoids Agno's multi-call CoT loop -(which breaks aimock fixtures) while still producing the proper AG-UI -events that CopilotKit's frontend renders via the reasoningMessage slot. +emits REASONING_MESSAGE_* AG-UI events. The primary channel is Agno's +native `reasoning_content` (chat-completions stream a `delta.reasoning_content` +field surfaced on each `RunContentEvent.reasoning_content`), tee'd into the +AG-UI reasoning stream; parsing of ... XML tags in the +model output remains as a defensive fallback when the native channel is empty. +Running with reasoning=False avoids Agno's multi-call CoT loop (which breaks +aimock fixtures) while still producing the proper AG-UI events that +CopilotKit's frontend renders via the reasoningMessage slot. For the reasoning-chain demo we also expose the same shared backend tools (`get_weather`, `search_flights`, `get_stock_price`, `roll_dice`) as the diff --git a/showcase/integrations/built-in-agent/manifest.yaml b/showcase/integrations/built-in-agent/manifest.yaml index 4bcad8f22c6..9e25dc28d06 100644 --- a/showcase/integrations/built-in-agent/manifest.yaml +++ b/showcase/integrations/built-in-agent/manifest.yaml @@ -52,8 +52,8 @@ features: - multimodal - byoc-hashbrown - byoc-json-render - - agentic-chat-reasoning - - reasoning-default-render + - reasoning-custom + - reasoning-default - tool-rendering-reasoning-chain - declarative-gen-ui - a2ui-fixed-schema @@ -346,23 +346,23 @@ demos: - src/app/demos/byoc-json-render/catalog.ts - src/lib/factory/byoc-json-render-factory.ts - src/app/api/copilotkit-byoc-json-render/route.ts - - id: agentic-chat-reasoning + - id: reasoning-custom name: Agentic Chat (Reasoning) description: Visible chain-of-thought during normal conversation, rendered via a custom reasoningMessage slot tags: [chat-ui] - route: /demos/agentic-chat-reasoning + route: /demos/reasoning-custom highlight: - - src/app/demos/agentic-chat-reasoning/page.tsx - - src/app/demos/agentic-chat-reasoning/reasoning-block.tsx + - src/app/demos/reasoning-custom/page.tsx + - src/app/demos/reasoning-custom/reasoning-block.tsx - src/lib/factory/reasoning-factory.ts - src/app/api/copilotkit-reasoning/route.ts - - id: reasoning-default-render + - id: reasoning-default name: Reasoning (Default Render) description: Zero-config rendering of the agent's reasoning chain via CopilotKit's built-in reasoning message tags: [chat-ui] - route: /demos/reasoning-default-render + route: /demos/reasoning-default highlight: - - src/app/demos/reasoning-default-render/page.tsx + - src/app/demos/reasoning-default/page.tsx - src/lib/factory/reasoning-factory.ts - src/app/api/copilotkit-reasoning/route.ts - id: tool-rendering-reasoning-chain diff --git a/showcase/integrations/built-in-agent/src/app/demos/agentic-chat-reasoning/README.md b/showcase/integrations/built-in-agent/src/app/demos/reasoning-custom/README.md similarity index 100% rename from showcase/integrations/built-in-agent/src/app/demos/agentic-chat-reasoning/README.md rename to showcase/integrations/built-in-agent/src/app/demos/reasoning-custom/README.md diff --git a/showcase/integrations/built-in-agent/src/app/demos/agentic-chat-reasoning/page.tsx b/showcase/integrations/built-in-agent/src/app/demos/reasoning-custom/page.tsx similarity index 100% rename from showcase/integrations/built-in-agent/src/app/demos/agentic-chat-reasoning/page.tsx rename to showcase/integrations/built-in-agent/src/app/demos/reasoning-custom/page.tsx diff --git a/showcase/integrations/built-in-agent/src/app/demos/agentic-chat-reasoning/reasoning-block.tsx b/showcase/integrations/built-in-agent/src/app/demos/reasoning-custom/reasoning-block.tsx similarity index 100% rename from showcase/integrations/built-in-agent/src/app/demos/agentic-chat-reasoning/reasoning-block.tsx rename to showcase/integrations/built-in-agent/src/app/demos/reasoning-custom/reasoning-block.tsx diff --git a/showcase/integrations/built-in-agent/src/app/demos/reasoning-default-render/README.md b/showcase/integrations/built-in-agent/src/app/demos/reasoning-default/README.md similarity index 91% rename from showcase/integrations/built-in-agent/src/app/demos/reasoning-default-render/README.md rename to showcase/integrations/built-in-agent/src/app/demos/reasoning-default/README.md index 620cc605b87..ef5df0885ef 100644 --- a/showcase/integrations/built-in-agent/src/app/demos/reasoning-default-render/README.md +++ b/showcase/integrations/built-in-agent/src/app/demos/reasoning-default/README.md @@ -1,7 +1,7 @@ # Reasoning Default Render (built-in-agent) Zero-config rendering of the agent's reasoning chain. Same backend as -`agentic-chat-reasoning` (`/api/copilotkit-reasoning`, reasoning-capable +`reasoning-custom` (`/api/copilotkit-reasoning`, reasoning-capable OpenAI model with `reasoning_effort: "low"`) — but the page passes no custom `reasoningMessage` slot, so CopilotKit's built-in `CopilotChatReasoningMessage` handles the render: a collapsible card with diff --git a/showcase/integrations/built-in-agent/src/app/demos/reasoning-default-render/page.tsx b/showcase/integrations/built-in-agent/src/app/demos/reasoning-default/page.tsx similarity index 94% rename from showcase/integrations/built-in-agent/src/app/demos/reasoning-default-render/page.tsx rename to showcase/integrations/built-in-agent/src/app/demos/reasoning-default/page.tsx index 58e01ef0991..096ee53b66b 100644 --- a/showcase/integrations/built-in-agent/src/app/demos/reasoning-default-render/page.tsx +++ b/showcase/integrations/built-in-agent/src/app/demos/reasoning-default/page.tsx @@ -4,7 +4,7 @@ // // Same backend (`/api/copilotkit-reasoning`, agent // `reasoning-default-render`, gpt-5.2 with reasoning_effort=low) as the -// agentic-chat-reasoning demo, but this page passes NO custom +// reasoning-custom demo, but this page passes NO custom // `reasoningMessage` slot. CopilotKit's built-in // `CopilotChatReasoningMessage` renders the chain as a collapsible card // with a "Thinking…" / "Thought for X" header — the zero-config path. diff --git a/showcase/integrations/built-in-agent/src/app/demos/tool-rendering-reasoning-chain/page.tsx b/showcase/integrations/built-in-agent/src/app/demos/tool-rendering-reasoning-chain/page.tsx index b987d5996c2..6476c82c848 100644 --- a/showcase/integrations/built-in-agent/src/app/demos/tool-rendering-reasoning-chain/page.tsx +++ b/showcase/integrations/built-in-agent/src/app/demos/tool-rendering-reasoning-chain/page.tsx @@ -4,7 +4,7 @@ // // Combines two patterns in a single cell: // 1. Reasoning tokens via a custom `reasoningMessage` slot — same -// approach as `agentic-chat-reasoning`. +// approach as `reasoning-custom`. // 2. Sequential tool calls rendered with: // get_weather → // search_flights → diff --git a/showcase/integrations/built-in-agent/src/lib/factory/reasoning-factory.ts b/showcase/integrations/built-in-agent/src/lib/factory/reasoning-factory.ts index b8b0f66b812..9592ace19e3 100644 --- a/showcase/integrations/built-in-agent/src/lib/factory/reasoning-factory.ts +++ b/showcase/integrations/built-in-agent/src/lib/factory/reasoning-factory.ts @@ -1,6 +1,9 @@ import { BuiltInAgent, convertInputToTanStackAI } from "@copilotkit/runtime/v2"; import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; +import { + OpenAIChatCompletionsTextAdapter, + type OpenAIChatCompletionsProviderOptions, +} from "@tanstack/ai-openai"; import { baseServerTools } from "./server-tools"; // Custom fetch that injects ALS-bound inbound x-* headers (e.g. // x-aimock-context) onto every outbound OpenAI call. Required so aimock @@ -14,16 +17,80 @@ import { forwardingFetch } from "../header-forwarding"; * GPT-5.2 is a reasoning-capable variant; OpenAI's chat completions API * accepts the `reasoning_effort` parameter for it. If GPT-5.2 isn't * accessible in your environment swap to another reasoning-capable model - * such as `o3` or `gpt-5-thinking`. The runtime's tanstack converter - * already translates upstream REASONING_START / REASONING_MESSAGE_CONTENT - * / REASONING_END events into AG-UI reasoning events, so once the model - * emits reasoning the chain surfaces on the frontend with no extra - * plumbing. + * such as `o3` or `gpt-5-thinking`. */ const REASONING_MODEL = process.env.REASONING_MODEL ?? "gpt-5.2"; /** - * Built-in agent for `agentic-chat-reasoning` — visible thinking chain + * Chat-completions adapter that surfaces the model's reasoning trace. + * + * Why a custom subclass instead of `openaiText`: + * - `openaiText` uses TanStack's OpenAI *Responses*-API adapter. On the + * reasoning path it emits the trace as `STEP_STARTED` / `STEP_FINISHED` + * (`stepType: "thinking"`) chunks. The `@copilotkit/runtime` tanstack + * converter only maps upstream `REASONING_*` chunks to AG-UI + * `REASONING_MESSAGE_*` events — it has no `STEP_*` handler — so the + * reasoning is silently dropped and the `` never mounts. + * - The base chat-completions adapter DOES emit the proper + * `REASONING_START` / `REASONING_MESSAGE_START` (role `"reasoning"`) / + * `REASONING_MESSAGE_CONTENT` lifecycle — but only when + * `extractReasoning()` returns text, and its default returns `undefined` + * because the vanilla OpenAI chat-completions chunk shape has no + * reasoning field. + * + * OpenAI's chat-completions streaming places reasoning on + * `choices[0].delta.reasoning_content` (the same field aimock emits for the + * reasoning fixtures). Overriding `extractReasoning` to read it makes the + * adapter emit `REASONING_MESSAGE_*`, which the runtime converter forwards + * to the frontend as AG-UI reasoning events. + */ +/** + * Provider options for the chat-completions reasoning path. + * + * TanStack's exported `OpenAIChatCompletionsProviderOptions` only models the + * *Responses*-API reasoning shape (`reasoning: { effort, summary }`) and has + * no flat `reasoning_effort` field. But `mapOptionsToRequest` spreads + * `modelOptions` verbatim into the `/v1/chat/completions` body, where OpenAI + * expects the flat scalar `reasoning_effort` — the nested `reasoning` object + * is a Responses-API construct that chat-completions rejects/ignores. We + * widen the provider-options type with the correct chat-completions field so + * the body carries `reasoning_effort` and the literal type-checks. + */ +type ReasoningProviderOptions = OpenAIChatCompletionsProviderOptions & { + reasoning_effort?: "minimal" | "low" | "medium" | "high"; +}; + +class ReasoningChatCompletionsAdapter extends OpenAIChatCompletionsTextAdapter< + "gpt-5.2", + ReasoningProviderOptions +> { + protected override extractReasoning( + chunk: unknown, + ): { text: string } | undefined { + const delta = ( + chunk as { + choices?: Array<{ + delta?: { reasoning_content?: string; reasoning?: string }; + }>; + } + )?.choices?.[0]?.delta; + const text = delta?.reasoning_content ?? delta?.reasoning; + return text ? { text } : undefined; + } +} + +function createReasoningAdapter() { + const apiKey = process.env.OPENAI_API_KEY ?? ""; + // REASONING_MODEL is read from env (string); the adapter's generic is + // pinned to a concrete reasoning-capable chat model for typing. + return new ReasoningChatCompletionsAdapter( + { apiKey, fetch: forwardingFetch }, + REASONING_MODEL as "gpt-5.2", + ); +} + +/** + * Built-in agent for `reasoning-custom` — visible thinking chain * during normal conversation. Uses the shared server tools so the model * can interleave tool calls with reasoning naturally. */ @@ -33,7 +100,7 @@ export function createAgenticChatReasoningAgent() { factory: ({ input, abortController }) => { const { messages, systemPrompts } = convertInputToTanStackAI(input); return chat({ - adapter: openaiText(REASONING_MODEL, { fetch: forwardingFetch }), + adapter: createReasoningAdapter(), messages, systemPrompts, tools: [...baseServerTools], @@ -47,8 +114,8 @@ export function createAgenticChatReasoningAgent() { } /** - * Built-in agent for `reasoning-default-render` — same backend behaviour - * as `agentic-chat-reasoning`; the demo's value is that the frontend + * Built-in agent for `reasoning-default` — same backend behaviour + * as `reasoning-custom`; the demo's value is that the frontend * passes NO custom `reasoningMessage` slot, so CopilotKit's built-in * `CopilotChatReasoningMessage` renders the chain. Kept as its own * factory for clarity even though the body is identical today. diff --git a/showcase/integrations/claude-sdk-python/PARITY_NOTES.md b/showcase/integrations/claude-sdk-python/PARITY_NOTES.md index 54f584e98f1..56abda09f3d 100644 --- a/showcase/integrations/claude-sdk-python/PARITY_NOTES.md +++ b/showcase/integrations/claude-sdk-python/PARITY_NOTES.md @@ -86,7 +86,7 @@ left for a follow-up. ### Require streaming Claude extended-thinking plumbing -- `agentic-chat-reasoning`, `reasoning-default-render`, +- `reasoning-custom`, `reasoning-default`, `tool-rendering-reasoning-chain` — require streaming Claude extended- thinking (reasoning) blocks as distinct AG-UI message parts. The current `src/agents/agent.py` AG-UI bridge does not translate Anthropic @@ -115,7 +115,7 @@ follow-up commits. ## Follow-up buckets - Reasoning / extended-thinking plumbing in `agent.py` (unlocks - `agentic-chat-reasoning`, `reasoning-default-render`, + `reasoning-custom`, `reasoning-default`, `tool-rendering-reasoning-chain`). - A2UI catalog demos (unlocks `declarative-gen-ui`, `a2ui-fixed-schema`). - MCP client integration (unlocks `mcp-apps`). diff --git a/showcase/integrations/claude-sdk-python/manifest.yaml b/showcase/integrations/claude-sdk-python/manifest.yaml index 15e6b614467..519d522dc69 100644 --- a/showcase/integrations/claude-sdk-python/manifest.yaml +++ b/showcase/integrations/claude-sdk-python/manifest.yaml @@ -60,8 +60,8 @@ features: - voice - agent-config - auth - - agentic-chat-reasoning - - reasoning-default-render + - reasoning-custom + - reasoning-default - tool-rendering-reasoning-chain - mcp-apps - beautiful-chat @@ -371,28 +371,28 @@ demos: - src/app/demos/agent-config/config-card.tsx - src/app/demos/agent-config/use-agent-config.ts - src/app/api/copilotkit-agent-config/route.ts - - id: agentic-chat-reasoning + - id: reasoning-custom name: Agentic Chat (Reasoning) description: Visible reasoning / thinking chain rendered via a custom messageView slot tags: - chat-ui - route: /demos/agentic-chat-reasoning + route: /demos/reasoning-custom animated_preview_url: highlight: - src/agents/reasoning_agent.py - - src/app/demos/agentic-chat-reasoning/page.tsx - - src/app/demos/agentic-chat-reasoning/reasoning-block.tsx + - src/app/demos/reasoning-custom/page.tsx + - src/app/demos/reasoning-custom/reasoning-block.tsx - src/app/api/copilotkit/route.ts - - id: reasoning-default-render + - id: reasoning-default name: Reasoning (Default Render) description: Reasoning rendered via CopilotKit's built-in collapsible card with zero custom config tags: - chat-ui - route: /demos/reasoning-default-render + route: /demos/reasoning-default animated_preview_url: highlight: - src/agents/reasoning_agent.py - - src/app/demos/reasoning-default-render/page.tsx + - src/app/demos/reasoning-default/page.tsx - src/app/api/copilotkit/route.ts - id: tool-rendering-reasoning-chain name: Tool Rendering (Reasoning Chain) diff --git a/showcase/integrations/claude-sdk-python/src/agent_server.py b/showcase/integrations/claude-sdk-python/src/agent_server.py index 92ccc90f2fa..bebb17cb626 100644 --- a/showcase/integrations/claude-sdk-python/src/agent_server.py +++ b/showcase/integrations/claude-sdk-python/src/agent_server.py @@ -210,7 +210,7 @@ async def event_stream() -> AsyncIterator[str]: async def reasoning_endpoint(request: Request) -> StreamingResponse: """Reasoning demo backend — emits AG-UI REASONING_MESSAGE_* events. - Shared by the agentic-chat-reasoning and reasoning-default-render + Shared by the reasoning-custom and reasoning-default demos. Both demos hit the same backend; the difference is purely on the frontend slot configuration. """ diff --git a/showcase/integrations/claude-sdk-python/src/agents/reasoning_agent.py b/showcase/integrations/claude-sdk-python/src/agents/reasoning_agent.py index 2f2c8c21964..df30a6837e8 100644 --- a/showcase/integrations/claude-sdk-python/src/agents/reasoning_agent.py +++ b/showcase/integrations/claude-sdk-python/src/agents/reasoning_agent.py @@ -1,8 +1,8 @@ """Reasoning agent — emits AG-UI REASONING_MESSAGE_* events. Shared by: - - agentic-chat-reasoning (custom amber ReasoningBlock slot on the frontend) - - reasoning-default-render (CopilotKit's built-in reasoning slot) + - reasoning-custom (custom amber ReasoningBlock slot on the frontend) + - reasoning-default (CopilotKit's built-in reasoning slot) Approach: The Anthropic Python SDK supports Claude's extended-thinking ("thinking @@ -15,7 +15,6 @@ from __future__ import annotations -import json import os import traceback from collections.abc import AsyncIterator @@ -34,6 +33,28 @@ ) from ag_ui.encoder import EventEncoder +# Extended-thinking budget for the native reasoning channel. Anthropic +# requires max_tokens > budget_tokens when thinking is enabled. +_THINKING_BUDGET_TOKENS = 1024 + +# System prompt for the NATIVE extended-thinking path (the default). +# Extended thinking is always enabled below, so the model already emits its +# step-by-step plan on the native `thinking` channel — which we forward as +# REASONING_MESSAGE_*. We must NOT also instruct the model to wrap a plan in +# `...` text tags: against real Claude that produces a +# SECOND reasoning block (native thinking + tag-text), i.e. the double-bubble. +# So the native prompt asks only for a clean final answer; the reasoning chain +# comes entirely from the native channel. +NATIVE_REASONING_SYSTEM_PROMPT = dedent(""" + You are a helpful assistant. Think through each user question + step-by-step, then give a single concise final answer for the user. + Do not wrap your answer in any XML or markup tags. +""").strip() + +# Fallback system prompt for a NO-native-thinking deployment. If extended +# thinking is ever disabled, the inline `...` tag +# convention (parsed by the dormant state machine below) is the only way to +# surface a reasoning chain, so this prompt re-instates the tag instruction. REASONING_SYSTEM_PROMPT = dedent(""" You are a helpful assistant. For each user question, FIRST emit a short step-by-step plan inside `...` tags @@ -94,7 +115,20 @@ async def run_reasoning_agent( RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id) ) - system = system_prompt or REASONING_SYSTEM_PROMPT + # Native extended thinking is always enabled on the stream below, so the + # reasoning chain comes from the native `thinking` channel. Pick the system + # prompt to MATCH that: the native prompt does NOT instruct the model to + # emit `` tags (which would double up as a second reasoning + # block against real Claude). The tag-instructing prompt + the inline-tag + # parser stay as a dormant fallback for a no-native-thinking deployment. + # An explicit caller-supplied `system_prompt` always wins. + _native_thinking_enabled = True + default_system = ( + NATIVE_REASONING_SYSTEM_PROMPT + if _native_thinking_enabled + else REASONING_SYSTEM_PROMPT + ) + system = system_prompt or default_system text_msg_id = f"msg-{run_id}-text" reasoning_msg_id = f"msg-{run_id}-reasoning" @@ -104,9 +138,31 @@ async def run_reasoning_agent( in_reasoning = False reasoning_started = False + # Idempotency guard for the inline-``-tag REASONING_MESSAGE_END. + # Set once END is emitted for `reasoning_msg_id` so the in-stream close, the + # post-stream buffer flush, and the error/early-end cleanup never + # double-emit an END for the same inline reasoning block. + reasoning_ended = False text_started = False buffer = "" + # Native extended-thinking streaming state. When the model (or aimock + # replay) emits Anthropic `thinking`/`thinking_delta` blocks we forward + # them as REASONING_MESSAGE_* directly — independent of the inline + # ``-tag text parsing below, which stays as the no-thinking + # fallback. Mirrors claude-sdk-typescript's /reasoning handler. + # + # A single turn may contain MORE THAN ONE `thinking` content block. Each + # gets its OWN message id (incorporating `id(block)`) assigned on its + # content_block_start, and `native_reasoning_started` is reset to False at + # BOTH block start and block stop so every thinking block emits its own + # balanced START/CONTENT/END lifecycle rather than reopening an already + # ENDED message id. `native_reasoning_id is not None` is the "a native + # thinking block is currently open" sentinel. Mirrors the sibling + # tool_rendering_reasoning_chain_agent.py pattern. + native_reasoning_id: str | None = None + native_reasoning_started = False + # Import lazily so missing imports don't crash module import-time # on older ag_ui versions. from ag_ui.core import ( @@ -115,18 +171,76 @@ async def run_reasoning_agent( ReasoningMessageStartEvent, ) + # Extended thinking enabled so Claude streams native thinking blocks. + # Overridable via ANTHROPIC_REASONING_MODEL (falling back to + # ANTHROPIC_MODEL). Under aimock replay the thinking channel comes from + # the fixture's `reasoning` field regardless of the model name. + reasoning_model = os.getenv( + "ANTHROPIC_REASONING_MODEL", + os.getenv("ANTHROPIC_MODEL", "claude-opus-4-5"), + ) + try: async with client.messages.stream( - model=os.getenv("ANTHROPIC_MODEL", "claude-opus-4-5"), - max_tokens=2048, + model=reasoning_model, + max_tokens=_THINKING_BUDGET_TOKENS + 2048, system=system, messages=messages, + thinking={ + "type": "enabled", + "budget_tokens": _THINKING_BUDGET_TOKENS, + }, ) as stream: async for event in stream: etype = type(event).__name__ + if etype == "RawContentBlockStartEvent": + block = event.content_block # type: ignore[attr-defined] + if getattr(block, "type", None) == "thinking": + # Fresh per-block id so two thinking blocks in one turn + # each get their own balanced lifecycle. Reset + # `started` so a new START is emitted for THIS block. + native_reasoning_id = f"msg-{run_id}-think-{id(block)}" + native_reasoning_started = False + continue + if etype in ( + "RawContentBlockStopEvent", + "ParsedContentBlockStopEvent", + ): + if native_reasoning_id is not None: + if native_reasoning_started: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=native_reasoning_id, + ) + ) + # Reset both so the next thinking block starts clean. + native_reasoning_id = None + native_reasoning_started = False + continue if etype != "RawContentBlockDeltaEvent": continue delta = event.delta # type: ignore[attr-defined] + if delta.type == "thinking_delta" and native_reasoning_id: + thinking_text = getattr(delta, "thinking", "") or "" + if thinking_text: + if not native_reasoning_started: + native_reasoning_started = True + yield encoder.encode( + ReasoningMessageStartEvent( + type=EventType.REASONING_MESSAGE_START, + message_id=native_reasoning_id, + role="reasoning", + ) + ) + yield encoder.encode( + ReasoningMessageContentEvent( + type=EventType.REASONING_MESSAGE_CONTENT, + message_id=native_reasoning_id, + delta=thinking_text, + ) + ) + continue if delta.type != "text_delta": continue buffer += delta.text @@ -178,13 +292,14 @@ async def run_reasoning_agent( delta=chunk, ) ) - if reasoning_started: + if reasoning_started and not reasoning_ended: yield encoder.encode( ReasoningMessageEndEvent( type=EventType.REASONING_MESSAGE_END, message_id=reasoning_msg_id, ) ) + reasoning_ended = True buffer = buffer[close_idx + len(REASONING_CLOSE) :] in_reasoning = False continue @@ -238,6 +353,20 @@ async def run_reasoning_agent( in_reasoning = True continue + # Lifecycle guarantee: if a native thinking block streamed content + # but its content_block_stop never arrived (e.g. stream ended early), + # still close the reasoning message so the UI doesn't render an + # in-flight thinking bubble. + if native_reasoning_id is not None and native_reasoning_started: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=native_reasoning_id, + ) + ) + native_reasoning_id = None + native_reasoning_started = False + # Flush any remaining buffered content as text. if buffer: if in_reasoning: @@ -257,12 +386,14 @@ async def run_reasoning_agent( delta=buffer, ) ) - yield encoder.encode( - ReasoningMessageEndEvent( - type=EventType.REASONING_MESSAGE_END, - message_id=reasoning_msg_id, + if not reasoning_ended: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=reasoning_msg_id, + ) ) - ) + reasoning_ended = True else: if not text_started: yield encoder.encode( @@ -280,7 +411,49 @@ async def run_reasoning_agent( delta=buffer, ) ) + + # Lifecycle guarantee for the inline-``-tag block: if the + # stream ended mid-inline-block (open `` with started content + # but no ``) and the buffer-flush above did not already emit + # the END (e.g. the buffer was empty because all content had already + # been streamed), close it now so the UI doesn't render a perpetual + # in-flight reasoning bubble. Mirrors the native-channel guard above. + if reasoning_started and not reasoning_ended: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=reasoning_msg_id, + ) + ) + reasoning_ended = True except Exception: + # Lifecycle guarantee on the error path: if the stream raised while a + # native thinking block was still open, close it before emitting the + # error text bubble so the UI doesn't render a perpetual in-flight + # thinking bubble. Mirrors the normal-completion guard above. + if native_reasoning_id is not None and native_reasoning_started: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=native_reasoning_id, + ) + ) + native_reasoning_id = None + native_reasoning_started = False + # Same guarantee for the inline-``-tag block: if the stream + # raised while inside an open inline reasoning block whose END had not + # yet been emitted, close it before the error text bubble so the UI + # doesn't strand a perpetual in-flight reasoning bubble. The + # `reasoning_ended` flag keeps this idempotent with the in-stream close + # and the normal-path cleanup. + if reasoning_started and not reasoning_ended: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=reasoning_msg_id, + ) + ) + reasoning_ended = True err_text = f"Agent error: {traceback.format_exc()}" if not text_started: yield encoder.encode( diff --git a/showcase/integrations/claude-sdk-python/src/agents/tool_rendering_reasoning_chain_agent.py b/showcase/integrations/claude-sdk-python/src/agents/tool_rendering_reasoning_chain_agent.py index 392735cae1a..11d5ccc5b7b 100644 --- a/showcase/integrations/claude-sdk-python/src/agents/tool_rendering_reasoning_chain_agent.py +++ b/showcase/integrations/claude-sdk-python/src/agents/tool_rendering_reasoning_chain_agent.py @@ -15,7 +15,7 @@ import traceback from collections.abc import AsyncIterator from textwrap import dedent -from typing import Any +from typing import Any, Literal, TypedDict, Union, cast import anthropic from ag_ui.core import ( @@ -36,6 +36,23 @@ ) from ag_ui.encoder import EventEncoder + +# Typed shapes for the assistant-history thinking blocks replayed to Anthropic +# on tool-loop continuation. Type-only — does NOT change how blocks are built. +class ThinkingBlock(TypedDict): + type: Literal["thinking"] + thinking: str + signature: str + + +class RedactedThinkingBlock(TypedDict): + type: Literal["redacted_thinking"] + data: str + + +ThinkingContentBlock = Union[ThinkingBlock, RedactedThinkingBlock] + + TOOLS: list[dict[str, Any]] = [ { "name": "get_weather", @@ -87,6 +104,39 @@ """).strip() +# Extended-thinking budget for the native reasoning channel. Anthropic +# requires max_tokens > budget_tokens when thinking is enabled. +_THINKING_BUDGET_TOKENS = 2048 + + +def _build_stream_kwargs(messages: list[dict[str, Any]]) -> dict[str, Any]: + """Build the Anthropic `messages.stream` kwargs with extended thinking + enabled so Claude streams native `thinking`/`thinking_delta` blocks. + + Mirrors claude-sdk-typescript's /reasoning handler: a thinking-capable + model plus `thinking={"type": "enabled", ...}`. The model is overridable + via ANTHROPIC_REASONING_MODEL (falling back to ANTHROPIC_MODEL) so a + deployment can pin a different extended-thinking model. Under aimock + replay the thinking channel is produced from the fixture's `reasoning` + field regardless of the model name. + """ + model = os.getenv( + "ANTHROPIC_REASONING_MODEL", + os.getenv("ANTHROPIC_MODEL", "claude-opus-4-5"), + ) + return { + "model": model, + "max_tokens": _THINKING_BUDGET_TOKENS + 2048, + "system": SYSTEM_PROMPT, + "messages": messages, + "tools": TOOLS, + "thinking": { + "type": "enabled", + "budget_tokens": _THINKING_BUDGET_TOKENS, + }, + } + + def _execute_tool(name: str, args: dict[str, Any]) -> dict[str, Any]: if name == "get_weather": return { @@ -188,7 +238,28 @@ async def run_tool_rendering_reasoning_chain_agent( response_text = "" tool_calls: list[dict[str, Any]] = [] - async def flush_reasoning(chunk: str): + # Native extended-thinking streaming state. Hoisted to the iteration + # scope (not the `async with` body) so the `except`/post-stream cleanup + # can close an open native thinking block on the error path. Also + # accumulates the thinking text + signature so the assistant turn can + # be replayed to Anthropic with its original thinking block(s) first + # (required when continuing a tool-use conversation with extended + # thinking enabled). + native_reasoning_id: str | None = None + native_reasoning_started = False + # Per-block accumulators for the thinking block currently streaming. + thinking_text_acc = "" + thinking_signature = "" + # ALL thinking blocks of this assistant turn, in stream order. Anthropic + # requires that when continuing a tool loop with extended thinking, the + # assistant turn is replayed with EVERY original thinking block (each + # with its signature) — not just the last one — as the leading content + # blocks. `redacted_thinking` blocks are captured here too (as + # `{"type": "redacted_thinking", "data": ...}`) since they must also be + # preserved verbatim or Anthropic 400s on continuation. + thinking_blocks: list[ThinkingContentBlock] = [] + + async def flush_reasoning(chunk: str) -> AsyncIterator[str]: nonlocal reasoning_started if not chunk: return @@ -211,20 +282,52 @@ async def flush_reasoning(chunk: str): try: async with client.messages.stream( - model=os.getenv("ANTHROPIC_MODEL", "claude-opus-4-5"), - max_tokens=2048, - system=SYSTEM_PROMPT, - messages=messages, - tools=TOOLS, + **_build_stream_kwargs(messages), ) as stream: current_tool_id: str | None = None current_tool_name: str | None = None current_tool_args = "" + # Native extended-thinking streaming. When the model (or + # aimock replay) emits Anthropic `thinking`/`thinking_delta` + # blocks, forward them as REASONING_MESSAGE_* — independent of + # the inline ``-tag text path below, which stays as + # the no-thinking fallback. Mirrors claude-sdk-typescript's + # /reasoning handler. (State declared at iteration scope above + # so error-path cleanup can close an open block.) async for event in stream: etype = type(event).__name__ if etype == "RawContentBlockStartEvent": block = event.content_block # type: ignore[attr-defined] + if block.type == "thinking": + native_reasoning_id = ( + f"think-{run_id}-{iteration}-{id(block)}" + ) + native_reasoning_started = False + # Reset per-block accumulators for the new thinking + # block. Seed the signature from the block's initial + # `signature` field if Anthropic provides one up + # front. The completed block is appended to + # `thinking_blocks` on its content_block_stop. + thinking_text_acc = "" + thinking_signature = getattr(block, "signature", "") or "" + continue + if block.type == "redacted_thinking": + # Redacted thinking has no deltas — its opaque + # `data` blob arrives on the block start. Capture it + # immediately so the assistant turn can be replayed + # with the redacted block preserved verbatim (else + # Anthropic 400s on tool-loop continuation). Not + # surfaced to the UI. + redacted_data = getattr(block, "data", "") or "" + if redacted_data: + thinking_blocks.append( + { + "type": "redacted_thinking", + "data": redacted_data, + } + ) + continue if block.type == "tool_use": # Flush any pending text/reasoning buffer first. if buffer: @@ -263,7 +366,36 @@ async def flush_reasoning(chunk: str): ) elif etype == "RawContentBlockDeltaEvent": delta = event.delta # type: ignore[attr-defined] - if delta.type == "text_delta": + if delta.type == "thinking_delta" and native_reasoning_id: + thinking_text = getattr(delta, "thinking", "") or "" + if thinking_text: + # Accumulate for history reconstruction. + thinking_text_acc += thinking_text + if not native_reasoning_started: + native_reasoning_started = True + yield encoder.encode( + ReasoningMessageStartEvent( + type=EventType.REASONING_MESSAGE_START, + message_id=native_reasoning_id, + role="reasoning", + ) + ) + yield encoder.encode( + ReasoningMessageContentEvent( + type=EventType.REASONING_MESSAGE_CONTENT, + message_id=native_reasoning_id, + delta=thinking_text, + ) + ) + elif delta.type == "signature_delta" and native_reasoning_id: + # Anthropic streams the thinking block's + # cryptographic signature as a `signature_delta` on + # the thinking content block. Accumulate it so the + # replayed assistant turn carries the original + # signature (required for tool-loop continuation + # with extended thinking). Not surfaced to the UI. + thinking_signature += getattr(delta, "signature", "") or "" + elif delta.type == "text_delta": buffer += delta.text # Drain while True: @@ -356,7 +488,31 @@ async def flush_reasoning(chunk: str): "RawContentBlockStopEvent", "ParsedContentBlockStopEvent", ): - if current_tool_id and current_tool_name: + if native_reasoning_id is not None: + if native_reasoning_started: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=native_reasoning_id, + ) + ) + # Finalize THIS thinking block into the per-turn + # list (in stream order) so the assistant turn is + # replayed with every thinking block, each carrying + # its own signature — not just the last one. + if thinking_text_acc: + thinking_blocks.append( + { + "type": "thinking", + "thinking": thinking_text_acc, + "signature": thinking_signature, + } + ) + thinking_text_acc = "" + thinking_signature = "" + native_reasoning_id = None + native_reasoning_started = False + elif current_tool_id and current_tool_name: yield encoder.encode( ToolCallEndEvent( type=EventType.TOOL_CALL_END, @@ -382,6 +538,20 @@ async def flush_reasoning(chunk: str): current_tool_name = None current_tool_args = "" except Exception: + # Lifecycle guarantee on the error path: if the stream raised while + # a native thinking block was still open, close it before emitting + # the error text bubble so the UI doesn't render a perpetual + # in-flight thinking bubble. (The post-stream cleanup below closes + # the ``-tag id; this closes the native one.) + if native_reasoning_id is not None and native_reasoning_started: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=native_reasoning_id, + ) + ) + native_reasoning_id = None + native_reasoning_started = False err_text = f"Agent error: {traceback.format_exc()}" if not text_started: text_started = True @@ -400,6 +570,20 @@ async def flush_reasoning(chunk: str): ) ) + # Lifecycle guarantee on the normal path: if a native thinking block + # streamed content but its content_block_stop never arrived (e.g. the + # stream ended early without raising), still close the reasoning + # message so the UI doesn't render an in-flight thinking bubble. + if native_reasoning_id is not None and native_reasoning_started: + yield encoder.encode( + ReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=native_reasoning_id, + ) + ) + native_reasoning_id = None + native_reasoning_started = False + # Flush remaining buffer. if buffer: if in_reasoning: @@ -454,7 +638,25 @@ async def flush_reasoning(chunk: str): break # Append assistant + tool_use blocks to history. + # + # Anthropic requires that when continuing a tool-use conversation with + # extended thinking enabled, the assistant turn that produced the + # tool_use is re-sent with ALL of its ORIGINAL thinking blocks — each + # including its `signature` — as the LEADING content blocks, in their + # original stream order. Without them, iteration 2+ of the tool loop + # fails with a 400 ("expected `thinking` ... as the first block / + # signature required"). A turn may contain MORE THAN ONE thinking block + # (and/or `redacted_thinking` blocks), so we replay the full + # `thinking_blocks` list rather than only the last one. Each thinking + # block's signature is accumulated from its `signature_delta` events + # during streaming; redacted blocks carry their opaque `data`. (Under + # aimock replay a signature is an empty string, preserved verbatim.) assistant_content: list[dict[str, Any]] = [] + # Type-only widening: TypedDict is invariant, so extending the + # `dict[str, Any]` list with `list[ThinkingContentBlock]` needs an + # explicit widen. No runtime/behavior change — the dicts are appended + # verbatim in stream order, exactly as before. + assistant_content.extend(cast("list[dict[str, Any]]", thinking_blocks)) if response_text: assistant_content.append({"type": "text", "text": response_text}) for tc in tool_calls: diff --git a/showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/README.md b/showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/README.md deleted file mode 100644 index 8aaa7568274..00000000000 --- a/showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Agentic Chat (Reasoning) - -Visible reasoning / thinking chain rendered via a custom `reasoningMessage` slot. - -The Python agent emits AG-UI `REASONING_MESSAGE_*` events by parsing -`...` blocks out of Claude's text stream. The -`ReasoningBlock` component re-skins them as an amber-tagged, italic -"Agent reasoning" card. diff --git a/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/README.md b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/README.md new file mode 100644 index 00000000000..406f4ad805c --- /dev/null +++ b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/README.md @@ -0,0 +1,12 @@ +# Reasoning (Custom Render) + +Visible reasoning / thinking chain rendered via a custom `reasoningMessage` slot. + +The Python agent emits AG-UI `REASONING_MESSAGE_*` events primarily by +forwarding Claude's native extended-thinking (`thinking_delta`) channel — +extended thinking is enabled on the stream, so Claude's step-by-step plan +arrives on the native `thinking` content blocks, which the agent maps +directly onto `REASONING_MESSAGE_*`. Parsing `...` +blocks out of the text stream remains only as a fallback for a +no-native-thinking deployment. The `ReasoningBlock` component re-skins the +reasoning as an amber-tagged, italic "Agent reasoning" card. diff --git a/showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/page.tsx b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/page.tsx similarity index 100% rename from showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/page.tsx rename to showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/page.tsx diff --git a/showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/reasoning-block.tsx b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/reasoning-block.tsx similarity index 100% rename from showcase/integrations/claude-sdk-python/src/app/demos/agentic-chat-reasoning/reasoning-block.tsx rename to showcase/integrations/claude-sdk-python/src/app/demos/reasoning-custom/reasoning-block.tsx diff --git a/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default-render/README.md b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default/README.md similarity index 70% rename from showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default-render/README.md rename to showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default/README.md index c1e92f66b90..d3ba37f9ee8 100644 --- a/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default-render/README.md +++ b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default/README.md @@ -1,6 +1,6 @@ # Reasoning (Default Render) -Same backend as `agentic-chat-reasoning`, but the page passes NO custom +Same backend as `reasoning-custom`, but the page passes NO custom `reasoningMessage` slot — CopilotKit's built-in `CopilotChatReasoningMessage` renders the reasoning as a collapsible "Thought for X" card. diff --git a/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default-render/page.tsx b/showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default/page.tsx similarity index 100% rename from showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default-render/page.tsx rename to showcase/integrations/claude-sdk-python/src/app/demos/reasoning-default/page.tsx diff --git a/showcase/integrations/claude-sdk-typescript/package-lock.json b/showcase/integrations/claude-sdk-typescript/package-lock.json index 8456c98b831..8fb5ecb8ab4 100644 --- a/showcase/integrations/claude-sdk-typescript/package-lock.json +++ b/showcase/integrations/claude-sdk-typescript/package-lock.json @@ -8,9 +8,9 @@ "name": "@copilotkit/showcase-claude-sdk-typescript", "version": "0.1.0", "dependencies": { - "@ag-ui/client": "^0.0.43", - "@ag-ui/core": "^0.0.48", - "@ag-ui/encoder": "^0.0.48", + "@ag-ui/client": "0.0.48", + "@ag-ui/core": "0.0.48", + "@ag-ui/encoder": "0.0.48", "@anthropic-ai/sdk": "^0.57.0", "@copilotkit/a2ui-renderer": "1.59.4", "@copilotkit/react-core": "1.59.4", @@ -100,13 +100,13 @@ "integrity": "sha512-9U4DtwJ6rHO4vn4ixYVnRJGrO7u07phT/AjgsHymLf4cvPw57PNZACc4y6eTtayG0IcySNqRGW/wE+qjlXzgzw==" }, "node_modules/@ag-ui/client": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.43.tgz", - "integrity": "sha512-150Y3TX+k83qvtmp4bTxmPW0Xu7to2y5vlhD8s5gof5/fxMdxybM1ieZazN7OIWU0nCK/hvhA+bVC7mmyby3gw==", + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.48.tgz", + "integrity": "sha512-JAYH8gScC4JXxfsH8mfhfMzoQ/V0pUK2EapNCDa9TBpma2nraPNWFfxCneClhrk97Kb0wZCxviBKeJbvS++QGQ==", "dependencies": { - "@ag-ui/core": "0.0.43", - "@ag-ui/encoder": "0.0.43", - "@ag-ui/proto": "0.0.43", + "@ag-ui/core": "0.0.48", + "@ag-ui/encoder": "0.0.48", + "@ag-ui/proto": "0.0.48", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", @@ -116,24 +116,6 @@ "zod": "^3.22.4" } }, - "node_modules/@ag-ui/client/node_modules/@ag-ui/core": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.43.tgz", - "integrity": "sha512-/T7kKwPAhtriFFiv3YxZ0yAzMHoY8a8I5UKzhPzwW934h92c6RJ54N93yb9PAqdX3XjCPId0C5gIBHb6QbeR3Q==", - "dependencies": { - "rxjs": "7.8.1", - "zod": "^3.22.4" - } - }, - "node_modules/@ag-ui/client/node_modules/@ag-ui/encoder": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.43.tgz", - "integrity": "sha512-0b7VXFW5KMctdWsGFQEkbIU2gY/tUv0eWBbMchIvjiIS1aHxdGTB6NXHXJzGvDcXEb3urkIvVIFGvpk0WOUhVw==", - "dependencies": { - "@ag-ui/core": "0.0.43", - "@ag-ui/proto": "0.0.43" - } - }, "node_modules/@ag-ui/core": { "version": "0.0.48", "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.48.tgz", @@ -152,16 +134,6 @@ "@ag-ui/proto": "0.0.48" } }, - "node_modules/@ag-ui/encoder/node_modules/@ag-ui/proto": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.48.tgz", - "integrity": "sha512-8kfOLdDRLg9B8I+eAlO/hqZ+iPPTqSYXrh9VN05cvLAEyoyeNE37A6XRDE1VBD+PevsCQmeJQDs2MfqY7UlDWQ==", - "dependencies": { - "@ag-ui/core": "0.0.48", - "@bufbuild/protobuf": "^2.2.5", - "@protobuf-ts/protoc": "^2.11.1" - } - }, "node_modules/@ag-ui/langgraph": { "version": "0.0.37", "resolved": "https://registry.npmjs.org/@ag-ui/langgraph/-/langgraph-0.0.37.tgz", @@ -248,24 +220,15 @@ } }, "node_modules/@ag-ui/proto": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.43.tgz", - "integrity": "sha512-JcGjwMyxd5KiRTVi3158eByPXm9lKDRMIApYgSlzhXpBwyfpub8waGFnEWqjE3U+gA1LaZHiVMIfD5NCVYj4WQ==", + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.48.tgz", + "integrity": "sha512-8kfOLdDRLg9B8I+eAlO/hqZ+iPPTqSYXrh9VN05cvLAEyoyeNE37A6XRDE1VBD+PevsCQmeJQDs2MfqY7UlDWQ==", "dependencies": { - "@ag-ui/core": "0.0.43", + "@ag-ui/core": "0.0.48", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1" } }, - "node_modules/@ag-ui/proto/node_modules/@ag-ui/core": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.43.tgz", - "integrity": "sha512-/T7kKwPAhtriFFiv3YxZ0yAzMHoY8a8I5UKzhPzwW934h92c6RJ54N93yb9PAqdX3XjCPId0C5gIBHb6QbeR3Q==", - "dependencies": { - "rxjs": "7.8.1", - "zod": "^3.22.4" - } - }, "node_modules/@ai-sdk/anthropic": { "version": "3.0.78", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.78.tgz", diff --git a/showcase/integrations/claude-sdk-typescript/package.json b/showcase/integrations/claude-sdk-typescript/package.json index a4560cd47f6..5a50c1d0329 100644 --- a/showcase/integrations/claude-sdk-typescript/package.json +++ b/showcase/integrations/claude-sdk-typescript/package.json @@ -11,9 +11,9 @@ "test:e2e": "playwright test" }, "dependencies": { - "@ag-ui/client": "^0.0.43", - "@ag-ui/core": "^0.0.48", - "@ag-ui/encoder": "^0.0.48", + "@ag-ui/client": "0.0.48", + "@ag-ui/core": "0.0.48", + "@ag-ui/encoder": "0.0.48", "@anthropic-ai/sdk": "^0.57.0", "@copilotkit/a2ui-renderer": "1.59.4", "@copilotkit/react-core": "1.59.4", diff --git a/showcase/integrations/claude-sdk-typescript/src/agent_server.ts b/showcase/integrations/claude-sdk-typescript/src/agent_server.ts index b3c4c87dcac..ecc4d4ff2db 100644 --- a/showcase/integrations/claude-sdk-typescript/src/agent_server.ts +++ b/showcase/integrations/claude-sdk-typescript/src/agent_server.ts @@ -564,7 +564,7 @@ function makeAgentHandler(config: DemoConfig = {}) { emit({ type: EventType.REASONING_MESSAGE_START, messageId: reasoningMsgId, - role: "assistant", + role: "reasoning", }); reasoningStarted = true; } diff --git a/showcase/integrations/crewai-crews/PARITY_NOTES.md b/showcase/integrations/crewai-crews/PARITY_NOTES.md index 39e0ce5861b..09e9dc664a2 100644 --- a/showcase/integrations/crewai-crews/PARITY_NOTES.md +++ b/showcase/integrations/crewai-crews/PARITY_NOTES.md @@ -41,8 +41,8 @@ Ported demos therefore fall into three categories: | chat-customization-css | Chrome | CSS custom-properties theming | | headless-simple | Chrome / Headless | `useAgent` + `useComponent` | | headless-complete | Chrome / Headless | Full headless implementation | -| agentic-chat-reasoning | Reasoning | Uses the shared crew; reasoning tokens if model emits | -| reasoning-default-render | Reasoning | Default CopilotChatReasoningMessage | +| reasoning-custom | Reasoning | Uses the shared crew; reasoning tokens if model emits | +| reasoning-default | Reasoning | Default CopilotChatReasoningMessage | | tool-rendering-default-catchall | Rendering | Out-of-the-box default renderer | | tool-rendering-custom-catchall | Rendering | Custom wildcard renderer | | tool-rendering-reasoning-chain | Rendering | Sequential tool calls + reasoning | @@ -203,3 +203,110 @@ Already covered implicitly by the root manifest. Only the three architectural-skips remain out of the LangGraph-Python demo set. + +## Reasoning demos — framework-bridge limitation (no `REASONING_MESSAGE_*`) + +### Affected cells + +- `reasoning-custom` +- `reasoning-default` +- `tool-rendering-reasoning-chain` + +All three are registered in `src/app/api/copilotkit/route.ts` as agent +names that resolve to the **shared `LatestAiDevelopment` crew** via +`HttpAgent` pointed at `/` (the FastAPI `add_crewai_crew_fastapi_endpoint` +mount). There is no dedicated reasoning agent module — these cells reuse +the shared crew, exactly like the other frontend-first ports. + +The Wave-1 table above lists these as ported with the caveat "reasoning +tokens if model emits." That caveat is structurally incorrect: the +CrewAI AG-UI bridge **cannot emit reasoning to AG-UI at all**, regardless +of model. This section documents why and what a real fix requires. + +### What backs the reasoning cells + +The frontend is correct and matches the LangGraph-Python gold standard: +`tool-rendering-reasoning-chain/page.tsx` (and the `reasoning-*` pages) +wire a `reasoningMessage` slot that renders the custom `ReasoningBlock`. +That slot only paints when the agent streams AG-UI `REASONING_MESSAGE_*` +events with `role: "reasoning"`. The demo is built right — the events +never arrive. + +### Why the bridge can't emit `REASONING_MESSAGE_*` (or anything reasoning) + +The request flows entirely through `ag-ui-crewai` (pinned +`>=0.2.0,<0.3.0`; verified against the installed `0.2.0`): + +1. `ag_ui_crewai.crews.ChatWithCrewFlow.chat()` runs the chat LLM via + `litellm.acompletion(model=self.crew.chat_llm, ..., stream=True)`. + The shared crew's `chat_llm` is **`gpt-4o`** (`src/agents/crew.py`), + a non-reasoning chat-completions model that emits no + `reasoning_content` in the first place. +2. The stream is consumed by `ag_ui_crewai.sdk.copilotkit_stream` → + `_copilotkit_stream_custom_stream_wrapper`. That loop reads **only** + `chunk.choices[0].delta.content` (→ `TEXT_MESSAGE_CHUNK`) and + `chunk.choices[0].delta.tool_calls` (→ `TOOL_CALL_CHUNK`). It never + inspects `delta.reasoning_content`. +3. The bridge's entire event vocabulary (`ag_ui_crewai/events.py`) is + four bridged types — `TextMessageChunkEvent`, `ToolCallChunkEvent`, + `CustomEvent`, `StateSnapshotEvent`. The FastAPI endpoint + (`ag_ui_crewai/endpoint.py`) registers AG-UI forwarding listeners for + exactly those four. **There is no reasoning event in the bridge** — + not `REASONING_MESSAGE_*` (the channel `@ag-ui/client` renders), and + not `THINKING_*` (which `@ag-ui/client` drops anyway). Nothing + reasoning-shaped is produced or forwarded. + +So even pointing the crew at a reasoning-capable model would not light +up the slot: the bridge discards `reasoning_content` before it can +become an AG-UI event. + +### Why the agno / claude-sdk-python custom-synth pattern does NOT port here + +Other non-Responses-API integrations (`agno/src/agent_server.py`, +`claude-sdk-python/src/agents/reasoning_agent.py`) DO emit +`REASONING_MESSAGE_*`. Their PRIMARY path reads the model's native +reasoning channel — agno reads `RunContentEvent.reasoning_content`; +claude-sdk-python reads Anthropic's Messages-API `thinking_delta` — and +re-emits it as reasoning-role events. Only as a FALLBACK (when no native +reasoning channel is present) do they buffer the assistant text, parse a +`` span, and re-emit that. Both paths work there +because **those integrations own their entire agent-server endpoint** — +they hand-write the async generator that yields the AG-UI event stream, +so they control native-channel forwarding, buffering, and emission. + +crewai-crews owns no such loop. The whole request lifecycle — +the litellm stream, the chunk→event translation, the crewai event bus, +the SSE encoder, kickoff/teardown — lives inside +`add_crewai_crew_fastapi_endpoint`. The showcase's only sanctioned +extension points are preseeding the system prompt +(`_chat_flow_helpers.preseed_system_prompt`) and monkey-patching +`ChatWithCrewFlow.__init__` (`install_custom_system_message`). Neither +touches the streaming path. Synthesizing reasoning would require forking +or monkey-patching `copilotkit_stream` itself — the chunk-by-chunk heart +of the bridge that never buffers a full assistant message — which is a +framework fork, brittle across `ag-ui-crewai` releases, and exactly the +kind of demo-hack this repo prohibits. There is no clean, supported +synth seam for crewai-crews. + +### What a real fix requires (upstream `ag-ui-crewai`) + +A first-class fix belongs in the bridge, not the showcase: + +1. Add a `BridgedReasoningMessageChunkEvent` (mapping to AG-UI + `REASONING_MESSAGE_*`, `role: "reasoning"`) to + `ag_ui_crewai/events.py`, and register a forwarding listener in + `endpoint.py`. +2. In `copilotkit_stream._copilotkit_stream_custom_stream_wrapper`, read + `chunk.choices[0].delta.reasoning_content` (the litellm + chat-completions reasoning field) and emit the new reasoning chunk + event, mirroring the existing `content` / `tool_calls` handling. +3. Point the reasoning cells' crew at a reasoning-capable chat-completions + model whose litellm adapter populates `reasoning_content` (e.g. a + DeepSeek-R1-class or o-series-via-litellm model), or wire a dedicated + reasoning crew on its own mount the way Wave 3 added dedicated crews. + +Until `ag-ui-crewai` surfaces reasoning, the three reasoning cells render +the assistant answer and any tool cards correctly, but the +`reasoningMessage` slot stays empty — the chain-of-thought channel is a +bridge-level dead end on CrewAI today. The cells are intentionally left +in place (frontend is parity-correct) rather than weakened or removed. diff --git a/showcase/integrations/langgraph-fastapi/requirements.txt b/showcase/integrations/langgraph-fastapi/requirements.txt index e27d116266f..917a0790ef1 100644 --- a/showcase/integrations/langgraph-fastapi/requirements.txt +++ b/showcase/integrations/langgraph-fastapi/requirements.txt @@ -7,7 +7,7 @@ langgraph-cli[inmem]==0.4.21 langgraph-api==0.7.101 langsmith==0.7.33 openai==1.109.1 -ag-ui-langgraph[fastapi]>=0.0.38 +ag-ui-langgraph[fastapi]>=0.0.40 ag-ui-protocol==0.1.18 pypdf>=4.0.0 deepagents>=0.5.3,<0.6.0 diff --git a/showcase/integrations/langgraph-typescript/src/agent/package-lock.json b/showcase/integrations/langgraph-typescript/src/agent/package-lock.json index bb1b684973b..1c08e4ae5ff 100644 --- a/showcase/integrations/langgraph-typescript/src/agent/package-lock.json +++ b/showcase/integrations/langgraph-typescript/src/agent/package-lock.json @@ -25,9 +25,9 @@ } }, "node_modules/@ag-ui/a2ui-toolkit": { - "version": "0.0.1-alpha.3", - "resolved": "https://registry.npmjs.org/@ag-ui/a2ui-toolkit/-/a2ui-toolkit-0.0.1-alpha.3.tgz", - "integrity": "sha512-9U4DtwJ6rHO4vn4ixYVnRJGrO7u07phT/AjgsHymLf4cvPw57PNZACc4y6eTtayG0IcySNqRGW/wE+qjlXzgzw==" + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@ag-ui/a2ui-toolkit/-/a2ui-toolkit-0.0.2.tgz", + "integrity": "sha512-HFphlNxBxGSQfvxlI2LCQValSMDUTh3MAsaFMgYlF8sQXgCrXNiLJ70+Dz3uyOv4y/rfqdFafvlo1GKQtEVIVA==" }, "node_modules/@ag-ui/client": { "version": "0.0.53", @@ -77,11 +77,11 @@ } }, "node_modules/@ag-ui/langgraph": { - "version": "0.0.37", - "resolved": "https://registry.npmjs.org/@ag-ui/langgraph/-/langgraph-0.0.37.tgz", - "integrity": "sha512-N/u2axTbnvd9MLIzHX1T7YE90X6zTEuTEI3yEud4ywIjBov5qdgA3MqhCqfcgjeJnKKp78AvcMCQ5zMk6aiPkA==", + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@ag-ui/langgraph/-/langgraph-0.0.39.tgz", + "integrity": "sha512-+pFw49I9liEt8omTFFiie2YdtRFodjnWQTgN0Vxgo2XdC68xtyUy6I68D0QlZJE2Yy29oEx377vvkrNkL2AplA==", "dependencies": { - "@ag-ui/a2ui-toolkit": "0.0.1-alpha.3", + "@ag-ui/a2ui-toolkit": "0.0.2", "@langchain/core": "^1.1.40", "@langchain/langgraph-sdk": "^1.8.8", "langchain": ">=1.2.0", diff --git a/showcase/integrations/langgraph-typescript/src/agent/package.json b/showcase/integrations/langgraph-typescript/src/agent/package.json index 694ebeaf1c9..abefb1f955f 100644 --- a/showcase/integrations/langgraph-typescript/src/agent/package.json +++ b/showcase/integrations/langgraph-typescript/src/agent/package.json @@ -23,6 +23,6 @@ "@types/node": "^22.9.0" }, "overrides": { - "@ag-ui/langgraph": "0.0.37" + "@ag-ui/langgraph": "0.0.39" } } diff --git a/showcase/integrations/llamaindex/manifest.yaml b/showcase/integrations/llamaindex/manifest.yaml index df06d8376ce..d6184938609 100644 --- a/showcase/integrations/llamaindex/manifest.yaml +++ b/showcase/integrations/llamaindex/manifest.yaml @@ -27,10 +27,10 @@ features: - beautiful-chat - cli-start - agentic-chat - - agentic-chat-reasoning + - reasoning-custom - gen-ui-tool-based - mcp-apps - - reasoning-default-render + - reasoning-default - tool-rendering-reasoning-chain - frontend-tools - frontend-tools-async @@ -364,6 +364,29 @@ demos: highlight: - src/app/demos/readonly-state-agent-context/page.tsx - src/app/api/copilotkit/route.ts + - id: reasoning-custom + name: Agentic Chat (Reasoning) + description: Visible reasoning / thinking chain rendered via a custom messageView slot + tags: + - chat-ui + route: /demos/reasoning-custom + animated_preview_url: + highlight: + - src/agents/reasoning_agent.py + - src/app/demos/reasoning-custom/page.tsx + - src/app/demos/reasoning-custom/reasoning-block.tsx + - src/app/api/copilotkit/route.ts + - id: reasoning-default + name: Reasoning (Default Render) + description: Reasoning rendered via CopilotKit's built-in collapsible card with zero custom config + tags: + - chat-ui + route: /demos/reasoning-default + animated_preview_url: + highlight: + - src/agents/reasoning_agent.py + - src/app/demos/reasoning-default/page.tsx + - src/app/api/copilotkit/route.ts - id: tool-rendering-reasoning-chain name: Tool Rendering + Reasoning Chain (testing) description: Sequential tool calls with reasoning tokens rendered side-by-side diff --git a/showcase/integrations/llamaindex/qa/agentic-chat-reasoning.md b/showcase/integrations/llamaindex/qa/reasoning-custom.md similarity index 87% rename from showcase/integrations/llamaindex/qa/agentic-chat-reasoning.md rename to showcase/integrations/llamaindex/qa/reasoning-custom.md index 390299b30c4..bd1773e73e4 100644 --- a/showcase/integrations/llamaindex/qa/agentic-chat-reasoning.md +++ b/showcase/integrations/llamaindex/qa/reasoning-custom.md @@ -1,4 +1,4 @@ -# QA: Agentic Chat (Reasoning) — LlamaIndex +# QA: Reasoning (Custom) — LlamaIndex ## Prerequisites @@ -9,7 +9,7 @@ ### 1. Basic Functionality -- [ ] Navigate to the agentic-chat-reasoning demo page +- [ ] Navigate to the reasoning-custom demo page (/demos/reasoning-custom) - [ ] Verify the chat interface loads with placeholder "Type a message" - [ ] Send a basic question such as "Why is the sky blue?" - [ ] Verify the agent responds with a text answer diff --git a/showcase/integrations/llamaindex/qa/reasoning-default-render.md b/showcase/integrations/llamaindex/qa/reasoning-default.md similarity index 91% rename from showcase/integrations/llamaindex/qa/reasoning-default-render.md rename to showcase/integrations/llamaindex/qa/reasoning-default.md index b2637b5e310..cc23cb99987 100644 --- a/showcase/integrations/llamaindex/qa/reasoning-default-render.md +++ b/showcase/integrations/llamaindex/qa/reasoning-default.md @@ -9,7 +9,7 @@ ### 1. Basic Functionality -- [ ] Navigate to the reasoning-default-render demo page +- [ ] Navigate to the reasoning-default demo page (/demos/reasoning-default) - [ ] Verify the chat interface loads with placeholder "Type a message" - [ ] Send a basic question such as "Explain briefly: why does ice float?" - [ ] Verify the agent responds with a text answer diff --git a/showcase/integrations/llamaindex/src/agents/_reasoning_router.py b/showcase/integrations/llamaindex/src/agents/_reasoning_router.py new file mode 100644 index 00000000000..e327c57ccfc --- /dev/null +++ b/showcase/integrations/llamaindex/src/agents/_reasoning_router.py @@ -0,0 +1,512 @@ +"""Reasoning-aware AG-UI workflow router for LlamaIndex. + +Why this module exists +---------------------- +The stock ``llama-index-protocols-ag-ui`` router +(``get_ag_ui_workflow_router``) cannot surface model reasoning to the +AG-UI ``REASONING_MESSAGE_*`` channel. Three independent reasons, all in +``llama-index-protocols-ag-ui==0.2.2``: + +1. ``AGUIChatWorkflow.chat`` reads ONLY ``resp.delta`` (assistant text) + from the LLM stream and emits ``TEXT_MESSAGE_CHUNK`` events. It never + inspects reasoning content. +2. The package defines no reasoning workflow event at all — there is no + ``ReasoningMessage*WorkflowEvent`` to emit. +3. ``AGUIWorkflowRouter.run`` only forwards events whose class is in the + ``AG_UI_EVENTS`` allowlist to the SSE stream; that tuple contains no + reasoning type, so a reasoning event would be filtered out even if a + custom workflow emitted one. + +Real OpenAI reasoning models (gpt-5, o3, o4-mini, …) stream the chain of +thought through the **Responses API** as +``response.reasoning_summary_text.delta`` events; aimock renders the +fixture's abstract ``reasoning`` field into that same Responses-API shape. +LlamaIndex's ``OpenAIResponses`` LLM does NOT surface those summary deltas +through its own stream processing (``process_response_event`` only captures +the terminal ``ResponseReasoningItem``), but it DOES attach the raw +``ResponseStreamEvent`` as ``ChatResponse.raw`` on every yielded chunk — so +the ``ResponseReasoningSummaryTextDeltaEvent`` still reaches us untouched on +``resp.raw``, where ``.delta`` carries the incremental summary text. + +The older chat-completions convention (``delta.reasoning_content`` on the +wire — DeepSeek / vLLM) is also still handled for backward compatibility: +LlamaIndex's chat-completions ``OpenAI`` LLM does not lift +``reasoning_content`` into ``ChatResponse.additional_kwargs``, but it DOES +attach the raw ``ChatCompletionChunk`` as ``ChatResponse.raw``, where the +OpenAI SDK preserves ``reasoning_content`` in ``delta.model_extra``. + +This module reuses the framework's workflow and router unchanged except +for the three gaps above: + +* ``ReasoningMessage{Start,Content,End}WorkflowEvent`` wrap the real + ``ag_ui.core`` reasoning events (``role="reasoning"``) so the encoder + serializes them as ``REASONING_MESSAGE_*`` SSE frames. +* ``ReasoningAGUIChatWorkflow`` subclasses ``AGUIChatWorkflow`` and + overrides ``chat`` to read the reasoning delta off each chunk's + ``resp.raw`` (``_extract_reasoning_delta`` — Responses-API summary deltas + first, chat-completions ``reasoning_content`` as a fallback) and emit a + reasoning message (START → CONTENT… → END) ahead of the assistant text, + then defer to the base behavior for text, tool calls, and looping. +* ``get_reasoning_ag_ui_workflow_router`` builds that workflow and a + router whose SSE allowlist additionally passes the reasoning events. + +No vendored package files are modified, and no demo behavior is weakened. +The agent endpoint still speaks the stock AG-UI protocol; it just no +longer drops the reasoning channel the model already produces. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any, Dict, List, Optional, Union + +from ag_ui.core import RunAgentInput +from ag_ui.core.events import ( + EventType, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, +) +from fastapi import APIRouter + +from llama_index.core.llms import ChatMessage, ChatResponse +from llama_index.core.llms.function_calling import FunctionCallingLLM +from llama_index.core.workflow import Context, Event, StopEvent, step +from llama_index.protocols.ag_ui.agent import ( + AGUIChatWorkflow, + InputEvent, + LoopEvent, + ToolCallEvent, +) +from llama_index.protocols.ag_ui.events import ( + TextMessageChunkWorkflowEvent, +) +from llama_index.protocols.ag_ui.router import AG_UI_EVENTS, AGUIWorkflowRouter +from llama_index.protocols.ag_ui.utils import timestamp + + +class ReasoningMessageStartWorkflowEvent(ReasoningMessageStartEvent, Event): + type: EventType = EventType.REASONING_MESSAGE_START # pyright: ignore[reportIncompatibleVariableOverride] + + +class ReasoningMessageContentWorkflowEvent(ReasoningMessageContentEvent, Event): + type: EventType = EventType.REASONING_MESSAGE_CONTENT # pyright: ignore[reportIncompatibleVariableOverride] + + +class ReasoningMessageEndWorkflowEvent(ReasoningMessageEndEvent, Event): + type: EventType = EventType.REASONING_MESSAGE_END # pyright: ignore[reportIncompatibleVariableOverride] + + +# Reasoning events the framework router's allowlist does not know about. +_REASONING_AG_UI_EVENTS = ( + ReasoningMessageStartWorkflowEvent, + ReasoningMessageContentWorkflowEvent, + ReasoningMessageEndWorkflowEvent, +) + +# Extended allowlist used by the reasoning router's SSE forwarder. +REASONING_AG_UI_EVENTS = (*AG_UI_EVENTS, *_REASONING_AG_UI_EVENTS) + + +# OpenAI Responses-API event type that carries an incremental reasoning +# summary delta. Matched by string so we don't import the openai type (the +# module already follows a "handle the raw event without importing it" +# convention; the type name has also moved across openai-python releases). +_RESPONSES_REASONING_DELTA_TYPE = "response.reasoning_summary_text.delta" + +logger = logging.getLogger(__name__) + +# Prefix shared by every Responses-API reasoning event type string +# (e.g. ``response.reasoning_summary_text.delta``, +# ``response.reasoning_text.delta``). Used only to detect a reasoning-shaped +# event we could not parse, so an upstream SDK rename surfaces loudly instead +# of silently dropping all reasoning. +_RESPONSES_REASONING_TYPE_PREFIX = "response.reasoning" + +# One-time-warning dedupe: distinct unmatched reasoning-shaped ``raw`` type +# strings already logged. Bounds log volume to one line per novel type so a +# rename warns once instead of spamming every chunk of a stream. +_warned_unmatched_reasoning_types: set[str] = set() + + +def _warn_unmatched_reasoning_once(raw_type: str) -> None: + """Emit a one-time warning for a reasoning-shaped but unparsed raw type. + + Reasoning is strictly additive, so the caller still gets ``""`` and the + stream is unaffected. This only makes an OpenAI-SDK event-type rename + greppable instead of an invisible regression. + """ + if raw_type in _warned_unmatched_reasoning_types: + return + _warned_unmatched_reasoning_types.add(raw_type) + logger.warning( + "Saw a reasoning-shaped stream event raw_type=%r that does not match " + "the known delta type %r (and exposed no extractable delta); dropping " + "reasoning for this chunk. The OpenAI SDK may have renamed the " + "reasoning event type — update _RESPONSES_REASONING_DELTA_TYPE.", + raw_type, + _RESPONSES_REASONING_DELTA_TYPE, + ) + + +def _extract_reasoning_delta(resp: ChatResponse) -> str: + """Pull an incremental reasoning delta off a streamed chat response. + + Handles both LLM transports this package can drive: + + * **Responses API** (``OpenAIResponses`` — the real-reasoning path used by + ``reasoning_agent`` / ``tool_rendering_reasoning_chain_agent``). Each + yielded ``ChatResponse`` carries the raw ``ResponseStreamEvent`` on + ``resp.raw``; a ``response.reasoning_summary_text.delta`` event exposes + the incremental summary text on ``.delta``. + * **Chat Completions** (legacy ``OpenAI`` LLM, DeepSeek / vLLM + ``reasoning_content`` convention). ``resp.raw`` is a + ``ChatCompletionChunk`` whose ``delta`` carries ``reasoning_content`` in + ``model_extra`` (pydantic) or directly. + + Returns ``""`` when the chunk carries no reasoning delta (or has an + unexpected shape) so the caller can treat reasoning as strictly additive. + """ + raw = getattr(resp, "raw", None) + if raw is None: + return "" + + # --- Responses API: response.reasoning_summary_text.delta ------------- + # ``raw`` is a single ResponseStreamEvent (pydantic) or a plain dict. + raw_type = getattr(raw, "type", None) + if raw_type is None and isinstance(raw, dict): + raw_type = raw.get("type") + if raw_type == _RESPONSES_REASONING_DELTA_TYPE: + delta = getattr(raw, "delta", None) + if delta is None and isinstance(raw, dict): + delta = raw.get("delta") + if delta: + return str(delta) + # Known reasoning type but no extractable delta: shape changed under us. + _warn_unmatched_reasoning_once(str(raw_type)) + return "" + + # An unmatched event that still clearly looks like reasoning (the SDK + # likely renamed the delta type) — warn once and drop, rather than + # silently swallowing every reasoning chunk. + if isinstance(raw_type, str) and raw_type.startswith( + _RESPONSES_REASONING_TYPE_PREFIX + ): + _warn_unmatched_reasoning_once(raw_type) + return "" + + # --- Chat Completions: delta.reasoning_content ------------------------ + # ``raw`` is a ChatCompletionChunk (pydantic) or a plain dict depending on + # the OpenAI SDK version; handle both without importing the chunk type. + choices = getattr(raw, "choices", None) + if choices is None and isinstance(raw, dict): + choices = raw.get("choices") + if not choices: + return "" + first = choices[0] + delta = getattr(first, "delta", None) + if delta is None and isinstance(first, dict): + delta = first.get("delta") + if delta is None: + return "" + # Pydantic model: non-standard fields land in ``model_extra``. + extra = getattr(delta, "model_extra", None) + if isinstance(extra, dict) and extra.get("reasoning_content"): + return str(extra["reasoning_content"]) + # Direct attribute (some SDK builds expose it) or plain dict. + direct = getattr(delta, "reasoning_content", None) + if direct: + return str(direct) + if isinstance(delta, dict) and delta.get("reasoning_content"): + return str(delta["reasoning_content"]) + return "" + + +class ReasoningAGUIChatWorkflow(AGUIChatWorkflow): + """``AGUIChatWorkflow`` that also surfaces ``reasoning_content``. + + Overrides the ``chat`` step so reasoning deltas streamed by the model + are emitted as AG-UI ``REASONING_MESSAGE_*`` events ahead of the + assistant text. Everything else (tool-call dispatch, message + snapshots, looping) is inherited from the base class unchanged. + """ + + @step + async def chat( + self, ctx: Context, ev: InputEvent | LoopEvent + ) -> Optional[Union[StopEvent, ToolCallEvent]]: + # This mirrors AGUIChatWorkflow.chat verbatim except for the + # reasoning-emission block inside the stream loop. Kept as an explicit + # override (not a super() call) because the base step streams the LLM + # response itself — there is no narrower hook to wrap. + if isinstance(ev, InputEvent): + chat_history = await self._prepare_input(ctx, ev) + else: + chat_history = await ctx.store.get("chat_history") + + tools = list(self.frontend_tools.values()) + tools.extend(list(self.backend_tools.values())) + + if tools: + resp_gen = await self.llm.astream_chat_with_tools( + tools=tools, + chat_history=chat_history, + allow_parallel_tool_calls=True, + ) + else: + # No-tools path: ``astream_chat_with_tools(tools=[])`` resolves the + # tool spec to ``None``, and LlamaIndex 0.5.6's + # ``OpenAIResponses._get_model_kwargs`` then crashes on + # ``[*initial_tools, *None]`` (it only defaults ``tools`` when the + # key is absent, not when it is explicitly ``None``). Stream + # directly to sidestep that bug — there are no tool calls to + # dispatch anyway, and ``_finalize_chat`` handles a tool-less + # response correctly. + resp_gen = await self.llm.astream_chat(chat_history) + + text_msg_id = str(uuid.uuid4()) + reasoning_msg_id: Optional[str] = None + resp = ChatResponse(message=ChatMessage(role="assistant", content="")) + + async for resp in resp_gen: + reasoning_delta = _extract_reasoning_delta(resp) + # Load-bearing non-empty guard: ReasoningMessageContentEvent.delta + # is Field(min_length=1), so emitting an empty delta would raise a + # pydantic ValidationError mid-stream — never gate on truthiness alone. + if reasoning_delta: + if reasoning_msg_id is None: + reasoning_msg_id = str(uuid.uuid4()) + ctx.write_event_to_stream( + ReasoningMessageStartWorkflowEvent( + message_id=reasoning_msg_id, + role="reasoning", + ) + ) + ctx.write_event_to_stream( + ReasoningMessageContentWorkflowEvent( + message_id=reasoning_msg_id, + delta=reasoning_delta, + ) + ) + + if resp.delta: + # Reasoning precedes the answer; close the reasoning message + # before the first text chunk so the frontend reasoning slot + # finalizes ahead of the assistant message. + if reasoning_msg_id is not None: + ctx.write_event_to_stream( + ReasoningMessageEndWorkflowEvent(message_id=reasoning_msg_id) + ) + reasoning_msg_id = None + ctx.write_event_to_stream( + TextMessageChunkWorkflowEvent( + role="assistant", + delta=resp.delta, + timestamp=timestamp(), + message_id=text_msg_id, + ) + ) + + # If the model produced reasoning but no assistant text (pure + # tool-call turn), still close the reasoning message. + if reasoning_msg_id is not None: + ctx.write_event_to_stream( + ReasoningMessageEndWorkflowEvent(message_id=reasoning_msg_id) + ) + + return await self._finalize_chat(ctx, resp, chat_history) + + async def _prepare_input(self, ctx: Context, ev: InputEvent) -> List[ChatMessage]: + """Build chat history + emit the initial state snapshot. + + Lifted verbatim from ``AGUIChatWorkflow.chat`` (the ``InputEvent`` + branch) so the override can reuse it without duplicating the state + handling and system-prompt injection. + """ + from llama_index.core.llms import TextBlock + from llama_index.protocols.ag_ui.agent import DEFAULT_STATE_PROMPT + from llama_index.protocols.ag_ui.events import StateSnapshotWorkflowEvent + from llama_index.protocols.ag_ui.utils import ( + ag_ui_message_to_llama_index_message, + ) + + ag_ui_messages = ev.input_data.messages + chat_history = [ag_ui_message_to_llama_index_message(m) for m in ag_ui_messages] + + state = ev.input_data.state + if isinstance(state, dict): + state.pop("messages", None) + elif isinstance(state, str): + state = json.loads(state) + state.pop("messages", None) + else: + state = self.initial_state.copy() + + await ctx.store.set("state", state) + ctx.write_event_to_stream(StateSnapshotWorkflowEvent(snapshot=state)) + + if state: + for msg in chat_history[::-1]: + if msg.role.value == "user": + msg.content = DEFAULT_STATE_PROMPT.format( + state=str(state), user_input=msg.content + ) + break + + if self.system_prompt: + if chat_history[0].role.value == "system": + chat_history[0].blocks.append(TextBlock(text=self.system_prompt)) + else: + chat_history.insert( + 0, ChatMessage(role="system", content=self.system_prompt) + ) + + await ctx.store.set("chat_history", chat_history) + return chat_history + + async def _finalize_chat( + self, + ctx: Context, + resp: ChatResponse, + chat_history: List[ChatMessage], + ) -> Optional[Union[StopEvent, ToolCallEvent]]: + """Append the assistant message, dispatch tool calls, loop or stop. + + Lifted verbatim from the tail of ``AGUIChatWorkflow.chat`` (after the + stream loop) so the override reuses the base tool-dispatch behavior. + """ + chat_history.append(resp.message) + self._snapshot_messages(ctx, [*chat_history]) + await ctx.store.set("chat_history", chat_history) + + tool_calls = self.llm.get_tool_calls_from_response( + resp, error_on_no_tool_call=False + ) + if tool_calls: + await ctx.store.set("num_tool_calls", len(tool_calls)) + frontend_tool_calls = [ + tc for tc in tool_calls if tc.tool_name in self.frontend_tools + ] + backend_tool_calls = [ + tc for tc in tool_calls if tc.tool_name in self.backend_tools + ] + + from llama_index.protocols.ag_ui.events import ( + ToolCallChunkWorkflowEvent, + ) + + for tool_call in backend_tool_calls: + ctx.send_event( + ToolCallEvent( + tool_call_id=tool_call.tool_id, + tool_name=tool_call.tool_name, + tool_kwargs=tool_call.tool_kwargs, + ) + ) + ctx.write_event_to_stream( + ToolCallChunkWorkflowEvent( + tool_call_id=tool_call.tool_id, + tool_call_name=tool_call.tool_name, + delta=json.dumps(tool_call.tool_kwargs), + ) + ) + + for tool_call in frontend_tool_calls: + ctx.send_event( + ToolCallEvent( + tool_call_id=tool_call.tool_id, + tool_name=tool_call.tool_name, + tool_kwargs=tool_call.tool_kwargs, + ) + ) + ctx.write_event_to_stream( + ToolCallChunkWorkflowEvent( + tool_call_id=tool_call.tool_id, + tool_call_name=tool_call.tool_name, + delta=json.dumps(tool_call.tool_kwargs), + ) + ) + + return None + + return StopEvent() + + +class ReasoningAGUIWorkflowRouter(AGUIWorkflowRouter): + """``AGUIWorkflowRouter`` whose SSE forwarder also passes reasoning events.""" + + async def run(self, input: RunAgentInput): # noqa: A002 - match base signature + from fastapi.responses import StreamingResponse + from llama_index.protocols.ag_ui.events import ( + RunErrorWorkflowEvent, + RunFinishedWorkflowEvent, + RunStartedWorkflowEvent, + ) + from llama_index.protocols.ag_ui.utils import workflow_event_to_sse + + workflow = await self.workflow_factory() + handler = workflow.run(input_data=input) + + async def stream_response(): + try: + yield workflow_event_to_sse( + RunStartedWorkflowEvent( + timestamp=timestamp(), + thread_id=input.thread_id, + run_id=input.run_id, + ) + ) + + async for ev in handler.stream_events(): + if isinstance(ev, REASONING_AG_UI_EVENTS): + yield workflow_event_to_sse(ev) + + _ = await handler + + yield workflow_event_to_sse( + RunFinishedWorkflowEvent( + timestamp=timestamp(), + thread_id=input.thread_id, + run_id=input.run_id, + ) + ) + except Exception as e: # pragma: no cover - mirrors base behavior + yield workflow_event_to_sse( + RunErrorWorkflowEvent( + timestamp=timestamp(), + message=str(e), + code=str(type(e)), + ) + ) + await handler.cancel_run() + raise + + return StreamingResponse(stream_response(), media_type="text/event-stream") + + +def get_reasoning_ag_ui_workflow_router( + llm: Optional[FunctionCallingLLM] = None, + frontend_tools: Optional[List[Any]] = None, + backend_tools: Optional[List[Any]] = None, + initial_state: Optional[Dict[str, Any]] = None, + system_prompt: Optional[str] = None, + timeout: Optional[float] = 120, +) -> APIRouter: + """Drop-in replacement for ``get_ag_ui_workflow_router`` that also emits + AG-UI ``REASONING_MESSAGE_*`` events for ``reasoning_content`` the model + streams. Same signature/usage as the stock factory. + """ + + async def workflow_factory(): + return ReasoningAGUIChatWorkflow( + llm=llm, + frontend_tools=frontend_tools, + backend_tools=backend_tools, + initial_state=initial_state, + system_prompt=system_prompt, + timeout=timeout, + ) + + return ReasoningAGUIWorkflowRouter(workflow_factory).router diff --git a/showcase/integrations/llamaindex/src/agents/reasoning_agent.py b/showcase/integrations/llamaindex/src/agents/reasoning_agent.py index fdff57710e0..8459498e36d 100644 --- a/showcase/integrations/llamaindex/src/agents/reasoning_agent.py +++ b/showcase/integrations/llamaindex/src/agents/reasoning_agent.py @@ -1,27 +1,44 @@ """ Reasoning agent for LlamaIndex. -Shared by `agentic-chat-reasoning` (custom amber ReasoningBlock slot) and -`reasoning-default-render` (CopilotKit's built-in reasoning slot). The agent -is built on the same shared `get_ag_ui_workflow_router` used by the rest of -the package. The system prompt asks the model to think step-by-step before -answering, so the LLM produces reasoning-style prose that the chat UI can -render. - -Note: `llama-index-protocols-ag-ui` streams chat deltas as assistant text. If -the underlying OpenAI model emits reasoning tokens via the responses API, -they will surface as REASONING_MESSAGE_* events; otherwise the reasoning -shows up as the first part of the assistant message. Either way the frontend -`CopilotChatReasoningMessage` slot composes with the flow — no custom backend -plumbing is needed for the happy path. +Shared by `reasoning-custom` (custom amber ReasoningBlock slot) and +`reasoning-default` (CopilotKit's built-in reasoning slot). The system +prompt asks the model to think step-by-step before answering, so the LLM +produces a reasoning channel that the chat UI can render. + +Why a reasoning model + the OpenAI Responses API +------------------------------------------------ +Mirrors the langgraph-python parity gold standard +(`init_chat_model("openai:", use_responses_api=True, +reasoning={"effort": "medium", "summary": "detailed"})`). The OpenAI +Responses API streams `response.reasoning_summary_text.delta` items only for +native reasoning models (gpt-5, o3, o4-mini, …); a non-reasoning model like +gpt-4.1 on the chat-completions wire emits NO reasoning channel against real +OpenAI, so the reasoning slot would only ever light up under aimock. Routing +through `OpenAIResponses` with a reasoning model makes the chain of thought +stream against a REAL provider; aimock renders the fixture's abstract +`reasoning` field into the same Responses-API shape for deterministic tests. +(LlamaIndex pins the default to `gpt-5` rather than langgraph's `gpt-5.4` +because LlamaIndex 0.5.6 rejects model names absent from its context-size +table at workflow construction — see REASONING_MODEL below.) + +Uses `get_reasoning_ag_ui_workflow_router` (a thin in-package extension of the +stock `get_ag_ui_workflow_router`) so the model's reasoning summary deltas +surface as AG-UI `REASONING_MESSAGE_*` events. The stock router reads only +assistant text and silently drops the reasoning channel; see +`_reasoning_router.py` for the three framework gaps it closes (and for how +`_extract_reasoning_delta` reads the Responses-API summary delta off +`resp.raw`, which LlamaIndex's own stream processing does not surface). The +frontend `CopilotChatReasoningMessage` slot then composes with the flow. """ from __future__ import annotations import os -from llama_index.llms.openai import OpenAI -from llama_index.protocols.ag_ui.router import get_ag_ui_workflow_router +from llama_index.llms.openai import OpenAIResponses + +from agents._reasoning_router import get_reasoning_ag_ui_workflow_router SYSTEM_PROMPT = ( @@ -30,13 +47,40 @@ "responses brief -- 1 to 3 sentences max." ) +# Reasoning-capable model routed through the OpenAI Responses API. +# +# Default is `gpt-5` (a native reasoning model), NOT the langgraph gold +# standard's `gpt-5.4`. LlamaIndex 0.5.6's `OpenAIResponses.metadata` resolves +# the context window through `openai_modelname_to_contextsize()`, which raises +# `ValueError: Unknown model` for names outside its hard-coded table — +# `AGUIChatWorkflow.__init__` reads `llm.metadata.is_function_calling_model`, +# so an unrecognized name (like `gpt-5.4`) crashes workflow construction at +# startup. `gpt-5` is in both that table AND the O1_MODELS reasoning list, so +# it streams reasoning natively against real OpenAI. Deployments can override +# via OPENAI_REASONING_MODEL (with any name LlamaIndex 0.5.6 recognizes). +REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5") + +# `summary: detailed` requests the streamed reasoning summary; `effort: +# medium` mirrors the gold config. We pass these through BOTH +# `reasoning_options` (idiomatic; honored for O1_MODELS like gpt-5) AND +# `additional_kwargs` (unconditionally merged into the /v1/responses body by +# `OpenAIResponses._get_model_kwargs`), so the `reasoning` param still reaches +# the wire if a deployment overrides to a reasoning model outside the +# O1_MODELS allowlist. +_REASONING_PARAMS = {"effort": "medium", "summary": "detailed"} + _openai_kwargs = {} if os.environ.get("OPENAI_BASE_URL"): _openai_kwargs["api_base"] = os.environ["OPENAI_BASE_URL"] -reasoning_router = get_ag_ui_workflow_router( - llm=OpenAI(model="gpt-4.1", **_openai_kwargs), +reasoning_router = get_reasoning_ag_ui_workflow_router( + llm=OpenAIResponses( + model=REASONING_MODEL, + reasoning_options=_REASONING_PARAMS, + additional_kwargs={"reasoning": _REASONING_PARAMS}, + **_openai_kwargs, + ), frontend_tools=[], backend_tools=[], system_prompt=SYSTEM_PROMPT, diff --git a/showcase/integrations/llamaindex/src/agents/tool_rendering_reasoning_chain_agent.py b/showcase/integrations/llamaindex/src/agents/tool_rendering_reasoning_chain_agent.py index fa5d5f97196..9c2d1f22430 100644 --- a/showcase/integrations/llamaindex/src/agents/tool_rendering_reasoning_chain_agent.py +++ b/showcase/integrations/llamaindex/src/agents/tool_rendering_reasoning_chain_agent.py @@ -6,7 +6,23 @@ custom `reasoningMessage` slot and paints `get_weather` / `search_flights` with rich cards, with every other tool falling back to a branded catch-all. -Mirrors `langgraph-python/src/agents/tool_rendering_reasoning_chain_agent.py`. +Mirrors `langgraph-python/src/agents/tool_rendering_reasoning_chain_agent.py`, +including its reasoning-capable model routed through the OpenAI Responses API +(`init_chat_model("openai:", use_responses_api=True, +reasoning={"effort": "medium", "summary": "detailed"})`). A reasoning model on +the Responses API streams `response.reasoning_summary_text.delta` items; a +non-reasoning chat-completions model emits no reasoning channel against real +OpenAI, so this cell would only light up under aimock without the switch. +(LlamaIndex defaults to `gpt-5`, not langgraph's `gpt-5.4`; see +reasoning_agent.py for the LlamaIndex 0.5.6 model-name constraint.) + +Uses ``get_reasoning_ag_ui_workflow_router`` (a thin in-package extension of +the stock ``get_ag_ui_workflow_router``) so the model's reasoning summary +deltas surface as AG-UI ``REASONING_MESSAGE_*`` events. The stock router +reads only assistant text and silently drops reasoning; see +``_reasoning_router.py`` for the three framework gaps it closes (and how +``_extract_reasoning_delta`` reads the Responses-API summary delta off +``resp.raw``). """ import json @@ -14,8 +30,9 @@ from random import choice, randint from typing import Annotated -from llama_index.llms.openai import OpenAI -from llama_index.protocols.ag_ui.router import get_ag_ui_workflow_router +from llama_index.llms.openai import OpenAIResponses + +from agents._reasoning_router import get_reasoning_ag_ui_workflow_router async def get_weather( @@ -96,12 +113,25 @@ async def roll_dice( ) +# Reasoning-capable model routed through the OpenAI Responses API. Default +# `gpt-5` (a native reasoning model LlamaIndex 0.5.6 recognizes); override via +# OPENAI_REASONING_MODEL. See reasoning_agent.py for the model-name constraint +# and why the reasoning params are passed through both reasoning_options and +# additional_kwargs. +REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5") +_REASONING_PARAMS = {"effort": "medium", "summary": "detailed"} + _openai_kwargs = {} if os.environ.get("OPENAI_BASE_URL"): _openai_kwargs["api_base"] = os.environ["OPENAI_BASE_URL"] -tool_rendering_reasoning_chain_router = get_ag_ui_workflow_router( - llm=OpenAI(model="gpt-4.1", **_openai_kwargs), +tool_rendering_reasoning_chain_router = get_reasoning_ag_ui_workflow_router( + llm=OpenAIResponses( + model=REASONING_MODEL, + reasoning_options=_REASONING_PARAMS, + additional_kwargs={"reasoning": _REASONING_PARAMS}, + **_openai_kwargs, + ), frontend_tools=[], backend_tools=[get_weather, search_flights, get_stock_price, roll_dice], system_prompt=SYSTEM_PROMPT, diff --git a/showcase/integrations/llamaindex/src/app/demos/reasoning-custom/page.tsx b/showcase/integrations/llamaindex/src/app/demos/reasoning-custom/page.tsx index 6ee8c09f502..9f1427c387c 100644 --- a/showcase/integrations/llamaindex/src/app/demos/reasoning-custom/page.tsx +++ b/showcase/integrations/llamaindex/src/app/demos/reasoning-custom/page.tsx @@ -4,7 +4,7 @@ // // Pairs with `reasoning-default` so users can compare default vs custom // reasoning rendering side by side. Both demos share the same backend -// (`reasoning_agent` graph) and runtime URL (/api/copilotkit). This cell +// (`reasoning_agent` router) and runtime URL (/api/copilotkit). This cell // overrides the `reasoningMessage` slot on the `messageView` slot with // `ReasoningBlock` — a tagged amber banner that emphasizes the agent's // thinking chain. @@ -22,7 +22,11 @@ import { ReasoningBlock } from "./reasoning-block"; import { useReasoningCustomSuggestions } from "./suggestions"; // @region[reasoning-block-render] -const AGENT_ID = "reasoning-custom"; +// Functional agent-registration key (matches the /api/copilotkit route's +// specializedAgents map and the backend /reasoning router). The manifest +// demo id is `reasoning-custom`; the agent key stays `agentic-chat-reasoning` +// to mirror built-in-agent / claude-sdk-python. +const AGENT_ID = "agentic-chat-reasoning"; export default function ReasoningCustomDemo() { return ( diff --git a/showcase/integrations/llamaindex/src/app/demos/reasoning-default/page.tsx b/showcase/integrations/llamaindex/src/app/demos/reasoning-default/page.tsx index 7546dc53f90..c28ab0a5813 100644 --- a/showcase/integrations/llamaindex/src/app/demos/reasoning-default/page.tsx +++ b/showcase/integrations/llamaindex/src/app/demos/reasoning-default/page.tsx @@ -8,7 +8,7 @@ // rendered by the built-in `CopilotChatReasoningMessage` component // (Thinking… / Thought for X header with an expandable content region). // -// Both demos share the same backend (`reasoning_agent` graph) and the +// Both demos share the same backend (`reasoning_agent` router) and the // same runtime URL (/api/copilotkit). The only difference is whether the // `messageView.reasoningMessage` slot is overridden. @@ -16,7 +16,11 @@ import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2"; import { useReasoningDefaultSuggestions } from "./suggestions"; // @region[default-reasoning-zero-config] -const AGENT_ID = "reasoning-default"; +// Functional agent-registration key (matches the /api/copilotkit route's +// specializedAgents map and the backend /reasoning router). The manifest +// demo id is `reasoning-default`; the agent key stays +// `reasoning-default-render` to mirror built-in-agent / claude-sdk-python. +const AGENT_ID = "reasoning-default-render"; export default function ReasoningDefaultDemo() { return ( diff --git a/showcase/integrations/llamaindex/tests/e2e/agentic-chat-reasoning.spec.ts b/showcase/integrations/llamaindex/tests/e2e/agentic-chat-reasoning.spec.ts deleted file mode 100644 index 9861352af09..00000000000 --- a/showcase/integrations/llamaindex/tests/e2e/agentic-chat-reasoning.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test.describe("Agentic Chat (Reasoning)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/demos/agentic-chat-reasoning"); - }); - - test("page loads with chat input", async ({ page }) => { - await expect(page.getByPlaceholder("Type a message")).toBeVisible(); - }); - - test("sends a message and gets an assistant response", async ({ page }) => { - const input = page.getByPlaceholder("Type a message"); - await input.fill("Why is the sky blue? Think step by step."); - await input.press("Enter"); - - await expect(page.locator('[data-role="assistant"]').first()).toBeVisible({ - timeout: 45000, - }); - }); -}); diff --git a/showcase/integrations/spring-ai/PARITY_NOTES.md b/showcase/integrations/spring-ai/PARITY_NOTES.md index 3dc3f7db185..80eaea9f30b 100644 --- a/showcase/integrations/spring-ai/PARITY_NOTES.md +++ b/showcase/integrations/spring-ai/PARITY_NOTES.md @@ -72,13 +72,72 @@ below are the ones where those primitives are genuinely unavailable. partial JSON; the Zod schemas accept partials so the chart components can render once enough fields are present. -- **agentic-chat-reasoning**, **reasoning-default-render**, +- **reasoning-custom**, **reasoning-default**, **tool-rendering-reasoning-chain** — frontend code is wired for - `REASONING_MESSAGE_*` events; when the adapter begins forwarding OpenAI - reasoning content (and/or a reasoning-capable model is wired through), - the reasoning UI lights up automatically. Until then the chat behaves - as a regular chat. Shipped as frontend code so the pattern is documented - end-to-end. + `REASONING_MESSAGE_*` events, but the Spring AI handler CANNOT emit + them. This is a genuine SDK limitation in Spring AI 1.0.1, not an + adapter or wiring gap. Details below. + + **What the demo needs.** The reasoning UI mounts only when the backend + emits AG-UI `REASONING_MESSAGE_START` / `_CONTENT` / `_END` events + (role `"reasoning"`). The canonical `langgraph-python` agent produces + these by routing the OpenAI model's reasoning summary through the + **OpenAI Responses API** (`reasoning={"effort": "medium", "summary": +"detailed"}`). The aimock fixtures for these spring-ai cells + (`d6/spring-ai/reasoning.json`, + `d6/spring-ai/tool-rendering-reasoning-chain.json`, copied from + langgraph-python) carry the reasoning text in a dedicated + `response.reasoning` field, which aimock renders over the OpenAI + **chat-completions** wire as streaming `delta.reasoning_content` + chunks (see `@copilotkit/aimock` `buildTextChunks` — + `delta: { reasoning_content: slice }`). + + **Why Spring AI 1.0.1 cannot surface it.** The spring-ai integration + speaks OpenAI chat-completions (`spring-ai-starter-model-openai`, + `/v1/chat/completions`). In `spring-ai-openai:1.0.1` the streaming + delta is bound to the record `OpenAiApi.ChatCompletionMessage`, whose + components are exactly `rawContent, role, name, toolCallId, toolCalls, +refusal, audioOutput, annotations` — there is **no `reasoning_content` + / `reasoning` field**, no metadata map, and no `@JsonAnySetter` + catch-all. The record is annotated `@JsonIgnoreProperties`, so the + inbound `reasoning_content` JSON property is **silently discarded at + deserialization**. It never reaches `ChatResponse` / + `Generation.getOutput()`, so the Java handler has no API to read it. + The reasoning-summary channel of the OpenAI **Responses API** is also + unavailable: `spring-ai-openai:1.0.1` ships no Responses-API client + (only `OpenAiApi` chat-completions classes exist), so the + langgraph-python parity path cannot be reproduced either. + + **Why the inline-``-tag workaround does not apply.** The + proven `claude-sdk-python` agent PRIMARILY maps Anthropic's native + extended-thinking channel: it enables `thinking={"type": "enabled", ...}` + on the Messages API, receives `thinking_delta` blocks, and re-routes + them to `REASONING_MESSAGE_*`. Only when no native thinking channel is + present does it FALL BACK to prompting the model to wrap its plan in + literal `...` text tags inside normal output and + parsing those tags out of the text stream. The inline-tag fallback IS + expressible in Spring AI (the handler already streams + `getOutput().getText()`). But neither claude-sdk path fits these cells: + the spring-ai aimock fixtures emit reasoning through the dedicated + `reasoning` field (→ `reasoning_content`), NOT via an Anthropic native + thinking channel and NOT as inline `` tags in `content`. + Rewriting the fixtures to embed inline tags — or hand-fabricating a + reasoning block in the handler — would be a demo-weakening fixture hack + that misrepresents the integration's real capability, so it is + deliberately not done. + + **What a real fix requires (upstream / out of scope here).** Either + (a) Spring AI adds a `reasoning_content` (or reasoning-summary) field + to its chat-completions delta record and exposes it on + `Generation`/output metadata; or (b) Spring AI ships an OpenAI + Responses-API client that surfaces the reasoning summary; or (c) a + custom `WebClient`-level interceptor parses the raw chat-completions + SSE for `delta.reasoning_content` BEFORE Spring AI's binding drops it, + bypassing `ChatClient` entirely (a substantial custom-parser effort + that re-implements the streaming pipeline). None of these is a + showcase-side change. Until one lands, these cells ship as frontend + code (so the pattern is documented end-to-end) and the chat behaves as + a regular chat with no reasoning block. - **multimodal** — the frontend sends image + PDF attachments through CopilotChat's `AttachmentsConfig`. Whether the adapter forwards them diff --git a/showcase/scripts/__tests__/verify-prod-display.bats b/showcase/scripts/__tests__/verify-prod-display.bats new file mode 100644 index 00000000000..046db1dd5c2 --- /dev/null +++ b/showcase/scripts/__tests__/verify-prod-display.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats +# Tests for verify-prod-display.sh — derives the verify-prod value shown in the +# promote-run Slack alert. +# +# The bug this guards (see run 27144525566 follow-up): the verify-prod job's +# empty-CSV skip branch exits 0, so the GitHub job `result` is `success` even +# though prod was NEVER probed. The notify step used to read that raw `result` +# and rendered a misleading `verify-prod=success`. The display value must +# instead distinguish: +# * success — prod was actually probed and passed (job result success + +# status output "success"). +# * skipped — nothing promoted, so prod verification was skipped (job result +# success + status output "skipped"). +# * failure — a real probe failure or contract violation (job result +# failure; the status output was never written). +# * — any other job result (cancelled, etc.) passes through. +# +# NB on assertion gating: bats does NOT run test bodies under `set -e`. Only the +# FINAL command decides pass/fail, so every non-final assertion is written +# `[[ ... ]] || fail "msg"` to force a hard failure with a diagnostic. + +fail() { + echo "$1" >&2 + return 1 +} + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../verify-prod-display.sh" + [ -x "$SCRIPT" ] || fail "verify-prod-display.sh missing or not executable at $SCRIPT" +} + +# run_display — invoke the script with the two inputs and +# capture stdout into $output (bats convention). +run_display() { + PROD="$1" PROD_STATUS="$2" run "$SCRIPT" +} + +@test "prod probed and passed -> success" { + run_display "success" "success" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status" + [ "$output" = "success" ] || fail "expected 'success', got '$output'" +} + +@test "nothing promoted, prod skipped -> skipped (not a misleading success)" { + run_display "success" "skipped" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status" + [ "$output" = "skipped" ] || fail "expected 'skipped', got '$output'" +} + +@test "real probe failure -> failure (status output never written)" { + run_display "failure" "" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status" + [ "$output" = "failure" ] || fail "expected 'failure', got '$output'" +} + +@test "cancelled job result passes through" { + run_display "cancelled" "" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status" + [ "$output" = "cancelled" ] || fail "expected 'cancelled', got '$output'" +} + +@test "skipped job result (verify-prod never ran) passes through" { + run_display "skipped" "" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status" + [ "$output" = "skipped" ] || fail "expected 'skipped', got '$output'" +} + +@test "defensive: job result success but status output empty -> falls back to result" { + # If verify-prod somehow exits 0 without writing status (should not happen, + # but be robust), do NOT fabricate a 'success'/'skipped' — surface the raw + # job result so the signal is never invented. + run_display "success" "" + [ "$status" -eq 0 ] || fail "expected exit 0, got $status" + [ "$output" = "success" ] || fail "expected fallback to 'success', got '$output'" +} diff --git a/showcase/scripts/fail-baseline.json b/showcase/scripts/fail-baseline.json index df1aeb76c5f..a6618c14ce0 100644 --- a/showcase/scripts/fail-baseline.json +++ b/showcase/scripts/fail-baseline.json @@ -1,6 +1,6 @@ { "_comment": "Drift ratchet baseline for showcase_validate.yml. `validatePinsFailCount` holds the last-known FAIL count; `validatePinsFailHash` is a SHA-256 of the sorted, deduplicated `[FAIL] ...` lines from validate-pins.ts. CI compares BOTH: if the count changes, it tells you to ratchet (up rejected, down instructed). If the count is equal but the hash differs, the FAIL *set* has drifted (one item fixed, another regressed) and CI fails with a diff. Never raise the count without an explicit review + sign-off. `baselineDemoCount` is the per-package e2e-spec-count floor (single source of truth consumed by both the workflow and validate-parity.ts). NOTE: validate-parity.ts `BASELINE_DEMO_COUNT` default must match `baselineDemoCount` here; keep them in sync. See .github/workflows/showcase_validate.yml 'Run validate-pins (ratchet)' step.", - "validatePinsFailCount": 63, - "validatePinsFailHash": "b2b21b3bd00090d28a2642a88ae39aa5862a08f952b4d3a87e817acc8d37b069", + "validatePinsFailCount": 60, + "validatePinsFailHash": "7db1cd422ee5a3394044e9aedc1ce8fc8fabca0107b2df47f7167bdf3c7556ce", "baselineDemoCount": 9 } diff --git a/showcase/scripts/verify-prod-display.sh b/showcase/scripts/verify-prod-display.sh new file mode 100755 index 00000000000..fccc3d5b58f --- /dev/null +++ b/showcase/scripts/verify-prod-display.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# verify-prod-display.sh — compute the verify-prod value shown in the promote +# Slack alert (`verify-prod=`). +# +# Extracted from .github/workflows/showcase_promote.yml so the derivation is +# unit-testable (see __tests__/verify-prod-display.bats), mirroring how +# promote-fleet.sh was extracted from the same workflow. +# +# The bug this fixes (follow-up to run 27144525566): verify-prod's empty-CSV +# skip branch exits 0, so the GitHub job `result` is `success` even though prod +# was NEVER probed. The notify step used to render that raw `result` and emit a +# misleading `verify-prod=success`. We instead read the verify-prod job's own +# `status` OUTPUT (which it sets to `success` after a real probe, or `skipped` +# when nothing promoted) and only fall back to the job result for failure / +# cancelled (where the output was never written). +# +# Usage: +# PROD= PROD_STATUS= scripts/verify-prod-display.sh +# +# Env: +# PROD (required) the verify-prod GitHub job `result` +# (success|failure|cancelled|skipped). +# PROD_STATUS (optional) the verify-prod job's `status` output +# (success|skipped); empty when the job +# failed/cancelled or never wrote it. +# +# Prints the display value to stdout. Always exits 0 — this is a pure mapping, +# not a gate. + +set -uo pipefail + +PROD="${PROD:-}" +PROD_STATUS="${PROD_STATUS:-}" + +# Prefer the job's own status output when the job ran cleanly (result success). +# That output disambiguates a real prod probe (`success`) from the empty-CSV +# skip (`skipped`) — both of which leave the job `result` as `success`. For any +# other result (failure, cancelled, skipped-because-upstream-aborted) the +# status output was never written, so surface the raw job result and never +# fabricate a success/skipped signal. +if [ "$PROD" = "success" ] && [ -n "$PROD_STATUS" ]; then + echo "$PROD_STATUS" +else + echo "$PROD" +fi diff --git a/showcase/shell-docs/src/content/docs/integrations/aws-strands/quickstart.mdx b/showcase/shell-docs/src/content/docs/integrations/aws-strands/quickstart.mdx index 760a7ff59d3..0f156e8bfb0 100644 --- a/showcase/shell-docs/src/content/docs/integrations/aws-strands/quickstart.mdx +++ b/showcase/shell-docs/src/content/docs/integrations/aws-strands/quickstart.mdx @@ -435,7 +435,7 @@ Before you begin, you'll need the following: ## Deploying to AWS? -If you're planning to deploy your Strands agent to AWS Bedrock AgentCore, see the [AgentCore deploy guide](/agentcore). +If you're planning to deploy your Strands agent to AWS Bedrock AgentCore, see the [AgentCore deploy guide](/deploy-agentcore). ## What's next? diff --git a/showcase/shell-docs/src/content/docs/integrations/langgraph/quickstart.mdx b/showcase/shell-docs/src/content/docs/integrations/langgraph/quickstart.mdx index f33b2c41be1..eb5ca8a863d 100644 --- a/showcase/shell-docs/src/content/docs/integrations/langgraph/quickstart.mdx +++ b/showcase/shell-docs/src/content/docs/integrations/langgraph/quickstart.mdx @@ -498,7 +498,7 @@ Before you begin, you'll need the following: ## Deploying to AWS? -If you're planning to deploy your LangGraph agent to AWS Bedrock AgentCore, see the [AgentCore deploy guide](/agentcore). +If you're planning to deploy your LangGraph agent to AWS Bedrock AgentCore, see the [AgentCore deploy guide](/deploy-agentcore). ## What's next? diff --git a/showcase/shell-docs/src/content/snippets/integrations/agentcore/index.mdx b/showcase/shell-docs/src/content/snippets/integrations/agentcore/index.mdx index 1384c77d9a7..c39efde6fab 100644 --- a/showcase/shell-docs/src/content/snippets/integrations/agentcore/index.mdx +++ b/showcase/shell-docs/src/content/snippets/integrations/agentcore/index.mdx @@ -38,8 +38,9 @@ Browser → CopilotKit Runtime → AgentCore Runtime → your agent ## Quickstart - Both paths below require AWS credentials configured locally. If you haven't done this yet, - follow the [AWS CLI getting started guide](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) + Both paths below require AWS credentials configured locally. If you haven't + done this yet, follow the [AWS CLI getting started + guide](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) before continuing. @@ -65,7 +66,7 @@ Browser → CopilotKit Runtime → AgentCore Runtime → your agent ### Run the CLI @@ -90,7 +91,7 @@ Browser → CopilotKit Runtime → AgentCore Runtime → your agent One command deploys everything — CDK infrastructure, AgentCore Runtime, and the frontend: @@ -335,6 +336,7 @@ Browser → CopilotKit Runtime → AgentCore Runtime → your agent the `/api/copilotkit` route forwards each call to the invocation URL using your token. + @@ -353,4 +355,4 @@ Browser → CopilotKit Runtime → AgentCore Runtime → your agent href="/langgraph/shared-state/in-app-agent-read" icon={} /> - \ No newline at end of file + diff --git a/showcase/shell-docs/src/lib/mdx-registry-loader.tsx b/showcase/shell-docs/src/lib/mdx-registry-loader.tsx index 2388c641953..9d9205667e8 100644 --- a/showcase/shell-docs/src/lib/mdx-registry-loader.tsx +++ b/showcase/shell-docs/src/lib/mdx-registry-loader.tsx @@ -89,9 +89,16 @@ function readPartial(relativePath: string): string | null { export async function PartialLoader({ relativePath, components, + scope, }: { relativePath: string; components: Record>>; + // Extra MDX scope variables made available to the partial as + // top-level identifiers (e.g. `{framework}`). next-mdx-remote passes + // these as function args of the compiled module, NOT as the rendered + // component's `props` — so the partial must reference them bare, not + // via `props.`. Optional; existing callers pass nothing. + scope?: Record; }): Promise { const body = readPartial(relativePath); if (body === null) { @@ -124,6 +131,7 @@ export async function PartialLoader({ components as React.ComponentProps["components"] } options={{ + scope, mdxOptions: { remarkPlugins: [remarkGfm], rehypePlugins: [ diff --git a/showcase/shell-docs/src/lib/mdx-registry.tsx b/showcase/shell-docs/src/lib/mdx-registry.tsx index c982dcfe8e4..a286d488c27 100644 --- a/showcase/shell-docs/src/lib/mdx-registry.tsx +++ b/showcase/shell-docs/src/lib/mdx-registry.tsx @@ -515,12 +515,28 @@ export const docsComponents = { SharedContent: ({ children }: { children: React.ReactNode }) => (
{children}
), - // is used by orphaned `deploy-agentcore` - // pages (langgraph/* + aws-strands) as a placeholder for content - // that was never authored. Without a registered shim, MDX rendering - // throws and ships a 500 in the public sitemap. - Content: ({ children }: { children?: React.ReactNode }) => ( -
{children}
+ // on the `deploy-agentcore` pages + // (langgraph/* + aws-strands) renders the shared AgentCore deploy + // partial at src/content/snippets/integrations/agentcore/index.mdx. + // Unlike the generic `stubWithPartial` stubs, this one threads the + // page's `framework` into the partial's MDX scope so the embedded + // `` collapses to the + // single relevant framework (Strands-only / LangGraph-only) instead + // of showing both. `stubWithPartial` can't do this — it discards + // props by design — so Content is a dedicated loader call. `scope` + // keys surface as bare identifiers in the partial (NOT `props.*`); + // see PartialLoader. + Content: ({ framework }: { framework?: string }) => ( + > + > + } + /> ), IframeSwitcher: RealIframeSwitcher, IframeSwitcherGroup: ({ children }: { children: React.ReactNode }) => (