diff --git a/.gitattributes b/.gitattributes index 366cc9f..98a0477 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,9 +4,14 @@ /.gitignore export-ignore /.editorconfig export-ignore /phpstan.neon.dist export-ignore +/phpstan.no-bundle.neon export-ignore /.php-cs-fixer.dist.php export-ignore /rector.php export-ignore /phpunit.xml.dist export-ignore /infection.json5 export-ignore /docs export-ignore +/recipe export-ignore /composer-dependency-analyser.php export-ignore +/phpstan.php85.neon export-ignore +/CONTRIBUTING.md export-ignore +/phpunit-baseline.xml export-ignore diff --git a/.github/nightly-issue.sh b/.github/nightly-issue.sh new file mode 100755 index 0000000..6eea709 --- /dev/null +++ b/.github/nightly-issue.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Opens one issue per failing nightly job, or comments on the open one. A person closes it after the fix. +# Usage: nightly-issue.sh [report file]. The text of the report file, if it exists, goes into the issue or comment. +set -euo pipefail +job="$1" +title="Nightly: ${job} failed" +run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +gh label create nightly --color B60205 --description "A scheduled upstream or release check failed" 2>/dev/null || true +number="$(gh issue list --label nightly --state open --limit 100 --json number,title --jq ".[] | select(.title == \"${title}\") | .number" | head -n 1)" +body="The nightly job \`${job}\` failed: ${run_url}" +report="${2:-}" +if [ -n "${report}" ] && [ -s "${report}" ]; then + # An issue body holds at most 65536 characters. + body+=$'\n\n'"$(head -c 60000 "${report}")" +fi +if [ -n "${number}" ]; then + gh issue comment "${number}" --body "${body}" +else + gh issue create --title "${title}" --label nightly --body "${body}" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2aa929..ad9c02b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,12 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Each job's timeout-minutes is about three times its usual run time; GitHub's default is 6 hours. jobs: static: name: Static analysis runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -33,13 +35,33 @@ jobs: - run: composer normalize --dry-run - run: vendor/bin/php-cs-fixer check --diff - run: vendor/bin/rector process --dry-run - - run: vendor/bin/phpstan analyse --no-progress --error-format=github + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.php85.neon - run: vendor/bin/composer-dependency-analyser - run: composer audit + static-lowest: + name: Static analysis (lowest dependencies) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: lowest + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.php85.neon + tests: name: Tests (PHP ${{ matrix.php }}, ${{ matrix.deps }}) runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -61,6 +83,7 @@ jobs: tests-next: name: Tests (PHP 8.6, experimental) runs-on: ubuntu-latest + timeout-minutes: 15 continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -73,11 +96,15 @@ jobs: - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 with: dependency-versions: highest - - run: vendor/bin/phpunit + # ergebnis/composer-normalize caps PHP below 8.6. + composer-options: --ignore-platform-req=php+ + # The baseline holds the deprecations that PHP 8.6 raises inside a dependency (opis/json-schema). + - run: vendor/bin/phpunit --use-baseline phpunit-baseline.xml coverage: name: Coverage runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -106,6 +133,8 @@ jobs: mutation: name: Mutation testing runs-on: ubuntu-latest + # The full run takes about 8 minutes; a hanging mutant holds a thread for the 120 s Infection timeout. + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -130,11 +159,136 @@ jobs: if: github.event_name != 'pull_request' run: vendor/bin/infection --threads=max --logger-github + discover: + name: Discover the MCP library releases (Packagist) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + releases: ${{ steps.releases.outputs.releases }} + mcp-sdk: ${{ steps.releases.outputs.mcp-sdk }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + # The newest patch of each minor release from the require-dev floor, so a new release joins the matrix by itself. + - name: Read the releases from Packagist + id: releases + run: | + releases="$(php -d display_errors=stderr tests/bin/released-versions.php)" + jq -e 'type == "array" and length > 0' <<<"$releases" >/dev/null + echo "releases=${releases}" >>"$GITHUB_OUTPUT" + echo "mcp-sdk=$(jq -c 'map(select(.library == "mcp/sdk"))' <<<"$releases")" >>"$GITHUB_OUTPUT" + jq -r '.[] | "- \(.library) \(.version) (lowest PHP \(.php))"' <<<"$releases" >>"$GITHUB_STEP_SUMMARY" + + released: + name: ${{ matrix.library }} ${{ matrix.version }} (PHP 8.5) + needs: discover + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.discover.outputs.releases) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install ${{ matrix.library }} ${{ matrix.version }} + env: + LIBRARY: ${{ matrix.library }} + VERSION: ${{ matrix.version }} + run: tests/bin/require-library.sh "$LIBRARY" "$VERSION" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=${{ matrix.library == 'mcp/sdk' && 'phpstan.no-bundle.neon' || 'phpstan.neon.dist' }} + - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + + runtime: + name: Runtime delivery (Docker, PHP 8.3) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Build the CLI and Swoole images and pull the others + run: | + docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker + docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole + for image in php:8.3.35-fpm-bookworm php:8.3.35-apache-bookworm dunglas/frankenphp:1.13.1-php8.3.35-bookworm unit:1.34.2-php8.3; do docker pull "$image"; done + - run: vendor/bin/phpunit --testsuite Runtime --group runtime + env: + AGENTCAT_RUNTIME_TESTS: '1' + + package: + name: Package probe (PHP ${{ matrix.php }}, mcp/sdk ${{ matrix.version }}) + needs: discover + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # Each mcp/sdk release, on the lowest PHP that CI tests and the release allows (8.3 today). + include: ${{ fromJSON(needs.discover.outputs.mcp-sdk) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + run: tests/bin/package-probe.sh "$MCP_SDK" + env: + MCP_SDK: ${{ matrix.version }} + + bc: + name: Backward compatibility + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + extensions: bcmath + coverage: none + - name: Compare with the latest 1.x tag + run: | + base="$(git tag --list 'v1.*' --sort=-v:refname | head -n 1)" + if [ -z "$base" ]; then echo "No v1.* tag yet: nothing to compare against (D63)."; exit 0; fi + composer global require --no-interaction roave/backward-compatibility-check:8.19.0 + "$(composer global config bin-dir --absolute)/roave-backward-compatibility-check" --from="$base" --format=github-actions --install-development-dependencies + all-green: name: All green if: always() - needs: [static, tests, coverage, mutation] + needs: [static, static-lowest, tests, coverage, mutation, discover, released, runtime, package, bc] runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..925abbc --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,545 @@ +name: Nightly + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + force_failure: + description: 'A job name to fail on purpose, to prove the issue step (for example audit)' + required: false + default: '' + type: string + pull_request: + paths: + - '.github/workflows/nightly.yml' + - '.github/nightly-issue.sh' + +permissions: + contents: read + issues: write + +# A new push cancels an older run of the same pull request; scheduled and dispatched runs are never cancelled. +concurrency: + group: nightly-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + mcp-sdk-dev-main: + name: mcp/sdk dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + run: composer remove --dev --no-update symfony/mcp-bundle + - name: Install mcp/sdk dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "mcp/sdk:dev-main as 0.9.99" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.no-bundle.neon + timeout-minutes: 6 + - name: Run the tests + timeout-minutes: 12 + env: + OUTPUT: ${{ runner.temp }}/phpunit.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee "$OUTPUT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + OUTPUT: ${{ runner.temp }}/phpunit.txt + REPORT: ${{ runner.temp }}/phpunit.md + run: | + # The failing tests and their messages, STDERR included, when PHPUnit ran: the issue names the failing test. + if [ -s "$OUTPUT" ]; then { printf '~~~text\n'; cat "$OUTPUT"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + laravel-mcp-dev-main: + name: laravel/mcp dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install laravel/mcp dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "laravel/mcp:dev-main as 1.0.99" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + timeout-minutes: 6 + - name: Run the tests + timeout-minutes: 12 + env: + OUTPUT: ${{ runner.temp }}/phpunit.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee "$OUTPUT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + OUTPUT: ${{ runner.temp }}/phpunit.txt + REPORT: ${{ runner.temp }}/phpunit.md + run: | + # The failing tests and their messages, STDERR included, when PHPUnit ran: the issue names the failing test. + if [ -s "$OUTPUT" ]; then { printf '~~~text\n'; cat "$OUTPUT"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + mcp-bundle-dev-main: + name: symfony/mcp-bundle dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install symfony/mcp-bundle dev-main with mcp/sdk 0.9 + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "symfony/mcp-bundle:dev-main as 0.14.99" "mcp/sdk:^0.9" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + timeout-minutes: 6 + - name: Run the tests + timeout-minutes: 12 + env: + OUTPUT: ${{ runner.temp }}/phpunit.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee "$OUTPUT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + OUTPUT: ${{ runner.temp }}/phpunit.txt + REPORT: ${{ runner.temp }}/phpunit.md + run: | + # The failing tests and their messages, STDERR included, when PHPUnit ran: the issue names the failing test. + if [ -s "$OUTPUT" ]; then { printf '~~~text\n'; cat "$OUTPUT"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + discover: + name: Discover the MCP library releases (Packagist) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + releases: ${{ steps.releases.outputs.releases }} + mcp-sdk: ${{ steps.releases.outputs.mcp-sdk }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + # The same releases as the released jobs of ci.yml: a release published since the last pull request runs here. + - name: Read the releases from Packagist + id: releases + timeout-minutes: 2 + env: + ERRORS: ${{ runner.temp }}/discover.txt + run: | + releases="$(php -d display_errors=stderr tests/bin/released-versions.php 2> >(tee "$ERRORS" >&2))" + jq -e 'type == "array" and length > 0' <<<"$releases" >/dev/null + echo "releases=${releases}" >>"$GITHUB_OUTPUT" + echo "mcp-sdk=$(jq -c 'map(select(.library == "mcp/sdk"))' <<<"$releases")" >>"$GITHUB_OUTPUT" + jq -r '.[] | "- \(.library) \(.version) (lowest PHP \(.php))"' <<<"$releases" >>"$GITHUB_STEP_SUMMARY" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + ERRORS: ${{ runner.temp }}/discover.txt + REPORT: ${{ runner.temp }}/discover.md + run: | + # The message of released-versions.php, when it wrote one: the issue says why the releases could not be read. + if [ -s "$ERRORS" ]; then { printf '~~~text\n'; cat "$ERRORS"; printf '~~~\n'; } >"$REPORT"; fi + .github/nightly-issue.sh "${{ github.job }}" "$REPORT" + + released: + name: ${{ matrix.library }} ${{ matrix.version }} (PHP 8.5) + needs: discover + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.discover.outputs.releases) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + # Each step adds its output to $LOG, so the issue shows why the install, PHPStan or the tests failed. + - name: Install ${{ matrix.library }} ${{ matrix.version }} + timeout-minutes: 8 + env: + LIBRARY: ${{ matrix.library }} + VERSION: ${{ matrix.version }} + LOG: ${{ runner.temp }}/released.txt + run: | + set -o pipefail + tests/bin/require-library.sh "$LIBRARY" "$VERSION" 2>&1 | tee -a "$LOG" + - name: PHPStan + timeout-minutes: 6 + env: + LOG: ${{ runner.temp }}/released.txt + run: | + set -o pipefail + vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=${{ matrix.library == 'mcp/sdk' && 'phpstan.no-bundle.neon' || 'phpstan.neon.dist' }} 2>&1 | tee -a "$LOG" + - name: Run the tests + timeout-minutes: 12 + env: + LOG: ${{ runner.temp }}/released.txt + run: | + set -o pipefail + vendor/bin/phpunit --testsuite Contract,Integration,E2E --no-progress --colors=never 2>&1 | tee -a "$LOG" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + LIBRARY: ${{ matrix.library }} + VERSION: ${{ matrix.version }} + LOG: ${{ runner.temp }}/released.txt + REPORT: ${{ runner.temp }}/released.md + run: | + # The end of the log: the failing step writes last, and an issue body holds at most 65536 characters. + if [ -s "$LOG" ]; then { printf '~~~text\n'; tail -c 50000 "$LOG"; printf '\n~~~\n'; } >"$REPORT"; fi + # One issue per minor release: a failure that lasts across its patches stays in one issue. + .github/nightly-issue.sh "released (${LIBRARY} $(cut -d. -f1-2 <<<"$VERSION"))" "$REPORT" + + package: + name: Package probe (PHP ${{ matrix.php }}, mcp/sdk ${{ matrix.version }}) + needs: discover + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.discover.outputs.mcp-sdk) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + timeout-minutes: 8 + env: + MCP_SDK: ${{ matrix.version }} + run: tests/bin/package-probe.sh "$MCP_SDK" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ matrix.version }} + run: .github/nightly-issue.sh "package (mcp/sdk $(cut -d. -f1-2 <<<"$VERSION"))" + + php-nightly: + name: Tests (PHP nightly) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: 'nightly' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + # ergebnis/composer-normalize caps PHP below the nightly. + composer-options: --ignore-platform-req=php+ + # display_errors=stderr: a dependency's deprecation (opis/json-schema) prints to STDERR, never before a test's headers; the log keeps it. + # The baseline holds the deprecations that PHP raises inside a dependency. + - run: vendor/bin/phpunit -d display_errors=stderr --use-baseline phpunit-baseline.xml + timeout-minutes: 20 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + mutation-full: + name: Mutation testing (full run) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: pcov + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: vendor/bin/infection --threads=max --logger-github + timeout-minutes: 50 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + audit: + name: Composer audit (highest dependencies) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: composer audit + timeout-minutes: 5 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + network: + name: Network tests (live OpenAPI schema) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: vendor/bin/phpunit --group network + timeout-minutes: 5 + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + runtime-latest: + name: Runtime delivery (Docker, latest image patches) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Build the CLI and Swoole images on the latest bases and pull the others + timeout-minutes: 8 + run: | + docker build --pull --build-arg BASE=php:8.3-cli-bookworm --tag agentcat-runtime-cli:latest tests/Runtime/docker + docker build --pull --build-arg BASE=phpswoole/swoole:6.2-php8.3 --tag agentcat-runtime-swoole:latest tests/Runtime/docker/swoole + for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm dunglas/frankenphp:1-php8.3-bookworm unit:php8.3; do docker pull "$image"; done + - name: Record the resolved PHP version of each image + timeout-minutes: 2 + run: | + for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm agentcat-runtime-cli:latest dunglas/frankenphp:1-php8.3-bookworm agentcat-runtime-swoole:latest unit:php8.3; do + printf '%s: ' "$image" + docker run --rm --pull never --network none --entrypoint php "$image" -r 'echo PHP_VERSION, "\n";' + done + - run: vendor/bin/phpunit --testsuite Runtime --group runtime + timeout-minutes: 15 + env: + AGENTCAT_RUNTIME_TESTS: '1' + AGENTCAT_RUNTIME_IMAGES: latest + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + package-dev-main: + name: Package probe (PHP 8.3, mcp/sdk dev-main) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + timeout-minutes: 8 + run: tests/bin/package-probe.sh "dev-main as 0.9.99" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + member-snapshot: + name: Member snapshot (${{ matrix.library }} dev-main) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - library: mcp/sdk + constraint: 'mcp/sdk:dev-main as 0.9.99' + - library: laravel/mcp + constraint: 'laravel/mcp:dev-main as 1.0.99' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + if: matrix.library == 'mcp/sdk' + run: composer remove --dev --no-update symfony/mcp-bundle + - name: Install ${{ matrix.library }} dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "${{ matrix.constraint }}" + - name: Diff the members against the newest committed snapshot + timeout-minutes: 5 + env: + LIBRARY: ${{ matrix.library }} + REPORT: ${{ runner.temp }}/member-snapshot.md + run: | + set -o pipefail + prefix="tests/Contract/snapshots/${LIBRARY//\//-}-" + newest="$(printf '%s\n' "$prefix"*.json | sort -V | tail -n 1)" + php tests/bin/member-snapshot.php "$LIBRARY" --diff "$newest" 2>&1 | tee "$REPORT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + LIBRARY: ${{ matrix.library }} + REPORT: ${{ runner.temp }}/member-snapshot.md + run: .github/nightly-issue.sh "member-snapshot (${LIBRARY})" "$REPORT" + + branding-drift: + name: README branding drift (TypeScript SDK main) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Compare the shared README sections and images with the TypeScript SDK's main branch + timeout-minutes: 3 + env: + GITHUB_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/branding-drift.md + run: | + set -o pipefail + php tests/bin/branding-drift.php 2>&1 | tee "$REPORT" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/branding-drift.md + run: .github/nightly-issue.sh "${{ github.job }}" "$REPORT" diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 949f5e2..264907b 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -7,7 +7,7 @@ $finder = Finder::create() ->in([__DIR__ . '/src', __DIR__ . '/tests']) - ->append([__DIR__ . '/rector.php', __DIR__ . '/composer-dependency-analyser.php', __FILE__]); + ->append([__DIR__ . '/rector.php', __DIR__ . '/composer-dependency-analyser.php', __DIR__ . '/config/agentcat.php', __FILE__]); return (new Config()) ->setRiskyAllowed(true) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e8d0e95 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `AgentCat::track()` and the `mcp/sdk` adapter: one call on the `Mcp\Server\Builder` tracks every tool of the server. +- The `laravel/mcp` bridge: every server of a Laravel application is tracked, with `config/agentcat.php` and `php artisan agentcat:install`. +- The Symfony bundle, `AgentCat\Symfony\AgentCatBundle`, for `symfony/mcp-bundle` servers. +- The `session_id`, `agent_id` and `context` parameters that AgentCat injects into each tool, and the `get_more_tools` tool. +- The hooks: `identify`, `resolveSessionId`, `eventTags`, `eventProperties`, `redactSensitiveInformation` and `redactEvent`. +- Custom events, with `AgentCat::publishCustomEvent()`. +- The four exporters: OpenTelemetry (OTLP), Datadog, Sentry and PostHog. +- Diagnostics, which send AgentCat the SDK's own log lines to find broken installs, and their opt-out (`disableDiagnostics`, `AGENTCAT_DISABLE_DIAGNOSTICS`). +- Delivery after the response on every runtime: the client has its answer before AgentCat runs the hooks and sends the events. +- `AgentCat::fake()` for your own tests, with `assertTracked()`, `assertNothingTracked()`, `assertTrackedCount()` and `assertCustomEventPublished()`. + +### Changed + +- AgentCat reads `tools/list` pages until it finds the called tool, so a tool on a later page keeps its own parameters. + The TypeScript SDK reads only the first page, and for a tool on a later page it guesses which arguments to remove: the guess can remove the tool's own `session_id` or `context` argument. + Under PHP-FPM and the other runtimes that serve each request in a fresh process, your own `tools/list` handler (one that you add to the server, or a `laravel/mcp` class that answers `tools/list`) then runs once for each page that AgentCat reads during a `tools/call`, so its queries, metrics and log lines repeat that many times. + A call to the last-page tool of a 300-tool `laravel/mcp` server takes 57.6 ms (2.7 ms before); a call to a page-1 tool costs the same as before. + +[Unreleased]: https://github.com/agentcathq/agentcat-php-sdk/commits/main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ea7b570 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,204 @@ +# Contributing to AgentCat ๐ŸŽ‰ + +Thank you for your interest in contributing to AgentCat! We're excited to have you join our community of developers building analytics tools for MCP servers. + +## Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR-USERNAME/agentcat-php-sdk.git + cd agentcat-php-sdk + ``` +3. **Install dependencies** using Composer (PHP 8.3 or later, with ext-curl, ext-json and ext-mbstring): + ```bash + composer install + ``` +4. **Create a branch** for your feature or fix: + ```bash + git checkout -b feature/your-feature-name + # or + git checkout -b fix/your-bug-fix + ``` + +## Development Process + +### Making Changes + +1. **Write your code** following our PHP standards: `declare(strict_types=1)`, `final` classes, native types, and `@internal` on every class that is not public API +2. **Add tests** for new features (required for feature additions) +3. **Run every check** to ensure everything passes: + ```bash + composer check + ``` +4. **Fix the code style** and apply Rector when `composer check` reports changes: + ```bash + composer cs:fix # Run php-cs-fixer + vendor/bin/rector process # Apply the Rector rules + ``` + +### Commit Conventions + +We follow [Conventional Commits](https://www.conventionalcommits.org/). Your commit messages should be structured as: + +``` +: + +[optional body] +``` + +**Types:** + +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `test`: Adding or updating tests +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `ci`: Changes to the CI workflows +- `build`: Changes to the dependencies or the package +- `chore`: Changes to build process or auxiliary tools + +**Examples:** + +```bash +git commit -m "feat: add telemetry exporters for observability" +git commit -m "fix: handle edge case in session tracking" +git commit -m "docs: update API documentation" +``` + +## Pull Request Process + +1. **Push your changes** to your fork: + + ```bash + git push origin feature/your-feature-name + ``` + +2. **Create a Pull Request** from your fork to our `main` branch + +3. **Fill out the PR description** with: + + - What changes you've made + - Why these changes are needed + - Any relevant context or screenshots + +4. **Wait for review** - We will review your PR as soon as we can + +5. **Address feedback** if any changes are requested + +6. **Celebrate** ๐ŸŽ‰ once your PR is merged! + +### The Required Check + +The one required check is `All green`. It passes when every CI job passes: static analysis on the lowest and the highest dependencies, the tests on PHP 8.3, 8.4 and 8.5, coverage, mutation testing, the released-version legs, the runtime suite, the package probe and the backward-compatibility check. The released-version legs take their versions from Packagist on each run: the newest patch of each minor release of `mcp/sdk`, `laravel/mcp` and `symfony/mcp-bundle`, from the lowest version that `require-dev` allows. The package probe runs on each of those `mcp/sdk` releases. The nightly runs the legs and the probe again, so a release published between pull requests that breaks them opens an issue. When the install step of a leg fails, `require-dev` does not allow that release yet: widen its range, or the range of the framework that it needs. + +### No Issue Required + +You don't need to open an issue before submitting a PR. Feel free to submit pull requests directly with your improvements! + +## Good First Issues + +Looking for a place to start? Check out issues labeled [`good first issue`](https://github.com/agentcathq/agentcat-php-sdk/labels/good%20first%20issue) - these are great for newcomers to the codebase. + +## Testing + +- New features **should include tests** to ensure reliability +- Run tests locally with `vendor/bin/phpunit` +- We use [PHPUnit](https://phpunit.de/) for our test suite and [Infection](https://infection.github.io/) for mutation testing +- Test files live under `tests/`, in the suite that fits (`Unit`, `Integration`, `Contract`, `E2E`, โ€ฆ), at the path of the code they test with a `Test.php` suffix: `tests/Unit/Internal/Engine/ToolListingTest.php` tests `src/Internal/Engine/ToolListing.php` + +Some tests need more than PHPUnit: + +```bash +# Mutation testing: every mutant of src/, or only the files you changed +vendor/bin/infection --threads=max +vendor/bin/infection --threads=max --filter=src/Internal/Engine/ToolListing.php + +# PHPStan and the adapter suites against mcp/sdk 0.9.0, in a throwaway copy under .cache/ +tests/bin/mcp-sdk-leg.sh + +# The releases that the released-version legs test, read from Packagist +php tests/bin/released-versions.php + +# The runtime suite: PHP-FPM, FrankenPHP, Apache, NGINX Unit, the CLI and Swoole, in Docker +AGENTCAT_RUNTIME_TESTS=1 vendor/bin/phpunit --testsuite Runtime --group runtime + +# The package probe: installs the dist (git archive HEAD, so commit first) in a fresh project and tracks a server +tests/bin/package-probe.sh 0.9.0 +``` + +The runtime containers write files as root or www-data into the test home under `.cache/test-home`, and a test that ends removes them. When you kill a runtime run before it ends (for example with Ctrl+C), those files can stay there: remove them with `docker run --rm -v "$PWD/.cache:/cache" php:8.3-cli rm -rf /cache/test-home`, or with `sudo rm -rf .cache/test-home`. + +## Code Quality + +Before submitting your PR, ensure your code passes all checks: + +```bash +# Run all checks at once +composer check + +# Or run them individually +composer validate --strict # Validate composer.json +composer normalize --dry-run # Check that composer.json is normalized +composer cs:check # Check code style +composer rector:check # Check the Rector rules +composer stan # Check types with PHPStan (level max, in both configurations) +composer deps # Check the declared dependencies +composer test # Run tests +``` + +Our CI will run these same checks on your PR. + +## Dependencies + +While we don't restrict adding new dependencies, they are generally **discouraged** unless absolutely necessary. If you need to add a dependency: + +1. Consider if the functionality can be achieved with existing dependencies +2. Check if the dependency is well-maintained and lightweight +3. Ensure it's compatible with our MIT license +4. Add it using Composer: `composer require ` + +## Project Structure + +``` +agentcat-php-sdk/ +โ”œโ”€โ”€ src/ # Source code +โ”‚ โ”œโ”€โ”€ AgentCat.php # Main entry point: track(), publishCustomEvent(), fake() +โ”‚ โ”œโ”€โ”€ McpSdk/ # The mcp/sdk adapter +โ”‚ โ”œโ”€โ”€ LaravelMcp/ # The laravel/mcp adapter +โ”‚ โ”œโ”€โ”€ Laravel/ # The Laravel service provider and agentcat:install +โ”‚ โ”œโ”€โ”€ Symfony/ # The Symfony bundle +โ”‚ โ”œโ”€โ”€ Exporters/ # The OTLP, Datadog, Sentry and PostHog exporters +โ”‚ โ”œโ”€โ”€ Testing/ # AgentCat::fake() and its assertions +โ”‚ โ””โ”€โ”€ Internal/ # The engine, delivery, telemetry and diagnostics +โ”œโ”€โ”€ config/ # config/agentcat.php for Laravel +โ”œโ”€โ”€ resources/boost/ # The Laravel Boost guidelines for AI agents +โ”œโ”€โ”€ recipe/ # The Symfony Flex recipe +โ”œโ”€โ”€ tests/ # Test files +โ””โ”€โ”€ docs/ # The README images +``` + +## Community + +- **Discord**: Join our [Discord server](https://discord.gg/n9qpyhzp2u) for discussions +- **Documentation**: Visit [docs.agentcat.com](https://docs.agentcat.com) for detailed guides +- **Issues**: Browse [open issues](https://github.com/agentcathq/agentcat-php-sdk/issues) for areas needing help + +## Versioning + +The AgentCat team handles versioning and releases. Your contributions will be included in the next appropriate release based on semantic versioning principles: everything that is not marked `@internal` is public API, and the backward-compatibility check compares each PR with the latest 1.x release. + +## Recognition + +All contributors are recognized in our repository. Your contributions help make AgentCat better for everyone building MCP servers! + +## Questions? + +If you have questions about contributing, feel free to: + +- Ask in our [Discord server](https://discord.gg/n9qpyhzp2u) +- Open an [issue](https://github.com/agentcathq/agentcat-php-sdk/issues) on GitHub + +We answer issues and pull requests as soon as we can. + +Thank you for contributing to AgentCat! ๐Ÿฑ diff --git a/README.md b/README.md index 0a4192f..2726265 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,273 @@ -# agentcat/sdk +
+ AgentCat โ€” see exactly how agents experience your product +
+

+ Getting Started + ยท + Features + ยท + Docs + ยท + Website + ยท + Open Source + ยท + Schedule a Demo +

+

+ Packagist version + Packagist downloads + License: MIT + PHP + GitHub issues + CI +

-Analytics for MCP (Model Context Protocol) servers. +> [!NOTE] +> AgentCat for PHP is compatible with the [MCP Protocol "Stateless" 2026-07-28 Update](https://blog.modelcontextprotocol.io/posts/2026-07-28/) as the PHP libraries put it into effect: [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sdk) over HTTP since 0.8 and over stdio since 0.9.0, and [`laravel/mcp`](https://github.com/laravel/mcp) 1.x. The stateless transition has a massive impact on analytics, as sessions were a built-in concept tying related tool calls together. AgentCat has now migrated its session tracking under guidance of the MCP core team's recommendations of using [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567). +> +> As a result AgentCat now injects a `session_id` on every MCP tool call to associate them under the same task umbrella. Our evals show much higher tool correlation accuracy at the cost of < 1% additional context pollution. -> **Status: in development.** This package is the official AgentCat PHP SDK -> for MCP servers. The first release is not published yet, so do not depend on -> it in production. A full README will ship with `1.0.0`. +AgentCat is an analytics platform for MCP server owners ๐Ÿฑ. It captures user intentions and behavior patterns to help you understand what AI users actually need from your tools โ€” eliminating guesswork and accelerating product development all with one-line of code. -AgentCat captures how AI agents actually use your MCP server: which tools get -called, how often, by whom, and โ€” via intent capture โ€” why. +This SDK also provides a free and simple way to forward telemetry like logs, traces, and errors to any Open Telemetry collector or popular tools like Datadog, Sentry, and PostHog. -## Available today +```bash +composer require agentcat/sdk +``` -| Language | Package | -| ---------- | ------------------------------------------------------------------------ | -| TypeScript | [`agentcat`](https://www.npmjs.com/package/agentcat) | -| Python | [`agentcat`](https://pypi.org/project/agentcat/) | -| Go | [`go.agentcat.com/sdk`](https://github.com/agentcathq/agentcat-go-sdk) | -| Ruby | [`agentcat`](https://rubygems.org/gems/agentcat) (placeholder) | -| .NET | [`AgentCat`](https://www.nuget.org/packages/AgentCat) (placeholder) | -| Rust | [`agentcat-sdk`](https://crates.io/crates/agentcat-sdk) (placeholder) | -| PHP | *in development* | +To learn more about us, check us out [here](https://agentcat.com). For detailed guides visit our [documentation](https://docs.agentcat.com). -- Website โ€” https://agentcat.com -- Docs โ€” https://docs.agentcat.com +## Why use AgentCat? ๐Ÿค” -## License +AgentCat helps builders of MCP servers, Claude Connectors, and ChatGPT Plugins learn how to improve them by capturing any agents goals and detecting when they get stuck. -MIT ยฉ AgentCat, Inc. +Use AgentCat for: + +- **Agent session replay** ๐ŸŽฌ. Follow alongside your users and their agents to understand why they're using your MCP servers, what functionality you're missing, and what clients they're coming from. +- **Trace debugging** ๐Ÿ”. See where your users are getting stuck, track and find when LLMs get confused by your API, and debug sessions across all deployments of your MCP server. +- **Existing platform support** ๐Ÿ“Š. Get logging and tracing out of the box for your existing observability platforms (OpenTelemetry, Datadog, Sentry) โ€” eliminating the tedious work of implementing telemetry yourself. + +AgentCat architecture โ€” the AgentCat SDK inside your MCP server sends analytics to your observability vendors and session replay to the AgentCat dashboard + +## How it works + +AgentCat works as a lightweight middleware inside your MCP server. When you call `track()`, it seamlessly modifies your registered tool schemas in place, following the MCP core team's [explicit handles (SEP-2567)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567) guidelines. Concretely, AgentCat adds the following to your server: + +- **`session_id`** โ€” a parameter injected into each tool's input schema. Agents echo it back on every call, letting AgentCat group related tool calls into one task even over stateless transports. Values are validated: anything that is not a well-formed AgentCat session ID is rejected rather than adopted, and the agent is told to re-send the ID it was given. +- **`agent_id`** _(off by default)_ โ€” enabled with `enableAgentTracking: true`. Each agent self-generates its own ID, keeping parallel agents working the same task individually attributable. +- **`context`** โ€” a parameter asking the agent to explain, in one sentence, why it is making this call. This is where intent data comes from. +- **`get_more_tools`** โ€” an additional tool, prompt-engineered so that agents readily report the features and tools they looked for but couldn't find โ€” surfacing your missing functionality directly from real usage. + +Injected parameters are stripped from arguments before your tool handler runs, so your code never sees them. For tools that declare an output schema, issued IDs are also mirrored into `structuredContent` (as `mcp_session`), so clients that only read structured results still receive them. + +## Getting Started + +To get started with AgentCat, first create an account and obtain your project ID by signing up at [agentcat.com](https://agentcat.com). For detailed setup instructions visit our [documentation](https://docs.agentcat.com). + +Once you have your project ID, set up AgentCat for your MCP library below; the [quickstart](https://docs.agentcat.com/quickstart) walks through each setup step by step. AgentCat stays off while your tests run, and `AgentCat::fake()` records the events of a test for your assertions. + +### Plain `mcp/sdk` + +Install AgentCat next to `mcp/sdk` (`composer require agentcat/sdk mcp/sdk`), then track the `Mcp\Server\Builder` with one call, before `build()`: + +```php +setServerInfo('echo-mcp', '0.1.0') + ->addTool(static fn(string $msg): string => $msg, 'echo', description: 'Echo a message'); + +// Track the server with AgentCat +AgentCat::track($builder, 'proj_0000000'); + +$builder->build()->run(new StdioTransport()); +``` + +With `null` instead of the ID, AgentCat reads it from `AGENTCAT_PROJECT_ID`. Under PHP-FPM every request runs your script, so every request builds and tracks a fresh server, and tracking it costs no more than tracking one long-lived server. Within one process, calling `track()` per instance is cheap โ€” the event queue, telemetry exporters, and diagnostics are initialized once and shared across instances. The [`mcp/sdk` guide](https://docs.agentcat.com/sdk/php/mcp-sdk) has the details, HTTP servers included. + +### Laravel + +Install the package and run its install command, which asks for your project ID and writes it to `.env` as `AGENTCAT_PROJECT_ID`: + +```bash +composer require agentcat/sdk +php artisan agentcat:install +``` + +That is all: every `laravel/mcp` server of the application is tracked, except Laravel Boost's, also when `laravel/mcp` builds the server for each request. To change the options, publish `config/agentcat.php`: + +```bash +php artisan vendor:publish --tag=agentcat-config +``` + +The [Laravel guide](https://docs.agentcat.com/sdk/php/laravel) has the details. + +### Symfony + +Register the bundle in `config/bundles.php`: + +```php +return [ + // ... + AgentCat\Symfony\AgentCatBundle::class => ['all' => true], +]; +``` + +Then set your project ID in `.env.local` (or in the environment of your server), and every `symfony/mcp-bundle` server of the application is tracked: + +```dotenv +AGENTCAT_PROJECT_ID=proj_0000000 +``` + +A `config/packages/agentcat.yaml` file is optional: `php bin/console config:dump-reference agentcat` lists the options it can hold. The [Symfony guide](https://docs.agentcat.com/sdk/php/symfony) has the details. + +### Identifying users + +We strongly encourage identifying every actor. If you can't resolve a real user, return a stable anonymized ID instead โ€” for example, a hash of the auth token or API key โ€” so that all events from the same end user still roll up to one actor in your dashboard rather than scattering into anonymous one-off sessions. + +`identify` (like every AgentCat hook) runs after the client has your tool's answer, so it never adds latency to that tool call: a slow lookup โ€” or a hook that fails outright โ€” costs analytics data for that event, never that call's response time. + +The callback receives a `ToolCall`: the tool name, the raw arguments and `_meta`, the HTTP headers, the client's name and version, and the request and context objects of your MCP library. Most identity signals live in its `headers`, with lower-case names (empty on stdio): + +```php +use AgentCat\AgentCat; +use AgentCat\Identity; +use AgentCat\Options; +use AgentCat\ToolCall; + +AgentCat::track($builder, 'proj_0000000', new Options( + identify: static function (ToolCall $call): ?Identity { + $token = $call->headers['authorization'] ?? null; + if ($token === null) { + return null; + } + + return new Identity( + userId: hash('sha256', $token), // a stable anonymized ID, or your own user ID + userData: ['orgId' => $call->headers['x-org-id'] ?? null], + ); + }, +)); +``` + +In Laravel, the `identify` key of `config/agentcat.php` names an invokable class that receives the same `ToolCall`; in Symfony, the `identify` key of the `agentcat` configuration names an invokable service. + +### Redacting sensitive data + +AgentCat redacts all data sent to its servers and encrypts at rest, but for additional security, it offers a hook to do your own redaction on all text data returned back to our servers. + +```php +use AgentCat\AgentCat; +use AgentCat\Options; + +AgentCat::track($builder, 'proj_0000000', new Options( + redactSensitiveInformation: static fn(string $text): string => preg_replace('/\bsk_live_\w+/', '[REDACTED]', $text) ?? $text, +)); +``` + +For redaction decisions that need more context than a single string โ€” such as which tool was called or what type of event is being published โ€” use the event-level `redactEvent` hook. It receives the full event object and returns a modified event, or `null` to drop the event entirely. It can be combined with `redactSensitiveInformation`. + +```php +use AgentCat\AgentCat; +use AgentCat\Event; +use AgentCat\Options; + +AgentCat::track($builder, 'proj_0000000', new Options( + redactEvent: static function (Event $event): ?Event { + // Drop events from tools that handle secrets entirely + if ($event->resourceName === 'get_credentials') { + return null; + } + // Strip response payloads from a specific tool + if ($event->resourceName === 'export_report') { + $event->response = null; + } + + return $event; + }, +)); +``` + +### Vendor Support + +AgentCat seamlessly integrates with your existing observability stack, providing automatic logging and tracing without the tedious setup typically required. Export telemetry data to multiple platforms simultaneously: + +```php +use AgentCat\AgentCat; +use AgentCat\Exporters\DatadogExporter; +use AgentCat\Exporters\OtlpExporter; +use AgentCat\Exporters\PostHogExporter; +use AgentCat\Exporters\SentryExporter; +use AgentCat\Options; + +// The project ID can be '' (or null without AGENTCAT_PROJECT_ID) if you just want to forward telemetry +AgentCat::track($builder, 'proj_0000000', new Options( + exporters: [ + 'otlp' => new OtlpExporter(endpoint: 'http://localhost:4318/v1/traces'), + 'datadog' => new DatadogExporter( + apiKey: (string) getenv('DD_API_KEY'), + site: 'datadoghq.com', + service: 'my-mcp-server', + ), + 'sentry' => new SentryExporter( + dsn: (string) getenv('SENTRY_DSN'), + environment: 'production', + ), + 'posthog' => new PostHogExporter( + apiKey: (string) getenv('POSTHOG_API_KEY'), + host: 'https://us.i.posthog.com', // Optional: defaults to US region + ), + ], +)); +``` + +Learn more about our free and open source [telemetry integrations](https://docs.agentcat.com/telemetry/integrations). + +### Internal diagnostics + +To help us catch and fix broken installs, the SDK sends AgentCat every line it logs, with its time and level. The lines can contain session IDs, actor IDs, tool names, error messages and the answers of exporters that failed. Records also carry operational metadata, such as your project ID (or, when none is set, an install ID that hashes the host name and the install path), the OS, and the versions of PHP, the SDK and `mcp/sdk`. Your local log is unchanged: `~/agentcat.log` (or the PSR-3 logger of your `mcp/sdk` Builder) in plain PHP, `storage/logs/agentcat.log` in Laravel and `%kernel.logs_dir%/agentcat.log` in Symfony. + +Diagnostics are on by default and can be turned off completely with either: + +- `AgentCat::track($builder, $projectId, new Options(disableDiagnostics: true))`, or +- the `AGENTCAT_DISABLE_DIAGNOSTICS` (or `DISABLE_DIAGNOSTICS`) environment variable. + +## PHP runtimes + +- **PHP-FPM**: AgentCat runs the hooks and delivers the events after `fastcgi_finish_request()`, when the client has its answer. +- **FrankenPHP**: classic mode works like PHP-FPM; in worker mode, a plain `mcp/sdk` worker calls `AgentCat::flush(1.0)` after each request in its `frankenphp_handle_request()` loop, and Laravel Octane and Symfony need nothing. +- **Apache mod_php and NGINX Unit**: the client gets its answer before AgentCat delivers; on mod_php the answer needs a `Content-Length`, which the Laravel and Symfony bridges set for you and which a plain `mcp/sdk` script must set on the answer itself. +- **The CLI**: a stdio server runs the hooks and delivers the events of a call when the next `tools/call` or `tools/list` arrives and when its input ends, so a slow hook delays that next request; AgentCat writes nothing to STDOUT or STDERR, so stdout stays JSON-RPC. +- **Containers**: start a stdio server with `docker run --init`, so that a stop signal ends PHP and AgentCat (with ext-pcntl) first delivers the queued events. +- **Swoole, RoadRunner and Workerman**: with Laravel or Symfony, AgentCat delivers in the framework's terminate hooks (Laravel's `terminating()` callbacks, also under Octane, and Symfony's `kernel.terminate`), waiting at most 1 s after each request; a plain `mcp/sdk` worker calls `AgentCat::flush(1.0)` after each request. + +AgentCat supports PHP 8.3, 8.4 and 8.5; `mcp/sdk` 0.8.1 or later; `laravel/mcp` 1.x with Laravel 12.41.1 or later and 13; and `symfony/mcp-bundle` 0.13 and 0.14 with Symfony 7.3 and 8.x. On every pull request and every night, CI reads Packagist and tests the newest patch of each minor release of these three libraries, from the lowest supported version up. The [runtimes guide](https://docs.agentcat.com/sdk/php/runtimes) covers each runtime, containers and hardened hosts, and the [limits page](https://docs.agentcat.com/sdk/php/limits) lists the differences from the TypeScript SDK and the known limits. + +## Free for open source + +AgentCat is free for qualified open source projects. We believe in supporting the ecosystem that makes MCP possible. If you maintain an open source MCP server, you can access our full analytics platform at no cost. + +**How to apply**: Email [hi@agentcat.com](mailto:hi@agentcat.com) with your repository link + +_Already using AgentCat? We'll upgrade your account immediately._ + +## Community Cats ๐Ÿฑ + +Meet the cats behind AgentCat! Add your cat to our community by submitting a PR with your cat's photo in the `docs/cats/` directory of the [TypeScript SDK](https://github.com/agentcathq/agentcat-typescript-sdk/tree/main/docs/cats), whose cats this README shows too. + +
+ bibi + zelda + void +
+ +_Want to add your cat? Create a PR adding your cat's photo to_ [`docs/cats/` of the TypeScript SDK](https://github.com/agentcathq/agentcat-typescript-sdk/tree/main/docs/cats) _and update its README!_ diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 251e0cd..4f4bf15 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -5,9 +5,46 @@ use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; -// Runtime dependencies are declared before the code that uses them lands (later plans). -// Remove this ignore once the code uses ext-curl. return (new Configuration()) - ->ignoreErrorsOnExtensions(['ext-curl'], [ErrorType::UNUSED_DEPENDENCY]) - // ext-posix is optional: FileLogger guards its use with function_exists(). - ->ignoreErrorsOnExtensions(['ext-posix'], [ErrorType::SHADOW_DEPENDENCY]); + // ext-pcntl, ext-posix and ext-session are optional: FileLogger, SignalDrain and RequestEnd guard their use with function_exists(). + ->ignoreErrorsOnExtensions(['ext-pcntl', 'ext-posix', 'ext-session'], [ErrorType::SHADOW_DEPENDENCY]) + // ext-tokenizer reads the attributes of the test files in TraceabilityTest; php-cs-fixer/shim, a dev dependency, requires it. + ->ignoreErrorsOnExtensionAndPath('ext-tokenizer', __DIR__ . '/tests/Architecture/TraceabilityTest.php', [ErrorType::SHADOW_DEPENDENCY]) + // ext-dom and ext-libxml read the PHPUnit baseline in PackageFilesTest; phpunit/phpunit requires them. + ->ignoreErrorsOnExtensionsAndPaths(['ext-dom', 'ext-libxml'], [__DIR__ . '/tests/Architecture/PackageFilesTest.php'], [ErrorType::SHADOW_DEPENDENCY]) + // Test fixture functions, loaded with require_once by ExceptionCaptureTest (not autoloadable). + ->ignoreUnknownFunctionsRegex('~^AgentCat\\\\Tests\\\\Fixtures\\\\Errors\\\\~') + // mcp/sdk is optional (ac-01-90): a dev dependency and a suggestion, used only by the adapter. + ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/McpSdk', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // AgentCatFake asserts with PHPUnit only when the class is loaded (class_exists()); phpunit stays a dev dependency. + ->ignoreErrorsOnPackageAndPath('phpunit/phpunit', __DIR__ . '/src/Testing', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // Psr\Container\ContainerInterface arrives with mcp/sdk; the adapter only passes it through. + ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/McpSdk', [ErrorType::SHADOW_DEPENDENCY]) + // Psr\EventDispatcher\EventDispatcherInterface arrives with mcp/sdk; the test double stands in for a customer's dispatcher. + ->ignoreErrorsOnPackageAndPath('psr/event-dispatcher', __DIR__ . '/tests/Support/McpSdk/RecordingDispatcher.php', [ErrorType::SHADOW_DEPENDENCY]) + // Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixtures build their request from the globals with it, like a customer. + ->ignoreErrorsOnPackageAndPaths('php-http/discovery', [__DIR__ . '/tests/E2E/Fixtures', __DIR__ . '/tests/Runtime/Fixtures'], [ErrorType::SHADOW_DEPENDENCY]) + // laravel/mcp and Laravel are optional (ac-01-90): dev dependencies used only by the Laravel adapter and bridge. + ->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // False positives: the analyser reads AgentCat's own `Env::get()` and `new Event(` as Laravel's global env() and + // event() helpers. PHPat keeps Laravel out of these files (LayeringTest). + ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Internal/Support/BridgeConfig.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // Symfony is optional (ac-01-90): dev dependencies used only by the Symfony bundle. + ->ignoreErrorsOnPackagesAndPaths(['symfony/config', 'symfony/dependency-injection', 'symfony/http-foundation', 'symfony/http-kernel'], [__DIR__ . '/src/Symfony'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // The bundle configures the Mcp\Server\Builder services of symfony/mcp-bundle, which requires mcp/sdk. + ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it. + ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle adds its kernel.response listener to the kernel's dispatcher, and the bundle tests read the listeners from it. + ->ignoreErrorsOnPackageAndPaths('symfony/event-dispatcher', [__DIR__ . '/src/Symfony', __DIR__ . '/tests/Support/Symfony', __DIR__ . '/tests/Unit/Symfony', __DIR__ . '/tests/Integration/Symfony'], [ErrorType::SHADOW_DEPENDENCY]) + // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. + ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. + ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\Console arrives with symfony/framework-bundle; a bundle test runs a console command as an application does. + ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/Integration/Symfony/BundleTest.php', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\Routing arrives with symfony/framework-bundle (and symfony/mcp-bundle); the test kernel imports the bundle's MCP routes with it, the kernel without symfony/mcp-bundle adds its own route, and a contract test reads the routes. + ->ignoreErrorsOnPackageAndPaths('symfony/routing', [__DIR__ . '/tests/Support/Symfony/TestKernel.php', __DIR__ . '/tests/Support/Symfony/WithoutMcpBundleKernel.php', __DIR__ . '/tests/Contract/Symfony/BundleServicesTest.php'], [ErrorType::SHADOW_DEPENDENCY]); diff --git a/composer.json b/composer.json index f963019..43b057f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "agentcat/sdk", - "description": "Analytics for MCP (Model Context Protocol) servers: session replay, intent, and telemetry for PHP MCP servers.", + "description": "Analytics tool for MCP (Model Context Protocol) servers and AI agents - tracks tool usage patterns and provides insights", "license": "MIT", "type": "library", "keywords": [ @@ -9,7 +9,26 @@ "analytics", "agentcat", "ai", - "llm" + "llm", + "authentication", + "observability", + "ai-agents", + "ai-platform", + "ai-agent", + "mcps", + "aiagents", + "ai-agent-tools", + "mcp-servers", + "mcp-server", + "mcp-tools", + "agent-runtime", + "mcp-framework", + "mcp-analytics", + "agentcat-analytics", + "php", + "laravel", + "symfony", + "mcp-sdk" ], "authors": [ { @@ -30,15 +49,25 @@ "ext-json": "*", "ext-mbstring": "*", "composer-runtime-api": "^2.2", + "agentcat/api": "~0.0.4", "psr/log": "^2.0 || ^3.0" }, "require-dev": { "brianium/paratest": "^7.8.6", + "composer/semver": "^3.4", "ergebnis/composer-normalize": "^2.54", "ergebnis/phpunit-slow-test-detector": "^2.26", "infection/infection": "^0.35.8", + "larastan/larastan": "^3.13", + "laravel/framework": "^12.41.1 || ^13.0", + "laravel/mcp": "^1.0", + "mcp/sdk": "^0.8.1", + "nyholm/psr7": "^1.8.2", + "opis/json-schema": "^2.6", + "orchestra/testbench": "^10.8 || ^11.3", "php-cs-fixer/shim": "3.95.27", "phpat/phpat": "^0.12.5", + "phpdocumentor/type-resolver": "^1.10 || ^2.0", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "2.3.1", "phpstan/phpstan-deprecation-rules": "^2.0.5", @@ -48,8 +77,34 @@ "rector/rector": "2.7.0", "shipmonk/composer-dependency-analyser": "^1.8.4", "spaze/phpstan-disallowed-calls": "^4.15", + "symfony/cache-contracts": "^2.5.3 || ^3.4.2", + "symfony/clock": "^6.4.3 || ^7.0.3 || ^8.0", + "symfony/config": "^7.3.4 || ^8.0", + "symfony/dependency-injection": "^7.3.4 || ^8.0", + "symfony/event-dispatcher-contracts": "^2.5.3 || ^3.4.2", + "symfony/framework-bundle": "^7.3 || ^8.0", + "symfony/http-foundation": "^7.3 || ^8.0", + "symfony/http-kernel": "^7.3 || ^8.0", + "symfony/mcp-bundle": "^0.13 || ^0.14", + "symfony/polyfill-uuid": "^1.20", + "symfony/process": "^7.4 || ^8.1", + "symfony/translation": "^6.4.3 || ^7.0.3 || ^8.0", + "symfony/translation-contracts": "^2.5.3 || ^3.4.2", + "symfony/uid": "^5.4.35 || ^6.4.3 || ^7.3 || ^8.0", + "symfony/yaml": "^7.3 || ^8.0", "tomasvotruba/type-coverage": "^2.5.1" }, + "conflict": { + "laravel/mcp": "<1.0", + "mcp/sdk": "<0.8.1" + }, + "suggest": { + "ext-pcntl": "Delivers the queued events when a CLI server (an MCP stdio server) gets SIGTERM or SIGINT", + "ext-posix": "Ends the process with the signal after that delivery, and finds the home directory for agentcat.log when HOME is not set", + "laravel/mcp": "Tracks every laravel/mcp server of a Laravel application, through the auto-discovered service provider", + "mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)", + "symfony/mcp-bundle": "Tracks every symfony/mcp-bundle server through AgentCat\\Symfony\\AgentCatBundle" + }, "minimum-stability": "stable", "autoload": { "psr-4": { @@ -65,10 +120,18 @@ "allow-plugins": { "ergebnis/composer-normalize": true, "infection/extension-installer": true, + "php-http/discovery": false, "phpstan/extension-installer": true }, "sort-packages": true }, + "extra": { + "laravel": { + "providers": [ + "AgentCat\\Laravel\\AgentCatServiceProvider" + ] + } + }, "scripts": { "check": [ "@composer validate --strict", diff --git a/config/agentcat.php b/config/agentcat.php new file mode 100644 index 0000000..94c43cf --- /dev/null +++ b/config/agentcat.php @@ -0,0 +1,58 @@ + env('AGENTCAT_ENABLED'), + + // Your AgentCat project ID (proj_โ€ฆ). Unset: no events go to AgentCat; exporters still run. + 'project_id' => env('AGENTCAT_PROJECT_ID'), + + // Servers AgentCat never tracks: server classes. A class also excludes its subclasses. + 'exclude' => [ + 'Laravel\Boost\Mcp\Boost', + ], + + // Another project for some servers: server class => project ID. The first class the server is an instance of wins. + 'projects' => [ + // App\Mcp\Servers\BillingServer::class => env('AGENTCAT_BILLING_PROJECT_ID'), + ], + + // The log channel for AgentCat's own lines, for example stack. Unset: their own file, storage/logs/agentcat.log. + 'log_channel' => env('AGENTCAT_LOG_CHANNEL'), + + // The AgentCat API. Unset: AgentCat's. MCPCAT_API_URL is the TypeScript SDK's name. + 'api_url' => env('AGENTCAT_API_URL', env('MCPCAT_API_URL')), + + 'enable_report_missing' => true, + 'enable_tracing' => true, + 'enable_tool_call_context' => true, + 'custom_context_description' => null, + 'enable_agent_tracking' => false, + + // Hooks: the class name of an invokable class, resolved from the container when the hook runs. + // Closures cannot go here: php artisan config:cache cannot store them. + 'identify' => null, + 'resolve_session_id' => null, + 'event_tags' => null, + 'event_properties' => null, + 'redact_sensitive_information' => null, + 'redact_event' => null, + + // Your own telemetry tools: name => ['type' => 'otlp'|'datadog'|'sentry'|'posthog', โ€ฆ]. A null entry is skipped. + 'exporters' => null, + + // Diagnostics: AgentCat's own log lines, sent to AgentCat so that it can find broken installs. true turns them off; + // in a list, one true value is enough. DISABLE_DIAGNOSTICS is the TypeScript SDK's name. + 'disable_diagnostics' => [env('AGENTCAT_DISABLE_DIAGNOSTICS'), env('DISABLE_DIAGNOSTICS')], + + // Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs) and its bearer token. Unset: AgentCat's. + 'diagnostics_endpoint' => env('AGENTCAT_DIAGNOSTICS_ENDPOINT'), + 'diagnostics_token' => env('AGENTCAT_DIAGNOSTICS_TOKEN'), + + 'disable_signal_handlers' => false, +]; diff --git a/docs/cats/bibi.png b/docs/cats/bibi.png new file mode 100644 index 0000000..263651e Binary files /dev/null and b/docs/cats/bibi.png differ diff --git a/docs/cats/void.jpg b/docs/cats/void.jpg new file mode 100644 index 0000000..11486d1 Binary files /dev/null and b/docs/cats/void.jpg differ diff --git a/docs/cats/zelda.jpg b/docs/cats/zelda.jpg new file mode 100644 index 0000000..cada64d Binary files /dev/null and b/docs/cats/zelda.jpg differ diff --git a/docs/static/architecture.png b/docs/static/architecture.png new file mode 100644 index 0000000..d89d36c Binary files /dev/null and b/docs/static/architecture.png differ diff --git a/docs/static/og-image.png b/docs/static/og-image.png new file mode 100644 index 0000000..a8fe6de Binary files /dev/null and b/docs/static/og-image.png differ diff --git a/infection.json5 b/infection.json5 index 0977bd8..08348ed 100644 --- a/infection.json5 +++ b/infection.json5 @@ -6,11 +6,14 @@ "logs": { "text": ".cache/infection.log" }, + // Seconds per mutant. A mutant whose covering tests take longer is skipped, not run: the transport + // helpers are covered by the whole delivery suite, which takes longer than the default 10 s. + "timeout": 120, "minMsi": 90, "minCoveredMsi": 95, "mutators": { "@default": true }, "testFramework": "phpunit", - "testFrameworkOptions": "--testsuite=Unit,Golden" + "testFrameworkOptions": "--testsuite=Unit,Golden,Integration,Contract" } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4550978..74a96be 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,17 @@ parameters: reportPossiblyNonexistentGeneralArrayOffset: true reportUnmatchedIgnoredErrors: true reportIgnoresWithoutComments: true + # Larastan: the SDK reads Env::get(), never env() (the only env() calls are in config/agentcat.php, which is not + # analysed), and it has no Eloquent models. + noEnvCallsOutsideOfConfig: false + checkModelProperties: false + ignoreErrors: + - + # The probe runs in a fresh project (tests/bin/package-probe.sh) whose vendor/ does not exist in this repository. + message: '#^Path in require\(\) .* is not a file or it does not exist\.$#' + identifier: require.fileNotFound + path: tests/Fixtures/package-probe/probe.php + count: 1 type_coverage: return: 100 param: 100 @@ -27,7 +38,12 @@ parameters: disallowedClasses: - class: ['ReflectionProperty', 'ReflectionMethod'] message: 'Reflection is for contract tests only' - allowIn: [tests/Contract/*] + allowIn: + - tests/Contract/* + # builds a cyclic `previous` chain and a corrupt trace (ac-05-E10) + - tests/Unit/Internal/Errors/ExceptionCaptureTest.php + # reads libcurl's queue of finished transfers off the transport's multi handle: no time can show it + - tests/Support/UnreadTransfers.php services: - class: AgentCat\Tests\Architecture\LayeringTest diff --git a/phpstan.no-bundle.neon b/phpstan.no-bundle.neon new file mode 100644 index 0000000..7879812 --- /dev/null +++ b/phpstan.no-bundle.neon @@ -0,0 +1,11 @@ +# PHPStan for the mcp/sdk legs (released versions and main), which remove symfony/mcp-bundle (it requires mcp/sdk ^0.8.1). +includes: + - phpstan.neon.dist +parameters: + tmpDir: .cache/phpstan-no-bundle + excludePaths: + - tests/Support/Symfony + - tests/Integration/Symfony + - tests/Contract/Symfony + - tests/Integration/Conformance/SymfonyConformanceTest.php + - tests/Runtime/Fixtures/symfony-front.php diff --git a/phpunit-baseline.xml b/phpunit-baseline.xml new file mode 100644 index 0000000..71e2bce --- /dev/null +++ b/phpunit-baseline.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d183bd6..4cd36c8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,12 +17,30 @@ tests/Golden + + tests/Integration + + + tests/Contract + + + tests/E2E + + + tests/Runtime + tests/Architecture tests/Architecture/LayeringTest.php + + + network + runtime + + src diff --git a/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml b/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml new file mode 100644 index 0000000..102d4e9 --- /dev/null +++ b/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml @@ -0,0 +1,7 @@ +# AgentCat tracks every symfony/mcp-bundle server of this application. +# Set AGENTCAT_PROJECT_ID (proj_โ€ฆ) in .env.local. Reference: bin/console config:dump-reference agentcat +agentcat: + project_id: '%env(default::AGENTCAT_PROJECT_ID)%' + # exclude_servers: [internal] + # projects: + # billing: '%env(default::AGENTCAT_BILLING_PROJECT_ID)%' diff --git a/recipe/agentcat/sdk/1.0/manifest.json b/recipe/agentcat/sdk/1.0/manifest.json new file mode 100644 index 0000000..915e872 --- /dev/null +++ b/recipe/agentcat/sdk/1.0/manifest.json @@ -0,0 +1,11 @@ +{ + "bundles": { + "AgentCat\\Symfony\\AgentCatBundle": ["all"] + }, + "copy-from-recipe": { + "config/": "%CONFIG_DIR%/" + }, + "env": { + "AGENTCAT_PROJECT_ID": "" + } +} diff --git a/recipe/agentcat/sdk/1.0/post-install.txt b/recipe/agentcat/sdk/1.0/post-install.txt new file mode 100644 index 0000000..d7f8261 --- /dev/null +++ b/recipe/agentcat/sdk/1.0/post-install.txt @@ -0,0 +1,2 @@ + * Set AGENTCAT_PROJECT_ID in .env.local to your AgentCat project ID (proj_โ€ฆ). + * AgentCat is off while your tests run; call AgentCat::fake() in a test to record its events. diff --git a/rector.php b/rector.php index cccc5e6..942987e 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; +use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\NoSetupWithParentCallOverrideRector; return RectorConfig::configure() ->withPaths([__DIR__ . '/src', __DIR__ . '/tests']) @@ -16,4 +18,11 @@ instanceOf: true, phpunitCodeQuality: true, ) + ->withSkip([ + // Keeps #[\Override] on setUp() and tearDown() that call the parent: PHPStan's checkMissingOverrideMethodAttribute requires it. + NoSetupWithParentCallOverrideRector::class, + // Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31). AfterRequest + // names the Octane event class, which is not a dependency either. + StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Laravel/Internal/AfterRequest.php'], + ]) ->withCache(__DIR__ . '/.cache/rector'); diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..41d4033 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,77 @@ +## AgentCat (agentcat/sdk) + +AgentCat is analytics for the MCP servers of this application. It records one event for each tool call of every `laravel/mcp` server, with the reason the agent gives for the call, and sends the events to an AgentCat project. + +### Install + +- Install the package with `composer require agentcat/sdk`. Laravel discovers its service provider. +- Run `php artisan agentcat:install`. It asks for the project ID (`proj_` and 27 letters or digits) and writes it to `.env` as `AGENTCAT_PROJECT_ID`. `php artisan agentcat:install proj_โ€ฆ --config` takes the ID as an argument and also publishes `config/agentcat.php`. +- Without the command, set `AGENTCAT_PROJECT_ID` in `.env`. Without a project ID, no event goes to AgentCat. + +### What AgentCat tracks with no code + +- Every `laravel/mcp` server of the application, also when `laravel/mcp` builds the server for each request. The server of Laravel Boost is excluded. +- Every tool call. AgentCat adds a `session_id` parameter to the input schema of each tool, a `context` parameter unless `enable_tool_call_context` is false, and `agent_id` when `enable_agent_tracking` is true. It removes them from the arguments before the tool runs. Do not declare or read these parameters in a tool. +- A `get_more_tools` tool, which agents call to report a tool that they looked for and did not find. Do not give a tool of your own this name. +- AgentCat runs its hooks and sends the events after the client has the answer of the call (over HTTP, in the `terminating()` callbacks of Laravel), so it adds no latency to that call. + +### Configuration + +Publish `config/agentcat.php` with `php artisan vendor:publish --tag=agentcat-config`. The keys for hooks and exclusion: + +- `exclude`: server classes that AgentCat never tracks. A class also excludes its subclasses. +- `projects`: another project for some servers, as server class => project ID. +- `enabled`: true or false for every server (`AGENTCAT_ENABLED`). Unset: on, except while tests run. +- `identify`, `resolve_session_id`, `event_tags`, `event_properties`, `redact_sensitive_information` and `redact_event`: the hooks. Each hook is the class name of an invokable class, which the container resolves when the hook runs. A closure cannot go in this file, because `php artisan config:cache` cannot store it. + +An `identify` hook receives the `AgentCat\ToolCall` (tool name, arguments, lower-case HTTP headers, client name and version) and returns who made the call, or null: + +```php +namespace App\AgentCat; + +use AgentCat\Identity; +use AgentCat\ToolCall; + +final class IdentifyActor +{ + public function __invoke(ToolCall $call): ?Identity + { + $token = $call->headers['authorization'] ?? null; + + return $token === null ? null : new Identity(userId: hash('sha256', $token)); + } +} +``` + +In `config/agentcat.php`: `'identify' => App\AgentCat\IdentifyActor::class,`. + +### Tests + +AgentCat is off while tests run. `AgentCat::fake()` records the events of a test and sends nothing. Call it before the test builds a server, and restore the real client with `AgentCat::setClient(null)`: + +```php +use AgentCat\AgentCat; +use AgentCat\Event; +use App\Mcp\Servers\WeatherServer; +use App\Mcp\Tools\CurrentWeatherTool; + +public function test_the_weather_tool_call_is_tracked(): void +{ + $fake = AgentCat::fake(); + + WeatherServer::tool(CurrentWeatherTool::class, ['location' => 'Paris'])->assertHasNoErrors(); + + $fake->assertTrackedCount(1); + $fake->assertTracked(fn (Event $event): bool => $event->isError !== true); +} + +protected function tearDown(): void +{ + AgentCat::setClient(null); + parent::tearDown(); +} +``` + +The fake also has `assertNothingTracked()`, `assertCustomEventPublished()`, `events()` and `logs()` (the lines AgentCat logged, hook errors included). + +The Laravel guide has the details: https://docs.agentcat.com/sdk/php/laravel diff --git a/src/AgentCat.php b/src/AgentCat.php new file mode 100644 index 0000000..f354e75 --- /dev/null +++ b/src/AgentCat.php @@ -0,0 +1,146 @@ +build()`. + * + * Every method uses one shared `Client`. Applications with a dependency-injection container can inject a `Client` + * instead, or give this class their own with `setClient()`. + */ +final class AgentCat +{ + private static ?Client $client = null; + + /** @codeCoverageIgnore A static class: nothing creates an instance, so no code runs here. */ + private function __construct() {} + + /** + * Adds AgentCat analytics to an MCP server. Call it before the server is built: for mcp/sdk, pass the + * `Mcp\Server\Builder` before `build()`. + * + * It never throws. When the server cannot be tracked, it logs why and returns the server unchanged. Tracking + * the same server again, from any client, changes nothing. + * + * `AGENTCAT_ENABLED` turns AgentCat off everywhere with `false`, `0`, `no` or `off`, and on everywhere with + * `true`, `1`, `yes` or `on`. Otherwise AgentCat does nothing in test environments (PHPUnit, Pest, `APP_ENV` + * `testing` or `test`). In a Laravel application, `enabled` in config/agentcat.php decides before the variable. + * + * @template T of object + * + * @param T $server The server to track. + * @param string|null $projectId Your AgentCat project ID (`proj_โ€ฆ`). Null reads `AGENTCAT_PROJECT_ID`; `''` sends + * the events to no AgentCat project (telemetry only). + * @param Options|null $options The tracking options; null uses the defaults. + * + * @return T The same server. + */ + public static function track(object $server, ?string $projectId = null, ?Options $options = null): object + { + return self::client()->track($server, $projectId, $options); + } + + /** + * Publishes a custom event, for a tracked server or for a session. + * + * With a server tracked by any client (the Builder, or the server built from it), the event goes to the project + * the server was tracked with, whatever `$projectId` says, and carries the server name and version and the SDK + * fields. The server's `redactEvent` option applies to it; `redactSensitiveInformation` does not. When the server + * was tracked with `enableTracing: false`, the event is dropped. Without `$event->sessionId`, the event has no + * session. + * + * With a session ID string, the event goes to `$projectId` for that session, used as it is; `$event->sessionId` + * wins over it. No server or SDK fields and no hooks apply. + * + * The event is queued, not sent: AgentCat delivers it like any other event. Apart from the two errors below, it + * never throws: a failure is logged. When a `track()` in this process could not track a server, or ran while + * AgentCat was off, a server that is not tracked logs a warning instead of the second error. In test environments + * it validates `$projectId` and then does nothing, like `track()` (see `AGENTCAT_ENABLED` there). + * + * In a file without `declare(strict_types=1)`, PHP turns an int, float or bool first argument into a session ID + * string. + * + * @param object|string $serverOrSessionId A tracked server, or the session ID of the event. + * @param string|null $projectId Your AgentCat project ID (`proj_โ€ฆ`); required, even for a tracked server. + * @param CustomEvent|null $event The event data; null sends an event with no data. + * + * @throws \InvalidArgumentException when `$projectId` is null or empty, or when the server is not tracked + */ + public static function publishCustomEvent(object|string $serverOrSessionId, ?string $projectId, ?CustomEvent $event = null): void + { + self::client()->publishCustomEvent($serverOrSessionId, $projectId, $event); + } + + /** + * Sends the queued events now and waits for them. Rarely needed: AgentCat delivers on its own, between requests + * and when the process ends. + * + * @param float|null $timeoutSeconds The longest wait in seconds; null waits until nothing is left to send. + * + * @return bool True when nothing is left to send (every event delivered, or given up after its retries, and no + * exporter request left); false when the timeout ended first. AgentCat's own diagnostics are neither + * counted nor waited for: they go out on their own, between requests and when the process ends. + */ + public static function flush(?float $timeoutSeconds = null): bool + { + return self::client()->flush($timeoutSeconds); + } + + /** + * Stops accepting events, then sends the queued ones for at most 5 seconds. Events after this are dropped. + * Rarely needed: AgentCat does this on its own when the process ends. + */ + public static function shutdown(): void + { + self::client()->shutdown(); + } + + /** + * Starts recording for your test suite: installs a client that records every event and sends nothing, and + * returns the fake to assert on. Restore the real client with `AgentCat::setClient(null)` in `tearDown()`. + * + * Call it first, before the application builds its servers. In a test environment, a server tracked before + * `fake()` is not tracked at all: no tool call of it is recorded. With `AGENTCAT_ENABLED=true`, a server tracked + * before `fake()` keeps the client that tracked it. + * + * While the fake is the shared client, AgentCat logs to the fake's `logs()` only, and adopts no Builder's PSR-3 + * logger. `setClient()` brings back the log routing from before the first `fake()`. + * + * The fake records whatever `AGENTCAT_ENABLED` says, so a CI that sets `AGENTCAT_ENABLED=false` still works. Test + * environments without `fake()` stay silent: `track()` does nothing there. + */ + public static function fake(): AgentCatFake + { + $fake = new AgentCatFake(); + self::setClient($fake->client()); + + return $fake; + } + + /** The shared client; the first call creates it. */ + public static function client(): Client + { + return self::$client ??= new Client(); + } + + /** + * Replaces the shared client, for example with one from your dependency-injection container. Null goes back to + * a new default client. Replacing the client of a fake brings back the log routing from before the first + * `fake()`. + */ + public static function setClient(?Client $client): void + { + self::$client = $client; + Log::endDiversion(); + $fakeLog = $client instanceof Client ? FakeLog::of($client) : null; + if ($fakeLog instanceof FakeLog) { + Log::divert($fakeLog); // a fake's client: AgentCat logs to the fake only + } + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..d4f5542 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,297 @@ +publisher = new Publisher($seams->queue ?? EventQueue::global()); + $this->interceptor = new CallInterceptor($this->publisher); + $this->enabled = $seams->enabled ?? TestMode::sdkEnabled(...); + $this->telemetry = $seams->telemetry ?? true; + $this->httpContext = $seams?->httpContext; + } + + /** + * Internal: this client with the HTTP request of a call from `$httpContext`, for every server the copy tracks. The + * copy keeps the event queue, the enabled gate and the telemetry setting, so the copy of a fake's client records + * into the fake. The Symfony bundle gives a fake's client its `RequestStack` this way (D41). + * + * @internal + * + * @param \Closure(): ?HttpContext $httpContext + */ + public function withHttpContext(\Closure $httpContext): self + { + return new self(ClientSeams::of($this->publisher->queue(), $this->enabled, $this->telemetry, $httpContext)); + } + + /** + * Adds AgentCat analytics to an MCP server. Call it before the server is built: for mcp/sdk, pass the + * `Mcp\Server\Builder` before `build()`. + * + * It never throws. When the server cannot be tracked, it logs why and returns the server unchanged. Tracking + * the same server again, from any client, changes nothing. + * + * `AGENTCAT_ENABLED` turns AgentCat off everywhere with `false`, `0`, `no` or `off`, and on everywhere with + * `true`, `1`, `yes` or `on`. Otherwise AgentCat does nothing in test environments (PHPUnit, Pest, `APP_ENV` + * `testing` or `test`). In a Laravel application, `enabled` in config/agentcat.php decides before the variable. + * + * @template T of object + * + * @param T $server The server to track. + * @param string|null $projectId Your AgentCat project ID (`proj_โ€ฆ`). Null reads `AGENTCAT_PROJECT_ID`; `''` sends + * the events to no AgentCat project (telemetry only). + * @param Options|null $options The tracking options; null uses the defaults. + * + * @return T The same server. + */ + public function track(object $server, ?string $projectId = null, ?Options $options = null): object + { + try { + if (!($this->enabled)()) { + TrackedServers::markSkipped(); // a fake() after this finds no tracked server: it must not throw then + + return $server; // D44 AGENTCAT_ENABLED, else D31: nothing in test environments unless fake() + } + $options ??= new Options(); + $projectId ??= BridgeConfig::env('AGENTCAT_PROJECT_ID') ?? ''; // D36: '' means telemetry only; a bridge's configuration first + if ($this->telemetry) { + // TS: the first statement of track(), before the server is checked; the first call in the process decides. + Diagnostics::init($projectId, $options->disableDiagnostics, $this->publisher->queue()); + } + if ($options->disableSignalHandlers && !$this->publisher->queue()->disableSignalHandlers() && $this->publisher->queue()->claimSignalHandlersWarning()) { + Log::write('WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored'); + } + $library = Libraries::for($server); + $tracked = TrackedServers::get($server) instanceof TrackedServer; + if (!$library instanceof Library) { + // The Server that a tracked Builder built: it is tracked through its Builder, which holds the handlers. + $library = Libraries::trackerOf($server); + $tracked = true; + } + if (!$library instanceof Library) { + $signals = Libraries::fingerprint($server); + Log::warning(sprintf('AgentCat SDK Compatibility: unrecognized server shape | signals %s. %s', $signals === '' ? '(none)' : $signals, Libraries::supportMatrix())); + + throw new \UnexpectedValueException('AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . Libraries::supportMatrix()); + } + $logger = $library->logger($server); + if ($logger instanceof LoggerInterface) { + Log::adopt($logger); // D30, D38 + } + if (!$tracked) { + $library->inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing + } + if (($options->apiBaseUrl ?? '') !== '' || BridgeConfig::env('AGENTCAT_API_URL') !== null || Env::get('MCPCAT_API_URL') !== null) { + $this->publisher->queue()->configure($options->apiBaseUrl); // before the already-tracked check; the last call wins + } + $project = $projectId !== '' ? $projectId : '(telemetry-only)'; + Log::write(sprintf('AgentCat setup started | project %s | sdk %s | signals %s', $project, $library->sdk($server), implode(',', $library->signals($server)))); + if ($tracked) { + Log::write('[SESSION DEBUG] track() - Server already being tracked, skipping initialization'); + + return $server; + } + if ($this->telemetry) { + TelemetryManager::install($this->publisher->queue(), $options->exporters); // first wins (TypeScript) + } + if ($projectId === '' && $options->exporters === null) { + Log::write('Warning: No projectId provided and no exporters configured. Events will not be sent anywhere.'); + } elseif ($projectId !== '' && preg_match(self::PROJECT_ID_PATTERN, $projectId) !== 1) { + Log::write(sprintf('WARN: Project ID "%s" does not match proj_ followed by 27 letters or digits; the AgentCat API drops its events', $projectId)); + } + $state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher, $this->httpContext); + $library->install($server, $state, $this->publisher, $this->interceptor); + TrackedServers::add($server, $state); + Log::write(sprintf( + 'AgentCat setup complete | project %s | tracing=%s context=%s reportMissing=%s exporters=%d', + $project, + $options->enableTracing ? 'true' : 'false', + $options->enableToolCallContext ? 'true' : 'false', + $options->enableReportMissing ? 'true' : 'false', + count($options->exporters ?? []), + )); + + return $server; + } catch (\Throwable $e) { + // Every failure ends here, after the shape or compatibility line where there is one. + TrackedServers::markIncomplete(); + Log::write('Warning: Failed to track server - ' . ErrorText::of($e)); + + return $server; + } + } + + /** + * Publishes a custom event, for a tracked server or for a session. + * + * With a server tracked by any client (the Builder, or the server built from it), the event goes to the project + * the server was tracked with, whatever `$projectId` says, and carries the server name and version and the SDK + * fields. The server's `redactEvent` option applies to it; `redactSensitiveInformation` does not. When the server + * was tracked with `enableTracing: false`, the event is dropped. Without `$event->sessionId`, the event has no + * session. + * + * With a session ID string, the event goes to `$projectId` for that session, used as it is; `$event->sessionId` + * wins over it. No server or SDK fields and no hooks apply. + * + * The event is queued, not sent: AgentCat delivers it like any other event. Apart from the two errors below, it + * never throws: a failure is logged. When a `track()` in this process could not track a server, or ran while + * AgentCat was off, a server that is not tracked logs a warning instead of the second error. In test environments + * it validates `$projectId` and then does nothing, like `track()` (see `AGENTCAT_ENABLED` there). + * + * In a file without `declare(strict_types=1)`, PHP turns an int, float or bool first argument into a session ID + * string. + * + * @param object|string $serverOrSessionId A tracked server, or the session ID of the event. + * @param string|null $projectId Your AgentCat project ID (`proj_โ€ฆ`); required, even for a tracked server. + * @param CustomEvent|null $event The event data; null sends an event with no data. + * + * @throws \InvalidArgumentException when `$projectId` is null or empty, or when the server is not tracked + */ + public function publishCustomEvent(object|string $serverOrSessionId, ?string $projectId, ?CustomEvent $event = null): void + { + if ($projectId === null || $projectId === '') { + throw new \InvalidArgumentException('projectId is required for publishCustomEvent'); + } + if (!($this->enabled)()) { + return; // D44 AGENTCAT_ENABLED, else D31: track() tracked nothing here, so no server is looked up + } + $event ??= new CustomEvent(); + $sessionId = $event->sessionId ?? ''; + $server = null; + if (is_string($serverOrSessionId)) { + $sessionId = $sessionId !== '' ? $sessionId : $serverOrSessionId; // verbatim: no derivation, '' is sessionless + } else { + $server = TrackedServers::get($serverOrSessionId) ?? Libraries::trackedServer($serverOrSessionId); + if (!$server instanceof TrackedServer) { + if (TrackedServers::incomplete()) { + // AgentCat's own failure, logged before: it must not throw into the customer's code + Log::write('Warning: Failed to publish custom event - the server is not tracked (see the earlier AgentCat log line)'); + + return; + } + if (TrackedServers::skipped()) { + // a test that tracked the server before fake(): its code must not get the exception + Log::write('Warning: Failed to publish custom event - the server is not tracked (track() ran while AgentCat was off; call AgentCat::fake() before track())'); + + return; + } + + $advice = Libraries::untrackedAdvice($serverOrSessionId); + if ($advice !== null) { + throw new \InvalidArgumentException('Server is not tracked: ' . $advice); + } + + throw new \InvalidArgumentException('Server is not tracked. Please call agentcat.track() first or provide a session ID string.'); + } + if ($sessionId === '') { + Log::write('publishCustomEvent: no sessionId provided; event will be published without a session. Pass eventData.sessionId to attribute it to a task.'); + } + } + + try { + $custom = new Event( + sessionId: $sessionId, + projectId: $projectId, + eventType: Constants::CUSTOM_EVENT_TYPE, + duration: $event->duration, + resourceName: $event->resourceName, + parameters: $event->parameters, + response: $event->response, + userIntent: $event->message, + isError: $event->isError, + error: $event->error, // sent as it is: no exception capture + tags: TagValidator::validate($event->tags), // null for null or no valid entry + ); + if (Values::isObjectLike($event->properties) && Values::entries($event->properties) !== []) { + $custom->properties = $event->properties; + } + if ($server instanceof TrackedServer) { + $server->publisher()->publishCustom($server, $custom); // the queue of the client that tracked the server + } else { + $this->publisher->queue()->add(new QueuedEvent($custom)); + } + Log::write(sprintf("Published custom event %s with type 'agentcat:custom'", $sessionId !== '' ? 'for session ' . $sessionId : 'without a session')); + } catch (\Throwable $e) { + Log::write('Warning: Failed to publish custom event - ' . ErrorText::of($e)); + } + } + + /** + * Sends the queued events now and waits for them. Rarely needed: AgentCat delivers on its own, between requests + * and when the process ends. + * + * @param float|null $timeoutSeconds The longest wait in seconds; null waits until nothing is left to send. + * + * @return bool True when nothing is left to send (every event delivered, or given up after its retries, and no + * exporter request left); false when the timeout ended first. AgentCat's own diagnostics are neither + * counted nor waited for: they go out on their own, between requests and when the process ends. + */ + public function flush(?float $timeoutSeconds = null): bool + { + return $this->publisher->queue()->flush($timeoutSeconds); + } + + /** + * Stops accepting events, then sends the queued ones for at most 5 seconds. Events after this are dropped. + * Rarely needed: AgentCat does this on its own when the process ends. + */ + public function shutdown(): void + { + $this->publisher->queue()->shutdown(); + } +} diff --git a/src/CustomEvent.php b/src/CustomEvent.php new file mode 100644 index 0000000..16248f3 --- /dev/null +++ b/src/CustomEvent.php @@ -0,0 +1,39 @@ +|null $tags Tags: string keys and string values. Invalid entries are left out and + * logged, like the tags of the `eventTags` option. + * @param mixed $properties JSON-encodable properties; sent only when they have at least one entry. + */ + public function __construct( + public ?string $sessionId = null, + public ?string $resourceName = null, + public mixed $parameters = null, + public mixed $response = null, + public ?string $message = null, + public int|float|null $duration = null, + public ?bool $isError = null, + public mixed $error = null, + public ?array $tags = null, + public mixed $properties = null, + ) {} +} diff --git a/src/Event.php b/src/Event.php new file mode 100644 index 0000000..5be4a8a --- /dev/null +++ b/src/Event.php @@ -0,0 +1,75 @@ +|null $tags Tags (string values only). + * @param mixed $properties Custom properties (JSON object). + * @param string|null $actorId Legacy actor ID; the wire sends `actorId ?: identifyActorGivenId`. + */ + public function __construct( + public string $id = '', + public string $sessionId = '', + public ?string $projectId = null, + public string $eventType = '', + public \DateTimeImmutable $timestamp = new \DateTimeImmutable('now', new \DateTimeZone('UTC')), + public int|float|null $duration = null, + public ?int $inputTokens = null, + public ?int $outputTokens = null, + public ?string $ipAddress = null, + public ?string $sdkLanguage = null, + public ?string $agentcatVersion = null, + public ?string $serverName = null, + public ?string $serverVersion = null, + public ?string $clientName = null, + public ?string $clientVersion = null, + public ?string $identifyActorGivenId = null, + public ?string $identifyActorName = null, + public mixed $identifyActorData = null, + public ?string $resourceName = null, + public mixed $parameters = null, + public mixed $response = null, + public ?string $userIntent = null, + public ?bool $isError = null, + public mixed $error = null, + public ?array $tags = null, + public mixed $properties = null, + public ?string $actorId = null, + ) {} +} diff --git a/src/Exporters/DatadogExporter.php b/src/Exporters/DatadogExporter.php new file mode 100644 index 0000000..dac3306 --- /dev/null +++ b/src/Exporters/DatadogExporter.php @@ -0,0 +1,25 @@ + new DatadogExporter(apiKey: 'โ€ฆ', site: 'datadoghq.com', service: 'my-mcp')])`. + */ +final readonly class DatadogExporter +{ + /** + * @param string $apiKey Your Datadog API key. + * @param string $site Your Datadog site, for example `datadoghq.com` or `datadoghq.eu`. + * @param string $service The service name of the logs and metrics. + * @param string|null $env Adds an `env:` tag to the logs and metrics. Default: null (no tag). + */ + public function __construct( + public string $apiKey, + public string $site, + public string $service, + public ?string $env = null, + ) {} +} diff --git a/src/Exporters/OtlpExporter.php b/src/Exporters/OtlpExporter.php new file mode 100644 index 0000000..ea11edf --- /dev/null +++ b/src/Exporters/OtlpExporter.php @@ -0,0 +1,21 @@ + new OtlpExporter(endpoint: 'http://localhost:4318')])`. + */ +final readonly class OtlpExporter +{ + /** + * @param string $endpoint The collector base URL. `/v1/traces` is added unless it is there already. + * @param array $headers Extra request headers, for example an API key. A `Content-Type` here replaces `application/json`. Default: none. + */ + public function __construct( + public string $endpoint, + public array $headers = [], + ) {} +} diff --git a/src/Exporters/PostHogExporter.php b/src/Exporters/PostHogExporter.php new file mode 100644 index 0000000..696f58e --- /dev/null +++ b/src/Exporters/PostHogExporter.php @@ -0,0 +1,23 @@ + new PostHogExporter(apiKey: 'phc_โ€ฆ')])`. + */ +final readonly class PostHogExporter +{ + /** + * @param string $apiKey The PostHog project API key (`phc_โ€ฆ`). + * @param string|null $host The PostHog host. Default: null, which uses `https://us.i.posthog.com`. + * @param bool $enableAITracing Also sends a `$ai_span` per tool call; your tags and properties can replace any `$ai_*` value. Default: false. + */ + public function __construct( + public string $apiKey, + public ?string $host = null, + public bool $enableAITracing = false, + ) {} +} diff --git a/src/Exporters/SentryExporter.php b/src/Exporters/SentryExporter.php new file mode 100644 index 0000000..a95e83a --- /dev/null +++ b/src/Exporters/SentryExporter.php @@ -0,0 +1,25 @@ + new SentryExporter(dsn: 'โ€ฆ')])`. + */ +final readonly class SentryExporter +{ + /** + * @param string $dsn Your project's DSN, `https://@/`. An invalid DSN logs one line and the exporter is skipped. + * @param string|null $environment Added as the `environment` tag of transactions and error events. Default: null (no tag). + * @param string|null $release Added as the `release` tag of transactions and error events. Default: null (no tag). + * @param bool $enableTracing Also sends one transaction per event. Default: false. + */ + public function __construct( + public string $dsn, + public ?string $environment = null, + public ?string $release = null, + public bool $enableTracing = false, + ) {} +} diff --git a/src/Identity.php b/src/Identity.php new file mode 100644 index 0000000..08beafe --- /dev/null +++ b/src/Identity.php @@ -0,0 +1,22 @@ + $userData Any extra JSON-encodable facts about the user. + */ + public function __construct( + public string $userId, + public ?string $userName = null, + public array $userData = [], + ) {} +} diff --git a/src/Internal/Access/TouchesPrivateMember.php b/src/Internal/Access/TouchesPrivateMember.php new file mode 100644 index 0000000..5ff5332 --- /dev/null +++ b/src/Internal/Access/TouchesPrivateMember.php @@ -0,0 +1,30 @@ +/Internal/Access are the single manifest of those members; the contract tests check each one against + * the installed library. + * + * @internal + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final readonly class TouchesPrivateMember +{ + /** + * @param class-string $class the class that declares the member + * @param string $member the property name without `$` + * @param string $type `(string) ReflectionProperty::getType()` + */ + public function __construct( + public string $class, + public string $member, + public string $type, + public string $visibility = 'private', + public bool $readonly = false, + public bool $static = false, + ) {} +} diff --git a/src/Internal/ClientSeams.php b/src/Internal/ClientSeams.php new file mode 100644 index 0000000..84f1a54 --- /dev/null +++ b/src/Internal/ClientSeams.php @@ -0,0 +1,42 @@ +headersSent = $headersSent ?? static fn(): bool => headers_sent(); + } + + /** + * The length in bytes to give an answer, or null when it goes as it is: an answer to a HEAD request (its length + * would be the GET answer's, RFC 9110 ยง8.6, but `Response::prepare()` has emptied the body), an answer without a body + * (informational, 204, 304, as `Response::prepare()` treats them), a streamed one (SSE), one with a `Content-Length` + * or `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or bytes waiting in + * an output buffer, which go out first and would make the length short). + * + * @param string $method the request's method (`Request::getMethod()`, upper case) + * @param array $headers the answer's headers by name, in any case (`HeaderBag::all()`) + * @param string|false $content the answer's body; false for a streamed answer (`StreamedResponse`) + */ + public function of(string $method, int $status, array $headers, string|false $content): ?string + { + if ($content === false || $method === 'HEAD' || $status < 200 || $status === 204 || $status === 304) { + return null; + } + $headers = array_change_key_case($headers); + if (array_key_exists('content-length', $headers) || array_key_exists('transfer-encoding', $headers)) { + return null; + } + if (($this->headersSent)() || $this->outputWaits()) { + return null; // stray output goes out before the answer: a length of the answer alone would cut its end + } + + return (string) strlen($content); + } + + /** Some output buffer level holds bytes, which PHP sends before the answer. */ + private function outputWaits(): bool + { + foreach (ob_get_status(true) as $status) { + $used = is_array($status) ? $status['buffer_used'] ?? null : null; + if (is_int($used) && $used > 0) { + return true; + } + } + + return false; + } +} diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php new file mode 100644 index 0000000..a14b5f1 --- /dev/null +++ b/src/Internal/Delivery/EventQueue.php @@ -0,0 +1,532 @@ + events whose hooks have not run */ + private readonly \SplQueue $waiting; + + /** @var \SplQueue events through the pipeline (`flush()`), each older than every waiting event */ + private readonly \SplQueue $ready; + + /** @var \Closure(Event): string */ + private readonly \Closure $encode; + + private string $endpoint; + + private bool $accepting = true; + + private bool $signalWarningLogged = false; + + /** True while `pump()` or `flush()` runs: guards against re-entrant calls from hooks and exporters. */ + private bool $busy = false; + + /** Fixed by the first `shutdown()`: later shutdowns deliver only until then (one budget per queue). */ + private ?float $shutdownDeadline = null; + + /** @var list */ + private array $companions = []; + + /** + * @param (\Closure(): void)|null $onEnqueue the delivery-trigger registrar, called on every `add()` and `arm()` + * @param (\Closure(Event): string)|null $encode builds the HTTP body; default `WireEncoder::encode(...)` + * @param HttpChannel $channel the side channel of the exporter and diagnostics requests + * @param SignalDrain|null $signalDrain the SIGTERM and SIGINT drain that `disableSignalHandlers()` switches off + */ + public function __construct( + private readonly EventProcessor $processor = new EventProcessor(), + private readonly Transport $transport = new CurlTransport(), + private ExporterSink $exporters = new NullExporterSink(), + private readonly ?\Closure $onEnqueue = null, + private readonly float $shutdownTimeoutSeconds = self::SHUTDOWN_TIMEOUT_SECONDS, + ?\Closure $encode = null, + private readonly HttpChannel $channel = new CurlHttpChannel(), + private readonly ?SignalDrain $signalDrain = null, + ) { + $this->waiting = new \SplQueue(); + $this->ready = new \SplQueue(); + $this->encode = $encode ?? WireEncoder::encode(...); + $this->endpoint = ApiBaseUrl::endpoint(ApiBaseUrl::resolve()); // environment or default, until configure() + } + + /** + * The queue of this process; its first event registers the shutdown delivery (which ends the request first on a + * web SAPI, `RequestEnd`) and installs the signal drain (CLI servers with ext-pcntl). + */ + public static function global(): self + { + if (!self::$global instanceof self) { + $finish = FinishRequest::detect(); + $flusher = new ShutdownFlusher(static fn(): self => self::global(), null, $finish, RequestEnd::detect($finish instanceof \Closure)); + $signals = new SignalDrain(static fn(): self => self::global()); + self::$global = new self(onEnqueue: static function () use ($flusher, $signals): void { + $flusher->ensureRegistered(); + $signals->install(); + }, signalDrain: $signals); + } + + return self::$global; + } + + /** Keeps the signal handlers out of the process. False when they are installed already. */ + public function disableSignalHandlers(): bool + { + return $this->signalDrain?->disable() ?? true; + } + + /** True once per queue (one per process for the global queue): for the "ignored" warning of `disableSignalHandlers`. */ + public function claimSignalHandlersWarning(): bool + { + $first = !$this->signalWarningLogged; + $this->signalWarningLogged = true; + + return $first; + } + + /** Tests only: replaces the global queue; null makes `global()` build a new one. */ + public static function setGlobal(?self $queue): void + { + self::$global = $queue; + } + + /** Option > `AGENTCAT_API_URL` > `MCPCAT_API_URL` > default. The last call wins (TypeScript `configure`). */ + public function configure(?string $apiBaseUrlOption): void + { + $this->endpoint = ApiBaseUrl::endpoint(ApiBaseUrl::resolve($apiBaseUrlOption)); + } + + public function setExporterSink(ExporterSink $exporters): void + { + $this->exporters = $exporters; + } + + public function exporterSink(): ExporterSink + { + return $this->exporters; + } + + /** The side channel: exporters and diagnostics post their requests here. */ + public function channel(): HttpChannel + { + return $this->channel; + } + + public function attach(QueueCompanion $companion): void + { + $this->companions[] = $companion; + } + + /** The delivery triggers (shutdown function, signal drain) without an event: diagnostics lines need them too. */ + public function arm(): void + { + if (!$this->onEnqueue instanceof \Closure) { + return; + } + + try { + ($this->onEnqueue)(); + } catch (\Throwable $e) { + Log::write('Failed to register AgentCat event delivery as a background task: ' . ErrorText::of($e)); + } + } + + /** Anything left to send: an event, a side request, or a companion's buffer. True when a check throws. */ + public function hasWork(): bool + { + try { + if ($this->queued() > 0 || $this->transport->inFlight() > 0 || $this->channel->pending() > 0) { + return true; + } + foreach ($this->companions as $companion) { + if ($companion->hasWork()) { + return true; + } + } + + return false; + } catch (\Throwable) { + return true; + } + } + + /** Stores the event. Nothing else runs on the request path. */ + public function add(QueuedEvent $event): void + { + if (!$this->accepting) { + Log::write('Queue is shutting down, event dropped'); + + return; + } + if ($this->queued() >= self::MAX_QUEUE_SIZE) { + Log::write('Event queue full, dropping oldest event'); + $this->ready->isEmpty() ? $this->waiting->dequeue() : $this->ready->dequeue(); + } + $this->waiting->enqueue($event); + $this->arm(); + } + + /** + * Non-blocking: starts the events queued when it began while fewer than 5 are in delivery (the pipeline of a + * waiting one runs then), pumps the transport, ticks the companions and pumps the side channel. An event a hook + * queues meanwhile waits for the next pump. A re-entrant call (from a hook or an exporter) does nothing. + * + * `pump()` is non-blocking only when libcurl resolves host names asynchronously (threaded resolver or + * c-ares). With a synchronous resolver, starting a transfer can block for the DNS lookup. + */ + public function pump(): void + { + if ($this->busy) { + return; + } + $this->busy = true; + $this->pumpOnce(); // never throws + $this->busy = false; + } + + /** + * Blocking: delivers the events and the side channel's exporter requests until no event waits or is in flight + * and no exporter request is pending (true), or the timeout passes (false); null = no limit. The background + * requests (diagnostics) are neither counted nor waited for: they go on in later passes and at `shutdown()`. A + * re-entrant call (from a hook or an exporter) returns false at once. + * + * With a window (a positive timeout, or none), each pass ends with the pipeline (the hooks first) of the events that + * waited when it began, whatever the free slots: so the hooks of those events run before the call returns, also + * when the deadline has passed. An event a hook queues meanwhile goes to the next pass, so a hook that keeps queueing + * cannot hold the call past its deadline. Without a window (0, negative or NaN) it is one non-blocking pass, which + * runs only the hooks of the events it starts; the others run later, in the shutdown function of the same request + * or process at the latest. + */ + public function flush(?float $timeoutSeconds = null): bool + { + return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false, prepare: $timeoutSeconds === null || $timeoutSeconds > 0); + } + + /** + * Stops accepting events, then delivers the events and the side requests, background ones included, for at most + * the shutdown timeout (TypeScript `destroy()`). The first call fixes the deadline; a later call delivers only until + * that same deadline, and dispatches nothing once it has passed. So a signal drain and the shutdown function after + * it, or a signal during the shutdown function's flush, share one budget. + * + * With companions, the end of the budget is kept for them: 10 % of the shutdown timeout, at most 0.5 s. The + * delivery stops when everything is delivered or only that reserve is left. Then each companion finishes + * (diagnostics posts its buffer, also when a hung exporter or a slow AgentCat API used the rest of the budget), and + * one more pass, to the full deadline, sends what they posted. That pass ticks no companion, so `finish()` is a + * companion's last call in a shutdown. A call whose delivery never started (a re-entrant call from a hook or an + * exporter, or a clock that cannot be read) finishes no companion. + */ + public function shutdown(): void + { + $this->accepting = false; + $deadline = fn(): float => $this->shutdownDeadline ??= $this->transport->now() + $this->budget($this->shutdownTimeoutSeconds); + if (!$this->shutdownDeadlinePassed()) { + if ($this->companions === []) { + $this->deliverUntil($deadline); + } else { + $this->deliverWithCompanions($deadline); + } + } + if ($this->queued() > 0) { + Log::write(sprintf('Shutting down with %d events still in queue', $this->queued())); + } + } + + /** + * `shutdown()` from the last shutdown function or from the signal drain. An `exit()` or a fatal error inside a + * `pump()` or `flush()` frame (in a hook or an exporter) skips the rest of it, `finally` included, and leaves the + * re-entrancy guard set; a signal can arrive while such a frame runs. Clear the guard first, so that the waiting + * events are still delivered. + */ + public function finalShutdown(): void + { + $this->busy = false; + $this->shutdown(); + } + + /** @return array{queueLength: int, activeRequests: int} */ + public function stats(): array + { + return ['queueLength' => $this->queued(), 'activeRequests' => $this->transport->inFlight()]; + } + + /** The events not yet in delivery: those that went through the pipeline, then those whose hooks have not run. */ + private function queued(): int + { + return $this->ready->count() + $this->waiting->count(); + } + + /** + * True when an earlier `shutdown()` fixed the deadline and that deadline has passed. + * + * @phpstan-impure it reads the clock + */ + private function shutdownDeadlinePassed(): bool + { + try { + return $this->shutdownDeadline !== null && $this->transport->now() >= $this->shutdownDeadline; + } catch (\Throwable) { + return false; // the delivery loop logs the transport's failure + } + } + + /** A NaN or negative timeout means "no time left". max() is no guard: max(0.0, NAN) is NaN on PHP 8.4+. */ + private function budget(float $timeoutSeconds): float + { + return is_nan($timeoutSeconds) ? 0.0 : max(0.0, $timeoutSeconds); + } + + /** + * The shutdown delivery with companions: up to the finish reserve, then each companion's `finish()`, then the + * last pass, to the full deadline, which ticks none of them. + * + * @param \Closure(): float $deadline fixes the shared deadline at its first call + */ + private function deliverWithCompanions(\Closure $deadline): void + { + $this->deliverUntil(fn(): float => $deadline() - $this->finishReserve()); + // Only after a delivery that ran: a re-entrant call finds the guard still taken by the frame it runs in, and + // an unreadable clock left the deadline unset. + if (!$this->busy && $this->shutdownDeadline !== null && !$this->shutdownDeadlinePassed()) { + $this->finishCompanions(); + $this->deliverUntil($deadline, tick: false); + } + } + + /** 10 % of the shutdown budget, at most 0.5 s; never NaN, as `budget()` is not. */ + private function finishReserve(): float + { + return min(self::FINISH_RESERVE_MAX_SECONDS, self::FINISH_RESERVE_SHARE * $this->budget($this->shutdownTimeoutSeconds)); + } + + /** + * The delivery loop of `flush()` and `shutdown()`: done when no event waits or is in flight and no side request is + * pending (the background ones count only with `$background`). A re-entrant call returns false at once. + * + * @param \Closure(): float $deadline read once, after the re-entrancy guard is taken + * @param bool $tick false after `finish()`: the passes tick no companion + * @param bool $background whether it waits for the background requests (diagnostics): `shutdown()` does + * @param bool $prepare whether each pass ends with the pipeline of the waiting events: a `flush()` with a window + */ + private function deliverUntil(\Closure $deadline, bool $tick = true, bool $background = true, bool $prepare = false): bool + { + if ($this->busy) { + return false; + } + $this->busy = true; + RepeatedLines::begin(); // a failure of every pass is logged once + + try { + $until = $deadline(); + while (true) { + $this->pumpOnce($tick); + if ($prepare) { + $this->prepareWaiting(); + } + if ($this->queued() === 0 && $this->transport->inFlight() === 0 && $this->channel->pending($background) === 0) { + return true; + } + if ($this->transport->now() >= $until) { + return false; + } + // Slots freed by the last pump are refilled at once; waiting would sleep with idle slots. A transport + // that cannot wait (a host without a sleep function) ends the delivery: no pass spins to the deadline. + if (($this->queued() === 0 || $this->transport->inFlight() >= self::CONCURRENCY) && !$this->wait($until)) { + return false; + } + } + } catch (\Throwable $e) { + Log::write(self::GAVE_UP . ErrorText::of($e)); + + return false; + } finally { + $this->busy = false; + RepeatedLines::end(); + } + } + + /** + * Waits on the transport alone, or, while side requests are pending, a slice on each that never ends past `$until`. + * `$until` is never NaN. The time left goes through `CurlSupport::waitSeconds()`: within 0 and the slice, and 0 for + * the NaN of INF - INF (a test clock that jumped to an INF deadline). False when the transport alone would wait and + * cannot (`Transport::waitForActivity()`); while side requests are pending, their transfers block the slice. + */ + private function wait(float $until): bool + { + if ($this->channel->pending() === 0) { + return $this->transport->waitForActivity($until); + } + $this->transport->waitForActivity(min($until, $this->transport->now() + self::SLICE_SECONDS)); + $this->channel->waitForActivity(CurlSupport::waitSeconds($until - $this->transport->now(), self::SLICE_SECONDS)); + + return true; + } + + /** + * One pass: starts the events queued when it began while fewer than 5 are in delivery, oldest first (those through + * the pipeline, then the waiting ones, whose pipeline runs now), and pumps the transport, then ticks each companion + * (unless `$tick` is false) and pumps the side channel. Never throws. + * + * An event a hook queues meanwhile waits for the next pass: an event that starts no transfer (telemetry only, an + * encode failure, dropped by a hook) leaves the slots free, so a hook that keeps queueing such events cannot hold + * the pass. A signal drain during a hook (`finalShutdown()`) delivers the whole queue: the pass stops when the + * queue is empty. + */ + private function pumpOnce(bool $tick = true): void + { + try { + for ($left = $this->queued(); $left > 0 && $this->queued() > 0 && $this->transport->inFlight() < self::CONCURRENCY; --$left) { + $ready = $this->ready->isEmpty() ? $this->prepare($this->waiting->dequeue()) : $this->ready->dequeue(); + if ($ready instanceof ReadyEvent) { + $this->send($ready); + } + } + $this->transport->pump(); + } catch (\Throwable $e) { + RepeatedLines::write(self::GAVE_UP . ErrorText::of($e)); + } + if ($tick) { + foreach ($this->companions as $companion) { + try { + $companion->tick(); + } catch (\Throwable) { + // a companion never stops the queue + } + } + } + + try { + $this->channel->pump(); // CurlHttpChannel never throws; a test double's callback might + } catch (\Throwable) { + // the side channel never stops the queue + } + } + + private function finishCompanions(): void + { + foreach ($this->companions as $companion) { + try { + $companion->finish(); // diagnostics posts the lines of the drain too (D07) + } catch (\Throwable) { + // a companion never stops the shutdown + } + } + } + + /** + * The pipeline of the events that waited when the pass began, whatever the free slots (`flush()`): the payloads wait + * in order. An event a hook queues meanwhile waits for the next pass. A signal drain during a hook + * (`finalShutdown()`) delivers the whole queue: the pass stops when no event waits. + */ + private function prepareWaiting(): void + { + for ($left = $this->waiting->count(); $left > 0 && !$this->waiting->isEmpty(); --$left) { + $ready = $this->prepare($this->waiting->dequeue()); + if ($ready instanceof ReadyEvent) { + $this->ready->enqueue($ready); + } + } + } + + /** TypeScript `processEvent` stages 0-5 (the hooks first), and the body; null when the pipeline dropped the event. */ + private function prepare(QueuedEvent $queued): ?ReadyEvent + { + $event = $this->processor->process($queued); + if (!$event instanceof Event) { + return null; + } + if (($event->projectId ?? '') === '') { + return new ReadyEvent($event, null); + } + + try { + return new ReadyEvent($event, ($this->encode)($event)); + } catch (\Throwable $e) { + return new ReadyEvent($event, null, ErrorText::of($e)); + } + } + + /** + * TypeScript `sendEvent`: the exporters alone for an event without a body, else the delivery to the API endpoint of + * this moment (the last `configure()` wins). + */ + private function send(ReadyEvent $ready): void + { + $event = $ready->event; + if ($ready->body === null) { + $this->export($event); + if ($ready->encodeError !== null) { + Log::write(sprintf('Failed to encode event %s: %s', $event->id, $ready->encodeError)); + } + + return; + } + foreach (WireMapper::droppedFields($event) as $key) { + Log::write(sprintf('WARN: Event %s: %s is not a JSON object or array and was not sent', $event->id, $key)); + } + $this->transport->start(Delivery::forEvent($event, $this->endpoint, $ready->body, function () use ($event): void { + $this->export($event); + })); + } + + private function export(Event $event): void + { + try { + $this->exporters->export($event); + } catch (\Throwable $e) { + Log::write('Telemetry export error: ' . ErrorText::of($e)); + } + } +} diff --git a/src/Internal/Delivery/ExporterSink.php b/src/Internal/Delivery/ExporterSink.php new file mode 100644 index 0000000..9043c69 --- /dev/null +++ b/src/Internal/Delivery/ExporterSink.php @@ -0,0 +1,23 @@ +` and never stops the delivery. + * + * @internal + */ +interface ExporterSink +{ + public function export(Event $event): void; +} diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php new file mode 100644 index 0000000..7d59358 --- /dev/null +++ b/src/Internal/Delivery/FinishRequest.php @@ -0,0 +1,118 @@ + $server `$_SERVER` + * @param \Closure(string): bool $functionExists + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` + */ + public static function functionFor(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): ?string + { + $function = match (true) { + $sapi === 'fpm-fcgi' => 'fastcgi_finish_request', + $sapi === 'litespeed' => 'litespeed_finish_request', + $sapi === 'frankenphp' && !self::isFrankenPhpWorker($server, $getenv) => $functionExists('frankenphp_finish_request') ? 'frankenphp_finish_request' : 'fastcgi_finish_request', + $sapi === 'cli-server' && self::isUnit($server) => 'fastcgi_finish_request', + default => null, + }; + + return $function !== null && $functionExists($function) ? $function : null; + } + + /** + * FrankenPHP worker mode: `$_SERVER['FRANKENPHP_WORKER']` is set, or the `FRANKENPHP_WORKER` environment variable. + * + * @param array $server `$_SERVER` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` + */ + public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = null): bool + { + return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? Env::lookup(...))('FRANKENPHP_WORKER') !== false; + } + + /** + * NGINX Unit: its PHP module reports the SAPI of `php -S` (`cli-server`), and Unit names itself in + * `SERVER_SOFTWARE` (`Unit/`), where `php -S` names PHP's development server. + * + * @param array $server `$_SERVER` + */ + private static function isUnit(array $server): bool + { + $software = $server['SERVER_SOFTWARE'] ?? null; + + return is_string($software) && str_starts_with($software, 'Unit/'); + } + + /** + * The process outlives the request: the command line (Octane on Swoole or RoadRunner, a Symfony Runtime worker, a + * stdio server) or FrankenPHP worker mode. + * + * @param array $server `$_SERVER` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` + */ + public static function outlivesRequest(string $sapi, array $server, ?\Closure $getenv = null): bool + { + return $sapi === 'cli' || $sapi === 'phpdbg' || ($sapi === 'frankenphp' && self::isFrankenPhpWorker($server, $getenv)); + } + + /** + * A web SAPI that runs one request per script and has no finish-request function (mod_php, php-cgi, `php -S`): + * the client has a response only when the script ends, unless the response carries its length (D57, D59). + * + * @param array $server `$_SERVER` + * @param \Closure(string): bool $functionExists + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` + */ + public static function responseEndsWithTheScript(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): bool + { + return !self::outlivesRequest($sapi, $server, $getenv) && self::functionFor($sapi, $server, $functionExists, $getenv) === null; + } + + /** @return (\Closure(): void)|null the finish call of this SAPI, if it has one */ + public static function detect(): ?\Closure + { + return self::closureFor(self::functionFor(PHP_SAPI, $_SERVER, static fn(string $name): bool => function_exists($name))); + } + + /** + * Calls `$function` after `ignore_user_abort(true)`, which keeps the script alive when the client + * disconnects after its response. A host can disable `ignore_user_abort()` (`disable_functions`): the call then + * runs alone. + * + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `ignore_user_abort()` exists; default `function_exists()` + * + * @return (\Closure(): void)|null null when `$function` is not callable + */ + public static function closureFor(?string $function, ?\Closure $functionExists = null): ?\Closure + { + if ($function === null || !is_callable($function)) { + return null; + } + $ignoreAbort = ($functionExists ?? function_exists(...))('ignore_user_abort'); + + return static function () use ($function, $ignoreAbort): void { + if ($ignoreAbort) { + ignore_user_abort(true); + } + $function(); + }; + } +} diff --git a/src/Internal/Delivery/NullExporterSink.php b/src/Internal/Delivery/NullExporterSink.php new file mode 100644 index 0000000..6f29ff1 --- /dev/null +++ b/src/Internal/Delivery/NullExporterSink.php @@ -0,0 +1,18 @@ + $session && session_status() === PHP_SESSION_ACTIVE, + static function (): void { + session_write_close(); + }, + static fn(): int => (int) ini_get('max_execution_time'), + static function (int $seconds) use ($limit): void { + if ($limit) { + set_time_limit($seconds); + } + }, + static function () use ($abort, $ini): void { + if (self::ignoreUserAbort($abort, $ini)) { + self::closeOutputBuffers(); + } + }, + ); + } + + public function prepare(float $budgetSeconds): void + { + if ($this->sapi === 'cli' || $this->sapi === 'phpdbg') { + return; // stdio servers: stdout is the protocol, and there is no request to end + } + + try { + if (($this->sessionActive)()) { + ($this->closeSession)(); + } + } catch (\Throwable) { + // the next step still runs + } + + try { + if (($this->timeLimit)() > 0) { + ($this->setTimeLimit)($this->timeLimitFor($budgetSeconds)); + } + } catch (\Throwable) { + // the next step still runs + } + if ($this->finishes) { + return; // the finish call sends the response + } + + try { + ($this->closeOutput)(); + } catch (\Throwable) { + // delivery matters more than the buffers + } + } + + /** + * Sends and closes the removable output buffers above `$keep` (Symfony `Response::closeOutputBuffers()`), from the + * top down to the first one that may not be removed or flushed, then flushes the SAPI. + */ + public static function closeOutputBuffers(int $keep = 0): void + { + for ($level = ob_get_level(); $level > $keep && self::canSendTopBuffer(); --$level) { // at most one pass per buffer + ob_end_flush(); + } + flush(); + } + + /** The buffer on top may be removed and flushed (Symfony's rule), so `ob_end_flush()` closes it without a notice. */ + private static function canSendTopBuffer(): bool + { + $flags = ob_get_status()['flags'] ?? null; + $required = PHP_OUTPUT_HANDLER_REMOVABLE | PHP_OUTPUT_HANDLER_FLUSHABLE; + + return is_int($flags) && ($flags & $required) === $required; + } + + /** + * Lets the script go on when the client leaves once it has the body. On mod_php and php-cgi, a write to a client + * that left ends the script at once unless PHP ignores the abort, so sending the buffers would end the shutdown + * pass before delivery. With `ignore_user_abort()` disabled, `ini_set()` still switches the setting on. True when + * PHP ignores the abort now. + */ + private static function ignoreUserAbort(bool $function, bool $ini): bool + { + if ($function) { + ignore_user_abort(true); + } elseif ($ini) { + ini_set('ignore_user_abort', '1'); + } + + return ini_get('ignore_user_abort') === '1'; // false also when an administrator locked the setting off + } + + /** + * The budget in whole seconds, rounded up, plus one. A negative budget counts as none; one that is not finite, or + * that `(int)` cannot convert (it warns on PHP 8.5), counts as the shutdown timeout. + */ + private function timeLimitFor(float $budgetSeconds): int + { + $budget = is_finite($budgetSeconds) && $budgetSeconds < PHP_INT_MAX ? max(0.0, $budgetSeconds) : EventQueue::SHUTDOWN_TIMEOUT_SECONDS; + + return (int) ceil($budget) + 1; + } +} diff --git a/src/Internal/Delivery/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php new file mode 100644 index 0000000..01f598d --- /dev/null +++ b/src/Internal/Delivery/RequestEndWait.php @@ -0,0 +1,43 @@ +|null $server `$_SERVER`; null reads it + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` + */ + public static function seconds(bool $console, string $sapi = PHP_SAPI, ?array $server = null, ?\Closure $getenv = null): float + { + if ($console) { + return 0.0; + } + + return FinishRequest::outlivesRequest($sapi, $server ?? $_SERVER, $getenv) ? self::WORKER_SECONDS : 0.0; + } +} diff --git a/src/Internal/Delivery/ShutdownFlusher.php b/src/Internal/Delivery/ShutdownFlusher.php new file mode 100644 index 0000000..5553654 --- /dev/null +++ b/src/Internal/Delivery/ShutdownFlusher.php @@ -0,0 +1,100 @@ +register = $register ?? (($functionExists ?? function_exists(...))('register_shutdown_function') ? register_shutdown_function(...) : null); + } + + /** + * Registers the shutdown function once; without `register_shutdown_function()`, logs NO_SHUTDOWN once instead. A + * registrar error reaches the caller (the queue logs it). + */ + public function ensureRegistered(): void + { + if ($this->registered) { + return; + } + if (!$this->register instanceof \Closure) { + $this->registered = true; + Log::write(self::NO_SHUTDOWN); + + return; + } + ($this->register)($this->onShutdown(...)); + $this->registered = true; + } + + /** First shutdown pass: moves the flush behind the shutdown functions registered after ours. */ + public function onShutdown(): void + { + try { + ($this->register ?? static function (\Closure $pass): void { + $pass(); // no registrar: the final pass runs now + })($this->onFinalShutdown(...)); + } catch (\Throwable) { + $this->onFinalShutdown(); // cannot move to the end of the list: flush now + } + } + + /** Final pass. A shutdown function must never throw. */ + public function onFinalShutdown(): void + { + try { + $queue = ($this->queue)(); + if ($queue->hasWork()) { + $this->requestEnd?->prepare(EventQueue::SHUTDOWN_TIMEOUT_SECONDS); // never throws + if ($this->finishRequest instanceof \Closure) { + try { + ($this->finishRequest)(); // the client gets its response before the events are sent + } catch (\Throwable) { + // keep going: delivery matters more than the finish call + } + } + } + $queue->finalShutdown(); + } catch (\Throwable) { + // a shutdown function must never throw + } + } +} diff --git a/src/Internal/Delivery/SignalDrain.php b/src/Internal/Delivery/SignalDrain.php new file mode 100644 index 0000000..020316f --- /dev/null +++ b/src/Internal/Delivery/SignalDrain.php @@ -0,0 +1,172 @@ +raise = $raise ?? $this->raiseWithDefaultDisposition(...); + $this->functionExists = $functionExists ?? function_exists(...); + $this->extensionLoaded = $extensionLoaded ?? extension_loaded(...); + $this->exit = $exit ?? static function (int $status): never { + exit($status); + }; + } + + /** Never installs after this. False when the handlers are installed already (then nothing changes). */ + public function disable(): bool + { + if ($this->installed) { + return false; + } + $this->disabled = true; + + return true; + } + + /** + * Installs the handlers once. Does nothing outside the CLI SAPI or under Swoole or OpenSwoole. Without a function of + * FUNCTIONS it logs one line once: `DISABLED` when ext-pcntl is loaded, else `NO_PCNTL` (none on Windows). + */ + public function install(): void + { + if ($this->installed || $this->disabled || $this->sapi !== 'cli') { + return; + } + foreach (['swoole', 'openswoole'] as $extension) { + if (($this->extensionLoaded)($extension)) { + return; // a coroutine server stops its workers with C-level handlers PHP cannot see or chain + } + } + $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); + if ($missing !== []) { + $this->disabled = true; // checked once + if (($this->extensionLoaded)('pcntl')) { + Log::write(sprintf(self::DISABLED, implode(', ', $missing))); // the extension is there: a host disabled them + } elseif ($this->osFamily !== 'Windows') { + Log::write(self::NO_PCNTL); + } + + return; + } + $this->installed = true; + $installedAny = false; + foreach ([\SIGTERM, \SIGINT] as $signal) { + $previous = pcntl_signal_get_handler($signal); + if ($previous === \SIG_IGN) { + continue; // the application ignores this signal: so do we + } + pcntl_signal($signal, function (int $signo, mixed $siginfo = null) use ($previous): void { + pcntl_signal($signo, $previous); // once: a second signal during the drain goes where it went before + $this->onSignal($signo, $previous, $siginfo); + }); + $installedAny = true; + } + if ($installedAny) { + pcntl_async_signals(true); // the stdio loop sleeps in usleep(): handlers must run without pcntl_signal_dispatch() + } + } + + /** + * Shuts the queue down, then calls the earlier handler (ac-03-11: the process keeps running) or raises the + * signal again (ac-03-10). + * + * @param mixed $previous the handler before ours: a callable, or `SIG_DFL` + * @param mixed $siginfo PHP's second handler argument (an array), passed on to `$previous` unchanged + */ + public function onSignal(int $signal, mixed $previous, mixed $siginfo = null): void + { + try { + ($this->queue)()->finalShutdown(); // at most what is left of EventQueue::SHUTDOWN_TIMEOUT_SECONDS + } catch (\Throwable) { + // a signal handler must never throw + } + if (is_callable($previous)) { + $previous($signal, $siginfo); + + return; + } + ($this->raise)($signal); + } + + private function raiseWithDefaultDisposition(int $signal): void + { + pcntl_signal($signal, \SIG_DFL); + if (($this->functionExists)('posix_kill') && ($this->functionExists)('posix_getpid')) { + posix_kill(posix_getpid(), $signal); // the default disposition of SIGTERM and SIGINT ends the process here + + return; + } + ($this->exit)(128 + $signal); // the shell's exit status for "ended by this signal" + } +} diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php new file mode 100644 index 0000000..b66c74d --- /dev/null +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -0,0 +1,214 @@ +}> */ + private array $buffer = []; + + /** The body bytes of `$buffer`. */ + private int $bufferBytes = 0; + + /** The clock reading of the oldest buffered line; null while the buffer is empty. */ + private ?float $firstAt = null; + + private bool $armed = false; + + /** + * @param list $resource + * @param list $recordAttributes + * @param \Closure(): float $clock monotonic seconds + * @param \Closure(): int $wallMillis wall-clock milliseconds + * @param bool $window whether queue passes post after 2 s + */ + private function __construct( + private readonly EventQueue $queue, + private readonly array $resource, + private readonly array $recordAttributes, + private readonly \Closure $clock, + private readonly \Closure $wallMillis, + private readonly bool $window, + ) {} + + /** + * TS `initDiagnostics`: the first call in the process decides; a later call changes nothing. A later + * `$disabled = true` while diagnostics run logs one line, once. A framework bridge's configuration can turn them off + * too (`BridgeConfig::diagnosticsOff()`), never on. Never throws. + * + * @param (\Closure(): bool)|null $inTestEnvironment default `TestMode::inTestEnvironment()` (P5) + * @param (\Closure(): float)|null $clock tests: monotonic seconds + * @param (\Closure(): int)|null $wallMillis tests: wall-clock milliseconds + * @param string|null $sapi tests: the SAPI, which decides the 2 s window; default `PHP_SAPI` + */ + public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null, ?string $sapi = null): void + { + try { + if (self::$initialized) { + if ($disabled && self::$active instanceof self && !self::$warned) { + self::$warned = true; + Log::write(self::IGNORED); + } + + return; + } + self::$initialized = true; + $testEnvironment = $inTestEnvironment instanceof \Closure ? $inTestEnvironment() : TestMode::inTestEnvironment(); + if (!DiagnosticsSwitch::enabled($disabled || BridgeConfig::diagnosticsOff(), Env::get('AGENTCAT_DISABLE_DIAGNOSTICS'), Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { + return; + } + $diagnostics = new self( + $queue, + DiagnosticsAttributes::resource($projectId), + DiagnosticsAttributes::record(), + $clock ?? Clock::monotonic(), + // Whole milliseconds, like Date.now(), from DateTimeImmutable: a host can disable microtime(). + $wallMillis ?? static fn(): int => (int) (new \DateTimeImmutable())->format('Uv'), + FinishRequest::outlivesRequest($sapi ?? PHP_SAPI, $_SERVER), + ); + self::$active = $diagnostics; + $queue->attach($diagnostics); + Log::setDiagnosticsSink($diagnostics->capture(...)); + } catch (\Throwable) { + // diagnostics setup must never throw + } + } + + public static function enabled(): bool + { + return self::$active instanceof self; + } + + /** Tests only: forgets the decision and removes the sink. */ + public static function reset(): void + { + self::$initialized = false; + self::$warned = false; + self::$active = null; + Log::setDiagnosticsSink(null); + } + + /** + * Posts the buffer once its oldest line is 2 s old (the TypeScript timer, checked on queue passes), in a process + * that outlives its requests only: a per-request runtime posts once, at shutdown (D47). + */ + #[\Override] + public function tick(): void + { + $firstAt = $this->firstAt; + if ($this->window && $firstAt !== null && ($this->clock)() - $firstAt >= self::BATCH_FLUSH_SECONDS) { + $this->flush(); + } + } + + /** Posts what is left; an empty buffer posts nothing (a signal drain and the shutdown function both finish). */ + #[\Override] + public function finish(): void + { + $this->flush(); + } + + #[\Override] + public function hasWork(): bool + { + return $this->buffer !== []; + } + + /** The `Log` sink: buffers one line; the first line arms the queue's shutdown delivery. Never throws. */ + private function capture(string $line): void + { + try { + $now = ($this->clock)(); + [$number, $text] = Severity::of($line); + $millis = ($this->wallMillis)(); + $bytes = strlen($line); + // The oldest go until this one fits; it is kept even when its body alone is over the budget. + while ($this->buffer !== [] && (count($this->buffer) >= self::MAX_BUFFER || $this->bufferBytes + $bytes > self::MAX_BUFFER_BYTES)) { + $oldest = array_shift($this->buffer); + $this->bufferBytes -= strlen($oldest['body']['stringValue']); + } + $this->bufferBytes += $bytes; + $this->buffer[] = [ + 'timeUnixNano' => $millis === 0 ? '0' : $millis . '000000', + 'severityNumber' => $number, + 'severityText' => $text, + 'body' => ['stringValue' => $line], + 'attributes' => $this->recordAttributes, + ]; + $this->firstAt ??= $now; + if (!$this->armed) { + $this->armed = true; // before arm(): its failure line comes back through capture() + $this->queue->arm(); + } + } catch (\Throwable) { + // capture must never throw + } + } + + /** Posts the buffer as one request on the queue's side channel; nothing when it is empty. Never throws. */ + private function flush(): void + { + if ($this->buffer === []) { + return; + } + $records = $this->buffer; + $this->buffer = []; + $this->bufferBytes = 0; + $this->firstAt = null; + + try { + $this->queue->channel()->post( + new HttpRequest(DiagnosticsEndpoint::url(), ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . DiagnosticsEndpoint::token()], DiagnosticsPayload::encode($this->resource, $records, SdkVersion::get()), background: true, connectTimeoutSeconds: self::CONNECT_TIMEOUT_SECONDS, totalTimeoutSeconds: self::TOTAL_TIMEOUT_SECONDS), + static function (): void {}, // the answer is ignored (TS) + ); + } catch (\Throwable) { + // the records are lost, like TS + } + } +} diff --git a/src/Internal/Diagnostics/DiagnosticsAttributes.php b/src/Internal/Diagnostics/DiagnosticsAttributes.php new file mode 100644 index 0000000..e153b63 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsAttributes.php @@ -0,0 +1,95 @@ + the resource attributes of every request + */ + public static function resource(string $projectId, ?\Closure $functionExists = null): array + { + $can = $functionExists ?? function_exists(...); + $host = $can('gethostname') ? (string) gethostname() : ''; + $uname = $can('php_uname'); + + return self::list([ + $projectId !== '' ? ['agentcat.project_id', $projectId] : ['agentcat.install_id', self::installId($host, 'file://' . dirname(__DIR__, 3))], + ['agentcat.sdk.language', 'php'], + ['agentcat.sdk.version', SdkVersion::get()], + ['agentcat.mcp_sdk.version', SdkVersion::installed('mcp/sdk')], + ['process.runtime.name', 'php'], + ['process.runtime.version', PHP_VERSION], + ['process.pid', $can('getmypid') ? (string) getmypid() : null], + ['os.type', self::osType(PHP_OS_FAMILY)], + ['os.version', $uname ? php_uname('r') : null], + ['host.arch', $uname ? self::arch(php_uname('m')) : null], + ['deployment.environment', BridgeConfig::env('APP_ENV')], // Laravel: `app.env` + ]); + } + + /** @return list on every record, so they survive flattening */ + public static function record(): array + { + return self::list([ + ['agentcat.sdk.version', SdkVersion::get()], + ['process.runtime.version', PHP_VERSION], + ['agentcat.mcp_sdk.version', SdkVersion::installed('mcp/sdk')], + ]); + } + + /** The first 16 hex digits of a SHA-256: the host name is hashed, never sent. */ + public static function installId(string $hostname, string $installPath): string + { + return substr(hash('sha256', $hostname . '|' . $installPath), 0, 16); + } + + /** Node `os.platform()` names: `PHP_OS_FAMILY` in lower case (`linux`, `darwin`), and `win32` for Windows. */ + public static function osType(string $family): string + { + return $family === 'Windows' ? 'win32' : strtolower($family); + } + + /** Node `os.arch()` names for x64 and arm64 (any case); any other machine name as is. */ + public static function arch(string $machine): string + { + return match (strtolower($machine)) { + 'x86_64', 'amd64' => 'x64', + 'aarch64', 'arm64' => 'arm64', + default => $machine, + }; + } + + /** + * @param list $pairs + * + * @return list + */ + private static function list(array $pairs): array + { + $out = []; + foreach ($pairs as [$key, $value]) { + if ($value !== null && $value !== '') { + $out[] = ['key' => $key, 'value' => ['stringValue' => $value]]; + } + } + + return $out; + } +} diff --git a/src/Internal/Diagnostics/DiagnosticsEndpoint.php b/src/Internal/Diagnostics/DiagnosticsEndpoint.php new file mode 100644 index 0000000..0995cc0 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsEndpoint.php @@ -0,0 +1,33 @@ + $resource + * @param list> $records + * + * @throws \JsonException + * @throws \UnexpectedValueException + */ + public static function encode(array $resource, array $records, string $version): string + { + return ExportJson::encode(['resourceLogs' => [[ + 'resource' => ['attributes' => $resource], + 'scopeLogs' => [['scope' => ['name' => Constants::DIAGNOSTICS_SCOPE_NAME, 'version' => $version], 'logRecords' => $records]], + ]]]); + } +} diff --git a/src/Internal/Diagnostics/DiagnosticsSwitch.php b/src/Internal/Diagnostics/DiagnosticsSwitch.php new file mode 100644 index 0000000..bdaeec8 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsSwitch.php @@ -0,0 +1,85 @@ +clock = $clock ?? static fn(): \DateTimeImmutable => new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + } + + /** + * `TSeam` lets PHPStan infer `TResult` from the seam's declared ancestor instead of comparing the two (PHPStan + * 2.3 does not match an invariant `CallSeam|โ€ฆ>` against itself). + * + * @template TResult + * @template TSeam of CallSeam + * + * @param TSeam $seam + * + * @return TResult + */ + public function intercept(TrackedServer $server, CallSeam $seam): mixed + { + $options = $server->options; + $name = $seam->toolName(); + $intercept = $options->enableReportMissing && $name === GetMoreTools::NAME; + + if (!$options->enableTracing) { + return $this->untraced($intercept, $seam); // D15 (B06): with tracing off the request goes on unstripped + } + + $start = ($this->clock)(); + try { + ToolListing::ensureRegistries($server, $name, $seam->listOriginal(...)); + $arguments = $seam->arguments(); + $resolution = HandleResolver::resolve($arguments, $server->hookMode(), $options->enableAgentTracking, !$server->injection->hasDeclaredSessionParam($name)); + $call = $this->start($server, $name, $arguments, $resolution, $seam->describe(), $start, $intercept); + } catch (\Throwable $e) { + Log::write(sprintf('Warning: AgentCat tracing failed for tool %s, falling back to original handler - %s', $name, ErrorText::of($e))); + + return $this->untraced($intercept, $seam); + } + + try { + if ($intercept) { + $tree = GetMoreTools::result($arguments); + + return $this->finish($call, $seam, $seam->answer($tree), new Observed($tree)); + } + $result = $seam->callStripped(ArgumentStripper::names($name, $server->registry)); + } catch (\Throwable $e) { + try { + $this->publishFailure($call, $e); + } catch (\Throwable $failure) { + // D32: recording the library's exception must never replace it. + Log::write(sprintf(self::POST_HANDLER_FAILED, $name, ErrorText::of($failure))); + } + + throw $e; + } + + $later = $this->later($call, $seam, $result); + if ($later !== null) { + return $later; + } + + return $this->finish($call, $seam, $result, null); + } + + /** + * A result that completes later (a stream) is finished when it completes; null for an ordinary result. A seam + * that fails here leaves the result to an ordinary finish. + * + * @template TResult + * + * @param CallSeam $seam + * @param TResult $result + * + * @return TResult|null + */ + private function later(TracedCall $call, CallSeam $seam, mixed $result): mixed + { + try { + return $seam->later( + $result, + fn(mixed $final): mixed => $this->finish($call, $seam, $final, null), + function (\Throwable $e) use ($call, $seam): void { + try { + $this->publishFailure($call, $e); + } catch (\Throwable $failure) { + Log::write(sprintf(self::POST_HANDLER_FAILED, $seam->toolName(), ErrorText::of($failure))); + } + }, + ); + } catch (\Throwable $e) { + Log::write(sprintf(self::POST_HANDLER_FAILED, $seam->toolName(), ErrorText::of($e))); + + return null; + } + } + + /** + * The tracing-off and degraded paths: no event, no strip, no mint-back. + * + * @template TResult + * + * @param CallSeam $seam + * + * @return TResult + */ + private function untraced(bool $intercept, CallSeam $seam): mixed + { + return $intercept ? $seam->answer(GetMoreTools::result($seam->arguments())) : $seam->callOriginal(); + } + + /** Builds the event and the deferred hooks; runs no customer code. */ + private function start( + TrackedServer $server, + string $name, + mixed $arguments, + HandleResolution $resolution, + CallContext $context, + \DateTimeImmutable $start, + bool $intercept, + ): TracedCall { + $event = new Event( + sessionId: $resolution->sessionId, + eventType: Constants::TOOLS_CALL_EVENT_TYPE, + timestamp: $start, + // mcp/sdk cannot tell empty arguments from absent ones, so empty arguments count no input tokens. + inputTokens: $arguments instanceof \stdClass && get_object_vars($arguments) === [] ? null : TokenEstimator::input($arguments), + resourceName: $name === '' ? 'Unknown Tool' : $name, + parameters: (object) ['request' => $context->request, 'extra' => $context->extra], + ); + $mrtr = $context->continuation ? [Constants::TAG_MRTR => 'continuation'] : []; + $event->tags = [...$mrtr, ...HandleTags::build($resolution, $context->protocolVersion)]; + // get_more_tools keeps its intent with enableToolCallContext off; with report-missing off it has none. + $intent = $intercept || ($server->options->enableToolCallContext && $name !== GetMoreTools::NAME) + ? HandleTags::userIntent($arguments, $name, true) + : null; + $invalidIntent = null; + if (is_string($intent)) { + $event->userIntent = $intent; + } elseif ($intent !== null && !is_array($intent)) { + $invalidIntent = get_debug_type($intent); + } + + return new TracedCall($server, $event, $resolution, $context, PendingHooks::build($server, $context->toolCall), $start, $invalidIntent); + } + + /** + * TS finish(): decorate, record, publish. Never throws: on failure the customer's result goes back untouched. + * + * @template TResult + * + * @param CallSeam $seam + * @param TResult $result + * + * @return TResult + */ + private function finish(TracedCall $call, CallSeam $seam, mixed $result, ?Observed $observed): mixed + { + try { + $observed ??= $seam->observe($result); + if ($observed->protocolError !== null) { + // A JSON-RPC error answer: the TS catch path, no mint-back. + $this->publishFailure($call, $observed->thrown ?? $observed->protocolError); + + return $result; + } + $event = $call->event; + $tree = $observed->tree; + $final = $result; + if ($tree instanceof \stdClass && ($tree->resultType ?? null) === 'input_required') { + $tags = $event->tags ?? []; + $tags[Constants::TAG_MRTR] = 'input_required'; + $event->tags = $tags; + } else { + $decorated = MintBack::decorate($tree, $call->resolution, $call->server->mirrorAllowed($seam->toolName())); + if ($decorated !== $tree && $decorated instanceof \stdClass) { + $final = $seam->replace($result, $decorated); + } + } + if ($tree instanceof \stdClass && ($tree->isError ?? null) === true) { + $event->isError = true; + $event->error = ExceptionCapture::capture($observed->thrown ?? $tree); + } + $event->response = $tree; // the mint-back is wire-only + $event->outputTokens = TokenEstimator::output($tree); + $event->duration = Publisher::elapsedMs($call->start, ($this->clock)()); + $this->publish($call); + + return $final; + } catch (\Throwable $e) { + Log::write(sprintf(self::POST_HANDLER_FAILED, $seam->toolName(), ErrorText::of($e))); + + return $result; + } + } + + /** The TS catch path: an error event for a library exception or a JSON-RPC error answer. */ + private function publishFailure(TracedCall $call, mixed $error): void + { + $event = $call->event; + $event->isError = true; + $event->error = ExceptionCapture::capture($error); + $event->duration = Publisher::elapsedMs($call->start, ($this->clock)()); + $this->publish($call); + } + + private function publish(TracedCall $call): void + { + if ($call->invalidIntent !== null) { + // B16: a number, a boolean or an object as the intent. TypeScript loses this event at truncation, after + // its hooks ran; here they never run. A list intent is published with no intent (PHP divergence: + // TypeScript sends the raw list). + Log::write(sprintf('Failed to truncate event: UnexpectedValueException: userIntent is not a string (%s)', $call->invalidIntent)); + + return; + } + $this->publisher->publishToolCall($call->server, $call->event, $call->context->clientName, $call->context->clientVersion, $call->pending); + } +} diff --git a/src/Internal/Engine/CallSeam.php b/src/Internal/Engine/CallSeam.php new file mode 100644 index 0000000..bb8de3b --- /dev/null +++ b/src/Internal/Engine/CallSeam.php @@ -0,0 +1,87 @@ + $names + * + * @return TResult + */ + public function callStripped(array $names): mixed; + + /** + * The tool-result tree of a result, or the message of the JSON-RPC error it carries, and the tool's own + * exception when the library caught one. + * + * @param TResult $result + */ + public function observe(mixed $result): Observed; + + /** + * The result with its tool-result tree replaced by a decorated one. Returns a new result and never changes + * `$result` in place: when publishing fails after `replace()`, the interceptor returns `$result` untouched. + * + * @param TResult $result + * + * @return TResult + */ + public function replace(mixed $result, \stdClass $tree): mixed; + + /** + * A successful answer with this tool-result tree, given without running the library's handler. + * + * @return TResult + */ + public function answer(\stdClass $tree): mixed; + + /** + * One page of the library's own `tools/list`: the tools of the page at `$cursor` (null for the first) and its next + * cursor (`ToolListing::nextCursor()`; null on the last page), or null when the answer has no tool list. May throw. + * + * @return array{list, ?string}|null + */ + public function listOriginal(?string $cursor): ?array; + + /** + * For a result that completes later (a stream), a result that calls `$complete` with the final result, and yields + * what it returns in its place, or calls `$fail` with what the stream throws; null for an ordinary result. Never + * throws. + * + * @param TResult $result + * @param \Closure(TResult): TResult $complete + * @param \Closure(\Throwable): void $fail + * + * @return TResult|null + */ + public function later(mixed $result, \Closure $complete, \Closure $fail): mixed; +} diff --git a/src/Internal/Engine/CallTrees.php b/src/Internal/Engine/CallTrees.php new file mode 100644 index 0000000..5c4f7e2 --- /dev/null +++ b/src/Internal/Engine/CallTrees.php @@ -0,0 +1,82 @@ +|null $meta + */ + public static function claimedVersion(?array $meta): ?string + { + $claim = $meta[Constants::META_PROTOCOL_VERSION_KEY] ?? null; + + return is_string($claim) && $claim !== '' ? $claim : null; + } + + /** + * `{"method":"tools/call","params":{โ€ฆ}}`, raw arguments included. The arguments are an object: empty arguments are + * `{}` and integer keys stay keys. A sent `_meta` is an object too, so a sent `{}` stays `{}`. + * + * @param array $params the request's params, as the library holds them + * @param array $arguments + * @param array|null $meta null when the call sent none + * + * @throws \JsonException for a value JSON cannot carry; a request decoded from JSON holds none + */ + public static function request(array $params, array $arguments, ?array $meta): \stdClass + { + $params['arguments'] = (object) $arguments; + if ($meta !== null) { + $params['_meta'] = (object) $meta; + } + $tree = new \stdClass(); + $tree->method = 'tools/call'; + $tree->params = JsonTree::of((object) $params); + + return $tree; + } + + /** + * `{sessionId?, requestId, _meta?, requestInfo?, authInfo?}`, in this order (D34). + * + * @param string|null $sessionId the transport session (`Mcp-Session-Id`), when there is one + * @param array|null $meta null when the call sent none + * @param \stdClass|null $authInfo `{scopes, claims}` of the request's access token, when there is one + * + * @throws \JsonException for a value JSON cannot carry; a request decoded from JSON holds none + */ + public static function extra(?string $sessionId, int|string $requestId, ?array $meta, ?HttpContext $http, ?\stdClass $authInfo = null): \stdClass + { + $extra = new \stdClass(); + if ($sessionId !== null) { + $extra->sessionId = $sessionId; + } + $extra->requestId = $requestId; + if ($meta !== null) { + $extra->_meta = JsonTree::of((object) $meta); + } + if ($http instanceof HttpContext) { + $extra->requestInfo = $http->toRequestInfo(); + } + if ($authInfo instanceof \stdClass) { + $extra->authInfo = $authInfo; + } + + return $extra; + } +} diff --git a/src/Internal/Engine/ClientIdentity.php b/src/Internal/Engine/ClientIdentity.php new file mode 100644 index 0000000..7d75983 --- /dev/null +++ b/src/Internal/Engine/ClientIdentity.php @@ -0,0 +1,39 @@ + $headers lower-case dashed names, values verbatim (D06) + */ + public function __construct( + public ?string $method, + public ?string $url, + public array $headers, + ) {} + + public static function capture(): ?self + { + return self::fromServer(PHP_SAPI, $_SERVER); + } + + /** + * @param array $server + */ + public static function fromServer(string $sapi, array $server): ?self + { + if ($sapi === 'cli' || $sapi === 'phpdbg') { + return null; + } + $headers = []; + foreach ($server as $key => $value) { + if (!is_string($key) || !is_string($value)) { + continue; + } + if (str_starts_with($key, 'HTTP_')) { + $headers[strtolower(str_replace('_', '-', substr($key, 5)))] = $value; + } elseif ($key === 'CONTENT_TYPE' || $key === 'CONTENT_LENGTH') { + $headers[strtolower(str_replace('_', '-', $key))] = $value; + } + } + $method = is_string($server['REQUEST_METHOD'] ?? null) ? $server['REQUEST_METHOD'] : null; + if ($method === null && $headers === []) { + return null; + } + + return new self($method, self::url($server), $headers); + } + + /** + * The request object of a framework (Symfony's `Request`, which Laravel's extends), from its plain values, with the + * URL and the headers as the client sent them, like the SAPI reading of `fromServer()`: + * - the URL from the scheme `HTTPS` gives, the `Host` header and the raw request URI, through `url()`: Laravel's + * `fullUrl()` sorts the query and drops a trailing slash, and Symfony's `getUri()` sorts the query and refuses + * an invalid host. The `Host` header, not the server variable: a request that a worker builds from PSR-7 has + * only the header; + * - the header names as the header bag keeps them (lower case); the values of a repeated header joined with `, `, + * like a SAPI joins them, a null value as `''`; the headers that Symfony makes up left out: `php-auth-*` from the + * credentials (`ServerBag`), and `x-php-ob-level`, which `HttpKernel::handle()` adds to each request it handles. + * + * @param array> $headers the request's header bag (`HeaderBag::all()`) + * @param mixed $https the `HTTPS` server variable + * @param mixed $requestUri the `REQUEST_URI` server variable + */ + public static function fromRequest(string $method, array $headers, mixed $https, ?string $host, mixed $requestUri): self + { + $values = []; + foreach ($headers as $name => $list) { + if (!in_array($name, self::MADE_UP_HEADERS, true)) { + $values[$name] = implode(', ', $list); + } + } + + return new self($method, self::url(['HTTPS' => $https, 'HTTP_HOST' => $host, 'REQUEST_URI' => $requestUri]), $values); + } + + /** + * The projection TypeScript v1 calls `requestInfo` (D34): `{headers, url?}`. `method` stays internal. + */ + public function toRequestInfo(): \stdClass + { + $info = new \stdClass(); + $info->headers = (object) $this->headers; + if ($this->url !== null) { + $info->url = $this->url; + } + + return $info; + } + + /** + * The full URL as a web server passes it to PHP: the scheme `HTTPS` gives, the `HTTP_HOST` and the raw + * `REQUEST_URI`, query and trailing slash as sent. Null without a host: no URL is made up. + * + * @param array $server + */ + private static function url(array $server): ?string + { + $host = $server['HTTP_HOST'] ?? null; + if (!is_string($host) || $host === '') { + return null; // no host, no full URL + } + $https = $server['HTTPS'] ?? null; + $scheme = is_string($https) && $https !== '' && strtolower($https) !== 'off' ? 'https' : 'http'; + $uri = $server['REQUEST_URI'] ?? ''; + + return $scheme . '://' . $host . (is_string($uri) ? $uri : ''); + } +} diff --git a/src/Internal/Engine/InjectedList.php b/src/Internal/Engine/InjectedList.php index 7036d6a..6aaac4c 100644 --- a/src/Internal/Engine/InjectedList.php +++ b/src/Internal/Engine/InjectedList.php @@ -15,10 +15,12 @@ * @param list $tools the tools to advertise * @param array> $registry injected parameters per tool name (PHP turns numeric-string names into int keys) * @param list $outputRegistry tools whose outputSchema declares `mcp_session` + * @param bool $degraded the injection failed as a whole and `$tools` is the original list */ public function __construct( public array $tools, public array $registry, public array $outputRegistry, + public bool $degraded = false, ) {} } diff --git a/src/Internal/Engine/InjectionState.php b/src/Internal/Engine/InjectionState.php index d79954d..eb13ea8 100644 --- a/src/Internal/Engine/InjectionState.php +++ b/src/Internal/Engine/InjectionState.php @@ -17,7 +17,8 @@ final class InjectionState /** @var array */ private array $reportedConflicts = []; - private bool $shadowWarned = false; + /** A page of this server named the customer's own get_more_tools. Only grows. */ + private bool $customerGetMoreTools = false; /** * Tools whose own schema declares `session_id`. Only grows. PHP turns numeric-string names into int keys. @@ -50,14 +51,20 @@ public function reportConflictOnce(string $tool): bool return true; } - /** True the first time the get_more_tools shadowing is warned about. */ - public function warnShadowOnce(): bool + /** Records that a page named the customer's own get_more_tools. True the first time: the shadowing is warned about once. */ + public function markCustomerGetMoreTools(): bool { - if ($this->shadowWarned) { + if ($this->customerGetMoreTools) { return false; } - $this->shadowWarned = true; + $this->customerGetMoreTools = true; return true; } + + /** A page that this server read (in this process) named the customer's own get_more_tools. */ + public function hasCustomerGetMoreTools(): bool + { + return $this->customerGetMoreTools; + } } diff --git a/src/Internal/Engine/Libraries.php b/src/Internal/Engine/Libraries.php new file mode 100644 index 0000000..ef07da7 --- /dev/null +++ b/src/Internal/Engine/Libraries.php @@ -0,0 +1,83 @@ + */ + public static function all(): array + { + return [new McpSdkLibrary(), new LaravelMcpLibrary()]; + } + + public static function for(object $server): ?Library + { + foreach (self::all() as $library) { + if ($library->supports($server)) { + return $library; + } + } + + return null; + } + + /** Every library's shape signals for the object, comma-joined (TS `fingerprintServerShape`); '' when none applies. */ + public static function fingerprint(object $server): string + { + $signals = []; + foreach (self::all() as $library) { + array_push($signals, ...$library->signals($server)); + } + + return implode(',', $signals); + } + + public static function supportMatrix(): string + { + return implode(' ', array_map(static fn(Library $library): string => $library->supportMatrix(), self::all())); + } + + /** What to pass instead of an object that no library can map to a tracked server; null when there is no advice. */ + public static function untrackedAdvice(object $server): ?string + { + return McpSdkLibrary::untrackedAdvice($server); + } + + /** + * The library that tracks the object through the object it was made from, as mcp/sdk tracks the Server that a + * tracked Builder built; null when no library does. Never throws. + */ + public static function trackerOf(object $server): ?Library + { + foreach (self::all() as $library) { + if ($library->trackedServer($server) instanceof TrackedServer) { + return $library; + } + } + + return null; + } + + public static function trackedServer(object $server): ?TrackedServer + { + foreach (self::all() as $library) { + $tracked = $library->trackedServer($server); + if ($tracked instanceof TrackedServer) { + return $tracked; + } + } + + return null; + } +} diff --git a/src/Internal/Engine/Library.php b/src/Internal/Engine/Library.php new file mode 100644 index 0000000..0557026 --- /dev/null +++ b/src/Internal/Engine/Library.php @@ -0,0 +1,56 @@ + + */ + public function signals(object $server): array; + + /** The PSR-3 logger set on the server; null when none is set or it cannot be read. Any object; never throws. */ + public function logger(object $server): ?LoggerInterface; + + /** + * Checks that AgentCat can instrument the server. Runs only after `supports()`. + * + * @throws \UnexpectedValueException with the compatibility message, after logging the compatibility line + */ + public function inspect(object $server): void; + + /** + * Reads the server name and version when an event is published. Runs only after `supports()`. + * + * @return \Closure(): array{0: ?string, 1: ?string} + */ + public function serverInfo(object $server): \Closure; + + /** Adds AgentCat's handlers to the server. Runs only after `supports()` and `inspect()`. */ + public function install(object $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void; + + /** The tracking state AgentCat keeps in the object; null when it has none. Takes any object; never throws. */ + public function trackedServer(object $server): ?TrackedServer; +} diff --git a/src/Internal/Engine/Observed.php b/src/Internal/Engine/Observed.php new file mode 100644 index 0000000..0eb2ad3 --- /dev/null +++ b/src/Internal/Engine/Observed.php @@ -0,0 +1,24 @@ + + */ + public static function build(TrackedServer $server, ToolCall $call): array + { + $options = $server->options; + $projectId = $server->projectId !== '' ? $server->projectId : null; + $pending = []; + if ($options->resolveSessionId instanceof \Closure) { + $hook = $options->resolveSessionId; + $pending[] = self::guarded('resolveSessionId', static function (Event $event) use ($hook, $call, $projectId): void { + [$sessionId, $source] = SessionId::fromHookValue(self::invoke($hook, $call, 'resolveSessionId hook error: '), $projectId); + $event->sessionId = $sessionId; + $tags = $event->tags ?? []; + $tags[Constants::TAG_SESSION_SOURCE] = $source->value; + $event->tags = $tags; + }); + } + if ($options->identify instanceof \Closure) { + $hook = $options->identify; + $pending[] = self::guarded('identify', static function (Event $event) use ($hook, $call): void { + $identity = self::invoke($hook, $call, 'Error: identify callback threw - '); + if ($identity === null) { + return; + } + if (!$identity instanceof Identity) { + Log::write(sprintf('WARN: identify returned %s, expected AgentCat\Identity or null; the event stays anonymous', get_debug_type($identity))); + + return; + } + $event->identifyActorGivenId = $identity->userId; + $event->identifyActorName = $identity->userName; + $event->identifyActorData = $identity->userData === [] ? new \stdClass() : $identity->userData; // TS `userData || {}` + }); + } + if ($options->eventTags instanceof \Closure) { + $hook = $options->eventTags; + $pending[] = self::guarded('eventTags', static function (Event $event) use ($hook, $call): void { + $valid = TagValidator::validate(self::invoke($hook, $call, 'eventTags callback error: ')); // null for a falsy value + if ($valid !== null) { + $event->tags = HandleTags::merge($valid, $event->tags ?? []); // customer first, SDK tags win (B13, D15) + } + }); + } + if ($options->eventProperties instanceof \Closure) { + $hook = $options->eventProperties; + $pending[] = self::guarded('eventProperties', static function (Event $event) use ($hook, $call): void { + $raw = self::invoke($hook, $call, 'eventProperties callback error: '); + if (!Js::truthy($raw)) { + return; + } + $event->properties = $raw === [] ? new \stdClass() : $raw; // TS keeps {} on tool calls + }); + } + + return $pending; + } + + /** + * Runs a step; a throw is logged as `Warning: