diff --git a/.gitattributes b/.gitattributes index 6d52733b0..f5a93d3fd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ +# Normalise text to LF in the repository and on checkout, so a Windows clone +# does not report every file as modified after a formatter run. +* text=auto eol=lf + /.github export-ignore /assets export-ignore /bin export-ignore @@ -9,4 +13,17 @@ /rector.php export-ignore /Taskfile.yml export-ignore /testbench.yaml export-ignore -/whisky.json export-ignore +/dev export-ignore +/composer-require-checker.json export-ignore + +# /docs itself SHIPS — the docs site renders markdown and the VitePress +# sidebar out of vendor/particle-academy/prism/docs, and installs with +# --prefer-dist, so export-ignoring the directory would take that site down. +# Only the parts nothing reads at runtime are excluded: the 9.6M committed +# build output, and the dev lockfile that makes dependency scanners flag +# consumers for advisories in our docs toolchain. +/docs/.vitepress/dist export-ignore +/docs/.vitepress/cache export-ignore +/docs/node_modules export-ignore +/docs/package.json export-ignore +/docs/package-lock.json export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..2d9819e2d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Every path in this repository is owned by the project owner. +# +# Paired with the "main protection" branch ruleset, which requires a pull +# request carrying an approving review from a code owner before anything can +# land on the default branch. Outside contributions therefore cannot merge +# without an explicit human review, and agents cannot approve on the owner's +# behalf. +# +# Repository admins bypass the ruleset, so day-to-day direct pushes by the +# owner are unaffected. + +* @wishborn diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 000000000..9a5fb6158 --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,50 @@ +# Prism release preflight + +Complete this checklist on the release candidate **before pushing a tag**. +Packagist publishes from the tag push. The Publish workflow can withhold the +GitHub release and flag a bad tag; it cannot prevent or undo that publication. + +- [ ] Fetch current Prism and prism-parity main branches and Prism tags. Record + the full candidate SHA. Do not overwrite another contributor's working files. +- [ ] Re-read every Prism claim against the candidate code, including unreleased + fixes. Use the shared checker's `--map` output as an inventory, and check nearby + behavior and numeric statements too: existence checks do not verify prose. + Fix drift and record what changed before reconciling. Commit the reviewed + Prism changes so the candidate checkout is clean. +- [ ] If the Prism entry is stale, run the manual command below with the full + SHA that was reviewed. Inspect and commit only the Prism changes in the shared + lock, describe the reviewed SHA in that commit, and push prism-parity. +- [ ] Run all four local gates: Pest, PHPStan, Pint plus Rector, and + composer-require-checker. Run the factcheck guard tests and the strict checker. +- [ ] Confirm successful Tests, PHPStan, Formatting, Require Checker and + **strict Factcheck** CI runs on the exact candidate SHA. A successful run on a + previous commit or a PR merge commit is insufficient. After reconciling the + shared lock, rerun Factcheck on the candidate using workflow_dispatch. +- [ ] Complete the prerelease security audit and obtain release approval. + Only the approved candidate may be tagged. Any candidate change repeats the + review and CI steps. + +From the Prism repository root, with prism-parity checked out beside it: + +```sh +node ../prism-parity/tools/factcheck.mjs --repo . --map +node .github/scripts/factcheck.mjs reconcile ../prism-parity FULL_REVIEWED_SHA +node --test .github/scripts/factcheck.test.mjs .github/scripts/release-factcheck.test.mjs +node .github/scripts/factcheck.mjs check ../prism-parity +``` + +The manual helper requires the supplied SHA to equal HEAD and a clean Prism +checkout. The shared checker's raw `--reconcile` replaces the whole lock even +with `--repo`; do not use it directly for a Prism-only review. The helper runs +that operation in a temporary copy and merges only the Prism repo and package +entries back into `prism-parity/tools/factcheck.lock.json`. Sibling evidence and +the global reconciliation timestamp remain unchanged. Review the diff and stage +that file explicitly, never unrelated work in prism-parity. CI never reconciles. + +The lock uses the nearest reachable tag, not a content digest. A new tag makes +the lock stale even when the code is identical: the first push after each +release will fail strict until its claims are reviewed and reconciled. The +failure prints the lock path, recorded and current versions, and the manual +command. Changes on the same tag are not detected by this currency heuristic; +the pre-tag review remains necessary. A shared content-based attestation is +separate work, not implemented by these guards. diff --git a/.github/scripts/factcheck.mjs b/.github/scripts/factcheck.mjs new file mode 100644 index 000000000..bf5c590f5 --- /dev/null +++ b/.github/scripts/factcheck.mjs @@ -0,0 +1,86 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; + +const [mode, parityArg, reviewedSha] = process.argv.slice(2); +const repo = process.cwd(); +const parity = resolve(parityArg ?? '.parity'); +const lockPath = join(parity, 'tools/factcheck.lock.json'); +const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim(); +const read = path => JSON.parse(readFileSync(path, 'utf8')); +const run = (root, ...args) => { + const result = spawnSync(process.execPath, [join(root, 'tools/factcheck.mjs'), '--repo', repo, ...args], { stdio: 'inherit' }); + if (result.error) throw result.error; + return result.status ?? 1; +}; + +/** + * Manual only: the shared --reconcile is not scope-safe, even with --repo. + * Run it in a temporary copy, then merge only repos.prism and + * packages['particle-academy/prism']; never replace any sibling's evidence. + * A reviewed SHA is an explicit operator assertion, not proof of a review. + */ +function reconcile() { + if (!reviewedSha || !/^[a-f0-9]{40}$/.test(reviewedSha) || reviewedSha !== git('rev-parse', 'HEAD')) { + throw new Error('Supply the full reviewed SHA, equal to HEAD, after re-reading every claim.'); + } + if (git('status', '--porcelain', '--untracked-files=normal')) { + throw new Error('Reconciliation requires a clean Prism checkout; commit the reviewed changes first.'); + } + const before = readFileSync(lockPath, 'utf8'); + const original = JSON.parse(before); + if (original.contract !== '1.0' || !original.repos?.prism || !original.packages?.['particle-academy/prism']) { + throw new Error('Expected the existing contract 1.0 Prism entries; refusing to replace an unknown lock.'); + } + const temp = mkdtempSync(join(tmpdir(), 'prism-reconcile-')); + try { + mkdirSync(join(temp, 'tools')); + cpSync(join(parity, 'tools/factcheck.mjs'), join(temp, 'tools/factcheck.mjs')); + cpSync(join(parity, 'docs/decisions'), join(temp, 'docs/decisions'), { recursive: true }); + writeFileSync(join(temp, 'tools/factcheck.lock.json'), before); + if (run(temp, '--reconcile') !== 0) throw new Error('Reconciliation refused; the shared lock is unchanged.'); + const updated = read(join(temp, 'tools/factcheck.lock.json')); + if (updated.packages?.['particle-academy/prism']?.published !== true) { + throw new Error('Could not confirm Prism publication; the shared lock is unchanged. Retry with network access.'); + } + original.repos.prism = updated.repos.prism; + original.packages['particle-academy/prism'] = updated.packages['particle-academy/prism']; + // Validate the actual merged result, including preserved sibling publication + // evidence used by Prism's companion-package install documentation. + writeFileSync(join(temp, 'tools/factcheck.lock.json'), JSON.stringify(original, null, 2) + '\n'); + if (run(temp, '--strict') !== 0) throw new Error('Reconciled claims still fail; the shared lock is unchanged.'); + if (readFileSync(lockPath, 'utf8') !== before || git('rev-parse', 'HEAD') !== reviewedSha || git('status', '--porcelain', '--untracked-files=normal')) { + throw new Error('The lock or reviewed checkout changed during reconciliation. Re-read and retry.'); + } + writeFileSync(lockPath, JSON.stringify(original, null, 2) + '\n'); + console.log(`Reconciled only Prism at reviewed SHA ${reviewedSha} into ${lockPath}.`); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +try { + if (basename(repo) !== 'prism' || read(join(repo, 'composer.json')).name !== 'particle-academy/prism') { + throw new Error('Run this command from the prism repository root.'); + } + if (mode === 'reconcile') { + reconcile(); + } else if (mode === 'check') { + const status = run(parity, '--strict'); + if (status !== 0) { + console.error(`\nVerification lock: ${lockPath}`); + console.error(`Recorded Prism version: ${read(lockPath).repos?.prism?.version ?? 'unrecorded'}`); + console.error(`Current Prism version: ${git('describe', '--tags', '--abbrev=0')}`); + console.error('Fix findings and re-read the claims against current code BEFORE reconciling. From a clean Prism checkout with prism-parity beside it, run:'); + console.error(`node .github/scripts/factcheck.mjs reconcile ../prism-parity ${git('rev-parse', 'HEAD')}`); + console.error('Review and commit only the Prism lock entry in prism-parity, push it, then rerun Factcheck. See .github/RELEASING.md. CI never reconciles.'); + } + process.exitCode = status; + } else { + throw new Error('Usage: node .github/scripts/factcheck.mjs check | reconcile '); + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/.github/scripts/factcheck.test.mjs b/.github/scripts/factcheck.test.mjs new file mode 100644 index 000000000..655d13bda --- /dev/null +++ b/.github/scripts/factcheck.test.mjs @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { execFileSync, spawnSync } from 'node:child_process'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const source = existsSync(join(root, '.parity/tools/factcheck.mjs')) + ? join(root, '.parity') : resolve(root, '../prism-parity'); + +function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), 'prism-factcheck-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const repo = join(dir, 'prism'); + const parity = join(dir, 'prism-parity'); + mkdirSync(join(repo, 'src'), { recursive: true }); + mkdirSync(join(parity, 'tools'), { recursive: true }); + cpSync(join(source, 'tools/factcheck.mjs'), join(parity, 'tools/factcheck.mjs')); + cpSync(join(source, 'docs/decisions'), join(parity, 'docs/decisions'), { recursive: true }); + writeFileSync(join(repo, 'composer.json'), JSON.stringify({ + name: 'particle-academy/prism', autoload: { 'psr-4': { 'Prism\\Prism\\': 'src/' } }, + })); + writeFileSync(join(repo, 'src/Example.php'), ' execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('init', '-q'); + git('add', '.'); + git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture'); + git('tag', 'v0.130.0'); + const sha = git('rev-parse', 'HEAD'); + const lock = join(parity, 'tools/factcheck.lock.json'); + const initial = { + contract: '1.0', reconciledAt: 'original timestamp', note: 'preserve this', + packages: { 'particle-academy/prism': { published: true }, 'particle-academy/sibling': { published: false, sentinel: 1 } }, + repos: { prism: { version: 'v0.121.0', census: { 'php-class': 1 } }, sibling: { version: 'v9.9.9', census: { 'local-link': 42 } } }, + }; + writeFileSync(lock, JSON.stringify(initial, null, 2) + '\n'); + // Only the test process tree stubs publication lookup; no network is needed. + const preload = join(dir, 'fetch.mjs'); + writeFileSync(preload, 'globalThis.fetch = async () => ({status: 200});'); + const run = (mode, reviewed = sha) => spawnSync(process.execPath, + [join(root, '.github/scripts/factcheck.mjs'), mode, parity, ...(mode === 'reconcile' ? [reviewed] : [])], + { cwd: repo, encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` } }); + return { repo, parity, sha, lock, initial, run, git, preload }; +} + +test('stale claims fail with versions, lock path and a manual command; checking never writes', t => { + const f = fixture(t); + const before = readFileSync(f.lock, 'utf8'); + const result = f.run('check'); + assert.equal(result.status, 1); + const output = result.stdout + result.stderr; + for (const value of ['v0.121.0', 'v0.130.0', 'factcheck.lock.json', 'reconcile', f.sha]) assert.ok(output.includes(value), output); + assert.equal(readFileSync(f.lock, 'utf8'), before); +}); + +test('reviewed reconciliation preserves every sibling entry and unrelated lock field', t => { + const f = fixture(t); + const result = f.run('reconcile'); + assert.equal(result.status, 0, result.stdout + result.stderr); + const after = JSON.parse(readFileSync(f.lock, 'utf8')); + assert.equal(after.repos.prism.version, 'v0.130.0'); + assert.deepEqual(after.repos.prism.census, { 'php-class': 1 }); + assert.equal(after.packages['particle-academy/prism'].published, true); + after.repos.prism = f.initial.repos.prism; + after.packages['particle-academy/prism'] = f.initial.packages['particle-academy/prism']; + assert.deepEqual(after, f.initial); + const checked = f.run('check'); + assert.equal(checked.status, 0, checked.stdout + checked.stderr); +}); + +test('a wrong or absent reviewed SHA cannot change the lock', t => { + const f = fixture(t); + const before = readFileSync(f.lock, 'utf8'); + for (const reviewed of ['', '0'.repeat(40)]) { + const result = f.run('reconcile', reviewed); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /reviewed.*SHA/i); + assert.equal(readFileSync(f.lock, 'utf8'), before); + } +}); + +test('uncommitted claim edits cannot be attested by a committed SHA', t => { + const f = fixture(t); + const before = readFileSync(f.lock, 'utf8'); + writeFileSync(join(f.repo, 'README.md'), 'Edited after review'); + const result = f.run('reconcile'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /clean|uncommitted/i); + assert.equal(readFileSync(f.lock, 'utf8'), before); +}); + +test('a broken documented class refuses reconciliation and preserves the entire lock', t => { + const f = fixture(t); + writeFileSync(join(f.repo, 'README.md'), '```php\nuse Prism\\Prism\\Missing;\n```\n'); + f.git('add', 'README.md'); + f.git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'broken claim'); + const before = readFileSync(f.lock, 'utf8'); + const result = f.run('reconcile', f.git('rev-parse', 'HEAD')); + assert.notEqual(result.status, 0); + assert.match(result.stdout + result.stderr, /unresolved finding|No such class/); + assert.equal(readFileSync(f.lock, 'utf8'), before); +}); + +test('an unavailable publication lookup cannot overwrite verified publication data', t => { + const f = fixture(t); + writeFileSync(f.preload, 'globalThis.fetch = async () => { throw new Error("offline"); };'); + const before = readFileSync(f.lock, 'utf8'); + const result = f.run('reconcile'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /publication/i); + assert.equal(readFileSync(f.lock, 'utf8'), before); +}); + +test('a concurrent shared-lock edit is preserved instead of overwritten', t => { + const f = fixture(t); + const concurrent = JSON.stringify({ ...f.initial, note: 'another review landed' }, null, 2) + '\n'; + writeFileSync(f.preload, `import {writeFileSync} from 'node:fs'; globalThis.fetch = async () => { + writeFileSync(${JSON.stringify(f.lock)}, ${JSON.stringify(concurrent)}); return {status: 200}; + };`); + const result = f.run('reconcile'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /changed during reconciliation/); + assert.equal(readFileSync(f.lock, 'utf8'), concurrent); +}); + +test('post-reconcile verification uses the preserved sibling publication evidence', t => { + const f = fixture(t); + f.initial.packages['particle-academy/sibling'].published = true; + writeFileSync(f.lock, JSON.stringify(f.initial)); + writeFileSync(join(f.repo, 'README.md'), '```sh\ncomposer require particle-academy/sibling\n```\n'); + f.git('add', 'README.md'); + f.git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'sibling install'); + const result = f.run('reconcile', f.git('rev-parse', 'HEAD')); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.doesNotMatch(result.stdout + result.stderr, /Not in the published census/); +}); diff --git a/.github/scripts/release-factcheck.mjs b/.github/scripts/release-factcheck.mjs new file mode 100644 index 000000000..2cef35816 --- /dev/null +++ b/.github/scripts/release-factcheck.mjs @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +export function requireFactcheck(runs, sha) { + // PR workflows can execute a synthetic merge commit instead of head_sha. + const found = runs.some(run => run.head_sha === sha && run.status === 'completed' + && run.conclusion === 'success' && ['push', 'workflow_dispatch', 'schedule'].includes(run.event)); + if (!found) throw new Error(`No successful strict Factcheck run for ${sha}. Refusing to release.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const { GITHUB_REPOSITORY: repo, GITHUB_SHA: sha } = process.env; + if (!repo || !/^[a-f0-9]{40}$/.test(sha ?? '')) throw new Error('GITHUB_REPOSITORY and full GITHUB_SHA are required.'); + const pages = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp', + `repos/${repo}/actions/workflows/factcheck.yml/runs?head_sha=${sha}&per_page=100`, + ], { encoding: 'utf8' })); + requireFactcheck(pages.flatMap(page => page.workflow_runs), sha); + console.log(`Strict Factcheck succeeded for ${sha}.`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/.github/scripts/release-factcheck.test.mjs b/.github/scripts/release-factcheck.test.mjs new file mode 100644 index 000000000..b3be9a6f7 --- /dev/null +++ b/.github/scripts/release-factcheck.test.mjs @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { requireFactcheck } from './release-factcheck.mjs'; + +const sha = 'a'.repeat(40); +const success = { head_sha: sha, status: 'completed', conclusion: 'success', event: 'push' }; + +test('only completed successful checks on the exact commit qualify', () => { + assert.doesNotThrow(() => requireFactcheck([success], sha)); + for (const runs of [[], [{ ...success, head_sha: 'b'.repeat(40) }], [{ ...success, status: 'in_progress' }], [{ ...success, conclusion: 'failure' }], [{ ...success, conclusion: 'skipped' }], [{ ...success, event: 'pull_request' }]]) { + assert.throws(() => requireFactcheck(runs, sha), /No successful strict Factcheck/); + } +}); + +test('manual and nightly runs can prove the exact candidate, regardless of unrelated runs', () => { + for (const event of ['workflow_dispatch', 'schedule']) { + assert.doesNotThrow(() => requireFactcheck([{ ...success, head_sha: 'b'.repeat(40) }, { ...success, event }], sha)); + } +}); diff --git a/.github/workflows/factcheck.yml b/.github/workflows/factcheck.yml new file mode 100644 index 000000000..11e4f8d84 --- /dev/null +++ b/.github/workflows/factcheck.yml @@ -0,0 +1,57 @@ +name: Factcheck + +# Verifies that this repository's PROSE agrees with its CODE — that a class an +# example imports exists, an artisan command it names is declared, an install +# line resolves, a cited decision is real, a link points at something. +# +# The checker lives in prism-parity and is checked out here as `.parity`, the +# same way the conformance loaders already arrive. It is maintained in ONE +# place: see prism-parity/docs/decisions/0019-checking-the-prose.md. + +on: + push: + branches: [main, "*.x"] + pull_request: + workflow_dispatch: + # Claims rot without anything here changing — a sibling renames a class, a + # package gets published. The nightly notices. + schedule: + - cron: "40 7 * * *" + +permissions: + contents: read + +jobs: + factcheck: + name: Claims + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + # Tags, not a shallow clone: the staleness lockfile records versions as + # git tags, and without them every repo reads as "unknown" and the + # currency check compares nothing to nothing and passes. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Checkout prism-parity + uses: actions/checkout@v4 + with: + repository: Particle-Academy/prism-parity + path: .parity + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Test factcheck and release guards + run: node --test .github/scripts/factcheck.test.mjs .github/scripts/release-factcheck.test.mjs + + # Every event checks Prism with --strict. A fresh tag makes the shared + # version lock stale; the next push must re-read and reconcile it. + # Reconciliation is a manual review step, never a CI mutation. + - name: Check claims and currency (strict) + run: node .github/scripts/factcheck.mjs check .parity diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml index 3a62eaf87..93a59338a 100644 --- a/.github/workflows/formatting.yml +++ b/.github/workflows/formatting.yml @@ -6,6 +6,8 @@ on: - main - "*.x" pull_request: + # Lets the gates be run again on the same SHA, without a commit. + workflow_dispatch: permissions: contents: read @@ -21,6 +23,82 @@ jobs: steps: - uses: actions/checkout@v4 + # BEFORE the install. `ramsey/composer-install` resolves and installs the + # whole dependency tree, which is an install performed by an ACTION — a thing + # neither this estate's audit nor the org's could see, because both asked + # whether a `run:` string contained an install command. 27 jobs passed both + # audits green on that question. + - name: Fetch the third-party allowlist + uses: actions/checkout@v4 + with: + repository: Particle-Academy/.github + ref: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + path: .third-party-allowlist + persist-credentials: false + + - name: Verify the pinned checkout + # Generated by pin-github-checks.mjs -- edit the pin with that tool, not by + # hand. The ref above is a commit SHA, and every file the fetched code reads + # is hashed here, so a push to the source repo cannot change what runs in + # this workflow until someone reviews it and re-pins. It runs from the + # workspace with nothing preloaded, so neither the job's working-directory + # nor a variable an earlier step left behind decides what it reads or runs. + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_OPTIONS: '' + NODE_PATH: '' + BASH_ENV: '' + ENV: '' + SHELLOPTS: '' + BASHOPTS: '' + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + DYLD_INSERT_LIBRARIES: '' + DYLD_LIBRARY_PATH: '' + PIN_ROOT: .third-party-allowlist + PIN_DIR: .third-party-allowlist/third-party + PIN_SHA: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + PIN_SHA256: | + 4b94acfac87136b0942123b9f227919d7e2e3d67863bcdceba32c9a73f765db0 check.mjs + 1547306d567749d7410040cae2bfb9a648902de95c3378f6c5af54245c896e11 gate-graph.mjs + b9423fcbc59c06b38862841f7111817cf586a3f5e7bcaf34349f26f7515b782d allowlist.json + run: | + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const env = process.env; + const fail = []; + const head = execFileSync("git", ["-C", env.PIN_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (head !== env.PIN_SHA) fail.push("checkout is at " + head + ", pinned " + env.PIN_SHA); + const want = env.PIN_SHA256.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean); + if (!want.length) fail.push("no file hashes are pinned"); + for (const entry of want) { + const [hash, ...rest] = entry.split(" ").filter(Boolean); + const file = rest.join(" "); + let got = "missing"; + try { + const text = fs.readFileSync(env.PIN_DIR + "/" + file, "latin1"); + const lf = text.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); + got = crypto.createHash("sha256").update(lf, "latin1").digest("hex"); + } catch {} + if (got !== hash) fail.push(file + " sha256 is " + got + ", pinned " + hash); + } + for (const f of fail) console.log("::error::Pinned checkout mismatch: " + f); + if (fail.length) { + console.log("::error::Refusing to run fetched code that does not match its pin. Review the upstream change, then re-pin with pin-github-checks.mjs."); + process.exit(1); + } + console.log("Pinned checkout verified: " + head + ", " + want.length + " files match."); + ' + + - name: Third-party dependency allowlist + run: | + node .third-party-allowlist/third-party/check.mjs --repo . + node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 9e9708a7b..fbb1c746e 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -6,6 +6,8 @@ on: - main - "*.x" pull_request: + # Lets the gates be run again on the same SHA, without a commit. + workflow_dispatch: permissions: contents: read @@ -21,6 +23,82 @@ jobs: steps: - uses: actions/checkout@v4 + # BEFORE the install. `ramsey/composer-install` resolves and installs the + # whole dependency tree, which is an install performed by an ACTION — a thing + # neither this estate's audit nor the org's could see, because both asked + # whether a `run:` string contained an install command. 27 jobs passed both + # audits green on that question. + - name: Fetch the third-party allowlist + uses: actions/checkout@v4 + with: + repository: Particle-Academy/.github + ref: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + path: .third-party-allowlist + persist-credentials: false + + - name: Verify the pinned checkout + # Generated by pin-github-checks.mjs -- edit the pin with that tool, not by + # hand. The ref above is a commit SHA, and every file the fetched code reads + # is hashed here, so a push to the source repo cannot change what runs in + # this workflow until someone reviews it and re-pins. It runs from the + # workspace with nothing preloaded, so neither the job's working-directory + # nor a variable an earlier step left behind decides what it reads or runs. + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_OPTIONS: '' + NODE_PATH: '' + BASH_ENV: '' + ENV: '' + SHELLOPTS: '' + BASHOPTS: '' + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + DYLD_INSERT_LIBRARIES: '' + DYLD_LIBRARY_PATH: '' + PIN_ROOT: .third-party-allowlist + PIN_DIR: .third-party-allowlist/third-party + PIN_SHA: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + PIN_SHA256: | + 4b94acfac87136b0942123b9f227919d7e2e3d67863bcdceba32c9a73f765db0 check.mjs + 1547306d567749d7410040cae2bfb9a648902de95c3378f6c5af54245c896e11 gate-graph.mjs + b9423fcbc59c06b38862841f7111817cf586a3f5e7bcaf34349f26f7515b782d allowlist.json + run: | + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const env = process.env; + const fail = []; + const head = execFileSync("git", ["-C", env.PIN_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (head !== env.PIN_SHA) fail.push("checkout is at " + head + ", pinned " + env.PIN_SHA); + const want = env.PIN_SHA256.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean); + if (!want.length) fail.push("no file hashes are pinned"); + for (const entry of want) { + const [hash, ...rest] = entry.split(" ").filter(Boolean); + const file = rest.join(" "); + let got = "missing"; + try { + const text = fs.readFileSync(env.PIN_DIR + "/" + file, "latin1"); + const lf = text.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); + got = crypto.createHash("sha256").update(lf, "latin1").digest("hex"); + } catch {} + if (got !== hash) fail.push(file + " sha256 is " + got + ", pinned " + hash); + } + for (const f of fail) console.log("::error::Pinned checkout mismatch: " + f); + if (fail.length) { + console.log("::error::Refusing to run fetched code that does not match its pin. Review the upstream change, then re-pin with pin-github-checks.mjs."); + process.exit(1); + } + console.log("Pinned checkout verified: " + head + ", " + want.length + " files match."); + ' + + - name: Third-party dependency allowlist + run: | + node .third-party-allowlist/third-party/check.mjs --repo . + node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..a6f48fbdb --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,304 @@ +name: Publish + +# Named `publish.yml`, the name every package in the estate publishes from. +# Packagist does not bind to a workflow filename the way npm's and PyPI's trusted +# publishers do, so nothing breaks here if it changes. Keep it anyway: one name +# means `gh run list --workflow=publish.yml` finds the publish run in any repo. + +# A release is a tag push, and nothing else. No manual dispatch: a release that +# can be cut from a branch is a release no commit claims. +# +# There is no upload step here, and that is not an omission. Composer resolves +# versions from git tags and Packagist mirrors them over a webhook, so pushing +# the tag IS the publish. What this workflow adds is the part that has actually +# gone wrong in this ecosystem: establishing that the tag was tested, and that +# the package really became installable afterwards. +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + guard: + name: Guard the tag + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Run again when the tag lands, not only when the commit did: a dependency + # that has since gone unmaintained, or an approval since withdrawn, fails + # here. That withholds the GitHub release and flags the tag. It does NOT + # stop the publish -- Packagist already has the version once the tag is + # pushed -- so the check that matters is the one in tests.yml, before tagging. + - name: Fetch the third-party allowlist + uses: actions/checkout@v4 + with: + repository: Particle-Academy/.github + ref: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + path: .third-party-allowlist + persist-credentials: false + + - name: Verify the pinned checkout + # Generated by pin-github-checks.mjs -- edit the pin with that tool, not by + # hand. The ref above is a commit SHA, and every file the fetched code reads + # is hashed here, so a push to the source repo cannot change what runs in + # this workflow until someone reviews it and re-pins. It runs from the + # workspace with nothing preloaded, so neither the job's working-directory + # nor a variable an earlier step left behind decides what it reads or runs. + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_OPTIONS: '' + NODE_PATH: '' + BASH_ENV: '' + ENV: '' + SHELLOPTS: '' + BASHOPTS: '' + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + DYLD_INSERT_LIBRARIES: '' + DYLD_LIBRARY_PATH: '' + PIN_ROOT: .third-party-allowlist + PIN_DIR: .third-party-allowlist/third-party + PIN_SHA: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + PIN_SHA256: | + 4b94acfac87136b0942123b9f227919d7e2e3d67863bcdceba32c9a73f765db0 check.mjs + 1547306d567749d7410040cae2bfb9a648902de95c3378f6c5af54245c896e11 gate-graph.mjs + b9423fcbc59c06b38862841f7111817cf586a3f5e7bcaf34349f26f7515b782d allowlist.json + run: | + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const env = process.env; + const fail = []; + const head = execFileSync("git", ["-C", env.PIN_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (head !== env.PIN_SHA) fail.push("checkout is at " + head + ", pinned " + env.PIN_SHA); + const want = env.PIN_SHA256.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean); + if (!want.length) fail.push("no file hashes are pinned"); + for (const entry of want) { + const [hash, ...rest] = entry.split(" ").filter(Boolean); + const file = rest.join(" "); + let got = "missing"; + try { + const text = fs.readFileSync(env.PIN_DIR + "/" + file, "latin1"); + const lf = text.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); + got = crypto.createHash("sha256").update(lf, "latin1").digest("hex"); + } catch {} + if (got !== hash) fail.push(file + " sha256 is " + got + ", pinned " + hash); + } + for (const f of fail) console.log("::error::Pinned checkout mismatch: " + f); + if (fail.length) { + console.log("::error::Refusing to run fetched code that does not match its pin. Review the upstream change, then re-pin with pin-github-checks.mjs."); + process.exit(1); + } + console.log("Pinned checkout verified: " + head + ", " + want.length + " files match."); + ' + + - name: Third-party dependency allowlist + run: | + node .third-party-allowlist/third-party/check.mjs --repo . + node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})" + + # Two separate questions, because they fail differently. + # + # "Did anything fail?" misses the case that matters most here: a package + # whose tests never ran at all reports nothing failed. prism-opentelemetry + # shipped v0.1.1 in exactly that state -- 32 tests on disk, no workflow + # invoking them, CI green. + - name: Tests must have SUCCEEDED for this exact commit + env: + GH_TOKEN: ${{ github.token }} + run: | + count="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/tests.yml/runs?head_sha=${GITHUB_SHA}&status=success" --jq '.total_count')" + echo "Successful Tests runs for ${GITHUB_SHA}: ${count}" + if [ "${count}" -lt 1 ]; then + echo "::error::No successful Tests run for ${GITHUB_SHA}. Refusing to release." + exit 1 + fi + + # Defence in depth only: Packagist already mirrors the pushed tag. + # This withholds the GitHub release, not publication. The pre-tag + # checklist in .github/RELEASING.md is the preventive control. + - name: Strict Factcheck must have SUCCEEDED for this exact commit + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/release-factcheck.mjs + + - name: No other gate may have failed on this commit + env: + GH_TOKEN: ${{ github.token }} + run: | + failed="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${GITHUB_SHA}&status=completed" --jq '[.workflow_runs[] | select(.conclusion == "failure" or .conclusion == "timed_out") | .name] | unique | join(", ")')" + if [ -n "${failed}" ]; then + echo "::error::These workflows failed on ${GITHUB_SHA}: ${failed}" + exit 1 + fi + echo "No failing workflows on ${GITHUB_SHA}." + + # Composer derives the version from the tag, so unlike the Python packages + # there is no declared version to disagree with. A `version` key in + # composer.json would REINTRODUCE that disagreement, which is why Composer + # advises against it -- so it is refused here rather than trusted. + - name: composer.json must not pin a version + run: | + composer validate --no-check-publish --no-check-all + if grep -Eq '^[[:space:]]*"version"[[:space:]]*:' composer.json; then + echo "::error::composer.json declares a version. Composer takes the version from the tag; remove the key." + exit 1 + fi + + # The annotation IS the changelog, so the one question a consumer most + # needs answered has to be in it. It usually was not: of the twenty most + # recent annotations across ten of these repositories, EIGHTEEN never used + # the word "breaking" at all -- including a prism-harness release that + # shipped a mandatory migration, a changed scope-matching rule and a + # raised framework floor. Nothing was concealing those. Nothing was asking. + # + # So this asks, and like the lightweight-tag refusal in the release job it + # treats silence as invalid rather than as "no". An annotation must declare + # its breaking status either way; see tools/check-release-notes.sh for the + # two accepted forms and why prose mentioning "breaking" is not one. + # + # It reads the annotation from the API for the same reason that job does: + # actions/checkout creates a LIGHTWEIGHT local tag, so reading the message + # locally yields the commit's instead, plausibly and silently. + # + # What this can and cannot do: it WITHHOLDS the GitHub release, and it + # cannot unpublish from Packagist, which has the version the moment the tag + # is pushed. Run tools/check-release-notes.sh on the message BEFORE tagging + # -- that is the only point at which refusing still prevents something. + - name: Release notes must declare their breaking status + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG_TYPE="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" --jq '.object.type')" + + if [ "${TAG_TYPE}" != "tag" ]; then + echo "::error::${GITHUB_REF_NAME} is a lightweight tag, so it carries no notes. Tag with 'git tag -a' -- the message IS the release notes." + exit 1 + fi + + TAG_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" --jq '.object.sha')" + gh api "repos/${GITHUB_REPOSITORY}/git/tags/${TAG_SHA}" --jq '.message' > "${RUNNER_TEMP}/release-notes.md" + + sh tools/check-release-notes.sh "${RUNNER_TEMP}/release-notes.md" + + release: + name: Create the GitHub release + needs: guard + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # `--notes-from-tag`, never `--generate-notes`. + # + # These packages ship no CHANGELOG file, so the ANNOTATED TAG is the + # changelog entry -- and `--generate-notes` silently discards it and + # substitutes a list of commit subjects plus a compare link. That is not a + # smaller version of the notes; it is different content written by nobody. + # + # It has already cost us once: prism-harness v0.3.0 was tagged with 1974 + # characters explaining a security fix that invalidates existing pins, and + # the release published 94 characters of autogenerated link. + # + # A consequence worth knowing rather than discovering: this REQUIRES an + # annotated tag. `git tag v1.2.3` with no message fails the step. That is + # deliberate -- a release with nothing to say about itself should not be + # quietly publishable. + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} already exists; leaving it alone." + exit 0 + fi + # The annotation is read from the GitHub API, and this is the THIRD + # mechanism tried. The two that failed both did so silently, which is + # the only reason this comment is worth its length: + # + # --generate-notes discarded the annotation and published a list + # of commit subjects. prism-harness v0.3.0 went + # out with 94 characters where 1974 were written. + # --notes-from-tag published the last COMMIT message. `gh` does + # not resolve the annotation in an Actions + # checkout and does not say so. + # git tag -l %(contents) published the last COMMIT message too, for + # the real reason: actions/checkout creates a + # LIGHTWEIGHT local tag, and %(contents) on a + # lightweight tag falls back to the commit. + # + # Every one of those produced something plausible rather than an error, + # which is why each was believed until a human read the release page. + # + # The API holds the real annotation object. Asking it directly also + # lets the lightweight case be DETECTED rather than silently degraded: + # `.object.type` is "tag" for an annotated tag and "commit" otherwise. + TAG_TYPE="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" --jq '.object.type')" + + if [ "${TAG_TYPE}" != "tag" ]; then + echo "::error::${GITHUB_REF_NAME} is a lightweight tag, so it carries no notes. Tag with 'git tag -a' -- the message IS the release notes." + exit 1 + fi + + TAG_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" --jq '.object.sha')" + gh api "repos/${GITHUB_REPOSITORY}/git/tags/${TAG_SHA}" --jq '.message' > /tmp/release-notes.md + + if [ ! -s /tmp/release-notes.md ]; then + echo "::error::${GITHUB_REF_NAME} has an empty annotation." + exit 1 + fi + + gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --notes-file /tmp/release-notes.md --verify-tag + + distribution: + name: Verify it is installable + needs: release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + # The step this ecosystem was missing. A tag is not a release and a + # release is not a distribution: particle-academy/prism-human-plus was + # tagged and had a GitHub release while `composer require` still failed, + # because nobody had submitted it to Packagist -- and nothing anywhere + # noticed. prism-memory and prism-workspace are in that state right now. + # + # Packagist mirrors over a webhook, so this polls rather than checking + # once, and says what to DO when it never appears. + - name: Packagist must serve this version + run: | + pkg="$(php -r 'echo json_decode(file_get_contents("composer.json"), true)["name"];')" + want="${GITHUB_REF_NAME}" + echo "Waiting for ${pkg} ${want} on Packagist." + for attempt in 1 2 3 4 5 6 7 8 9 10; do + body="$(curl -fsS "https://repo.packagist.org/p2/${pkg}.json" 2>/dev/null || true)" + if [ -z "${body}" ]; then + echo " attempt ${attempt}: package not found on Packagist at all." + elif printf '%s' "${body}" | grep -q "\"version\":\"${want}\""; then + echo "${pkg} ${want} is installable." + exit 0 + else + echo " attempt ${attempt}: package present, ${want} not published yet." + fi + sleep 30 + done + echo "::error::${pkg} ${want} never appeared on Packagist. If the package has never been published, submit it at https://packagist.org/packages/submit -- after that Packagist follows tags on its own. If it IS registered, check the GitHub webhook." + exit 1 diff --git a/.github/workflows/require-checker.yml b/.github/workflows/require-checker.yml new file mode 100644 index 000000000..f48cf21ca --- /dev/null +++ b/.github/workflows/require-checker.yml @@ -0,0 +1,113 @@ +name: Require Checker + +on: + push: + branches: + - main + - "*.x" + pull_request: + # Lets the gates be run again on the same SHA, without a commit. + workflow_dispatch: + +permissions: + contents: read + +env: + phpv: 8.2 + +jobs: + require-checker: + name: composer-require-checker + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + # BEFORE the install. `ramsey/composer-install` resolves and installs the + # whole dependency tree, which is an install performed by an ACTION — a thing + # neither this estate's audit nor the org's could see, because both asked + # whether a `run:` string contained an install command. 27 jobs passed both + # audits green on that question. + - name: Fetch the third-party allowlist + uses: actions/checkout@v4 + with: + repository: Particle-Academy/.github + ref: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + path: .third-party-allowlist + persist-credentials: false + + - name: Verify the pinned checkout + # Generated by pin-github-checks.mjs -- edit the pin with that tool, not by + # hand. The ref above is a commit SHA, and every file the fetched code reads + # is hashed here, so a push to the source repo cannot change what runs in + # this workflow until someone reviews it and re-pins. It runs from the + # workspace with nothing preloaded, so neither the job's working-directory + # nor a variable an earlier step left behind decides what it reads or runs. + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_OPTIONS: '' + NODE_PATH: '' + BASH_ENV: '' + ENV: '' + SHELLOPTS: '' + BASHOPTS: '' + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + DYLD_INSERT_LIBRARIES: '' + DYLD_LIBRARY_PATH: '' + PIN_ROOT: .third-party-allowlist + PIN_DIR: .third-party-allowlist/third-party + PIN_SHA: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + PIN_SHA256: | + 4b94acfac87136b0942123b9f227919d7e2e3d67863bcdceba32c9a73f765db0 check.mjs + 1547306d567749d7410040cae2bfb9a648902de95c3378f6c5af54245c896e11 gate-graph.mjs + b9423fcbc59c06b38862841f7111817cf586a3f5e7bcaf34349f26f7515b782d allowlist.json + run: | + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const env = process.env; + const fail = []; + const head = execFileSync("git", ["-C", env.PIN_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (head !== env.PIN_SHA) fail.push("checkout is at " + head + ", pinned " + env.PIN_SHA); + const want = env.PIN_SHA256.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean); + if (!want.length) fail.push("no file hashes are pinned"); + for (const entry of want) { + const [hash, ...rest] = entry.split(" ").filter(Boolean); + const file = rest.join(" "); + let got = "missing"; + try { + const text = fs.readFileSync(env.PIN_DIR + "/" + file, "latin1"); + const lf = text.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); + got = crypto.createHash("sha256").update(lf, "latin1").digest("hex"); + } catch {} + if (got !== hash) fail.push(file + " sha256 is " + got + ", pinned " + hash); + } + for (const f of fail) console.log("::error::Pinned checkout mismatch: " + f); + if (fail.length) { + console.log("::error::Refusing to run fetched code that does not match its pin. Review the upstream change, then re-pin with pin-github-checks.mjs."); + process.exit(1); + } + console.log("Pinned checkout verified: " + head + ", " + want.length + " files match."); + ' + + - name: Third-party dependency allowlist + run: | + node .third-party-allowlist/third-party/check.mjs --repo . + node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})" + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.phpv }} + coverage: none + tools: composer-require-checker + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Check declared dependencies match used symbols + run: composer-require-checker check --config-file=composer-require-checker.json composer.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd87a8548..6c2f0bdca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,8 @@ on: - main - "*.x" pull_request: + # Lets the gates be run again on the same SHA, without a commit. + workflow_dispatch: permissions: contents: read @@ -13,6 +15,7 @@ permissions: jobs: test: runs-on: ${{ matrix.os }} + needs: third-party timeout-minutes: 5 strategy: fail-fast: true @@ -58,5 +61,216 @@ jobs: - name: List Installed Dependencies run: composer show -D + # NOTHING HERE HAD EVER AUDITED A PHP DEPENDENCY. composer.lock is + # gitignored -- correct for a library, since consumers resolve their own -- + # which leaves Dependabot no lock to scan, so it reported zero and the zero + # was never a measurement. `composer audit` found four advisories in the + # production tree the first time it was run, including a Laravel XSS and a + # Flysystem path-normalizer bypass. + # + # Placed INSIDE the matrix on purpose: it then audits the resolution of both + # legs, and the `prefer-lowest` leg is the one that matters. That leg is what + # our declared FLOOR permits, and the floor is what a consumer with old pins + # actually gets -- which is exactly how the vulnerable Laravel range stayed + # legal here while a fresh install looked fine. + - name: Audit dependencies for known advisories + run: composer audit --no-interaction + - name: Execute tests run: vendor/bin/pest --parallel + + third-party: + name: Third-party dependency allowlist + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + # This job only reads files, so it keeps no token in .git/config for + # the fetched checker to find. + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Every direct dependency must be approved on the org allowlist and still + # maintained. The list is fetched, never vendored, so there is no copy here + # to drift. This has to pass BEFORE a tag is pushed: Packagist publishes a + # Composer package the moment its tag exists, so the release guard, which + # requires this workflow to have succeeded, can only withhold the GitHub + # release. It cannot withdraw a version. + - name: Fetch the third-party allowlist + uses: actions/checkout@v4 + with: + repository: Particle-Academy/.github + ref: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + path: .third-party-allowlist + persist-credentials: false + + - name: Verify the pinned checkout + # Generated by pin-github-checks.mjs -- edit the pin with that tool, not by + # hand. The ref above is a commit SHA, and every file the fetched code reads + # is hashed here, so a push to the source repo cannot change what runs in + # this workflow until someone reviews it and re-pins. It runs from the + # workspace with nothing preloaded, so neither the job's working-directory + # nor a variable an earlier step left behind decides what it reads or runs. + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_OPTIONS: '' + NODE_PATH: '' + BASH_ENV: '' + ENV: '' + SHELLOPTS: '' + BASHOPTS: '' + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + DYLD_INSERT_LIBRARIES: '' + DYLD_LIBRARY_PATH: '' + PIN_ROOT: .third-party-allowlist + PIN_DIR: .third-party-allowlist/third-party + PIN_SHA: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + PIN_SHA256: | + 4b94acfac87136b0942123b9f227919d7e2e3d67863bcdceba32c9a73f765db0 check.mjs + 1547306d567749d7410040cae2bfb9a648902de95c3378f6c5af54245c896e11 gate-graph.mjs + b9423fcbc59c06b38862841f7111817cf586a3f5e7bcaf34349f26f7515b782d allowlist.json + run: | + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const env = process.env; + const fail = []; + const head = execFileSync("git", ["-C", env.PIN_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (head !== env.PIN_SHA) fail.push("checkout is at " + head + ", pinned " + env.PIN_SHA); + const want = env.PIN_SHA256.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean); + if (!want.length) fail.push("no file hashes are pinned"); + for (const entry of want) { + const [hash, ...rest] = entry.split(" ").filter(Boolean); + const file = rest.join(" "); + let got = "missing"; + try { + const text = fs.readFileSync(env.PIN_DIR + "/" + file, "latin1"); + const lf = text.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); + got = crypto.createHash("sha256").update(lf, "latin1").digest("hex"); + } catch {} + if (got !== hash) fail.push(file + " sha256 is " + got + ", pinned " + hash); + } + for (const f of fail) console.log("::error::Pinned checkout mismatch: " + f); + if (fail.length) { + console.log("::error::Refusing to run fetched code that does not match its pin. Review the upstream change, then re-pin with pin-github-checks.mjs."); + process.exit(1); + } + console.log("Pinned checkout verified: " + head + ", " + want.length + " files match."); + ' + + - name: Third-party dependency allowlist + run: | + node .third-party-allowlist/third-party/check.mjs --repo . + node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})" + + release-notes: + name: Release-notes check + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # The checker is vendored rather than fetched -- the pre-tag run is the + # only control that can prevent a Composer publish, so it has to work from + # the tree being tagged, offline, without piping a remote script into a + # shell. That buys a copy, and an unchecked copy is one that has already + # drifted without telling anyone. So the copy is diffed against the + # canonical file instead of trusted, the same argument prism-parity makes + # for the corpus copies it generates and then checks. + - name: Fetch the canonical checker + uses: actions/checkout@v4 + with: + repository: Particle-Academy/.github + ref: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + path: .canonical + persist-credentials: false + + - name: Verify the pinned checkout + # Generated by pin-github-checks.mjs -- edit the pin with that tool, not by + # hand. The ref above is a commit SHA, and every file the fetched code reads + # is hashed here, so a push to the source repo cannot change what runs in + # this workflow until someone reviews it and re-pins. It runs from the + # workspace with nothing preloaded, so neither the job's working-directory + # nor a variable an earlier step left behind decides what it reads or runs. + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_OPTIONS: '' + NODE_PATH: '' + BASH_ENV: '' + ENV: '' + SHELLOPTS: '' + BASHOPTS: '' + LD_PRELOAD: '' + LD_LIBRARY_PATH: '' + LD_AUDIT: '' + DYLD_INSERT_LIBRARIES: '' + DYLD_LIBRARY_PATH: '' + PIN_ROOT: .canonical + PIN_DIR: .canonical/third-party + PIN_SHA: b3b5e499c78467bd1e98d80af23a6b4861489ac5 + PIN_SHA256: | + 4b94acfac87136b0942123b9f227919d7e2e3d67863bcdceba32c9a73f765db0 check.mjs + 1547306d567749d7410040cae2bfb9a648902de95c3378f6c5af54245c896e11 gate-graph.mjs + b9423fcbc59c06b38862841f7111817cf586a3f5e7bcaf34349f26f7515b782d allowlist.json + run: | + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const { execFileSync } = require("node:child_process"); + const env = process.env; + const fail = []; + const head = execFileSync("git", ["-C", env.PIN_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (head !== env.PIN_SHA) fail.push("checkout is at " + head + ", pinned " + env.PIN_SHA); + const want = env.PIN_SHA256.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean); + if (!want.length) fail.push("no file hashes are pinned"); + for (const entry of want) { + const [hash, ...rest] = entry.split(" ").filter(Boolean); + const file = rest.join(" "); + let got = "missing"; + try { + const text = fs.readFileSync(env.PIN_DIR + "/" + file, "latin1"); + const lf = text.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); + got = crypto.createHash("sha256").update(lf, "latin1").digest("hex"); + } catch {} + if (got !== hash) fail.push(file + " sha256 is " + got + ", pinned " + hash); + } + for (const f of fail) console.log("::error::Pinned checkout mismatch: " + f); + if (fail.length) { + console.log("::error::Refusing to run fetched code that does not match its pin. Review the upstream change, then re-pin with pin-github-checks.mjs."); + process.exit(1); + } + console.log("Pinned checkout verified: " + head + ", " + want.length + " files match."); + ' + + - name: The vendored checker must match the canonical one + run: | + for pair in check.sh:check-release-notes.sh check.test.sh:check-release-notes.test.sh; do + diff -u ".canonical/release-notes/${pair%%:*}" "tools/${pair##*:}" || { + echo "::error::tools/${pair##*:} has drifted from the pinned canonical repository release-notes/${pair%%:*}. Change the canonical file and re-vendor it; do not patch the copy." + exit 1 + } + done + echo "Both vendored files match the canonical checker." + node -e "require('node:fs').rmSync('.canonical',{recursive:true,force:true})" + + # tools/check-release-notes.sh refuses a tag annotation that does not say + # whether the release breaks anything. It runs in publish.yml's guard, and + # a release manager runs it by hand before tagging -- which is the only + # moment refusing still prevents a Composer publish. + # + # It is tested HERE because a guard nobody tried to defeat is the recurring + # way one of these ends up checking nothing. The suite asserts both + # directions, and its sharpest case is a note that discusses breakage in + # prose without declaring any: a plain `grep -i breaking` passes that, and + # three real annotations in this estate are exactly that shape. + - name: tools/check-release-notes.sh must refuse what it claims to + run: sh tools/check-release-notes.test.sh diff --git a/.gitignore b/.gitignore index c7dcf43d9..80cdb9747 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ phpunit.xml *.code-workspace .vscode ray.php -CLAUDE.md .phpunit.result.cache .ddev/ .claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a238eb020 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# AGENTS.md — particle-academy/prism + +The core package: a unified shuttle to eighteen LLM provider APIs. + +> **Read [the shared guide](https://github.com/Particle-Academy/prism-parity/blob/main/docs/AGENTS.md) +> first.** It carries the boundary, the gates, the decisions that bind every +> repository here, and the review skills. This file is only what is true of +> *this* one. +> +> The model itself — pending requests, the provider contract, message value +> objects, request mapping, response parsing, errors — is documented **once**, in +> [`prism-parity/docs/patterns/`](https://github.com/Particle-Academy/prism-parity/tree/main/docs/patterns). +> Link there. Do not restate it here or in `docs/`. + +## This is a fork, and that changes how you work + +`origin` is `Particle-Academy/prism`. `upstream` is `prism-php/prism`. This is +the actively maintained fork, and a meaningful share of incoming work is +absorbed from upstream rather than authored here. + +**Every upstream absorption goes through `pr-security-review` before it +merges** — not because upstream is hostile, but because an absorbed change +arrives with the reviewer's attention already spent on whether it merges +cleanly. Absorbed PRs have previously shipped large features with tests that +could never actually run. + +```sh +git fetch upstream +refs/pull/N/head:refs/heads/pr-N +git diff main...pr-N # workflows and composer FIRST, then source +``` + +## What must not land here + +**Core is a shuttle, not an agent framework.** The rule and the satellite map +are in the shared guide; what matters at this end is that the pressure to break +it always arrives as a small, reasonable-looking convenience. + +The test is not "is this useful" — it is **"do all eighteen providers now owe +this forever?"** If yes, and it is agentic, it belongs in a satellite. Sessions, +memory, workspaces, MCP brokering, telemetry export and retrieval all already +have homes, and none of those homes is here. + +**Core depends on no satellite.** A `require` pointing outward from this +package is the boundary inverting. + +## The two Tool behaviours that surprise integrators + +Both are load-bearing, both have bitten, and neither is guessable from the +method name. + +**`as()` is an instance method, not a static constructor.** + +```php +(new Tool)->as('search')->for('…')->using(fn () => …); // right +Tool::as('search') // fatal +``` + +**`handle()` catches an exception from your handler and RETURNS a `ToolError`.** +It does not propagate. The signature says so — +`handle(): string|ToolOutput|ToolError` — and an integration that only catches +exceptions will treat that failure object as a successful result and hand it to +a model as data. `prism-mcp` and `prism-harness` both have explicit branches for +it; anything new that invokes tools needs one too. + +## Unsupported means throw + +A provider asked for a capability it does not have **raises**. It never returns +successfully having quietly done less. + +This is not a style preference. A run that completes with a capability silently +dropped is indistinguishable, from the outside, from a model that chose not to +use it — and that ambiguity is precisely what the Perplexity `withTools()` +incident cost days to. Decision +[0011](https://github.com/Particle-Academy/prism-parity/blob/main/docs/decisions/0011-when-silence-is-allowed.md) +records when silence is nonetheless allowed; it is a short list. + +## Changes the ports can see + +`prism-ts` and `prism-py` mirror the text capability, and the shared corpus in +`prism-parity/suites/` is what notices when they stop matching. **PHP is the +reference implementation**: goldens are generated by executing this code, never +hand-authored. + +So a change to request mapping, response parsing, error codes, or value-object +serialisation is not finished when this suite is green. It is finished when the +corpus is updated in the same breath. Otherwise the ports drift, and the only +mechanism that would have caught it is the one that was skipped. + +## Gates + +```sh +composer test # pest --parallel +composer types # phpstan +composer format # pint --parallel, then rector +``` + +CI runs `tests`, `phpstan`, `formatting` and `require-checker` as four separate +jobs. All four, locally, before pushing. + +## Documentation + +`docs/` is the published user-facing site, and `docs/documentation-style-guide.md` +governs it. It is written for people **using** Prism. + +It is not the place for ecosystem rules, decisions, or the shared model — those +live in `prism-parity` and are linked, so that there is one copy to keep true. + +The **human** contribution process — branching, Conventional Commits, issue and +PR etiquette — is [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md), and it +applies to you too. This file does not repeat it. + +## Releasing + +Complete the [release preflight](.github/RELEASING.md), including strict +factcheck CI on the exact candidate SHA, before pushing any tag. + +Run **`prerelease-audit`** over the diff since the previous tag before you cut +one. It is a release gate, not a formality, and it has blocked releases before. + +Sandbox dogfoods dev releases (`-alpha.N`) ahead of stable. Remember that +Composer will not resolve a prerelease from `minimum-stability` alone — the +constraint floor itself has to be a prerelease. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fea33fb01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [`AGENTS.md`](AGENTS.md). It is the agent guide for this repository; this file exists only so harnesses that look for `CLAUDE.md` find it. diff --git a/README.md b/README.md index 0c62ea9ba..a68f64da3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ Prism is a powerful Laravel package for integrating Large Language Models (LLMs) into your applications. It provides a fluent interface for generating text, handling multi-step conversations, and utilizing tools with various AI providers. This way, you can focus on developing outstanding AI applications for your users without getting lost in the technical intricacies. +> **Working on this package?** Read **[`AGENTS.md`](AGENTS.md)** first — the boundary +> this package has to hold, the gates that must be green, and the traps that have +> already caught someone. +> `@link AGENTS.md` + ## Official Documentation Official documentation can be found on the [Prism website](https://prismphp.com). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..aad7675fc --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,114 @@ +# Releasing + +A release is a tag push. `.github/workflows/publish.yml` does the rest. + +There is **no package upload step or package upload token**. Composer resolves versions from git +tags and Packagist mirrors them over a webhook, so pushing the tag *is* the +publish. What the workflow adds is the part that has gone wrong here before: +proving the tag was tested, and proving the package actually became installable. + +## Cutting a release + +Complete the [release preflight](.github/RELEASING.md), including claim review +and successful strict Factcheck CI on the exact candidate SHA, before tagging. +The publish workflow runs after Packagist can see the tag; it cannot prevent +publication of an unchecked package. + +1. Land the work on `main` and wait for all four gates and strict Factcheck to go green. +2. Tag it and push: + + ``` + git tag -a v0.2.0 # annotated: the message BECOMES the release notes + git push origin v0.2.0 + ``` + + **The tag annotation is the changelog.** These packages ship no CHANGELOG + file, so what you write in the tag message is what a consumer reads on the + release page and what arrives in their inbox. The workflow publishes it + verbatim. + + A lightweight tag — `git tag v0.2.0` with no `-a` and no message — FAILS the + release step rather than publishing an empty release. If a version is worth + cutting, it is worth a sentence saying why. + + **Every annotation must declare its breaking status**, and the release + refuses one that does not. Write one of these two lines, whichever is true: + + ``` + BREAKING CHANGE: + ``` + + ``` + No breaking changes. + ``` + + A `## Breaking changes` heading with content under it also satisfies the + check, but **only if you tag with `--cleanup=verbatim` or `-F`**. Under git's + default cleanup, every `#` line in a tag message is treated as a comment and + **deleted** — so `git tag -a -m '## Breaking changes …'` publishes an + annotation with the heading silently missing. The two forms above carry no + `#` to lose, which is why they come first. + + This file already asked for all of this in prose, and asking did not work: of + the twenty most recent annotations across ten of these repositories, EIGHTEEN + never used the word "breaking" at all. One of them shipped a mandatory + migration, a changed scope-matching rule and a raised framework floor, under + headings that described each change accurately and labelled none of them + breaking. A consumer scanning that release page for the word found nothing. + + So check it BEFORE you push the tag — that is the only moment refusing still + prevents a Composer publish: + + ``` + git tag -a v0.2.0 # write the message + sh tools/check-release-notes.sh --tag v0.2.0 # must pass + git push origin v0.2.0 # only then + ``` + + Use `--tag`, not a pipe from `git tag -l --format='%(contents)'`: on a + lightweight tag that format yields the *commit* message instead, so the check + would read text the release will never publish and approve it. `--tag` + refuses that case by name. + + Prose mentioning "breaking" does not satisfy the check — it matches the + structural form, so a note that merely discusses breakage still has to say + which it is. Anyone holding a pinned digest or matching on an error code + learns it here or not at all. + +Composer takes the version from the tag, so there is nothing to bump in +`composer.json` — and a `version` key there is refused, because it reintroduces +exactly the tag-versus-declared disagreement Composer avoids by not having one. + +## What it refuses, and why + +- **No successful `tests.yml` run for that exact commit.** Not "nothing failed" + — *succeeded*. A package whose tests never ran reports nothing failed, which + is how `prism-opentelemetry` shipped v0.1.1 with 32 tests on disk that CI had + never once executed. +- **Any other gate failed on that commit** — PHPStan, Formatting, Require + Checker, Factcheck, whichever this repo has. +- **No successful strict Factcheck run for that exact commit.** A run on an + earlier commit does not satisfy the release guard. +- **An annotation that does not declare its breaking status**, in either + direction. Silence is not read as "nothing breaks" — it is refused, the same + way a lightweight tag is, and for the same reason: the annotation is the only + changelog a consumer gets. A `## Breaking changes` heading with nothing under + it is refused too, and so is an annotation that declares both ways at once. +- **`composer.json` declares a `version`.** +- **Packagist never serves the version.** The release job succeeds and this one + still fails, deliberately: a tag and a GitHub release are not a distribution. + +## First publication + +If the package has never been published, the last job fails no matter how good +the release is, because nothing is listening for the webhook yet. Submit the +package once at ; Packagist follows the +repository's tags on its own afterwards, and re-running that job then passes. + +This is not hypothetical. `prism-human-plus` was tagged, released on GitHub and +uninstallable for days because nobody had submitted it, and nothing anywhere +reported a problem. `prism-memory`, `prism-workspace` and `prism-browser` were +named here as being in that state; all three have since been submitted and +Packagist serves them. Check before believing this paragraph about any package — +`curl -s -o /dev/null -w '%{http_code}' https://repo.packagist.org/p2//.json` +answers it in one line, and a list of names in a document does not. diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 000000000..4996da3d7 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,10 @@ +{ + "symbol-whitelist": [ + "Laravel\\Mcp\\Request", + "Laravel\\Mcp\\Response", + "Laravel\\Mcp\\ResponseFactory", + "Laravel\\Mcp\\Server\\Tool", + "Laravel\\Mcp\\Support\\ValidationMessages", + "PHPUnit\\Framework\\Assert" + ] +} diff --git a/composer.json b/composer.json index 17929474a..ccc482158 100644 --- a/composer.json +++ b/composer.json @@ -1,98 +1,101 @@ { - "name": "prism-php/prism", - "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.", - "type": "library", - "license": "MIT", - "autoload": { - "psr-4": { - "Prism\\Prism\\": "src/" + "name": "particle-academy/prism", + "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications. Actively maintained fork of prism-php/prism.", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Prism\\Prism\\": "src/" + }, + "files": [ + "src/helpers.php" + ] }, - "files": [ - "src/helpers.php" - ] - }, - "authors": [ - { - "name": "TJ Miller", - "email": "hello@echolabs.dev" - } - ], - "require": { - "php": "^8.2", - "ext-fileinfo": "*", - "laravel/framework": "^12.61.1|^13.12.0" - }, - "config": { - "allow-plugins": { - "php-http/discovery": true, - "pestphp/pest-plugin": true, - "phpstan/extension-installer": true - } - }, - "require-dev": { - "pestphp/pest": "^3.0|^4.0", - "brianium/paratest": "^7.8.4", - "laravel/pint": "^1.14", - "phpstan/phpstan": "2.1.34", - "pestphp/pest-plugin-arch": "^3.0|^4.0", - "pestphp/pest-plugin-laravel": "^3.0|^4.0", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan-deprecation-rules": "^2.0", - "rector/rector": "2.3.3", - "projektgopher/whisky": "^0.7.0", - "orchestra/testbench": "^10|^11", - "mockery/mockery": "^1.6", - "symplify/rule-doc-generator-contracts": "^11.2", - "phpstan/phpdoc-parser": "^2.0", - "spatie/laravel-ray": "^1.39", - "laravel/mcp": "^0.6.0" - }, - "autoload-dev": { - "psr-4": { - "Tests\\": "tests/", - "Workbench\\App\\": "workbench/app/", - "Workbench\\Database\\Factories\\": "workbench/database/factories/", - "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" - } - }, - "scripts": { - "post-install-cmd": [ - "whisky update" - ], - "post-update-cmd": [ - "whisky update" - ], - "post-autoload-dump": [ - "@clear", - "@prepare" - ], - "format": [ - "@php vendor/bin/pint --ansi --parallel", - "@php vendor/bin/rector process --no-diffs" - ], - "test": [ - "@php vendor/bin/pest --parallel" - ], - "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", - "prepare": "@php vendor/bin/testbench package:discover --ansi", - "build": "@php vendor/bin/testbench workbench:build --ansi", - "serve": [ - "Composer\\Config::disableProcessTimeout", - "@build", - "@php vendor/bin/testbench serve --ansi" + "authors": [ + { + "name": "TJ Miller", + "email": "hello@echolabs.dev" + } ], - "types": [ - "@php vendor/bin/phpstan analyse --verbose --no-ansi" - ] - }, - "extra": { - "laravel": { - "providers": [ - "Prism\\Prism\\PrismServiceProvider" - ], - "aliases": { - "PrismServer": "Prism\\Prism\\Facades\\PrismServer" - } + "require": { + "php": "^8.2", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "laravel/framework": "^12.69.0|^13.30.0", + "laravel/serializable-closure": "^1.3.4|^2.0", + "psr/http-message": "^1.1|^2.0", + "symfony/console": "^7.2|^8.0", + "symfony/http-foundation": "^7.2|^8.0", + "symfony/polyfill-php83": "^1.32" + }, + "replace": { + "prism-php/prism": "self.version" + }, + "suggest": { + "laravel/mcp": "Required to expose Prism tools through a Laravel MCP server (LaravelMcpTool)." + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "require-dev": { + "pestphp/pest": "^3.0|^4.0", + "brianium/paratest": "^7.8.4", + "laravel/pint": "1.30.4", + "phpstan/phpstan": "2.1.34", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^2.0", + "rector/rector": "2.3.3", + "orchestra/testbench": "^10|^11", + "mockery/mockery": "^1.6", + "laravel/mcp": "^0.6.0|^1.0" + }, + "autoload-dev": { + "psr-4": { + "Prism\\Dev\\": "dev/", + "Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + } + }, + "scripts": { + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "format": [ + "@php vendor/bin/pint --ansi --parallel", + "@php vendor/bin/rector process --no-diffs" + ], + "test": [ + "@php vendor/bin/pest --parallel" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" + ], + "types": [ + "@php vendor/bin/phpstan analyse --verbose --no-ansi" + ] + }, + "extra": { + "laravel": { + "providers": [ + "Prism\\Prism\\PrismServiceProvider" + ], + "aliases": { + "PrismServer": "Prism\\Prism\\Facades\\PrismServer" + } + } } - } } diff --git a/config/prism.php b/config/prism.php index 99f3ea584..51782c227 100644 --- a/config/prism.php +++ b/config/prism.php @@ -7,12 +7,33 @@ 'enabled' => env('PRISM_SERVER_ENABLED', false), ], 'request_timeout' => env('PRISM_REQUEST_TIMEOUT', 30), // The timeout for requests in seconds. + 'telemetry' => [ + // Master switch. When false, telemetry is a complete no-op: no contexts + // are minted and no events are dispatched. + 'enabled' => env('PRISM_TELEMETRY_ENABLED', false), + + // Include prompts, completions, and tool arguments in telemetry payloads. + // Off by default: this data can contain PII. Only enable it where the + // telemetry sink is trusted. + 'capture_content' => env('PRISM_TELEMETRY_CAPTURE_CONTENT', false), + + // Include attachment BYTES (images, documents, audio, video) in captured + // content. Off by default: with it off, a media part keeps its kind, mime + // type and file id, and its bytes are replaced by their size. + 'capture_media' => env('PRISM_TELEMETRY_CAPTURE_MEDIA', false), + + // Bound opt-in stream reconstruction so telemetry cannot turn an + // otherwise streaming response into unbounded process memory. + 'content_max_length' => (int) env('PRISM_TELEMETRY_CONTENT_MAX_LENGTH', 65_536), + 'content_max_items' => (int) env('PRISM_TELEMETRY_CONTENT_MAX_ITEMS', 256), + ], 'providers' => [ 'openai' => [ 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), 'api_key' => env('OPENAI_API_KEY', ''), 'organization' => env('OPENAI_ORGANIZATION', null), 'project' => env('OPENAI_PROJECT', null), + 'api_format' => env('OPENAI_API_FORMAT', 'responses'), ], 'anthropic' => [ 'api_key' => env('ANTHROPIC_API_KEY', ''), @@ -61,9 +82,47 @@ 'x_title' => env('OPENROUTER_SITE_X_TITLE', null), ], ], + 'replicate' => [ + 'api_key' => env('REPLICATE_API_KEY', ''), + 'url' => env('REPLICATE_URL', 'https://api.replicate.com/v1'), + 'webhook_url' => env('REPLICATE_WEBHOOK_URL', null), + 'use_sync_mode' => env('REPLICATE_USE_SYNC_MODE', true), // Use Prefer: wait header + 'polling_interval' => env('REPLICATE_POLLING_INTERVAL', 1000), + 'max_wait_time' => env('REPLICATE_MAX_WAIT_TIME', 60), + // Hosts generated images/audio may be downloaded from (HTTPS, port 443; + // includes subdomains). Downloads omit API credentials and refuse redirects. + 'download_hosts' => ['replicate.delivery'], + ], + 'qwen' => [ + 'api_key' => env('QWEN_API_KEY', ''), + 'url' => env('QWEN_URL', 'https://dashscope-intl.aliyuncs.com/api/v1'), + ], + 'azure' => [ + 'url' => env('AZURE_AI_URL', ''), + 'api_key' => env('AZURE_AI_API_KEY', ''), + 'api_version' => env('AZURE_AI_API_VERSION', '2024-10-21'), + 'deployment_name' => env('AZURE_AI_DEPLOYMENT', ''), + ], + 'requesty' => [ + 'api_key' => env('REQUESTY_API_KEY', ''), + 'url' => env('REQUESTY_URL', 'https://router.requesty.ai/v1'), + 'site' => [ + 'http_referer' => env('REQUESTY_SITE_HTTP_REFERER', null), + 'x_title' => env('REQUESTY_SITE_X_TITLE', null), + ], + ], 'perplexity' => [ 'api_key' => env('PERPLEXITY_API_KEY', ''), 'url' => env('PERPLEXITY_URL', 'https://api.perplexity.ai'), + // Deep research can take several minutes. Per-request client + // options still take precedence. No automatic retries are added. + 'deep_research_timeout' => (int) env('PERPLEXITY_DEEP_RESEARCH_TIMEOUT', 600), + ], + 'vertex' => [ + 'project_id' => env('VERTEX_PROJECT_ID', ''), + 'region' => env('VERTEX_REGION', 'us-central1'), + 'access_token' => env('VERTEX_ACCESS_TOKEN', null), + 'credentials_path' => env('VERTEX_CREDENTIALS_PATH', null), ], 'z' => [ 'url' => env('Z_URL', 'https://api.z.ai/api/paas/v4'), diff --git a/src/Rectors/ReorderMethodsRector.php b/dev/Rectors/ReorderMethodsRector.php similarity index 98% rename from src/Rectors/ReorderMethodsRector.php rename to dev/Rectors/ReorderMethodsRector.php index 717060029..70afe0a5e 100644 --- a/src/Rectors/ReorderMethodsRector.php +++ b/dev/Rectors/ReorderMethodsRector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Prism\Prism\Rectors; +namespace Prism\Dev\Rectors; use PhpParser\Node; use PhpParser\Node\Stmt; diff --git a/dev/generate-embedding-corpus.php b/dev/generate-embedding-corpus.php new file mode 100644 index 000000000..bd5ae663f --- /dev/null +++ b/dev/generate-embedding-corpus.php @@ -0,0 +1,43 @@ + 2 || (isset($argv[1]) && $argv[1] !== '--write')) { + throw new InvalidArgumentException('Usage: php dev/generate-embedding-corpus.php [--write]'); +} + +$fixture = __DIR__.'/../tests/Fixtures/embedding-decoding.json'; +$corpus = json_decode(file_get_contents($fixture), true, flags: JSON_THROW_ON_ERROR); +foreach ($corpus['cases'] as &$case) { + $previous = json_encode($case['expected']['php'], JSON_THROW_ON_ERROR); + try { + $result = ['embedding' => EmbeddingDecoder::decode($case['embedding'])->embedding]; + } catch (PrismException $exception) { + $result = ['exception' => $exception::class]; + } + $case['expected']['php'] = $result; + if (json_encode($result, JSON_THROW_ON_ERROR) !== $previous) { + // PHP cannot establish new port agreement without executing those ports. + $case['agrees'] = false; + } +} +unset($case); + +// The canonical JSON writer uses two-space indentation. Keep the mirror exact. +$json = json_encode($corpus, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); +$json = preg_replace_callback('/^(?: {4})+/m', fn (array $matches): string => str_repeat(' ', intdiv(strlen($matches[0]), 2)), $json)."\n"; +if (($argv[1] ?? null) === '--write') { + if (file_put_contents($fixture, $json) === false) { + throw new RuntimeException('Could not write the embedding corpus.'); + } +} else { + echo $json; +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 464ee2f0a..9d52967a9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -113,6 +113,10 @@ export default defineConfig({ text: "Tool & Function Calling", link: "/core-concepts/tools-function-calling", }, + { + text: "Human-in-the-Loop", + link: "/core-concepts/human-in-the-loop", + }, { text: "Structured Output", link: "/core-concepts/structured-output", @@ -133,6 +137,14 @@ export default defineConfig({ text: "Audio", link: "/core-concepts/audio", }, + { + text: "Batch Processing", + link: "/core-concepts/batch", + }, + { + text: "Files", + link: "/core-concepts/files", + }, { text: "Schemas", link: "/core-concepts/schemas", @@ -183,6 +195,10 @@ export default defineConfig({ text: "DeepSeek", link: "/providers/deepseek", }, + { + text: "Azure", + link: "/providers/azure", + }, { text: "ElevenLabs", link: "/providers/elevenlabs", @@ -211,10 +227,26 @@ export default defineConfig({ text: "OpenRouter", link: "/providers/openrouter", }, + { + text: "Replicate", + link: "/providers/replicate", + }, + { + text: "Qwen", + link: "/providers/qwen", + }, + { + text: "Requesty", + link: "/providers/requesty", + }, { text: "Voyage AI", link: "/providers/voyageai", }, + { + text: "Vertex", + link: "/providers/vertex", + }, { text: "XAI", link: "/providers/xai", @@ -240,6 +272,10 @@ export default defineConfig({ text: "Custom Providers", link: "/advanced/custom-providers", }, + { + text: "Request Payload Projection", + link: "/advanced/payload-projection", + }, { text: "Handling Rate Limits", link: "/advanced/rate-limits", @@ -248,15 +284,48 @@ export default defineConfig({ text: "Provider Interoperability", link: "/advanced/provider-interoperability", }, + { + text: "Telemetry", + link: "/advanced/telemetry", + }, ], }, { - text: "Packages", + text: "Companion Packages", items: [ { - text: "Relay", - link: "https://github.com/prism-php/relay", + text: "Overview", + link: "/companion-packages/", + }, + { + text: "Harness (RC)", + link: "/companion-packages/harness", + }, + { + text: "MCP", + link: "/companion-packages/mcp", + }, + { + text: "Memory", + link: "/companion-packages/memory", + }, + { + text: "Workspace", + link: "/companion-packages/workspace", }, + { + text: "Perplexity", + link: "https://github.com/Particle-Academy/prism-perplexity", + }, + { + text: "OpenTelemetry", + link: "/advanced/telemetry", + }, + ], + }, + { + text: "Other Packages", + items: [ { text: "Bedrock", link: "https://github.com/prism-php/bedrock", diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 6dc77687a..0e0e9a3d7 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -284,3 +284,40 @@ div[class*="language-"] { .VPLocalNav { border-bottom: none !important; } + +.vp-doc .image-examples { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2rem; + align-items: start; +} + +.vp-doc .image-example { + min-width: 0; +} + +.vp-doc .image-example figure { + margin: 1rem 0; +} + +.vp-doc .image-example img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1; + object-fit: contain; + border-radius: 0.75rem; +} + +.vp-doc .image-example figcaption { + margin-top: 0.5rem; + font-size: 0.85rem; + line-height: 1.6; + overflow-wrap: anywhere; +} + +@media (max-width: 64rem) { + .vp-doc .image-examples { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/docs/README.md b/docs/README.md index 730e58a47..e88b38781 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,27 +1,29 @@ -# Prism Docs +# Prism documentation -The Prism docs are built using [Vitepress](https://vitepress.dev) and deployed via [Netlify](https://www.netlify.com). +The Markdown in this directory ships with `particle-academy/prism`. The VitePress configuration in `.vitepress/config.mts` defines its navigation. Follow the [documentation style guide](./documentation-style-guide.md) when editing pages. -> [!IMPORTANT] -> Make sure you review the [documentation style guide](./documentation-style-guide.md)! +## Local preview -## Development +Run these commands from `docs/` with Node.js and npm installed: ```shell +npm ci npm run docs:dev ``` -### Adding a new Provider - -- Update `getting-started/introduction.md` and add the new provider to the supported provider list - - Ensure the provider list remains in alphanumeric order -- Create a provider page in `providers/{provider.md}` using the template (`providers/provider.md.template`) - - Remove any sections that are not needed -- Update `./vitepress/config.mts` and add the provider to the provider sidebar - - Ensure the provider sidebar section remains in alphanumeric order - -## Production +## Build ```shell npm run docs:build +npm run docs:preview ``` + +The build writes `.vitepress/dist/`. The preview command serves that output locally. + +## Adding a provider guide + +1. Create `providers/.md` using `providers/providers.md.template`; remove unused sections. +2. Verify API names, options, errors and examples against the provider implementation. +3. Add the provider to [Introduction](./getting-started/introduction.md) and the sidebar in `.vitepress/config.mts`. +4. Keep provider lists in alphabetical order and update capability tables in `components/` when support changes. +5. Check PHP examples and build the documentation. diff --git a/docs/advanced/custom-providers.md b/docs/advanced/custom-providers.md index 039249745..d8ec98df7 100644 --- a/docs/advanced/custom-providers.md +++ b/docs/advanced/custom-providers.md @@ -1,126 +1,147 @@ # Custom Providers -Want to add support for a new AI provider in Prism? This guide will walk you through creating and registering your own custom provider implementation. +Extend `Prism\Prism\Providers\Provider` and register it with the Prism manager. Override the capabilities your service implements; inherited capability methods raise an unsupported-action `PrismException`. -## Building Your Provider +## Building your provider -All providers must extend the `Prism\Prism\Providers\Provider` abstract class. This base class provides default implementations for all required methods, throwing exceptions for unsupported actions. - -When creating your provider, you'll only need to override the methods for the features you want to support: -- `text()` - For text generation -- `structured()` - For structured output generation -- `embeddings()` - For creating embeddings -- `images()` - For image generation -- `stream()` - For streaming text responses - -Here's what that looks like in practice: +This complete local example returns the prompt as text. It demonstrates the required response shape without making an HTTP request. It supports a single plain-text prompt; it refuses other inputs and controls rather than silently ignoring them. ```php namespace App\Prism\Providers; +use Prism\Prism\Enums\FinishReason; +use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Provider; use Prism\Prism\Text\Request as TextRequest; use Prism\Prism\Text\Response as TextResponse; +use Prism\Prism\ValueObjects\Media\Text; +use Prism\Prism\ValueObjects\Messages\AssistantMessage; +use Prism\Prism\ValueObjects\Messages\UserMessage; +use Prism\Prism\ValueObjects\Meta; +use Prism\Prism\ValueObjects\Usage; -class MyCustomProvider extends Provider +class LocalTextProvider extends Provider { - public function __construct( - protected string $apiKey, - ) {} - public function text(TextRequest $request): TextResponse { - // Your text generation logic here - // Make API calls, process the response, and return a TextResponse + $messages = $request->messages(); + if ($request->prompt() === null || count($messages) !== 1 + || ! $messages[0] instanceof UserMessage || count($messages[0]->additionalContent) !== 1 + || ! $messages[0]->additionalContent[0] instanceof Text + || $request->systemPrompts() !== [] || $request->tools() !== [] + || $request->providerTools() !== [] || $request->providerOptions() !== [] + || $request->toolChoice() !== null || $request->maxTokens() !== null + || $request->temperature() !== null || $request->topP() !== null + || $request->topK() !== null || $request->reasoningEnabled() !== null) { + throw new PrismException('LocalTextProvider accepts one plain-text prompt only.'); + } + + $text = $request->prompt(); + + return new TextResponse( + steps: collect([]), + text: $text, + finishReason: FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new Usage(0, 0), + meta: new Meta('local-response', $request->model()), + messages: collect([...$messages, new AssistantMessage($text)]), + additionalContent: [], + raw: null, + ); } - - // Only override the methods you need! } ``` -## Registration Process +A network-backed implementation must map supported messages and controls, validate the provider response, map usage and finish reasons, and preserve provider state needed for continuation. Implement tools or streaming only when those paths honor the same input and authorization requirements. Use application-controlled credentials and endpoints; do not allow model output to choose them. + +## Registration process -Once you've created your provider, you'll need to register it with Prism. Let's add it to a service provider: +Register a lower-case key in a Laravel service provider: ```php namespace App\Providers; -use App\Prism\Providers\MyCustomProvider; +use App\Prism\Providers\LocalTextProvider; use Illuminate\Support\ServiceProvider; +use Prism\Prism\PrismManager; class AppServiceProvider extends ServiceProvider { public function boot(): void { - $this->app['prism-manager']->extend('my-custom-provider', function ($app, $config) { - return new MyCustomProvider( - apiKey: $config['api_key'] ?? '', - ); - }); + $this->app->make(PrismManager::class)->extend( + 'local-text', + fn ($app, array $config) => new LocalTextProvider, + ); } } ``` -Next, add your provider configuration to `config/prism.php`: +The callback receives the Laravel application and the provider configuration. Add network credentials under `providers.` in the existing `config/prism.php` when your implementation needs them. -```php -return [ - 'providers' => [ - // ... other providers ... - 'my-custom-provider' => [ - 'api_key' => env('MY_CUSTOM_PROVIDER_API_KEY'), - ], - ], -]; -``` - -That's it! You're ready to use your custom provider: +`resolve()` lowercases provider names, while `extend()` stores the key exactly as supplied. Register lower-case keys so the custom creator can be found. ```php use Prism\Prism\Facades\Prism; $response = Prism::text() - ->using('my-custom-provider', 'model-name') - ->withPrompt('Hello, custom AI!') + ->using('local-text', 'local-echo') + ->withPrompt('Hello, custom provider!') ->asText(); + +echo $response->text; ``` -## Custom Error Handling +## Capability methods + +The base class exposes these 21 capability methods. Use its parameter and return types when overriding them: + +| Capability | Methods | +|---|---| +| Text and structured output | `text()`, `structured()`, `stream()` | +| Embeddings, images and moderation | `embeddings()`, `images()`, `moderation()` | +| Audio | `textToSpeech()`, `speechToText()`, `speechToTextProviderId()`, `speechToTextAsync()` | +| Fill-in-the-middle | `fim()` | +| Batch | `batch()`, `retrieveBatch()`, `listBatches()`, `getBatchResults()`, `cancelBatch()` | +| Files | `uploadFile()`, `listFiles()`, `getFileMetadata()`, `deleteFile()`, `downloadFile()` | -Your provider inherits a default `handleRequestException` method that handles common HTTP status codes. You can override this method to handle provider-specific errors or add custom logic: +For example, `stream(TextRequest $request): Generator` yields `StreamEvent` objects, and `downloadFile(DownloadFileRequest $request): string` returns bytes. A method being declared does not mean it is implemented by an existing built-in provider. + +## Request payload projection + +The base provider returns `null` from `projectPayload()`. You can override it +with a side-effect-free mapping consistent with your sender. See +[Request payload projection](./payload-projection) for the supported modes, +approval-resolution behavior, and data handling requirements. + +## Custom error handling + +The separate `handleRequestException(string $model, RequestException $e): never` hook maps HTTP failures. Its default implementation maps 413 to `PrismRequestTooLargeException`, 429 to `PrismRateLimitedException`, 529 to `PrismProviderOverloadedException`, and other statuses to `PrismException`. + +Add provider-specific mapping in your subclass when needed: ```php use Illuminate\Http\Client\RequestException; -use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Exceptions\PrismRateLimitedException; +use Prism\Prism\Providers\Provider; -class MyCustomProvider extends Provider +class RateAwareProvider extends Provider { - // ... other methods ... - public function handleRequestException(string $model, RequestException $e): never { - // Handle provider-specific error codes - match ($e->response->getStatusCode()) { - 429 => throw PrismRateLimitedException::make( - rateLimits: $this->processRateLimits($e->response), - retryAfter: $e->response->header('retry-after') === '' - ? null - : (int) $e->response->header('retry-after'), - ), - default => parent::handleRequestException($model, $e), - }; + if ($e->response->status() === 429) { + $retry = $e->response->header('retry-after'); + throw PrismRateLimitedException::make( + rateLimits: [], + retryAfter: ctype_digit($retry) ? (int) $retry : null, + ); + } + + parent::handleRequestException($model, $e); } } ``` -The method must throw an exception (return type `never`). If you don't handle a specific status code, make sure to call the parent method to maintain the default error handling. - -## Best Practices - -- **Start small**: Begin by implementing just the methods you need. You don't have to support every feature right away. -- **Handle errors gracefully**: Leverage the inherited error handling or override `handleRequestException()` for provider-specific errors (see Custom Error Handling section above). -- **Test thoroughly**: Make sure to test your provider with various inputs and edge cases. -- **Document your models**: Let users know which models your provider supports and any special parameters they can use. - -> [!TIP] -> Looking at existing provider implementations in Prism's source code can give you great insights into best practices and patterns to follow. +`processRateLimits()` is not a base-class method. Parse your service's headers yourself or use a compatible provider-specific trait. Exception messages and response bodies can contain provider content; expose them only according to your application's data policy. Test the implementation with `Http::fake()` and `Http::preventStrayRequests()` when it uses Laravel's HTTP client. diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md index 3b01896a8..8cbc1a0cd 100644 --- a/docs/advanced/error-handling.md +++ b/docs/advanced/error-handling.md @@ -4,24 +4,85 @@ import ExceptionSupport from '../components/ExceptionSupport.vue' # Error handling -By default, Prism throws a `PrismException` for Prism errors, or a `PrismServerException` for Prism Server errors. +Catch specific exception types for application decisions, with a fallback for `PrismException`. Transport failures and invalid application inputs can use other exception types. -For production use cases, you may find yourself needing to catch Exceptions more granularly, for instance to provide more useful error messages to users or to implement failover or retry logic. +## Exception types -Prism has begun rolling out more specific exceptions as use cases arise. +The following classes live under `Prism\Prism\Exceptions`. All extend `PrismException` except `PrismServerException`, which extends PHP's `Exception` directly. -## Provider agnostic exceptions +| Exception | When to handle it | +|---|---| +| `PrismException` | Invalid request state, unsupported actions, tool failures or generic provider failures | +| `PrismStructuredDecodingException` | A structured response cannot be decoded; its message includes the response text | +| `PrismStreamDecodeException` | Stream decoding fails; inspect the previous exception privately | +| `PrismUrlRefused` | An explicit guarded media fetch refuses a URL, redirect or size | +| `PrismRateLimitedException` | A provider maps rate/quota exhaustion; exposes `rateLimits` and optional `retryAfter` seconds | +| `PrismProviderOverloadedException` | A provider maps temporary capacity exhaustion | +| `PrismRequestTooLargeException` | A provider maps an oversized request | +| `PrismBatchRequestLimitExceededException` | A local batch exceeds the integration's request count limit | +| `PrismBatchPayloadSizeExceededException` | A local batch exceeds its configured payload limit | +| `PrismRunException` | A Perplexity run is incomplete, failed or cancelled, even with HTTP 200 | +| `PrismRefusalException` | OpenAI Chat Completions supplies a nonempty refusal | +| `PrismServerException` | Server model resolution or message mapping fails | -- `PrismStructuredDecodingException` where a provider has returned invalid JSON for a structured request. +A refusal can also be represented by a response's `FinishReason::Refusal`; it does not always throw. Inspect [finish reasons](/core-concepts/text-generation#why-generation-stopped) before treating returned text as a completed result. Streamed deltas are provisional until the stream completes successfully. -## Exceptions based on provider feedback +## Handling failures -Prism currently supports three exceptions based on provider feedback: +This example records a transport failure without copying potentially sensitive exception messages: -- `PrismRateLimitedException` where you have hit a rate limit or quota (see [Handling rate limits](/advanced/rate-limits.html) for more info). -- `PrismProviderOverloadedException` where the provider is unable to fulfil your request due to capacity issues. -- `PrismRequestTooLargeException` where your request is too large. +```php +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Support\Facades\Log; +use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Exceptions\PrismRateLimitedException; +use Prism\Prism\Facades\Prism; -However, as providers all handle errors differently, support is being rolled out incrementally. If you'd like to make your first contribution, adding one or more of these exceptions for a provider would make a great first contribution. If you'd like to discuss, start an issue on Github, or just jump straight into a pull request. +try { + $response = Prism::text() + ->using('anthropic', 'claude-sonnet-5-5') + ->withPrompt('Explain database indexes.') + ->asText(); +} catch (PrismRateLimitedException $error) { + // Your queue can reschedule using $error->retryAfter or rateLimits. + throw $error; +} catch (PrismException $error) { + Log::warning('Provider request failed', [ + 'exception_type' => $error::class, + 'http_status' => $error->httpStatus, + ]); + throw $error; +} catch (ConnectionException $error) { + Log::warning('Provider connection failed'); + throw $error; +} +``` - \ No newline at end of file +Some audio operations and input validation can throw generic exceptions, `InvalidArgumentException` or native PHP errors. Keep a suitable application-level `Throwable` handler; do not assume every possible failure is a `PrismException`. Tool handlers normally return `ToolError` rather than throwing; see [tool error handling](/core-concepts/tools-function-calling#error-handling). + +## Diagnostics and codes + +`PrismException::$httpStatus` and `$responseBody` are nullable public fields. Integrations populate them selectively; `getCode()` is not a reliable cross-provider HTTP-status accessor. Exception messages, chained exceptions and response bodies can contain prompts, partial output, credentials or provider diagnostics. Do not return them directly to a browser or log them indiscriminately. + +`PrismUrlRefused::code()` returns a stable string: + +| Code | Meaning | +|---|---| +| `scheme_not_allowed` | Missing/invalid absolute HTTP(S) URL or a refused scheme | +| `private_address_refused` | Private, loopback, reserved or invalid address | +| `host_did_not_resolve` | No public address could be resolved | +| `redirect_refused` | A redirected destination was refused | +| `too_many_redirects` | The redirect limit was exceeded | +| `media_too_large` | Declared or streamed content exceeded the byte limit | + +Do not substitute the unguarded fetch after a refusal. See [remote media](/input-modalities/images#fetching-remote-media) for limits and DNS-boundary details. + +`PrismRefusalException::code()` returns `response_refused`; `refusal()` exposes the provider's refusal text explicitly. `PrismRunException::code()` identifies `run_incomplete`, `run_failed` or `run_cancelled`. Its accessors include `status()`, `runId()`, `incompleteReason()`, `incompleteDetails()`, `output()`, `citations()`, `usage()` and `usageDetails()`. These diagnostics can contain partial answers and sensitive content. See [Perplexity run failures](/providers/perplexity#failures-arrive-as-http-200). + +## Provider mappings + +Exception mapping varies by integration. The table covers rate-limit, overload and request-size mappings; keep a fallback for generic failures. + + + +See [rate limits](/advanced/rate-limits) for quota buckets and retry guidance, and [Batch](/core-concepts/batch#job-metadata-and-limits) for local submission limits. diff --git a/docs/advanced/payload-projection.md b/docs/advanced/payload-projection.md new file mode 100644 index 000000000..684e2a9da --- /dev/null +++ b/docs/advanced/payload-projection.md @@ -0,0 +1,75 @@ +# Request payload projection + +For Anthropic and OpenAI, `projectPayload()` returns the JSON body for a text, +structured, or streaming request without sending it. It includes the provider's +message mapping, tools, options, and structured output instructions. OpenAI uses +the configured `api_format`. + +```php +use Prism\Prism\Enums\Provider; +use Prism\Prism\Facades\Prism; + +$generation = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('Describe the purpose of a queue.'); + +$payload = $generation->provider()->projectPayload( + $generation->toRequest(), + 'text', // 'text', 'structured', or 'stream' +); +``` + +Projection preserves the supplied request and does not execute tools, evaluate +approval callbacks, dispatch telemetry, or fetch remote media. Structured +instructions are added to a clone. The mode must match the request type. + +Other providers return `null`. A custom provider can override `projectPayload()` +to expose its own mapping; it must keep that projection consistent with its +sender. + +## Payload format + +Anthropic and OpenAI implement the optional +`Prism\Prism\Contracts\DescribesProjectedPayload` interface. Its +`projectedPayloadFormat(): ?string` method identifies the projected wire format +without projecting or sending a request. + +| Provider configuration | Identifier | +| --- | --- | +| Anthropic | `anthropic-messages` | +| OpenAI Responses | `openai-responses` | +| OpenAI Chat Completions | `openai-chat-completions` | + +```php +use Prism\Prism\Contracts\DescribesProjectedPayload; + +$provider = $generation->provider(); +$format = $provider instanceof DescribesProjectedPayload + ? $provider->projectedPayloadFormat() + : null; +``` + +The interface is separate from `ProjectsRequestPayload`; existing projection +implementers do not need to add a method. A format declaration alone does not +provide a projection. Treat `null` or an unrecognised identifier as an unknown +format, and inspect only formats your consumer explicitly supports. + +A decorator that preserves the projected format should implement both +interfaces and forward `projectedPayloadFormat()` alongside `projectPayload()`. +If it changes the format, it must declare the format it actually returns or +return `null`. + +## Approval resolution + +If the sender must resolve outstanding tool approvals before dispatch, projection +throws `Prism\Prism\Exceptions\PrismUnprojectableRequestException`. Approval +resolution can execute a tool and produce new input whose contents cannot be +predicted without running it. History that already contains those tool results +can be projected. + +## Limits + +The returned body contains prompt and tool content. Treat it as sensitive data. +Credentials, headers, and transport options are excluded. This is a JSON payload +projection, not a token count or price estimate. Media, server tools, and context +stored by the provider can incur costs beyond the returned JSON bytes. diff --git a/docs/advanced/provider-interoperability.md b/docs/advanced/provider-interoperability.md index dc5724562..296378e52 100644 --- a/docs/advanced/provider-interoperability.md +++ b/docs/advanced/provider-interoperability.md @@ -1,108 +1,80 @@ # Provider Interoperability -When working with Prism, you might need to customize requests based on which provider you're using. Different providers have unique capabilities, configuration options, and requirements that can affect how you structure your requests for optimal results. +Provider capabilities, models and option names differ. Select a model supported by the chosen provider, then apply only options that integration maps. -## Using the `whenProvider` Method +## Using the `whenProvider` method -The `whenProvider` method lets you easily customize your requests for specific providers while maintaining clean, readable code. +`whenProvider()` compares the selected provider key to a string or `Provider` enum and runs the callback only on a match. Return the builder from the callback: ```php +use Prism\Prism\Enums\Provider; +use Prism\Prism\Facades\Prism; + $response = Prism::text() - ->using(Provider::OpenAI, 'gpt-4o') - ->withPrompt('Who are you?') + ->using(Provider::Anthropic, 'claude-sonnet-5-5') ->whenProvider( Provider::Anthropic, - fn ($request) => $request - ->withProviderOptions([ - 'cacheType' => 'ephemeral', - ]) + fn ($request) => $request->withProviderOptions(['effort' => 'medium']) ) + ->withPrompt('Explain database indexes.') ->asText(); ``` -In this example, the `withProviderOptions` settings will only be applied when using Anthropic's provider. If you're using OpenAI (as specified in the `using` method), these customizations are simply skipped. - -## Key Benefits - -- **Cleaner Code**: Keep your provider-specific customizations encapsulated and only apply them when needed -- **Easy Provider Switching**: Swap between providers without rewriting your configuration code -- **Maintainable Applications**: Define provider-specific behaviors in one place +Use lower-case provider keys consistently. `whenProvider()` compares keys exactly; it does not translate a model ID when you change providers. -## Advanced Usage +## Conditional request controls -You can chain multiple `whenProvider` calls to handle different provider scenarios: +Apply multiple conditions when the provider and model are chosen by your application: ```php +use Prism\Prism\Facades\Prism; + +// $provider and $model are an application-approved provider/model pair. $response = Prism::text() - ->using(Provider::OpenAI, 'gpt-4o') - ->withPrompt('Generate a creative story about robots.') - ->whenProvider( - Provider::Anthropic, - fn ($request) => $request - ->withMaxTokens(4000) - ->withProviderOptions(['cacheType' => 'ephemeral']) - ) - ->whenProvider( - Provider::OpenAI, - fn ($request) => $request - ->withMaxTokens(2000) - ->withProviderOptions(['response_format' => ['type' => 'text']]) - ) + ->using($provider, $model) + ->whenProvider('anthropic', fn ($request) => $request + ->withMaxTokens(4000) + ->withProviderOptions(['effort' => 'medium'])) + ->whenProvider('openai', fn ($request) => $request + ->withMaxTokens(2000) + ->withProviderOptions(['store' => false])) + ->withPrompt('Explain database indexes.') ->asText(); ``` -## Using Invokable Classes +`withProviderOptions()` replaces its array. Combine options in one call when they must coexist. Consult the [Anthropic](/providers/anthropic) and [OpenAI](/providers/openai) guides for each option's supported models and API format. + +## Using invokable classes -For more complex provider-specific configurations, you can use invokable classes instead of closures: +An invokable configurator can share conditional settings across requests: ```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\Text\PendingRequest; + class AnthropicConfigurator { - public function __invoke($request) + public function __invoke(PendingRequest $request): PendingRequest { return $request ->withMaxTokens(4000) - ->withProviderOptions([ - 'cacheType' => 'ephemeral', - 'citations' => true, - ]); + ->withProviderOptions(['effort' => 'medium']); } } $response = Prism::text() - ->using(Provider::Anthropic, 'claude-3-sonnet') + ->using('anthropic', 'claude-sonnet-5-5') + ->whenProvider('anthropic', new AnthropicConfigurator) ->withPrompt('Explain the theory of relativity.') - ->whenProvider(Provider::Anthropic, new AnthropicConfigurator()) ->asText(); ``` -This approach can be especially helpful when you have complex or reusable provider configurations. - -> [!TIP] -> The `whenProvider` method works with all request types in Prism including text, structured output, and embeddings requests. - -## Best Practices +The method is available on builders using provider configuration, including structured and embeddings requests. Match a configurator's parameter type to the builder it receives. -### Avoiding SystemMessages with Multiple Providers +## Messages and media -When working with multiple providers, it's best to avoid using `SystemMessages` directly in your `withMessages` array. Instead, use the `withSystemPrompt` method as it offers better provider interoperability. - -```php -// Avoid this when switching between providers -$response = Prism::text() - ->using(Provider::OpenAI, 'gpt-4o') - ->withMessages([ - new SystemMessage('You are a helpful assistant.'), - new UserMessage('Tell me about AI'), - ]) - ->asText(); +Use `withSystemPrompt()` for instructions and `withMessages()` for conversation turns; system-message mapping inside the conversation varies by integration. A shared builder method does not guarantee provider or model support for an input. -// Prefer this instead -$response = Prism::text() - ->using(Provider::OpenAI, 'gpt-4o') - ->withSystemPrompt('You are a helpful assistant.') - ->withPrompt('Tell me about AI') - ->asText(); -``` +Per-message cache options belong on messages, and per-tool strictness belongs on tools. Document citation options require a document and a provider that maps them. Putting those keys into an unrelated request option array does not enable the feature. -This approach allows Prism to handle the provider-specific formatting of system messages, making your code more portable across different LLM providers. +File IDs and provider replay metadata belong to their originating provider. When changing providers, rebuild history in a supported form instead of forwarding provider-specific reasoning items, signed thinking blocks or file IDs blindly. See [input modalities](/input-modalities/documents) and each provider's guide before moving a multimodal conversation. diff --git a/docs/advanced/rate-limits.md b/docs/advanced/rate-limits.md index 34768f7b5..096debe90 100644 --- a/docs/advanced/rate-limits.md +++ b/docs/advanced/rate-limits.md @@ -1,30 +1,38 @@ # Handling Rate Limits -Hitting issues with rate limits? We've got you covered! - -In this guide we will look at handling: -- situations where you actually hit a rate limit (i.e. HTTP 429); and -- dynamic rate limiting (figuring out when you can make your next request, from a successful request). +Handle rate-limit errors and use provider quota information to schedule requests. ## Provider support -Prism throws a `PrismRateLimitedException` for all providers other than DeepSeek (which does not have rate limits). +Prism maps rate-limit responses to `PrismRateLimitedException` where the provider +integration implements that mapping. DeepSeek currently uses the generic +`PrismException` path; this does not imply that its API has no rate limits. + +The exception's `rateLimits` array and response metadata contain +`ProviderRateLimit` objects when the integration can extract quota details. +OpenAI, Anthropic, Groq and Mistral parse provider-specific rate-limit headers; Gemini can extract quota details from an error payload. An array can be empty, so handle the exception even when no +quota buckets are available. -Prism provides an array of `ProviderRateLimit` value objects on the exception and on meta for all providers other than OpenAI, Gemini, xAI and VoyageAI - as they do not provide the necessary headers to do so. +`$error->retryAfter` is an optional delay in seconds, separate from its quota buckets. Not every integration populates it, and a value of zero is not a promise of available capacity. Use a bounded queue/backoff policy when the provider supplies no usable delay. + +Bucket names differ: Anthropic can report `input-tokens`/`output-tokens`, while Groq and Mistral use names such as `tokens`. Do not assume one bucket name works for every provider. Mistral emits a bucket only when both its limit and remaining headers are present. It casts those reported values to integers and adds the reset header's delay to the current time; a missing reset header is treated as zero seconds. ## The ProviderRateLimit value object Throughout this guide, we'll talk about the `ProviderRateLimit` value object. Each `ProviderRateLimit` has four properties: -- name - the name given to that rate limit by the provider - e.g. "input-tokens" +- name - the bucket identifier assigned by the integration - e.g. "input-tokens" - limit - the current limit set on your API key by the provider - e.g. for input-tokens, perhaps 80000 - remaining - how many you have left - e.g. for input-tokens if you have used 30000 out of your 80000 limit - this will be 50000 -- resetsAt - a Carbon instance with the date and time at which remaining will reset to limit +- resetsAt - a Carbon instance with the reset or retry time parsed from the provider response + +`limit`, `remaining` and `resetsAt` can be `null` when the provider does not +report them. Check for a reset time before scheduling a retry from that value. ## Handling a rate limit hit -Prism throws a `PrismRateLimitedException` when you hit a rate limit. +For integrations with the mapping described above, catch `PrismRateLimitedException` when a request is rate limited. You can catch that exception, gracefully fail and inspect the `rateLimits` property which contains an array of `ProviderRateLimit`s. @@ -36,7 +44,7 @@ use Prism\Prism\Exceptions\PrismRateLimitedException; try { Prism::text() - ->using(Provider::Anthropic, 'claude-3-5-sonnet-20241022') + ->using(Provider::Anthropic, 'claude-sonnet-5-5') ->withPrompt('Hello world!') ->asText(); } @@ -52,15 +60,14 @@ catch (PrismRateLimitedException $e) { ### Figuring out which rate limit you have hit -In a simple world, they'd only be one rate limit. +Providers can enforce separate limits for requests, input tokens and output tokens. A response may report several limits, including ones that have not been exhausted. -However most providers implement various rate limits (e.g. request, input tokens, output tokens, etc.) and provide you with information on all of them on all requests, regardless of which you have hit. - -For simple rate limits like "requests", the `remaining` property on `ProviderRateLimit` will be 0 if you have hit it. These are easy to find: +When the provider reports an exhausted bucket with `remaining` set to 0, you can find it as follows. A missing value does not mean that capacity is available: ```php use Prism\Prism\ValueObjects\ProviderRateLimit; use Illuminate\Support\Arr; +use Prism\Prism\Exceptions\PrismRateLimitedException; try { // Your request @@ -70,13 +77,16 @@ catch (PrismRateLimitedException $e) { } ``` -For less simple rate limits like input tokens, the `remaining` property may not be zero. For instance, if you have 5,000 input tokens remaining and submit a request requiring 6,000 tokens, you'll be rate limited but remaining will still show 5,000. +An input-token bucket can have capacity remaining that is insufficient for your +next request. For example, a reported `remaining` value of 5,000 is less than an +estimated request size of 6,000 tokens. Do not rely only on a zero check. Here, you may need to implement some logic to approximate how many tokens your request will use before sending it, and then test against that: ```php use Prism\Prism\ValueObjects\ProviderRateLimit; use Illuminate\Support\Arr; +use Prism\Prism\Exceptions\PrismRateLimitedException; try { // Your request @@ -84,23 +94,23 @@ try { catch (PrismRateLimitedException $e) { $input_token_limit = Arr::first($e->rateLimits, fn(ProviderRateLimit $rate_limit) => $rate_limit->name === 'input-tokens'); - if ($input_token_limit < $your_token_estimate) { + // $your_token_estimate is calculated by your application. + if ($input_token_limit?->remaining !== null && $input_token_limit->remaining < $your_token_estimate) { // Handle } } ``` -To help with approximating input token usage, we plan to implement Anthopic's token counting endpoint in a future release. - -For providers that don't have a token counting endpoint, you could either roll your own token counter or use something like [tiktoken](https://github.com/openai/tiktoken) if you are comfortable calling out to Python. +Estimate token use with a tokenizer appropriate for your model when the provider does not expose a token-counting endpoint. -Once you know which rate limit you have hit, you'll want to ensure your app does not continue making requests until after the `ProviderRateLimit` `resetsAt` property. +When `resetsAt` is available, use it to delay further requests for that bucket. Otherwise, use the provider's retry guidance or your application's backoff policy. If you aren't sure where to start with that, check out the [What should you do with rate limit information](#what-should-you-do-with-rate-limit-information) section below. ## Dynamic rate limiting -Prism adds the same rate limit information to every successful request: +Supported integrations can also include quota information on successful +responses. The array is empty when those details are unavailable: ```php use Prism\Prism\Facades\Prism; @@ -108,7 +118,7 @@ use Prism\Prism\Enums\Provider; use Prism\Prism\ValueObjects\ProviderRateLimit; $response = Prism::text() - ->using(Provider::Anthropic, 'claude-3-5-sonnet-20241022') + ->using(Provider::Anthropic, 'claude-sonnet-5-5') ->withPrompt('Hello world!') ->asText(); @@ -123,8 +133,8 @@ Armed with that information, you'll probably want to [update your app's rate lim ## What should you do with rate limit information? -You'll likely want to implement a rate limiter within your app. Thankfully Laravel, as always, makes this very easy! +Apply rate limits in your application or queue to delay requests until capacity is available. -You should take a look at the [rate limiting](https://laravel.com/docs/11.x/rate-limiting) docs, and if you are firing requests from your queue, check out the [job middleware](https://laravel.com/docs/11.x/queues#job-middleware) docs. +You should take a look at the [rate limiting](https://laravel.com/docs/13.x/rate-limiting) docs, and if you are firing requests from your queue, check out the [job middleware](https://laravel.com/docs/13.x/queues#job-middleware) docs. -You should implement a rate limiter / job middleware for each of the provider rate limits your application typically hits. +You should implement a rate limiter / job middleware for each of the provider rate limits your application typically hits. diff --git a/docs/advanced/telemetry.md b/docs/advanced/telemetry.md new file mode 100644 index 000000000..5b6b0bfdf --- /dev/null +++ b/docs/advanced/telemetry.md @@ -0,0 +1,234 @@ +# Telemetry + +Prism emits Laravel events for the generation lifecycle, including steps, +tool calls, completion and failure. Telemetry is disabled by default. + +Use it to trace and measure generations — latency, token usage, cost, finish +reasons, tool calls — wired into your own logging and metrics, or exported to an +OpenTelemetry backend such as Arize Phoenix with the companion bridge. + +## Stream failures + +Event-signalled stream failures emit `Prism\Prism\Events\Telemetry\GenerationErrored`. Its `status` is `error`; its optional `failureUsage` contains provider-reported, provisional usage for the failed attempt. It carries no prompt, generated content, provider diagnostics or raw body. A foreign read exception with reported usage also produces this event after its final stream error event, then reaches the existing `GenerationFailed` path. Without reported usage, no synthetic error event is added. Existing provider HTTP exception mapping is preserved, and mapped SDK exceptions retain the snapshot. SDK exceptions thrown by a provider handler supply their optional failure usage on `GenerationFailed` directly. + +Failed streams no longer emit `GenerationCompleted`, including streams that end normally at the iterator level with an Error finish reason. Consumers counting successful completions must use this distinction. `StepCompleted` includes the step's actual finish reason; do not treat a failed step as successful simply because it has usage. A recoverable failed attempt followed by a successful terminal can still complete the generation. + +Record a failed attempt once across stream and telemetry channels. No usage is invented when a provider reported none. These numeric fields are available with content capture disabled, and do not change the default privacy settings. + +## Enabling telemetry + +Telemetry is disabled by default. Turn it on with an environment variable: + +```env +PRISM_TELEMETRY_ENABLED=true +``` + +The full configuration block in `config/prism.php`: + +```php +'telemetry' => [ + // Master switch. When false, telemetry is a complete no-op: no contexts + // are minted and no events are dispatched. + 'enabled' => env('PRISM_TELEMETRY_ENABLED', false), + + // Include prompts, completions, and tool arguments in payloads. Off by + // default — this data can contain PII. Only enable where the sink is trusted. + 'capture_content' => env('PRISM_TELEMETRY_CAPTURE_CONTENT', false), + + // Bounds so telemetry can never turn a streaming response into unbounded memory. + 'content_max_length' => (int) env('PRISM_TELEMETRY_CONTENT_MAX_LENGTH', 65_536), + 'content_max_items' => (int) env('PRISM_TELEMETRY_CONTENT_MAX_ITEMS', 256), +], +``` + +## The events + +All telemetry events live in the `Prism\Prism\Events\Telemetry` namespace and +are dispatched through Laravel's event system, so you consume them with ordinary +listeners. + +| Event | Dispatched when | Key payload (beyond `$context`) | +|---|---|---| +| `GenerationStarted` | a generation begins | `$request`, `$tools` | +| `StepCompleted` | each step finishes | `$finishReason`, `$usage`, `$rateLimits`, `$step` | +| `ToolInvoked` | a tool call resolves | `$toolName`, `$toolCallId`, `$durationMs`, `$failed`, `$failure`, `$toolCall`, `$toolResult` | +| `GenerationCompleted` | the generation finishes | `$durationMs`, `$finishReason`, `$usage`, `$rateLimits`, `$response` | +| `GenerationFailed` | the generation throws | `$durationMs`, `$exception` | + +Every event carries a `TelemetryContext` (see [below](#the-telemetry-context)) +that ties the whole lifecycle together. + +## Listening + +Subscribe like any Laravel event: + +```php +use Illuminate\Support\Facades\Event; +use Prism\Prism\Events\Telemetry\GenerationCompleted; + +Event::listen(function (GenerationCompleted $event): void { + logger()->info('prism.generation', [ + 'trace_id' => $event->context->traceId, + 'provider' => $event->context->provider, + 'model' => $event->context->model, + 'duration_ms' => $event->durationMs, + 'finish_reason' => $event->finishReason?->value, + 'prompt_tokens' => $event->usage?->promptTokens, + 'completion_tokens' => $event->usage?->completionTokens, + 'rate_limits' => array_map(fn ($limit) => $limit->toArray(), $event->rateLimits), + ]); +}); +``` + +For anything more involved, register a listener class in your application's event +service provider. + +## Tool failures + +`ToolInvoked.failed` and `ToolInvoked.failure` are always available, including +when content capture is off. A successful call has `false` and `null`. A native +tool failure has `true` and a short kind: `tool_error` for a returned `ToolError`, +or `exception:` for an exception. Exception messages stay out of the +kind; an invalid or oversized class name becomes `exception:unknown`. + +For tools that return a failure inside their result, register a classifier at +application boot: + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\ValueObjects\ToolCall; + +Prism::classifyToolResultsUsing(function (ToolCall $call, mixed $result): ?string { + if ($call->name !== 'research' || ! is_string($result)) { + return null; + } + + $data = json_decode($result, true); + + return is_array($data) && ($data['success'] ?? null) === false + ? 'research.failed' : null; +}); +``` + +The classifier receives the post-tool result value that the model receives, +with the same type on sequential and concurrent paths. It runs in the +registering process once per completed call; the host Closure is never sent to +concurrent workers. Native failures bypass it. The registration is process-wide; +set it at boot, and pass `null` to clear it. + +Return `null` for success or a fixed failure kind of 1–64 characters from +`A-Z`, `a-z`, `0-9`, `_`, `.`, `:` and `-`. Other returned values become +`classified_failure`. If the classifier throws, the kind is `classifier_error`. +Return coarse labels rather than customer data or result bodies. + +A classified failure sets `ToolInvoked.failed` to `true` and the stream +`ToolResultEvent.success` flag to `false`. It preserves the model-facing result, +artifacts, conversation, provider request bytes and step count. Classifier +exceptions do not interrupt the tool call. With content capture off, +`ToolInvoked.toolCall` and `ToolInvoked.toolResult` remain null. + +## The telemetry context + +`Prism\Prism\Telemetry\TelemetryContext` is the correlation object shared by +every event of a single generation: + +| Property | Description | +|---|---| +| `traceId` | stable id for the whole generation — join every event on this | +| `operation` | a `TelemetryOperation` describing the kind of call | +| `provider` / `model` | the provider key and model id | +| `stepIndex` / `toolIndex` | ordinals nesting steps and tool calls under the generation | +| `userId` / `sessionId` | your optional metadata (see below) | +| `startedAt` | monotonic start; `elapsedMs()` returns elapsed time | + +## Attaching user & session metadata + +Correlate telemetry with your own users and conversations using the +`withTelemetryMetadata()` builder method — the values flow onto every event's +context: + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\Enums\Provider; + +$response = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-5-5') + ->withTelemetryMetadata(userId: (string) $user->id, sessionId: $conversation->id) + ->withPrompt('Summarise this thread') + ->asText(); +``` + +Every telemetry event for that call now carries `$context->userId` and +`$context->sessionId`, so you can group traces per user or per conversation in +your backend. + +## Capturing prompt & completion content + +By default telemetry records **structure and metrics only** — timings, token +usage, finish reasons, model, and the provider's rate-limit buckets — never the +prompt or completion text, because that content can contain PII. + +`capture_content` controls prompt and response content. Usage and rate-limit +metadata remain available when provided by the API. + +When telemetry is enabled, `GenerationStarted::$tools` also carries each +advertised tool's name and a SHA-256 digest of its declaration (name, description +and parameters), even when `capture_content` is off. Full descriptions and +parameter schemas remain behind the content gate. These fingerprints help +diagnose cache misses caused by changes to the tool list in a provider's prompt +prefix. + +Use stable, application-defined tool names. If you derive a tool name from user +data, such as a user-owned document's title, that name is exposed to telemetry +listeners on every generation that advertises the tool, even with content capture +disabled. The digest is a fingerprint, not a copy of the declaration; it still +allows comparison with known or guessed declarations. + +Opt in with `capture_content`, and only where the telemetry sink is trusted: + +```env +PRISM_TELEMETRY_CAPTURE_CONTENT=true +``` + +Configure capture limits to bound the amount of retained content: + +- `content_max_length` (default `65_536`) caps per-item text length. +- `content_max_items` (default `256`) caps how many message items are captured. + +### Attachments + +Captured messages can hold images, documents, audio or video. **Their bytes are not +captured by default.** A media part keeps its kind, mime type, file id and filename, +and its `base64` is replaced by `omitted_bytes`, the size of what was left out: + +```json +{"kind": "image", "url": null, "base64": null, "mime_type": "image/png", "file_id": null, "filename": null, "omitted_bytes": 48213} +``` + +To capture the bytes too, where your telemetry sink is trusted with your users' +files: + +```env +PRISM_TELEMETRY_CAPTURE_MEDIA=true +``` + +Only media Prism serialises is recognised. Bytes a user pastes into a prompt as text +are text, and `capture_content` alone decides whether they are captured. + +## Exporting to OpenTelemetry / Arize Phoenix + +Because the events above are provider-neutral, any exporter can consume them. The +companion package +[`particle-academy/prism-opentelemetry`](https://github.com/Particle-Academy/prism-opentelemetry) +subscribes to them and emits standard **OpenTelemetry** spans — a root span per +generation, child spans per step and per tool call, with token, cost, model, and +finish-reason attributes, in both the GenAI and OpenInference conventions — over +OTLP to [Arize Phoenix](https://phoenix.arize.com), Jaeger, Tempo, or any OTLP +backend. + +```bash +composer require particle-academy/prism-opentelemetry +``` + +With telemetry enabled and the bridge installed, generations are exported as +nested traces. diff --git a/docs/companion-packages/harness.md b/docs/companion-packages/harness.md new file mode 100644 index 000000000..8aca54a44 --- /dev/null +++ b/docs/companion-packages/harness.md @@ -0,0 +1,149 @@ +# Prism Harness + +Harness adds persistent conversations, session settings, modes, tool authorization, approvals and subagents to Laravel applications using Prism. + +## Installation + +```bash +composer require particle-academy/prism-harness +php artisan vendor:publish --tag=prism-harness-config +php artisan migrate +php artisan harness:doctor +``` + +Settings live in `config/prism-harness.php`. Package migrations are loaded automatically; publish them with `prism-harness-migrations` only when you need local migration files. Configure provider credentials in Prism before sending a request. + +## Sessions + +Resolve a session for a persisted Eloquent participant and an application-authorized scope: + +```php +use Prism\Harness\Facades\PrismHarness; + +// $user is the authenticated, persisted application participant. +$session = PrismHarness::for($user)->session('support') + ->usingProvider('anthropic') + ->usingModel('claude-sonnet-5-5') + ->usingMode('chat'); + +$result = $session->send('Explain how to update my billing address.'); + +echo $result->text(); +$thread = $session->thread(); +``` + +`AgentResponse` exposes `runId`, the core `response`, optional `parentRunId`/`rootRunId`, and `text()`, `correlation()`, `isChildRun()`, `awaitingApproval()` and `pendingApprovals()`. Inspect `$result->response->finishReason` for truncation or a step ending in tool calls. + +`usingProvider()`, `usingModel()`, `usingMode()` and `usingMaxSteps()` persist session choices. A per-run `$toolNames` argument narrows the mode's toolset; it cannot add a tool outside that mode. + +`newConversation()` retires the current thread and creates another at the same participant/scope. `forget()` clears ephemeral settings without deleting the conversation. `state()`, `key()` and `scope()` expose session metadata. Capability state uses `capability($name)`, `usingCapability($name, $state)` and `forgetCapability($name)`. + +## Modes, tools and skills + +Define modes under `agent.modes` in the existing configuration: + +```php +'agent' => [ + 'provider' => 'anthropic', + 'model' => 'claude-sonnet-5-5', + 'default' => 'support', + 'authorize_tools' => true, + 'modes' => [ + 'support' => [ + 'system_prompt' => 'Help with support requests using the offered tools.', + 'tools' => ['lookup_order'], + 'skills' => [], + 'max_steps' => 4, + 'requires_approval' => [], + 'provider_options' => ['effort' => 'medium'], + ], + ], +], +``` + +Merge these settings with your existing `agent` configuration. Mode keys are `system_prompt`, `tools`, `skills`, `max_steps`, `requires_approval`, `provider_options` and `subagents`. Select provider/model with session setters or `agent.provider`/`agent.model`; placing them in a mode entry does not select them. + +Register application-owned Prism tools in a service provider's `boot()` method: + +```php +use Prism\Harness\Tools\ToolRegistry; + +// $lookupOrder is a Prism Tool whose handler authorizes access to each order. +app(ToolRegistry::class)->register($lookupOrder); +``` + +The registry also accepts `registerMany()`, `registerFactory($name, Closure $factory)` and `registerProvider(Closure $provider)`. Session-aware factories/providers receive the session. + +Enable `agent.authorize_tools` and define `harness.tool` to filter tools before offering them. The optional `harness.tool.call` Gate checks invocation arguments. Both receive the participant as the Gate user, then `$session`, `$tool`, and, for call authorization, `$arguments`. Keep data-level authorization in handlers too. Defining the offer Gate while disabling authorization raises `UnsafeAuthorizationConfiguration`. + +Skill names load `SKILL.md` from the package's `resources/skills//` directory. Selected skills extend the system prompt and make `skill_read` available for referenced resources; a skill is not a file in the application's Workspace. + +## Approvals + +A tool's `requiresApproval()` or a mode's `requires_approval` list can pause a run. A list containing `*` covers every offered tool. Store the pending requests and present them to an authorized user: + +```php +if ($result->awaitingApproval()) { + $pending = $result->pendingApprovals(); + // Persist the pending IDs and display the exact proposed calls. +} +``` + +After authenticating and authorizing the decision, reload the participant's session and call `approve($requestOrId)` or `deny($requestOrId, reason: $reason)`. For multiple pending calls, submit decisions together; resuming after just one leaves unanswered calls denied: + +```php +use Prism\Prism\ValueObjects\ToolApprovalResponse; + +// $authorizedDecisions contains every decision validated by your application. +$decisions = array_map( + fn (array $decision) => new ToolApprovalResponse( + approvalId: $decision['approval_id'], + approved: $decision['approved'], + reason: $decision['reason'] ?? null, + ), + $authorizedDecisions, +); +$result = $session->decide($decisions); +``` + +Do not approve from model output or unvalidated request values. Decisions continue the stored conversation; do not resend the original prompt. + +## Streaming and structured output + +`stream($prompt, ?array $toolNames = null, array $additionalContent = [], ?RunContext $context = null)` yields core stream events. Consume the generator and handle terminal failures; an early delta is not proof of successful completion. + +`sendStructured($prompt, Schema $schema, ...)` returns `StructuredAgentResponse`, with `structured()`, `text()` and run correlation. Invalid output raises `StructuredSchemaViolation`. Your application's domain rules still need validation. See the core [schemas](/core-concepts/schemas) and [streaming](/core-concepts/streaming-output) guides. + +## Threads and locks + +To use persistent history without the agent runtime, resolve `Prism\Harness\Models\Thread::forParticipant($user, $scope)`, supply it to core `withThread()`, then call `$thread->record($response->messages)`. Preserve the complete messages, including tool and provider replay data. + +`$session->lock($callback, ttlSeconds: 10, waitSeconds: 5)` runs the callback under the session store's lock and raises `SessionLocked` if it cannot acquire it. Agent runs have their own `agent.lock_ttl` and `agent.lock_wait`. Choose expiry bounds for the workload and avoid wrapping a run in an unrelated nested lock. + +## Subagents, tasks and integrations + +A mode's `subagents` map names child configurations with `description`, `mode`, optional `scope`, `max_steps`, `max_cost_usd`, `max_seconds` and `max_output_tokens_per_call`. The child mode must exist. `RunBudget` tracks these bounds across nested runs; cost enforcement needs configured pricing and per-call output bounds. Inspect child outcomes and the core finish reason instead of treating a budget stop as a complete answer. + +`PrismHarness::tasks($list)` and `$session->tasks()` return durable task sources. `add($instruction, $id)` creates work; `claim($worker, leaseSeconds: ...)` leases it, and `extendLease($task, $worker, $ledger, $budget, $leaseSeconds)` renews an owned lease within its run budget. Configure `tasks.lease_seconds` and `tasks.lock_wait`; lease loss must be handled before recording completion. + +Run events are `RunStarted`, `RunFinished` and `RunFailed` under `Prism\Harness\Events`. Voice integration is available through `VoiceExchange`. Optional `Flow\PrismLlmClient` and `Flow\HarnessToolInvoker` adapters require the corresponding Fancy Flow package; they are not installed by core. See the [package guide](https://github.com/Particle-Academy/prism-harness#usage) for these integrations and their requirements. + +## Configuration and failures + +| Settings | Purpose | +|---|---| +| `default_scope`, `ephemeral_ttl` | Session addressing and ephemeral-state lifetime; supply an integer TTL in PHP configuration | +| `stores.ephemeral`, `stores.durable` | Both default to `database`; configure Redis explicitly when used | +| `drivers.database.*`, `drivers.redis.*` | Connection/table or Redis prefix and durability declaration | +| `context.*` | Recent-turn retention, recall budget and summarization | +| `agent.provider`, `agent.model`, `agent.default`, `agent.modes` | Default runtime and modes | +| `agent.lock_ttl`, `agent.lock_wait` | Run-lock expiry and acquisition wait | +| `agent.authorize_tools`, `agent.max_steps_ceiling` | Gate enforcement and ceiling on session step overrides | +| `agent.max_output_tokens_per_call`, `pricing`, `cost_observer` | Per-call output bounds, pricing and observed cost | +| `tasks.lease_seconds`, `tasks.lock_wait` | Durable task leasing | + +A durable slot refuses volatile stores with `UnsafeStateConfiguration`. Redis reports volatile unless `drivers.redis.durable` is explicitly true; set it only for infrastructure you operate durably. + +Exceptions under `Prism\Harness\Exceptions` include `SessionLocked`, `RunNotPermitted`, `ToolNotAvailable`, `CallRefused`, `StructuredSchemaViolation`, `UnsafeStateConfiguration`, `UnsafeAuthorizationConfiguration`, `UnsafeAudioSource`, `UnacceptableAttachment`, `UnmappableContent`, `UnmappableTask`, `InvalidTaskIdentifier`, `InvalidTaskLease`, `InvalidTaskOutcome`, `LeaseNotExtendable` and `TaskNotReleasable`. Core provider failures can also propagate. + +`php artisan harness:doctor` checks stores, authorization, mode definitions, tool references and skills. Resolve its failures before running agents. diff --git a/docs/companion-packages/index.md b/docs/companion-packages/index.md new file mode 100644 index 000000000..6f99f7d46 --- /dev/null +++ b/docs/companion-packages/index.md @@ -0,0 +1,35 @@ +# Companion Packages + +Install companion packages alongside `particle-academy/prism` for persistent sessions, semantic memory, scoped file access, remote tools and telemetry export. + +## Available packages + +| Package | Use | Setup | +|---|---|---| +| [Harness](/companion-packages/harness) | Conversations, session modes, approvals, authorization and subagents | Publish configuration, migrate and run `harness:doctor` | +| [MCP](/companion-packages/mcp) | Consume remote tools with trust, pins and invocation controls | Configure server endpoints, credentials and trust | +| [Memory](/companion-packages/memory) | Store records and recall relevant context | Configure embeddings, migrate and run the embedding queue | +| [Workspace](/companion-packages/workspace) | Guard file operations within an owner-scoped disk | Configure the disk, owner addressing and authorization | +| [Perplexity](https://github.com/Particle-Academy/prism-perplexity) | Perplexity endpoints outside the core provider API | Configure provider credentials and transport | +| [OpenTelemetry](/advanced/telemetry) | Export core telemetry events as spans | Install the exporter and configure a trusted telemetry sink | + +## Installation + +Choose the packages your application needs: + +```bash +composer require particle-academy/prism-harness +composer require particle-academy/prism-mcp +composer require particle-academy/prism-memory +composer require particle-academy/prism-workspace +composer require particle-academy/prism-perplexity +composer require particle-academy/prism-opentelemetry +``` + +Run each guide's setup for its configuration, migrations, credentials and access policies. Installing a companion does not automatically expose its capabilities to a model. + +## Combining packages + +Harness owns the conversation and run lifecycle. Memory supplies recalled context or stores evicted turns. Workspace provides file operations you can wrap in application-authorized tools. MCP supplies remote tools after trust admission and invocation policy. OpenTelemetry exports events emitted by core. + +Authorize participant/owner scopes before resolving them, preserve provider replay data when persisting conversations, and keep tool policies active when introducing recalled or remote content. Each package's guide describes the controls and defaults you must configure. diff --git a/docs/companion-packages/mcp.md b/docs/companion-packages/mcp.md new file mode 100644 index 000000000..5665aafc3 --- /dev/null +++ b/docs/companion-packages/mcp.md @@ -0,0 +1,120 @@ +# Prism MCP + +Consume remote MCP tools as Prism tools, with explicit trust declarations, optional definition pins, invocation gates and bounded results. + +## Installation + +```bash +composer require particle-academy/prism-mcp +php artisan vendor:publish --tag=prism-mcp-config +``` + +Settings live in `config/prism-mcp.php`. Configure an application-controlled server endpoint and credentials. This client uses Streamable HTTP and speaks MCP revision `2026-07-28`; the server must support that revision. It does not provide stdio transport or an OAuth authorization flow. + +## Configure a server + +Add an entry under the existing `servers` key: + +```php +'servers' => [ + 'github' => [ + 'url' => env('MCP_GITHUB_URL'), + 'headers' => [ + 'Authorization' => 'Bearer '.env('MCP_GITHUB_TOKEN', ''), + ], + 'trust' => [ + 'tools' => ['search_repositories', 'get_file_contents'], + ], + 'gate' => 'laravel', + ], +], +``` + +Define the application's `mcp.call` Gate before using this example. Trust chooses which definitions reach the model; the Gate decides whether the current actor may invoke a trusted tool. The default gate is null, which permits trusted calls. Laravel's default ability is configurable at `gates.laravel.ability` and receives server name, tool name and arguments after the Gate user. + +```php +use Prism\Mcp\Facades\PrismMcp; +use Prism\Prism\Facades\Prism; + +$tools = PrismMcp::server('github')->tools(); + +$response = Prism::text() + ->using('anthropic', 'claude-sonnet-5-5') + ->withTools($tools) + ->withMaxSteps(3) + ->withPrompt('Find repositories relevant to our billing integration.') + ->asText(); +``` + +Tool names are namespaced by server for the model. Keep server descriptions and results untrusted; admission and framing do not make remote instructions safe to obey. + +## Trust and pins + +`tools()` refuses an undeclared trust policy before contacting the server. An explicit tool list admits those names. `trust.tools = '*'` or `trustingEveryTool()` admits whatever the server publishes; choose it only when that is your intended policy. + +Inspect definitions and obtain digests before pinning: + +```bash +php artisan prism-mcp:pins github --tool=search_repositories --json +``` + +The command contacts the configured server through the raw client without offering tools to a model. Review the definition, then copy its exact digest into `trust.pins[]` or pass the map to `pinning()`. Pins detect a changed reviewed definition; they do not establish that its behavior or output is trustworthy. + +## Connection options + +The ad-hoc form has the same trust requirement: + +```php +use Prism\Mcp\Facades\PrismMcp; + +// $mcpUrl is an application-approved endpoint; $mcpToken is server-side. +$connection = PrismMcp::client($mcpUrl, ['name' => 'internal']) + ->trusting(['lookup_order']) + ->withToken($mcpToken) + ->withTimeout(15) + ->withMaxResultBytes(65536); + +$tools = $connection->tools(); +``` + +| Method | Purpose | +|---|---| +| `trusting($names)`, `trustingEveryTool()` | Definition admission policy | +| `pinning($digests)` | Add definition pins | +| `withTimeout($seconds)` | Transport timeout | +| `withHeaders($headers)`, `withToken($token)` | Server-side request credentials | +| `usingGate(ToolGate $gate)` | Invocation authorization | +| `filteringResults(Closure $filter)` | Transform result text; receives text, server and tool | +| `withMaxResultBytes($bytes)` | Refuse oversized results | +| `withoutProvenanceFraming()` | Disable result framing; use only with your own equivalent controls | +| `withoutCache()`, `cachingFor($seconds, $store = null)` | Tool-list caching | +| `config()` | Inspect resolved server configuration | +| `client()` | Raw protocol client; bypasses tool trust, pins, gates and result controls | + +Use application-authorized endpoints and credentials. A raw client call is outside the guarded Prism-tool path. + +## Results and failures + +Results, including error results, are size-checked and framed with source attribution and a per-call delimiter nonce. Over-limit results raise `ResultTooLarge`; they are not silently truncated. Filters can transform text before it reaches the model. Framing remains a prompt-injection mitigation rather than an authorization mechanism. + +Exceptions under `Prism\Mcp\Exceptions` include `McpException`, `ServerNotConfigured`, `ServerNotTrusted`, `ToolDefinitionChanged`, `ToolDenied`, `ResultTooLarge`, `MirroredParameterRefused`, `ProtocolFailure`, `TransportFailure`, `ToolCallFailed`, `ServerTimedOut`, `UnsupportedProtocolVersion` and `UnsupportedTransport`. Handle them separately from a model choosing not to call a tool. + +Core tool handling can convert invocation exceptions to `ToolError` results instead of propagating them. Configure a redacted failure callback when appropriate and inspect tool failures; see [tool error handling](/core-concepts/tools-function-calling#error-handling). + +## Configuration reference + +| Key | Default / purpose | +|---|---| +| `client.name`, `client.version` | Self-reported client identification; name uses `PRISM_MCP_CLIENT_NAME` or `APP_NAME` | +| `defaults.timeout` | 30 seconds, via `PRISM_MCP_TIMEOUT` | +| `defaults.cache_ttl` | null honors server TTL; 0 disables caching | +| `defaults.max_result_bytes` | 262144 bytes | +| `defaults.frame_results` | true | +| `defaults.gate` | null, via `PRISM_MCP_GATE` | +| `servers..*` | Endpoint, headers, trust, pins and per-server overrides | +| `gates.laravel.ability` | `mcp.call` | +| `gates.fms.feature`, `gates.fms.per_server` | `mcp.tools`, true; optional Feature Management System integration | + +Custom gates implement `Prism\Mcp\Contracts\ToolGate`. The `fms` gate requires its optional Feature Management System package. To expose your application's own tools as an MCP server, use a server package such as `laravel/mcp`; check protocol compatibility independently. For application-local Prism tools, use `(new Tool)->as(...)`. + +See the [package guide](https://github.com/Particle-Academy/prism-mcp) for protocol and custom-gate integration. diff --git a/docs/companion-packages/memory.md b/docs/companion-packages/memory.md new file mode 100644 index 000000000..ff5ba6d03 --- /dev/null +++ b/docs/companion-packages/memory.md @@ -0,0 +1,115 @@ +# Prism Memory + +Store text and conversation-derived records by owner and scope, then retrieve related context with embeddings and a token budget. + +## Installation + +```bash +composer require particle-academy/prism-memory +php artisan vendor:publish --tag=memory-config +php artisan migrate +``` + +Settings live in `config/memory.php`. Configure the embedding provider's credentials in Prism. The default store is a database table; its portable migration loads automatically. Publish `memory-migrations` only when you need local migration files. + +For PostgreSQL with pgvector, configure the `pgvector` driver, choose dimensions matching the embedding model, publish `memory-pgvector-migrations` and run migrations. The database role must be allowed to install the `vector` extension. + +## Remembering and recalling + +The owner can be an Eloquent model or a stable string. Authorize the owner and scope in your application before resolving them: + +```php +use Prism\Memory\Facades\PrismMemory; +use Prism\Prism\Facades\Prism; + +// $user is the authenticated owner. Inline embedding allows immediate recall. +$memory = PrismMemory::for($user, scope: 'support')->synchronously(); +$ids = $memory->remember('The customer prefers email for billing questions.'); + +$relevant = $memory->recall('billing contact preference', budget: 1500); + +$response = Prism::text() + ->using('anthropic', 'claude-sonnet-5-5') + ->withSystemPrompt('Use recalled context as reference material.') + ->withSystemPrompt($relevant->asContext()) + ->withPrompt('How should we contact this customer about billing?') + ->asText(); +``` + +`remember(iterable|Message|string $subject, array $metadata = [], ?Carbon $occurredAt = null): array` accepts a string, one core message, or iterable messages such as `$response->messages`. It returns record IDs. Metadata values are scalar or null; role and record-kind metadata are populated by the package. Tool results are stored by default. + +Embedding normally runs through the `EmbedMemories` queue job after records are written. A record cannot be recalled before its vector is stored. Run your configured queue, inspect `pending()`, or call `embedPending(?int $batch = null)` to process pending records. `synchronously()` returns a copy that embeds new records inline. A synchronous Laravel queue also runs dispatched jobs inline. + +Repeated content/role/tool identity within a collection replaces its existing record. Changing the embedding model or dimensions changes the embedding space; migrate or re-embed data rather than mixing incompatible vectors. + +## Recall options + +```php +use Prism\Memory\ValueObjects\Weighting; + +$relevant = $memory->recall( + query: 'billing address', + limit: 8, + budget: 1500, + weighting: new Weighting(relevance: 0.7, recency: 0.3), + minScore: 0.2, + filter: ['role' => 'user'], +); +``` + +The full signature is `recall(string $query, ?int $limit = null, ?int $budget = null, ?Weighting $weighting = null, ?float $minScore = null, array $filter = []): Recollection`. A budget selects records within an estimated token count; it is not an exact model-token or billing bound. The candidate search uses the configured overfetch limit. + +`Weighting` accepts `relevance`, `recency` and `halfLifeSeconds`; weights must be nonnegative with a nonzero total, and half-life must be positive. The defaults use relevance alone. `filter` matches metadata, including lists of accepted scalar values. + +## Recollection + +| Member | Use | +|---|---| +| `asContext()` | One text block for a system prompt | +| `asSystemMessage()` | One core SystemMessage | +| `asMessages()` | Empty array or one SystemMessage | +| `all()`, iteration | Chronological records with stable ID ordering for time ties | +| `ranked()` | Records sorted by score | +| `estimatedTokens` | Token estimate for the selected records | +| `digest()` | SHA-256 of the rendered context | +| `isEmpty()`, `count()` | Inspect the result | + +Each recalled record includes content, kind, role, similarity, recency, combined score, occurrence time and metadata. Use `withSystemPrompt($relevant->asContext())` alongside a new prompt. A nonempty explicit `withMessages()` list cannot be combined with `withPrompt()`; append the user message explicitly when using a message list. + +Stored content can include untrusted instructions. Retain handler authorization and tool restrictions when using recalled material. + +## Forgetting and retention + +```php +$deleted = $memory->forget($ids); // Specific records in this collection. +$total = $memory->count(); +``` + +`forget()` with no IDs, or an empty array, deletes the entire owner/scope collection. `forgetBefore(DateTimeInterface $before)` deletes records by occurrence time. Both return the deletion count; authorize destructive operations before calling them. + +`retention` is disabled by default. Configured expiry is checked during retrieval; expiry does not replace a deletion policy for stored rows. + +## Configuration + +| Key | Default / purpose | +|---|---| +| `default_scope`, `remember_tool_results` | `default`, true | +| `embeddings.provider`, `embeddings.model`, `embeddings.batch` | `openai`, `text-embedding-3-small`, 96 | +| `recall.limit`, `recall.overfetch`, `recall.min_score` | 8, 8, null | +| `recall.relevance`, `recall.recency`, `recall.half_life` | 1.0, 0.0, 604800 seconds | +| `recall.query_cache` | 300 seconds for repeated query embeddings | +| `retention` | null; optional lifetime in seconds | +| `store`, `require_durable` | `database`, true | +| `drivers.database.connection`, `table` | Database connection and `memory_vectors` | +| `drivers.database.index` | `strategy`, `bits`, `candidates`, `seed`, `exact_scan_limit` | +| `drivers.pgvector.*` | Connection/table, `dimensions` (1536), `ef_search` (100) | + +The published file maps these to `MEMORY_*` variables, including `MEMORY_EMBEDDINGS_PROVIDER/MODEL/BATCH`, `MEMORY_RECALL_*`, `MEMORY_RETENTION`, `MEMORY_STORE`, `MEMORY_REQUIRE_DURABLE`, `MEMORY_DB_CONNECTION`, `MEMORY_INDEX_*` and `MEMORY_PGVECTOR_*`. Changing database signature bits or seed requires re-indexing stored signatures. + +## Harness integration and errors + +With Harness installed, `Prism\Memory\Harness\MemoryContextRecall` implements its context recall contract, and `MemoryEvictionSink` stores evicted messages. Construct both with the same memory manager and authorized owner; use the same Harness-supplied scope for storage and retrieval. Their optional configuration does not install Harness automatically. + +Exceptions under `Prism\Memory\Exceptions` are `EmbeddingFailed`, `EmbeddingSpaceMismatch`, `InvalidVector`, `UnsafeMemoryConfiguration` and `UnstorableMemory`. Handle queue failures and embedding/provider errors rather than treating missing vectors as successful recall. An application-specific vector backend implements `Contracts\VectorStore` and registers it through the store manager's `extend()` method. + +See the [package guide](https://github.com/Particle-Academy/prism-memory) for custom stores and integration setup. diff --git a/docs/companion-packages/workspace.md b/docs/companion-packages/workspace.md new file mode 100644 index 000000000..dce920983 --- /dev/null +++ b/docs/companion-packages/workspace.md @@ -0,0 +1,96 @@ +# Prism Workspace + +Read and write files inside an owner-scoped Laravel storage directory. Workspace guards paths and local filesystem boundaries. It provides file operations; it does not execute code or expose model tools automatically. + +## Installation + +```bash +composer require particle-academy/prism-workspace +php artisan vendor:publish --tag=prism-workspace-config +``` + +Settings live in `config/prism-workspace.php`. Configure the underlying Laravel storage disk and authorize which owners your application can address. + +## Basic usage + +```php +use Prism\Workspace\Facades\PrismWorkspace; + +// $ownerKey is a stable identifier chosen after application authorization. +$workspace = PrismWorkspace::for($ownerKey); + +$workspace->write('reports/q1.md', '# Q1 report'); +$content = $workspace->read('reports/q1.md'); + +foreach ($workspace->list('reports', recursive: true) as $entry) { + echo $entry->path; +} +``` + +`for()` accepts a Harness session, a `WorkspaceOwner`, an Eloquent model or a stable string. It resolves the same directory for the same address. A custom owner implements `Prism\Workspace\Contracts\WorkspaceOwner::workspaceKey(): string`; keep its key stable across workers and requests. + +## File operations + +| Methods | Result / behavior | +|---|---| +| `address()`, `path($path)` | Workspace address; guarded normalized path | +| `exists($path)`, `read($path)`, `readStream($path)` | Existence, bytes or a stream resource | +| `write($path, $contents)`, `writeStream($path, $resource)`, `append($path, $contents)` | Write operations; return the workspace | +| `copy($from, $to)`, `move($from, $to)` | Guard both paths | +| `delete($path)`, `makeDirectory($path)`, `deleteDirectory($path)` | Mutations; directory deletion is recursive | +| `size($path)`, `lastModified($path)` | Byte count or modification timestamp | +| `list($directory = '', $recursive = true)` | LazyCollection of WorkspaceEntry objects | +| `clear()` | Delete the workspace contents, retaining its directory | + +Entries expose `path`, `isDirectory`, nullable `size` and nullable `lastModified`. Close stream resources when finished. Authorize deletion and `clear()` as destructive application actions. + +`disk()` returns the underlying adapter and bypasses the Workspace guards. `root()` exposes the adapter path; use it only where your storage driver supports local paths. Keep these escape hatches out of model-facing tool APIs. + +## Configuration and authorization + +| Key | Default | Environment variable | +|---|---|---| +| `disk` | `local` | `PRISM_WORKSPACE_DISK` | +| `root` | `workspaces` | `PRISM_WORKSPACE_ROOT` | +| `max_path_length` | 1024 | Set in PHP config | +| `max_segment_length` | 255 | Set in PHP config | +| `windows_max_path` | 259 | Set in PHP config; null disables this local Windows bound | +| `authorize` | false | `PRISM_WORKSPACE_AUTHORIZE` | +| `gate_prefix` | `workspace` | `PRISM_WORKSPACE_GATE_PREFIX` | + +Authorization is off by default. Enable it to call Laravel Gate abilities `workspace.read`, `workspace.write`, `workspace.delete` and `workspace.list`. Policies receive the authenticated user, Workspace and guarded path; root listing and `clear()` can supply no path. Configure authentication for queue work as needed; a storage scope alone does not authorize a user to access that scope. + +Local disks also check the filesystem destination for links escaping the root. This is not operating-system isolation: code with direct filesystem access or access to `disk()` can bypass the wrapper. + +## Refusals and storage failures + +Catch `Prism\Workspace\Exceptions\PathRefused` and branch on `$error->refusal->value`. The `Refusal` codes are: + +- `path_traverses_outside_workspace`, `path_is_absolute`, `path_is_unc_or_device_namespace` +- `path_contains_null_byte`, `path_contains_control_character`, `path_contains_invisible_character` +- `path_contains_separator_homoglyph`, `path_is_not_valid_utf8`, `path_contains_encoded_separator` +- `path_contains_alternate_data_stream`, `path_is_reserved_device_name`, `path_has_edge_dot_or_space` +- `path_uses_short_name_alias`, `path_uses_home_expansion`, `path_is_empty` +- `path_too_long`, `path_segment_too_long`, `path_escapes_workspace_via_link` + +`WorkspaceFailed` exposes `$error->fault` with `workspace_file_missing`, `workspace_write_failed`, `workspace_delete_failed` or `workspace_owner_not_addressable`. Laravel authorization failures are separate from path and disk failures. Raw `$error->path` can contain hostile characters; do not print it unescaped. + +## Checking your storage configuration + +Run the shipped escape corpus against an isolated workspace on the same disk configuration: + +```php +use Prism\Workspace\Facades\PrismWorkspace; +use Prism\Workspace\Security\CorpusRunner; + +// $testOwnerKey identifies a dedicated workspace your application owns. +$report = (new CorpusRunner)->against(PrismWorkspace::for($testOwnerKey)); + +if (! $report->passed()) { + throw new RuntimeException($report->summary()); +} +``` + +The runner attempts refused paths and checks for escaped marker files. A failed boundary can write those markers, so use an isolated test owner and surrounding directory. The result checks that configuration; it does not establish isolation for arbitrary code running in your application. + +See [Harness](/companion-packages/harness) for sessions and the [Workspace package guide](https://github.com/Particle-Academy/prism-workspace) for custom storage integration. diff --git a/docs/components/ExceptionSupport.vue b/docs/components/ExceptionSupport.vue index 177de7e5b..0a85301e8 100644 --- a/docs/components/ExceptionSupport.vue +++ b/docs/components/ExceptionSupport.vue @@ -1,5 +1,10 @@