diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 41afa1f..a945ff9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" @@ -57,7 +57,7 @@ "command": "uvx", "args": [ "--from", - "papervizagent==0.3.0", + "papervizagent==0.4.0", "papervizagent", "mcp" ], diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 3332fbf..21c3ecf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 4e44e8e..7007f2a 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" diff --git a/.dockerignore b/.dockerignore index 0ee881f..a9de72c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,24 @@ dist web/dist .env .env.* + +# tf:dockerignore +.git/ +.env +.env.* +!.env.example +node_modules/ +**/node_modules/ +.venv/ +**/.venv/ +dist/ +coverage/ +test-results/ +playwright-report/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +__pycache__/ +**/__pycache__/ +.vscode-test/ +# /tf:dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b81a9b1..6749596 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ permissions: jobs: test: runs-on: ubuntu-latest + env: + TOOLFACTORY_SYSTEM_NATIVE_JOBS: "true" steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -42,15 +44,15 @@ jobs: - name: Install core into Hermes run: uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable . - name: toolfactory check - run: npx --yes toolfactory@0.1.2 check + run: npx --yes toolfactory@0.2.1 check - name: toolfactory build (output files) - run: npx --yes toolfactory@0.1.2 build + run: npx --yes toolfactory@0.2.1 build - name: Install Claude Code CLI run: npm i -g @anthropic-ai/claude-code - name: Install Chromium dependencies run: npx --yes playwright install-deps chromium - name: toolfactory validate - run: npx --yes toolfactory@0.1.2 validate + run: npx --yes toolfactory@0.2.1 validate - name: author tests run: uv run --with pytest pytest -q - name: openclaw end-to-end (scripted model, no LLM key) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcae3be..9b227c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# Adopted from ToolFactory 0.1.2 for workflow permissions and container inputs. +# Adopted from ToolFactory 0.2.1 for workflow permissions and container inputs. # `gate` decides once which registries this tag can publish to (a job-level `if` cannot read # secrets) and every leg obeys its output, so a tag with no secrets is still green: gate, # package, the Release, and whatever else applies. After adding a secret, re-run with @@ -93,15 +93,15 @@ jobs: - name: Install core into Hermes run: uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable . - name: toolfactory check - run: npx --yes toolfactory@0.1.2 check + run: npx --yes toolfactory@0.2.1 check - name: toolfactory build (output files) - run: npx --yes toolfactory@0.1.2 build + run: npx --yes toolfactory@0.2.1 build - name: Install Claude Code CLI run: npm i -g @anthropic-ai/claude-code - name: Install Chromium dependencies run: npx --yes playwright install-deps chromium - name: toolfactory validate - run: npx --yes toolfactory@0.1.2 validate + run: npx --yes toolfactory@0.2.1 validate - name: author tests run: uv run --with pytest pytest -q - name: openclaw end-to-end (scripted model, no LLM key) @@ -158,7 +158,7 @@ jobs: - name: install run: uv sync - name: toolfactory build (output files) - run: npx --yes toolfactory@0.1.2 build + run: npx --yes toolfactory@0.2.1 build - name: web build run: npm -C web ci && npm -C web run build env: @@ -178,11 +178,20 @@ jobs: - name: web tarball run: tar -czf dist/release/papervizagent-web.tar.gz -C web/dist . - name: browser extension zips - run: npm --prefix hosts/browser install && npm --prefix hosts/browser exec --no -- wxt zip hosts/browser -b chrome && npm --prefix hosts/browser exec --no -- wxt zip hosts/browser -b firefox && npm --prefix hosts/browser exec --no -- wxt zip hosts/browser -b edge && cp hosts/browser/.output/papervizagent-0.3.0-chrome.zip dist/release/ && cp hosts/browser/.output/papervizagent-0.3.0-firefox.zip dist/release/ && cp hosts/browser/.output/papervizagent-0.3.0-edge.zip dist/release/ && cp hosts/browser/.output/papervizagent-0.3.0-sources.zip dist/release/ + working-directory: hosts/browser + run: |- + npm install + npm exec --no -- wxt zip -b chrome + npm exec --no -- wxt zip -b firefox + npm exec --no -- wxt zip -b edge + cp .output/papervizagent-0.4.0-{chrome,firefox,edge,sources}.zip ../../dist/release/ + cd ../.. + git archive --format=zip --output=dist/release/papervizagent-0.4.0-sources.zip HEAD + zip -q dist/release/papervizagent-0.4.0-sources.zip web/src/ops.json - name: Firefox signed xpi (only when FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET are set) run: if [ -n "$FIREFOX_JWT_ISSUER" ] && [ -n "$FIREFOX_JWT_SECRET" ]; then npm --prefix hosts/browser exec --no -- web-ext sign --channel=unlisted --api-key="$FIREFOX_JWT_ISSUER" --api-secret="$FIREFOX_JWT_SECRET" --source-dir hosts/browser/.output/firefox-mv2 --artifacts-dir dist/release; fi - name: coverage report - run: cp COVERAGE.md dist/release/ && npx --yes toolfactory@0.1.2 coverage > dist/release/coverage.json + run: cp COVERAGE.md dist/release/ && npx --yes toolfactory@0.2.1 coverage > dist/release/coverage.json - uses: actions/upload-artifact@v7 with: name: release-assets @@ -213,7 +222,7 @@ jobs: - name: npm publish env: NPM_TOKEN: ${{ vars.NPM_TRUSTED_PUBLISHER != 'true' && secrets.NPM_TOKEN || '' }} - run: 'if npm view papervizagent@0.3.0 version >/dev/null 2>&1; then echo "::notice::npm: papervizagent@0.3.0 already published; skipping immutable version."; elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish ./release-assets/papervizagent-0.3.0.tgz --access public; else NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish ./release-assets/papervizagent-0.3.0.tgz --access public; fi' + run: 'if npm view papervizagent@0.4.0 version >/dev/null 2>&1; then echo "::notice::npm: papervizagent@0.4.0 already published; skipping immutable version."; elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish ./release-assets/papervizagent-0.4.0.tgz --access public; else NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish ./release-assets/papervizagent-0.4.0.tgz --access public; fi' publish-pypi: needs: - gate @@ -254,7 +263,7 @@ jobs: - name: install run: uv sync --frozen - name: generate container build inputs - run: npx --yes toolfactory@0.1.2 build + run: npx --yes toolfactory@0.2.1 build - uses: docker/login-action@v4 with: registry: ghcr.io @@ -312,7 +321,7 @@ jobs: uses: openclaw/clawhub/.github/workflows/package-publish.yml@main with: package_artifact_name: release-assets - package_artifact_path: openclaw-plugin-papervizagent-0.3.0.tgz + package_artifact_path: openclaw-plugin-papervizagent-0.4.0.tgz dry_run: false wait_for_publication: true secrets: @@ -366,20 +375,20 @@ jobs: path: release-assets - name: wxt submit --dry-run (zips, plus a live credential read for Chrome and Firefox) run: |- - flags="" - if [ -n "$CHROME_EXTENSION_ID" ] && [ -n "$CHROME_PUBLISHER_ID" ] && [ -n "$CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL" ] && [ -n "$CHROME_SERVICE_ACCOUNT_PRIVATE_KEY" ]; then flags="$flags --chrome-zip release-assets/papervizagent-0.3.0-chrome.zip"; fi - if [ -n "$FIREFOX_EXTENSION_ID" ] && [ -n "$FIREFOX_JWT_ISSUER" ] && [ -n "$FIREFOX_JWT_SECRET" ]; then flags="$flags --firefox-zip release-assets/papervizagent-0.3.0-firefox.zip --firefox-sources-zip release-assets/papervizagent-0.3.0-sources.zip"; fi - if [ -n "$EDGE_PRODUCT_ID" ] && [ -n "$EDGE_CLIENT_ID" ] && [ -n "$EDGE_API_KEY" ]; then flags="$flags --edge-zip release-assets/papervizagent-0.3.0-edge.zip"; fi - if [ -z "$flags" ]; then echo "No browser store credentials configured; skipping wxt submit."; exit 0; fi - npm --prefix hosts/browser exec --no -- wxt submit --dry-run $flags + flags=() + if [ -n "$CHROME_EXTENSION_ID" ] && [ -n "$CHROME_PUBLISHER_ID" ] && [ -n "$CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL" ] && [ -n "$CHROME_SERVICE_ACCOUNT_PRIVATE_KEY" ]; then flags+=(--chrome-zip release-assets/papervizagent-0.4.0-chrome.zip); fi + if [ -n "$FIREFOX_EXTENSION_ID" ] && [ -n "$FIREFOX_JWT_ISSUER" ] && [ -n "$FIREFOX_JWT_SECRET" ]; then flags+=(--firefox-zip release-assets/papervizagent-0.4.0-firefox.zip --firefox-sources-zip release-assets/papervizagent-0.4.0-sources.zip); fi + if [ -n "$EDGE_PRODUCT_ID" ] && [ -n "$EDGE_CLIENT_ID" ] && [ -n "$EDGE_API_KEY" ]; then flags+=(--edge-zip release-assets/papervizagent-0.4.0-edge.zip); fi + if [ "${#flags[@]}" -eq 0 ]; then echo "No browser store credentials configured; skipping wxt submit."; exit 0; fi + npm --prefix hosts/browser exec --no -- wxt submit --dry-run "${flags[@]}" - name: wxt submit run: |- - flags="" - if [ -n "$CHROME_EXTENSION_ID" ] && [ -n "$CHROME_PUBLISHER_ID" ] && [ -n "$CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL" ] && [ -n "$CHROME_SERVICE_ACCOUNT_PRIVATE_KEY" ]; then flags="$flags --chrome-zip release-assets/papervizagent-0.3.0-chrome.zip"; fi - if [ -n "$FIREFOX_EXTENSION_ID" ] && [ -n "$FIREFOX_JWT_ISSUER" ] && [ -n "$FIREFOX_JWT_SECRET" ]; then flags="$flags --firefox-zip release-assets/papervizagent-0.3.0-firefox.zip --firefox-sources-zip release-assets/papervizagent-0.3.0-sources.zip"; fi - if [ -n "$EDGE_PRODUCT_ID" ] && [ -n "$EDGE_CLIENT_ID" ] && [ -n "$EDGE_API_KEY" ]; then flags="$flags --edge-zip release-assets/papervizagent-0.3.0-edge.zip"; fi - if [ -z "$flags" ]; then echo "No browser store credentials configured; skipping wxt submit."; exit 0; fi - npm --prefix hosts/browser exec --no -- wxt submit $flags + flags=() + if [ -n "$CHROME_EXTENSION_ID" ] && [ -n "$CHROME_PUBLISHER_ID" ] && [ -n "$CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL" ] && [ -n "$CHROME_SERVICE_ACCOUNT_PRIVATE_KEY" ]; then flags+=(--chrome-zip release-assets/papervizagent-0.4.0-chrome.zip); fi + if [ -n "$FIREFOX_EXTENSION_ID" ] && [ -n "$FIREFOX_JWT_ISSUER" ] && [ -n "$FIREFOX_JWT_SECRET" ]; then flags+=(--firefox-zip release-assets/papervizagent-0.4.0-firefox.zip --firefox-sources-zip release-assets/papervizagent-0.4.0-sources.zip); fi + if [ -n "$EDGE_PRODUCT_ID" ] && [ -n "$EDGE_CLIENT_ID" ] && [ -n "$EDGE_API_KEY" ]; then flags+=(--edge-zip release-assets/papervizagent-0.4.0-edge.zip); fi + if [ "${#flags[@]}" -eq 0 ]; then echo "No browser store credentials configured; skipping wxt submit."; exit 0; fi + npm --prefix hosts/browser exec --no -- wxt submit "${flags[@]}" release: needs: - gate @@ -414,7 +423,7 @@ jobs: - name: install run: uv sync - name: unpublish dropped registries - run: npx --yes toolfactory@0.1.2 unpublish + run: npx --yes toolfactory@0.2.1 unpublish env: GH_TOKEN: ${{ github.token }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -464,14 +473,14 @@ jobs: - name: install run: uv sync - name: toolfactory build (output files) - run: npx --yes toolfactory@0.1.2 build + run: npx --yes toolfactory@0.2.1 build - uses: actions/configure-pages@v5 - name: web build env: PAGES_BASE: /${{ github.event.repository.name }}/ run: npm -C web ci && npm -C web run build - name: publish the schema and the coverage report alongside the app - run: cp dev.toolfactory/tool.schema.json COVERAGE.md web/dist/ && npx --yes toolfactory@0.1.2 coverage > web/dist/coverage.json + run: cp dev.toolfactory/tool.schema.json COVERAGE.md web/dist/ && npx --yes toolfactory@0.2.1 coverage > web/dist/coverage.json - uses: actions/upload-pages-artifact@v3 with: path: web/dist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c942839..adf2153 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Development -Install Node.js 24, npm, Python 3.11+ and uv. ToolFactory 0.1.2 is pinned by the -Makefile and generated workflows. `uv sync` installs the runtime dependencies. +Install Node.js 24, npm, Python 3.11+ and uv. ToolFactory 0.2.1 is pinned by the +Makefile and workflows. `uv sync` installs the runtime dependencies. ```sh make build @@ -17,11 +17,13 @@ backends. Local live tests use the normal signed-in Codex account and are not CI ## Ownership Edit `plugin.json` for identity/version, `dev.toolfactory/tool.json` for surfaces, -`src/papervizagent/{config,backends,ops}.py` for runtime behavior, and skill +`src/papervizagent/{config,backends,events,ops}.py` for runtime behavior, and skill bodies/reference adapters for native behavior. ToolFactory owns generated kernels, host adapters, manifests, skill frontmatter, README installation regions and the lock. Never edit generated regions; adopt first when necessary. -`AGENTS.md` is adopted; CI and release workflows are generated again in v0.3. +`AGENTS.md` and the release workflow are adopted. CI remains generated; the +release workflow preserves repository-specific permissions, container inputs +and complete Firefox source archives. Reuse upstream source in `src/papervizagent/upstream`. Keep original prompt constants unchanged and retain source hashes and modified-file notices. The diff --git a/COVERAGE.md b/COVERAGE.md index c7bbaad..023c1a5 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -10,4 +10,4 @@ Generated by `toolfactory build`. Verdicts per operation and surface: `native` ( | `status` | bridged (agent-mediated) | native | native | degraded (loader-unverified) | degraded (loader-unverified) | native | native | native | native | native | native | native | degraded (out-of-process) | native | native | native | native | native | | `web` | bridged (agent-mediated) | native | native | degraded (loader-unverified) | degraded (loader-unverified) | native | native | native | native | native | native | native | degraded (out-of-process) | native | native | native | native | native | -4 of 99 managed files adopted as manual (4%): `.github/workflows/agent-config.yml`, `.github/workflows/release.yml`, `AGENTS.md`, `Dockerfile`. +4 of 100 managed files adopted as manual (4%): `.github/workflows/agent-config.yml`, `.github/workflows/release.yml`, `AGENTS.md`, `Dockerfile`. diff --git a/Makefile b/Makefile index 51434a6..9f6e3e4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -FACTORY ?= npx --yes toolfactory@0.1.2 +FACTORY ?= npx --yes toolfactory@0.2.1 .PHONY: build check validate package test build: diff --git a/README.md b/README.md index 581d874..b8ac830 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The Python processor, seven agent implementations and complete style guides are **Release status:** GitHub/source installation is available; the PyPI, npm, ClawHub and browser-store commands below require the publisher setup described in [RELEASING.md](RELEASING.md). Until PyPI is published, run the runtime from the release source: ```sh -uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@v0.3.0 papervizagent mcp +uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@v0.4.0 papervizagent mcp ``` @@ -23,18 +23,18 @@ uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@v0.3.0 papervizagent - **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/PaperVizAgent` - **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout; published: `openclaw plugins install clawhub:openclaw-plugin-papervizagent` - **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/PaperVizAgent#hosts/hermes/papervizagent_hermes` -- **DSH plugin** (experimental) — `dsh plugin --profile add ./hosts/dsh` from a checkout, or the release tarball `papervizagent-dsh-0.3.0.tgz` +- **DSH plugin** (experimental) — `dsh plugin --profile add ./hosts/dsh` from a checkout, or the release tarball `papervizagent-dsh-0.4.0.tgz` - **Browser extension** — from a checkout: `npm --prefix hosts/browser install && npm --prefix hosts/browser exec --no -- wxt build`, then `chrome://extensions` → developer mode → Load unpacked → `hosts/browser/.output/chrome-mv3` (Firefox: `npm --prefix hosts/browser exec --no -- web-ext run`). Each GitHub Release attaches the - store uploads `papervizagent-0.3.0-chrome.zip`, `papervizagent-0.3.0-firefox.zip`, `papervizagent-0.3.0-edge.zip`. When Firefox signing credentials are configured, it also attaches a + store uploads `papervizagent-0.4.0-chrome.zip`, `papervizagent-0.4.0-firefox.zip`, `papervizagent-0.4.0-edge.zip`. When Firefox signing credentials are configured, it also attaches a Mozilla-signed `.xpi`; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's submit step has each store's credentials. Then pair it: `uvx papervizagent mcp --http --pair` prints the `#` the extension's options page accepts. - **Web app** — `uvx papervizagent mcp --http --open` serves the operations page beside the MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and returns the URL. -- **npm package** — `npm install papervizagent`; requires `uv`, and `papervizagent` delegates to `uvx --from papervizagent==0.3.0 papervizagent` +- **npm package** — `npm install papervizagent`; requires `uv`, and `papervizagent` delegates to `uvx --from papervizagent==0.4.0 papervizagent` - **PyPI package** — `uv add papervizagent` @@ -76,6 +76,21 @@ Both diagrams and Matplotlib plots support vanilla, planner, planner+stylist, pl Original prompt constants and style guides remain unchanged. Each native role has a fresh subagent; each runtime role makes an isolated provider request or Codex thread. Critic revisions produce fresh renders; polish is a separate editing workflow. Plot execution runs generated Python in a temporary child process with a configurable timeout and 300 DPI output. This isolates plotting state and hangs; it is **not an OS security sandbox**. Use the host’s execution sandbox for untrusted inputs. +## Background execution + +In hosts with background completion notifications, the skill dispatches one +coordinator to own generation, isolated roles, critique and review. The main +conversation receives the completion or failure event. It does not schedule +check-ins or repeatedly poll files for progress. + +Runtime integrations keep one awaited CLI process or MCP request. Embedded +Python hosts can use `papervizagent.ops.generate_events` for run, candidate and +role lifecycle events, or supply an async `on_event` callback to `generate`. +The host owns the background task and delivers the result when it completes; +there is no additional daemon or scheduler. Hosts without a background completion +channel use one awaited call. See [completion events](skills/papervizagent/references/completion.md) +for the event contract and cancellation behavior. + ## Development and fidelity ```sh diff --git a/RELEASING.md b/RELEASING.md index 75d8a11..e8fb81c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Release coverage -ToolFactory 0.1.2 generates 18 selected surfaces from one Python runtime: +ToolFactory 0.2.1 generates 18 selected surfaces from one Python runtime: Agent Skill, Agent Plugins, Claude, Codex, Cursor, Gemini, MCP, CLI, PyPI, npm, MCP Registry, MCPB, OpenClaw, Hermes, ClawHub, web, DSH and browser extensions. The browser extension is a local MCP client, not a hosted inference service. @@ -13,7 +13,7 @@ and MCPB do not require an existing PyPI package. ## Current publication status -The v0.3.0 GitHub release provides downloadable integrations independently of +The v0.4.0 GitHub release provides downloadable integrations independently of registry accounts. PyPI and npm publication, ClawHub listings and browser-store submission remain pending their publisher setup. The GitHub `pypi` environment and Pages source are configured; the PyPI trusted-publisher record must still be @@ -54,3 +54,7 @@ while the Codex path has live end-to-end coverage. Before a local `docker build`, run `make build` to generate the web operations schema. The release container job performs this step, then checks that the built image starts the CLI and contains the compiled web interface. + +The release workflow runs WXT from the browser project directory. Its Firefox +source archive contains the tagged repository plus the generated web operations +schema, including the shared web sources imported by the extension. diff --git a/bin/papervizagent.mjs b/bin/papervizagent.mjs index cf603c9..2300430 100644 --- a/bin/papervizagent.mjs +++ b/bin/papervizagent.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -const child = spawnSync("uvx", ["--from", "papervizagent==0.3.0", "papervizagent", ...process.argv.slice(2)], { +const child = spawnSync("uvx", ["--from", "papervizagent==0.4.0", "papervizagent", ...process.argv.slice(2)], { stdio: "inherit", }); if (child.error) throw child.error; diff --git a/dev.toolfactory/lock.json b/dev.toolfactory/lock.json index b6c1529..a3b017a 100644 --- a/dev.toolfactory/lock.json +++ b/dev.toolfactory/lock.json @@ -1,5 +1,5 @@ { - "toolfactoryVersion": "0.1.2", + "toolfactoryVersion": "0.2.1", "files": { ".agents/mcp/servers.json": { "sha256": "15346e6ef8e1b2f1413cb7456c38921a4af1bd9847ab11d03a8c07627ee2a35b", @@ -12,8 +12,13 @@ ] }, ".agents/plugins/marketplace.json": { - "sha256": "c9ecadf972ed7daac4e2b75fa208a1c59ce5554102601db6af9beae0d2604df5", - "state": "generated" + "sha256": "87426f138bd5d677d11bc69ba106d23871e6d7a998ae8a09f0df4d061f4e334a", + "state": "generated", + "keys": [ + "name", + "interface.displayName", + "plugins" + ] }, ".agents/README.md": { "sha256": "617acad815520ca8ee30e928e5e7a890aa2953287d4f849bd98a7475d0d71359", @@ -42,24 +47,124 @@ "state": "generated" }, ".claude-plugin/marketplace.json": { - "sha256": "cab4001bf5657c24be45490b116dc699736f0189d4f489d4f04614f029e99aeb", - "state": "generated" + "sha256": "adba1609cf9f1f9a152c4040b7f69debe3c72d3d083a4ff5e3e7aca8b1be919b", + "state": "generated", + "keys": [ + "$schema", + "name", + "description", + "owner.name", + "plugins" + ] }, ".claude-plugin/plugin.json": { - "sha256": "214ce4cb32f7d4ff9352fcd1b8d890dfbe5fe5333c5d8194f5eab6160dd28066", - "state": "generated" + "sha256": "f232488d642b84e1ede5d7a37946d5636356a404f84b94547cff26ff028d8cbd", + "state": "generated", + "keys": [ + "$schema", + "name", + "version", + "description", + "author.name", + "author.email", + "author.url", + "homepage", + "repository", + "license", + "keywords", + "userConfig.papervizagent_config.type", + "userConfig.papervizagent_config.title", + "userConfig.papervizagent_config.description", + "userConfig.codex_oauth_token.type", + "userConfig.codex_oauth_token.title", + "userConfig.codex_oauth_token.description", + "userConfig.codex_oauth_token.sensitive", + "userConfig.codex_account_id.type", + "userConfig.codex_account_id.title", + "userConfig.codex_account_id.description", + "userConfig.google_api_key.type", + "userConfig.google_api_key.title", + "userConfig.google_api_key.description", + "userConfig.google_api_key.sensitive", + "userConfig.openai_api_key.type", + "userConfig.openai_api_key.title", + "userConfig.openai_api_key.description", + "userConfig.openai_api_key.sensitive", + "userConfig.anthropic_api_key.type", + "userConfig.anthropic_api_key.title", + "userConfig.anthropic_api_key.description", + "userConfig.anthropic_api_key.sensitive", + "mcpServers.papervizagent.command", + "mcpServers.papervizagent.args", + "mcpServers.papervizagent.env.PAPERVIZAGENT_CONFIG", + "mcpServers.papervizagent.env.CODEX_OAUTH_TOKEN", + "mcpServers.papervizagent.env.CODEX_ACCOUNT_ID", + "mcpServers.papervizagent.env.GOOGLE_API_KEY", + "mcpServers.papervizagent.env.OPENAI_API_KEY", + "mcpServers.papervizagent.env.ANTHROPIC_API_KEY" + ] }, ".claude/settings.json": { "sha256": "5ee165b2a1f069ac273d89ac660cca5992893a63fab8a96f93ffb625b507a538", "state": "generated" }, ".codex-plugin/plugin.json": { - "sha256": "a7aee6295c4fec47f10048f5ca2d9acacbfdcde55d59ede5902d2345753c3190", - "state": "generated" + "sha256": "ccfc02cf2168b0c1472cc8916ca65b254da106f6ff7e8ee9e281fb5a7153a904", + "state": "generated", + "keys": [ + "name", + "version", + "description", + "author.name", + "homepage", + "repository", + "license", + "keywords", + "skills", + "interface.displayName", + "interface.shortDescription", + "interface.longDescription", + "interface.developerName", + "interface.category", + "interface.capabilities", + "interface.defaultPrompt" + ] }, ".cursor-plugin/plugin.json": { - "sha256": "23b3e883599a2b0cbb4ccd6fb7a7cda84fb951b045dadf988d356c821f9e5e58", - "state": "generated" + "sha256": "7398249bdcf942d15f66d3c12307507c3bb15592e63d4b3b3fa91cfcb85e138c", + "state": "generated", + "keys": [ + "name", + "version", + "description", + "author.name", + "homepage", + "repository", + "license", + "keywords", + "skills", + "mcpServers", + "variables.type", + "variables.properties.PAPERVIZAGENT_CONFIG.type", + "variables.properties.PAPERVIZAGENT_CONFIG.title", + "variables.properties.PAPERVIZAGENT_CONFIG.description", + "variables.properties.CODEX_OAUTH_TOKEN.type", + "variables.properties.CODEX_OAUTH_TOKEN.title", + "variables.properties.CODEX_OAUTH_TOKEN.description", + "variables.properties.CODEX_ACCOUNT_ID.type", + "variables.properties.CODEX_ACCOUNT_ID.title", + "variables.properties.CODEX_ACCOUNT_ID.description", + "variables.properties.GOOGLE_API_KEY.type", + "variables.properties.GOOGLE_API_KEY.title", + "variables.properties.GOOGLE_API_KEY.description", + "variables.properties.OPENAI_API_KEY.type", + "variables.properties.OPENAI_API_KEY.title", + "variables.properties.OPENAI_API_KEY.description", + "variables.properties.ANTHROPIC_API_KEY.type", + "variables.properties.ANTHROPIC_API_KEY.title", + "variables.properties.ANTHROPIC_API_KEY.description", + "variables.required" + ] }, ".cursor/environment.json": { "sha256": "559393387fffd75060e1c2b09ea284969712a8976ba13720bb3854d1712002a3", @@ -73,6 +178,16 @@ "sha256": "710e6c9d3fc037871ede60912a009223fccc4e6dd6fc9880e57911bab3677ccf", "state": "generated" }, + ".dockerignore": { + "sha256": "9ba62e0cf3fdcb752e8ef2bd7a54b7505e70ebe01b385370f43323c3b6e275dd", + "state": "generated", + "regions": [ + { + "begin": "# tf:dockerignore", + "end": "# /tf:dockerignore" + } + ] + }, ".env.example": { "sha256": "9399c5a88b0c66b0286cfce57dc6355626fb1e6b3d1c6c79dcc7abe3a9e71591", "state": "generated" @@ -86,7 +201,7 @@ "state": "manual" }, ".github/workflows/ci.yml": { - "sha256": "468a8558f283da202704065f31a30027716eb195ea34fba0437c0bd233ff8e43", + "sha256": "098d6f2fa1361dd8273c4f2510316e4a1121cd65fc24bd7fe4d1f2b8a704bc57", "state": "generated" }, ".github/workflows/copilot-setup-steps.yml": { @@ -112,7 +227,7 @@ "state": "manual" }, "bin/papervizagent.mjs": { - "sha256": "151d7dbfd3cb319107b2aa07b81ff9e5e58ce00258acc5c60d1b8233a7653fa3", + "sha256": "e77728870f5cb2eed0019fc16f5cdc9a379492852fdeb37d432b4e62f9d5d832", "state": "generated" }, "compose.toolfactory.yaml": { @@ -120,7 +235,7 @@ "state": "generated" }, "COVERAGE.md": { - "sha256": "07d92eee3f03d6aeb41c102680745de19b1aad1d837d6976f245f9da5cd86a72", + "sha256": "26da1872538719f065bd8caf6fb09fc2bcd36c3a716617566569857e5964a78a", "state": "generated" }, "dev.toolfactory/coverage.json": { @@ -132,7 +247,7 @@ "state": "generated" }, "dev.toolfactory/tool.schema.json": { - "sha256": "2608c18d3889e6d846e463ba044f7ce80af09eb8e6f43edb7656646da231774f", + "sha256": "41a69c7cbcaad8741f80559f127eb32b43bd9e4147e6f958639d9e914e9f7e61", "state": "generated" }, "Dockerfile": { @@ -140,8 +255,17 @@ "state": "manual" }, "gemini-extension.json": { - "sha256": "73b95fee2554a439b7bbd9b2d495893f2bc54859723f90341cb47f5e03250563", - "state": "generated" + "sha256": "78abcf22de834de213589befefd48f228208103bbdf7efed1b01c578e0a78852", + "state": "generated", + "keys": [ + "name", + "version", + "description", + "mcpServers.papervizagent.command", + "mcpServers.papervizagent.args", + "contextFileName", + "settings" + ] }, "hosts/browser/.gitignore": { "sha256": "b727faab67a7bdc19094e7915a3b0b343b3d8af60bbb0db49401e088ae9b06a5", @@ -188,7 +312,7 @@ "state": "generated" }, "hosts/browser/package.json": { - "sha256": "44d1b46abd2827cdfacee8f57e4cd1470848ce7c64598ae248901bbd2f9a9582", + "sha256": "dfefd1b5909bedb441394f9f8a76ea150efa48af0156131ace3023e93bbd0161", "state": "generated", "keys": [ "name", @@ -271,28 +395,80 @@ ] }, "hosts/dsh/cordis.local.patch.yml": { - "sha256": "d2e66deacfcb9205d8c0e81542cfd8548d31c6dba69418497cf3aabd1a251601", - "state": "generated" + "sha256": "420c80b800cbc779293517b6f28470d19e770fc60a1f5bfa2a2817ca782a94f2", + "state": "generated", + "regions": [ + { + "begin": "# tf:mcp", + "end": "# /tf:mcp" + } + ] }, "hosts/dsh/cordis.patch.yml": { - "sha256": "bc93ef478c97d321f34ce31d777d5f77083c688f88b70956db869b8c09319a97", - "state": "generated" + "sha256": "77cbf0305ea06fa814d6ad05ae1bfd0844c2731838f687fccda7a03599bcd11b", + "state": "generated", + "regions": [ + { + "begin": "# tf:mcp", + "end": "# /tf:mcp" + } + ] }, "hosts/dsh/package.json": { - "sha256": "7654fcca02bc3412949ae6a4db757de1424cd49da6b1adbb993e7e1d9df434b4", - "state": "generated" + "sha256": "4ae45bb1b484df3f93548cb65f7340f54592611ab3e26551225095293a9a1d99", + "state": "generated", + "keys": [ + "name", + "version", + "description", + "license", + "files", + "dsh.bundle.patch" + ] }, "hosts/hermes/papervizagent_hermes/__init__.py": { - "sha256": "cf6a52b68cc0d03fb87f1d706ea26dc2cd63373bd039cf5b7be55ac3c4c87a85", - "state": "generated" + "sha256": "ce74cc0cc27e21e2fb68bbe5cf7392d493b2b15a5837075fe7da03d3145ac729", + "state": "generated", + "regions": [ + { + "begin": "# tf:init", + "end": "# /tf:init" + } + ] }, "hosts/hermes/papervizagent_hermes/plugin.yaml": { - "sha256": "6ff327d7bc6a2f3148f06cbf8cb0b3916b546ec53d496992efe765e9d8e60e86", - "state": "generated" + "sha256": "887ca0b8136a95936e16beb95e481af20fc7c0d37c5b70fada2826b3647307be", + "state": "generated", + "keys": [ + "manifest_version", + "name", + "version", + "description", + "author", + "license", + "homepage", + "provides_tools", + "optional_env", + "python_dependencies" + ] }, "hosts/hermes/pyproject.toml": { - "sha256": "907a6fad06f96ccfec75b9cfcb561a5598a8a5a0907efae956cf7b0e8645eac8", - "state": "generated" + "sha256": "ce6c861244ea2b83fc4288a0ac8dc3b82e0fbce04c6aee130b21a29c48491a33", + "state": "generated", + "keys": [ + "project.name", + "project.version", + "project.description", + "project.license", + "project.requires-python", + "project.dependencies", + "project.entry-points.hermes_agent.plugins.papervizagent", + "build-system.requires", + "build-system.build-backend", + "tool.hatch.build.targets.wheel.packages", + "tool.uv.sources.papervizagent.path", + "tool.uv.sources.papervizagent.editable" + ] }, "hosts/hermes/README.md": { "sha256": "9aa5e36be28a8693335c0d581eda1952fdff67825f8f0e5b0243b16c1f02c2ef", @@ -303,7 +479,7 @@ "state": "generated" }, "hosts/mcpb/manifest.json": { - "sha256": "c705c11144a6e519eb08876e114cdfb1ae8e0dbe1f2b1c4e6f3dd8368bff1b96", + "sha256": "1eb98a562ac5d3a3e3066677e6990e192de936c12465c211200021156818005d", "state": "generated" }, "hosts/openclaw/.npmrc": { @@ -319,12 +495,63 @@ "state": "generated" }, "hosts/openclaw/openclaw.plugin.json": { - "sha256": "a1e35dbcaa3417e4d19aaac24a5fc256a8f653ff8fdbfc2678c44f492aab0df7", - "state": "generated" + "sha256": "af646fbc0ca4a81d99cd5dae9a53953fcd17ebaa0123f5e77f4e6f2e34048da2", + "state": "generated", + "keys": [ + "id", + "name", + "description", + "version", + "configSchema.type", + "configSchema.properties.papervizagent_config.type", + "configSchema.properties.papervizagent_config.description", + "configSchema.properties.codex_oauth_token.type", + "configSchema.properties.codex_oauth_token.description", + "configSchema.properties.codex_oauth_token.x-toolfactory.sensitive", + "configSchema.properties.codex_account_id.type", + "configSchema.properties.codex_account_id.description", + "configSchema.properties.google_api_key.type", + "configSchema.properties.google_api_key.description", + "configSchema.properties.google_api_key.x-toolfactory.sensitive", + "configSchema.properties.openai_api_key.type", + "configSchema.properties.openai_api_key.description", + "configSchema.properties.openai_api_key.x-toolfactory.sensitive", + "configSchema.properties.anthropic_api_key.type", + "configSchema.properties.anthropic_api_key.description", + "configSchema.properties.anthropic_api_key.x-toolfactory.sensitive", + "uiHints.codex_oauth_token.sensitive", + "uiHints.google_api_key.sensitive", + "uiHints.openai_api_key.sensitive", + "uiHints.anthropic_api_key.sensitive", + "activation.onStartup", + "contracts.tools" + ] }, "hosts/openclaw/package.json": { - "sha256": "840ded6891667d532fb2b7bbaa7d234230c925451d0449039c681f55d3fe2f32", - "state": "generated" + "sha256": "d0254adfde2b737ebb81483ad4de1c006088af5f16cf5fda17f88dd4c0c8ff90", + "state": "generated", + "keys": [ + "name", + "version", + "description", + "type", + "private", + "scripts.build", + "scripts.plugin:build", + "scripts.plugin:validate", + "scripts.test", + "scripts.test:e2e", + "files", + "peerDependencies.openclaw", + "dependencies.typebox", + "devDependencies.openclaw", + "devDependencies.typescript", + "devDependencies.vitest", + "devDependencies.@copilotkit/aimock", + "openclaw.extensions", + "openclaw.compat.pluginApi", + "openclaw.build.openclawVersion" + ] }, "hosts/openclaw/README.md": { "sha256": "0e7e313cd98fdc62fec4515a201235a529c111f7c4289258b9b1d835198144fb", @@ -357,11 +584,17 @@ "state": "generated" }, "mcp.json": { - "sha256": "9604dfa9aac2eea23bf19722fcc127ac54cd91a09e6ef5750f8caddf4cf40a71", - "state": "generated" + "sha256": "07f1809d8324578e7ca90df6e3157b15bb1172eb2009a875d9c2fc5e8ef85d4c", + "state": "generated", + "keys": [ + "$schema", + "mcpServers.papervizagent.type", + "mcpServers.papervizagent.command", + "mcpServers.papervizagent.args" + ] }, "package.json": { - "sha256": "088311c39418186a297ce5ce646d0c10011a5f8de32853143b2cb582e9a609b9", + "sha256": "87fca59451f58aa366fcc7c9e7aa63892fc7126868a4ea7882701751807158de", "state": "generated", "keys": [ "name", @@ -379,7 +612,7 @@ ] }, "pyproject.toml": { - "sha256": "c4525e1be9c3ed3c1708202a09033b52ef06ebd0ac5958d179d332d6542f92b6", + "sha256": "f523d2799e08b825f5c995f181787d02a24b61cf216fc3186a8c6566ef26427d", "state": "generated", "keys": [ "project.name", @@ -400,7 +633,7 @@ ] }, "README.md": { - "sha256": "b100c0a1c34235490fb69594b2ac4b32bd7f1ce70a78f83f6857a73e080fd017", + "sha256": "16c0f7180140524133ab26df12061b3e40e2b2c6d93beabb9cd430257e0d15b6", "state": "generated", "regions": [ { @@ -424,7 +657,7 @@ ] }, "server.json": { - "sha256": "0a9225dbba5130c2a814590ffdea875fd1366eeb391f88cd9ffe367076ae219b", + "sha256": "bf3be8fe1b0d92b0a2686607e9244a48764ca9cb153f1fc648d0524de12eaad9", "state": "generated" }, "skills/papervizagent/SKILL.md": { @@ -454,7 +687,7 @@ "state": "generated" }, "src/papervizagent/toolfactory/mcp.py": { - "sha256": "ce342c6b9c7126bc16ad513195638a39ba3697b5007bbcdc8c9c9c2906f2d28e", + "sha256": "d56d14ee87533952a2c5ff2e9c5b41bb567cc49d618d63bc082cb9b7a7ccf6e9", "state": "generated" }, "src/papervizagent/toolfactory/types.py": { @@ -478,7 +711,7 @@ "state": "generated" }, "web/package.json": { - "sha256": "c9627ffab1618967ea95eebef7d2c677294d00e7d9ba9465d0ed95745d2b792d", + "sha256": "2bb75e6f57a4829ca5ccef45ebbccab53e1ed31567c499f265d2b4e0f16c9224", "state": "generated", "keys": [ "name", @@ -538,7 +771,7 @@ "state": "generated" }, "web/src/ops.json": { - "sha256": "52b7ea0927109aea4a729adcca629f3179c1b7988f632c135397fdd93098700d", + "sha256": "695e840101cda22c9ddee63699a464ededdb05e355031b341863d238508d41f4", "state": "generated" }, "web/src/schema-form.tsx": { diff --git a/dev.toolfactory/tool.json b/dev.toolfactory/tool.json index df83a3b..1673654 100644 --- a/dev.toolfactory/tool.json +++ b/dev.toolfactory/tool.json @@ -83,5 +83,10 @@ } } } + }, + "openclaw": { + "devDependencies": { + "openclaw": "2026.9.2" + } } } diff --git a/dev.toolfactory/tool.schema.json b/dev.toolfactory/tool.schema.json index ab4bfdc..0c6e037 100644 --- a/dev.toolfactory/tool.schema.json +++ b/dev.toolfactory/tool.schema.json @@ -55,7 +55,20 @@ "readme", "gemini", "mcpb", - "browser-extension" + "browser-extension", + "vscode-extension", + "cargo", + "nuget", + "maven-central", + "rubygems", + "packagist", + "go-module", + "homebrew", + "winget", + "scoop", + "chocolatey", + "apt", + "rpm" ] }, "description": "Selected surfaces. A file exists iff a selected surface owns it." @@ -208,6 +221,163 @@ } } }, + "vscode": { + "type": "object", + "properties": { + "publisher": { + "description": "Your registered Visual Studio Marketplace/Open VSX publisher; repository owner is the local packaging default.", + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]*$" + }, + "marketplace": { + "default": true, + "type": "boolean" + }, + "openvsx": { + "default": true, + "type": "boolean" + } + } + }, + "nativePackages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "cargo", + "nuget", + "maven-central", + "rubygems", + "packagist", + "go-module" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "identity": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Registry package identity, including its required namespace." + }, + "versionCommand": { + "description": "Command run in path that prints exactly the authored native package version.", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "path", + "identity", + "name" + ], + "additionalProperties": false + } + }, + "systemPackages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "homebrew", + "winget", + "scoop", + "chocolatey", + "apt", + "rpm" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "identity": { + "type": "string", + "minLength": 1, + "description": "Native package metadata path relative to path." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Native package/catalog identity; never inferred from Toolfactory name." + }, + "versionCommand": { + "type": "string", + "minLength": 1, + "description": "Command run in path that prints only the authored package version." + }, + "buildCommand": { + "description": "Author-owned native build command, run in path before an archive or installer is staged.", + "type": "string", + "minLength": 1 + }, + "buildDependencies": { + "default": [], + "description": "Native OS packages installed by the Debian or Fedora CI builder.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "asset": { + "description": "Repository-relative, author-built archive or installer when the manager consumes one.", + "type": "string", + "minLength": 1 + }, + "tap": { + "description": "Author-controlled Homebrew tap (owner/homebrew-tap).", + "type": "string", + "minLength": 1 + }, + "bucket": { + "description": "Author-controlled Scoop bucket repository (owner/repo).", + "type": "string", + "minLength": 1 + }, + "bucketName": { + "description": "Local Scoop bucket name used by install commands.", + "type": "string", + "minLength": 1 + }, + "catalogPath": { + "description": "Repository-relative destination inside the author-controlled tap or bucket.", + "type": "string", + "minLength": 1 + }, + "ppa": { + "description": "Launchpad PPA as owner/archive.", + "type": "string", + "minLength": 1 + }, + "coprProject": { + "description": "COPR project name (owner/project).", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "path", + "identity", + "name", + "versionCommand" + ], + "additionalProperties": false + } + }, "openclaw": { "type": "object", "properties": { diff --git a/evals/completion-events/README.md b/evals/completion-events/README.md new file mode 100644 index 0000000..2c4defb --- /dev/null +++ b/evals/completion-events/README.md @@ -0,0 +1,39 @@ +# Completion-event verification + +Live smoke run on September 9, 2026 with official Codex SDK managed sign-in. +The account catalog verified `gpt-5.6-luna` with low effort; this override was +confined to the smoke configuration and did not change product defaults. + +The real upstream VanillaAgent generated an `Input → Transform → Output` +diagram through `generate_events`. The consumer received exactly six pushed +events: run started, candidate started, role started, role completed, candidate +completed, run completed. Sequences were contiguous and run/candidate/call IDs +matched. Candidate files existed before candidate completion, and the trace +existed before run completion. The final result retained `candidates` and `trace`. +No timer, status query, scheduled follow-up or generation retry was used. + +Exactly one image call, one small text call and one image-input vision call ran +in three distinct ephemeral Codex threads: + +| Role/modality | Thread ID | +|---|---| +| Planner / text | `01a08826-321b-7803-9584-8694a254ce25` | +| Vanilla / image | `01a08826-4275-7e92-9479-73559e582fd4` | +| Critic / vision | `01a08827-07ec-7220-bc23-7a56d1f676ea` | + +The text check returned `EVENT_OK`; the vision check correctly read the three +labels and two rightward arrows. The actual artifact was also visually inspected. +Vanilla mode has no critic loop: its stop reason was null, not critic approval. +The vision call was a label-reading smoke test, not a full Critic acceptance. + +The final change also passes 44 tests and 52 parameterized subtests. They cover +concurrent correlation, queued-candidate timing, partial results, failure +notifications, cancellation and iterator cleanup. The queued-candidate event was +moved inside the semaphore after the live smoke; that concurrency correction +was verified with a gated regression test, without another paid image run. +An independent skill forward-test confirmed one background coordinator, return +of main-session control, and delivery through the native completion channel. + +Build/check, skill validation and package generation passed. The wheel was +inspected for the exact updated runtime files. This smoke does not establish +new transport-level progress support, API-provider coverage or benchmark scores. diff --git a/gemini-extension.json b/gemini-extension.json index 7e41023..bfd0722 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,13 +1,13 @@ { "name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "mcpServers": { "papervizagent": { "command": "uvx", "args": [ "--from", - "papervizagent==0.3.0", + "papervizagent==0.4.0", "papervizagent", "mcp" ] diff --git a/hosts/browser/package.json b/hosts/browser/package.json index c0249dd..1438978 100644 --- a/hosts/browser/package.json +++ b/hosts/browser/package.json @@ -2,7 +2,7 @@ "name": "papervizagent-browser", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "wxt", diff --git a/hosts/dsh/cordis.local.patch.yml b/hosts/dsh/cordis.local.patch.yml index 89f86a9..54ef6c9 100644 --- a/hosts/dsh/cordis.local.patch.yml +++ b/hosts/dsh/cordis.local.patch.yml @@ -1,3 +1,4 @@ +# tf:mcp - insert: - id: mcp-papervizagent name: "@deepseek-ai/dsh-mcp-client" @@ -19,3 +20,6 @@ OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY ?? "" ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY ?? "" failOnStartupError: true +# /tf:mcp + +# Yours: add Cordis rows or local code configuration below this marker. diff --git a/hosts/dsh/cordis.patch.yml b/hosts/dsh/cordis.patch.yml index a65ac73..ed50089 100644 --- a/hosts/dsh/cordis.patch.yml +++ b/hosts/dsh/cordis.patch.yml @@ -1,3 +1,4 @@ +# tf:mcp - insert: - id: mcp-papervizagent name: "@deepseek-ai/dsh-mcp-client" @@ -7,7 +8,7 @@ command: uvx args: - --from - - papervizagent==0.3.0 + - papervizagent==0.4.0 - papervizagent - mcp env: @@ -18,3 +19,6 @@ OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY ?? "" ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY ?? "" failOnStartupError: true +# /tf:mcp + +# Yours: add Cordis rows or local code configuration below this marker. diff --git a/hosts/dsh/package.json b/hosts/dsh/package.json index 060d822..01264a1 100644 --- a/hosts/dsh/package.json +++ b/hosts/dsh/package.json @@ -1,6 +1,6 @@ { "name": "papervizagent-dsh", - "version": "0.3.0", + "version": "0.4.0", "description": "DSH bundle for papervizagent: attaches its MCP server through @deepseek-ai/dsh-mcp-client.", "license": "Apache-2.0", "files": [ diff --git a/hosts/hermes/papervizagent_hermes/__init__.py b/hosts/hermes/papervizagent_hermes/__init__.py index 9f7016a..373faee 100644 --- a/hosts/hermes/papervizagent_hermes/__init__.py +++ b/hosts/hermes/papervizagent_hermes/__init__.py @@ -1,4 +1,4 @@ -# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate. +# tf:init# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate. """Hermes plugin for papervizagent, registered through PluginContext. Every handler returns a JSON string and never raises: Hermes dispatches tool @@ -221,7 +221,7 @@ def call(args: dict, **_kwargs: Any) -> str: return call -def register(ctx: Any) -> None: +def _register_generated(ctx: Any) -> None: # Hermes' own per-plugin data directory, through the variable every host uses. os.environ.setdefault("PAPERVIZAGENT_DATA_DIR", str(ctx.state.data_dir)) for schema in TOOLS: @@ -234,3 +234,14 @@ def register(ctx: Any) -> None: requires_env=list(REQUIRES_ENV), description=schema["description"], ) + + +def register(ctx: Any) -> None: + _register_generated(ctx) + register_native(ctx) +# /tf:init + + +# Yours: use the full PluginContext here. This hook runs after Toolfactory's generated tools. +def register_native(ctx: Any) -> None: + pass diff --git a/hosts/hermes/papervizagent_hermes/plugin.yaml b/hosts/hermes/papervizagent_hermes/plugin.yaml index a93b111..3a0bbf7 100644 --- a/hosts/hermes/papervizagent_hermes/plugin.yaml +++ b/hosts/hermes/papervizagent_hermes/plugin.yaml @@ -1,7 +1,6 @@ -# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate. manifest_version: 1 name: papervizagent -version: 0.3.0 +version: 0.4.0 description: Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available. @@ -34,4 +33,4 @@ optional_env: description: Optional Anthropic API key. secret: true python_dependencies: - - papervizagent>=0.3.0,<1 + - papervizagent>=0.4.0,<1 diff --git a/hosts/hermes/pyproject.toml b/hosts/hermes/pyproject.toml index 428b7a9..50732ad 100644 --- a/hosts/hermes/pyproject.toml +++ b/hosts/hermes/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "papervizagent-hermes-plugin" -version = "0.3.0" +version = "0.4.0" description = "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available. — Hermes plugin." license = "Apache-2.0" requires-python = ">=3.11" -dependencies = [ "papervizagent>=0.3.0,<1" ] +dependencies = [ "papervizagent>=0.4.0,<1" ] [project.entry-points."hermes_agent.plugins"] papervizagent = "papervizagent_hermes:register" diff --git a/hosts/mcpb/manifest.json b/hosts/mcpb/manifest.json index 7a5dd99..c3aab36 100644 --- a/hosts/mcpb/manifest.json +++ b/hosts/mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.4", "name": "papervizagent", "display_name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" diff --git a/hosts/openclaw/openclaw.plugin.json b/hosts/openclaw/openclaw.plugin.json index 30d8800..af283c5 100644 --- a/hosts/openclaw/openclaw.plugin.json +++ b/hosts/openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "papervizagent", "name": "Papervizagent", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", - "version": "0.3.0", + "version": "0.4.0", "configSchema": { "type": "object", "properties": { diff --git a/hosts/openclaw/package.json b/hosts/openclaw/package.json index 35a23ae..cb0f900 100644 --- a/hosts/openclaw/package.json +++ b/hosts/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-plugin-papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "type": "module", "private": true, @@ -23,7 +23,7 @@ "typebox": "^1.1.38" }, "devDependencies": { - "openclaw": "latest", + "openclaw": "2026.9.2", "typescript": "^5.9.0", "vitest": "^3.2.0", "@copilotkit/aimock": "^1.39.0" diff --git a/mcp.json b/mcp.json index 0f8b9b4..dc42eb2 100644 --- a/mcp.json +++ b/mcp.json @@ -6,7 +6,7 @@ "command": "uvx", "args": [ "--from", - "papervizagent==0.3.0", + "papervizagent==0.4.0", "papervizagent", "mcp" ] diff --git a/package.json b/package.json index 297aded..c5463aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "license": "Apache-2.0", "homepage": "https://github.com/GoatInAHat/PaperVizAgent", diff --git a/plugin.json b/plugin.json index ec35f81..1329e35 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "license": "Apache-2.0", "repository": "https://github.com/GoatInAHat/PaperVizAgent", diff --git a/pyproject.toml b/pyproject.toml index f90f667..a0b65fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "papervizagent" -version = "0.3.0" +version = "0.4.0" description = "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available." readme = "README.md" requires-python = ">=3.11" diff --git a/server.json b/server.json index b069459..3160f9b 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.GoatInAHat/papervizagent", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and", - "version": "0.3.0", + "version": "0.4.0", "repository": { "url": "https://github.com/GoatInAHat/PaperVizAgent", "source": "github" @@ -12,7 +12,7 @@ { "registryType": "pypi", "identifier": "papervizagent", - "version": "0.3.0", + "version": "0.4.0", "transport": { "type": "stdio" }, @@ -49,7 +49,7 @@ }, { "registryType": "oci", - "identifier": "ghcr.io/goatinahat/papervizagent:0.3.0", + "identifier": "ghcr.io/goatinahat/papervizagent:0.4.0", "transport": { "type": "stdio" }, diff --git a/skills/papervizagent/SKILL.md b/skills/papervizagent/SKILL.md index 7665792..da2bb31 100644 --- a/skills/papervizagent/SKILL.md +++ b/skills/papervizagent/SKILL.md @@ -9,11 +9,37 @@ license: Apache-2.0 # PaperVizAgent Run Google Research PaperVizAgent's reference-driven roles in **separate role -contexts**. The current agent coordinates; it must not impersonate the Retriever, +contexts**. A coordinator must not impersonate the Retriever, Planner, Stylist, Visualizer, or Critic in one conversation. This is an independent port of the pinned official implementation; model and host substitutions are documented in [compatibility.md](references/compatibility.md). +## Background execution and completion + +When the host supports background workers with completion notifications, dispatch +one background coordinator for the whole requested run. Give it the source paths, +constraints, resolved configuration, destination, and image budget. That worker +owns role dispatch, handoffs, critique, artifact review, and the final result; +each model role still gets a fresh context. Use this section as a dispatch rule +for the main session, not a reason for the worker to spawn another coordinator. + +Register or establish the host's completion channel when dispatching, save the +returned worker/task ID, then return control to the user. Resume delivery on the +worker's completion or failure notification. Keep intermediate role results and +logs in the worker; surface only a required user decision or the reviewed result. +Do not create scheduled check-ins, heartbeat automations, sleep loops, or repeated +file/status reads to discover completion. A user-requested status check can read +the current state once without restarting work. + +For runtime execution, the background worker awaits `generate` once. Embedded +Python callers can consume `generate_events` from `papervizagent.ops`, or pass +an async `on_event` callback to `generate`; see +[completion.md](references/completion.md). CLI integrations use process-exit +notifications, and MCP/web integrations await the existing request's result. +These are completion channels, not detached jobs. If the host cannot deliver +background completion, keep one awaited call and explain that limitation; +do not promise an unsolicited message after the session has ended. + ## Capability resolution and role execution Inspect the host tools once before starting. If the runtime is connected, call diff --git a/skills/papervizagent/references/completion.md b/skills/papervizagent/references/completion.md new file mode 100644 index 0000000..ca4def4 --- /dev/null +++ b/skills/papervizagent/references/completion.md @@ -0,0 +1,69 @@ +# Completion events + +The host owns the background task and its lifetime. In an agent host with native +background completion, one coordinator runs the workflow outside the main +conversation and sends its final reviewed result through that host's completion +channel. Dependent roles start only after their inputs have actually completed. +Preserve the worker ID so a status request or cancellation targets the existing +run. No scheduled task or status-polling loop is needed. + +## Runtime integrations + +`await generate(args, ctx)` retains its existing result shape. CLI callers start +one child process and handle its exit; MCP, browser and web clients await their +existing tool-call response. Run these calls inside the host's background worker +when the host must remain interactive. Do not detach a coroutine from a CLI +process that exits, or assume an MCP server can wake a closed host conversation. + +Embedded Python hosts can subscribe to the same run before it starts: + +```python +from contextlib import aclosing +from pathlib import Path + +from papervizagent.ops import GenerateInput, generate_events +from papervizagent.toolfactory import Context + +async def render_in_background(data, deliver): + args = GenerateInput(data=data) + ctx = Context(config={}, data_dir=Path("outputs/figures").resolve()) + async with aclosing(generate_events(args, ctx)) as events: + async for event in events: + if event["type"] == "run.completed": + await deliver(event["result"]) +``` + +The application owns and retains the task running this coroutine. It may instead +call `await generate(args, ctx, on_event=handle_event)` with an async callback. +The callback runs in sequence and should return promptly; a callback failure is +propagated to the owner. The iterator waits on a queue for pushed events, without +a timer. Exiting its `aclosing` scope cancels and joins unfinished work. An +exception from the run is re-raised after its failure event. The owner should +handle that exception and deliver one failure report, not duplicate messages for +every role/candidate/run failure event. + +Events carry `type`, `run_id`, `sequence`, and UTC `timestamp`: + +| Event | Additional fields | +|---|---| +| `run.started` | `run_dir`, `num_candidates` | +| `candidate.started` | `candidate_id` | +| `role.started`, `role.completed` | `candidate_id`, `call_id`, `role`, `modality` | +| `role.failed`, `role.cancelled` | Same correlation fields; failures add `error_type` | +| `candidate.completed` | `candidate_id`, `artifact`, `result`, `critic_stop_reason`, `status` | +| `candidate.failed`, `candidate.cancelled` | `candidate_id`; failures add `error_type` | +| `run.completed` | `result` (the ordinary generate result), `status` | +| `run.failed`, `run.cancelled` | `trace`; failures add `error_type` | + +`call_id` identifies the observed role invocation; it is not a provider request +ID. Actual provider/thread IDs remain in the saved trace. Events exclude model +prompts, image bytes, credentials, provider responses and raw exception text. +They expose local artifact paths and should stay within the caller's chosen host. +`role.completed` means the model request returned, not that its result passed +Critic validation. `run.completed` means the pipeline returned its result; inspect +candidate status and `critic_stop_reason` for partial results or `round_limit`. +Visual review is still required before presenting a figure as the selected output. + +This is a live subscription, not a durable job service. The process and transport +must remain alive. After a disconnect or restart, inspect saved artifacts once +and resume deliberately; reconnecting must not silently start another paid run. diff --git a/src/papervizagent/backends.py b/src/papervizagent/backends.py index 6e2f865..2492c30 100644 --- a/src/papervizagent/backends.py +++ b/src/papervizagent/backends.py @@ -6,6 +6,7 @@ import json import os import tempfile +from contextlib import suppress from pathlib import Path from typing import Any from uuid import uuid4 @@ -169,6 +170,7 @@ async def _codex_generate(self, spec, role, modality, system, contents, options) record['unavailable_upstream_options'] = sorted(set(options) - supported) self.trace.append(record) texts, images = [], [] + terminal = False try: while True: event = await client.next_turn_notification(tid) @@ -182,11 +184,16 @@ async def _codex_generate(self, spec, role, modality, system, contents, options) elif item.saved_path: images.append(base64.b64encode(Path(item.saved_path.root).read_bytes()).decode()) elif event.method == 'turn/completed': + terminal = True if event.payload.turn.status.value != 'completed': raise RuntimeError('Codex inference failed or was interrupted; check authentication and account limits. Manually supplied OAuth tokens must be refreshed by their owner.') break except BaseException: - await client.turn_interrupt(thread.thread.id, tid) + # A terminal event needs no interrupt. If cancellation races with + # completion, cleanup must not replace the original failure. + if not terminal: + with suppress(Exception): + await client.turn_interrupt(thread.thread.id, tid) raise finally: client.unregister_turn_notifications(tid) diff --git a/src/papervizagent/events.py b/src/papervizagent/events.py new file mode 100644 index 0000000..791641b --- /dev/null +++ b/src/papervizagent/events.py @@ -0,0 +1,56 @@ +"""In-process lifecycle notifications; the caller owns the task and transport.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from .upstream.utils.inference import InferenceBackend + +EventSink = Callable[[dict[str, Any]], Awaitable[None]] + + +class RunEvents: + def __init__(self, run_id: str, sink: EventSink | None): + self.run_id = run_id + self.sink = sink + self.sequence = 0 + self._lock = asyncio.Lock() + + async def emit(self, event_type: str, **fields: Any) -> None: + if self.sink is None: + return + async with self._lock: + self.sequence += 1 + await self.sink({ + 'type': event_type, 'run_id': self.run_id, + 'sequence': self.sequence, + 'timestamp': datetime.now(timezone.utc).isoformat(), **fields, + }) + + +class ObservedBackend: + """Observe actual model calls without changing upstream roles or routing.""" + def __init__(self, backend: InferenceBackend, events: RunEvents, candidate_id: int): + self.backend = backend + self.events = events + self.candidate_id = candidate_id + + async def generate(self, role, modality, system, contents, options): + call = {'candidate_id': self.candidate_id, 'call_id': str(uuid4()), + 'role': role, 'modality': modality} + await self.events.emit('role.started', **call) + try: + result = await self.backend.generate(role, modality, system, contents, options) + except asyncio.CancelledError: + await self.events.emit('role.cancelled', **call) + raise + except Exception as error: + # Provider errors can contain request bodies or credentials. Only the + # exception type crosses the notification boundary. + await self.events.emit('role.failed', **call, error_type=type(error).__name__) + raise + await self.events.emit('role.completed', **call) + return result diff --git a/src/papervizagent/ops.py b/src/papervizagent/ops.py index de9ed29..11efc48 100644 --- a/src/papervizagent/ops.py +++ b/src/papervizagent/ops.py @@ -4,6 +4,7 @@ import asyncio import base64 import json +from collections.abc import AsyncIterator from io import BytesIO from pathlib import Path from typing import Any @@ -14,6 +15,7 @@ from .backends import Backend from .config import Modality, Role, load_settings +from .events import EventSink, ObservedBackend, RunEvents from .toolfactory import Context, Operation @@ -117,7 +119,7 @@ async def infer(args: InferInput, ctx: Context) -> dict: return {'outputs': outputs, 'trace': backend.trace} -async def generate(args: GenerateInput, ctx: Context) -> dict: +async def generate(args: GenerateInput, ctx: Context, *, on_event: EventSink | None = None) -> dict: from .pipeline import run_pipeline configured = settings_for(ctx) options = {**configured.pipeline, **args.settings} @@ -128,13 +130,16 @@ async def generate(args: GenerateInput, ctx: Context) -> dict: if num_candidates < 1 or max_concurrent < 1: raise ValueError('num_candidates and max_concurrent must be positive.') root = _run_dir(ctx) + events = RunEvents(root.name, on_event) work_dir = Path(options.pop('work_dir', root)).expanduser().resolve() semaphore = asyncio.Semaphore(max_concurrent) - async with Backend(configured) as backend: - async def candidate(index): + async def candidate(index, backend): + try: async with semaphore: + await events.emit('candidate.started', candidate_id=index) data = {**args.data, 'candidate_id': index} - result = await run_pipeline(backend, data, work_dir=work_dir, **options) + observed = ObservedBackend(backend, events, index) if on_event else backend + result = await run_pipeline(observed, data, work_dir=work_dir, **options) result_path = root / f'candidate-{index}.json' result_path.write_text(json.dumps(result, indent=2)) image_key = result.get('eval_image_field') @@ -146,20 +151,67 @@ async def candidate(index): if not artifact and options.get('exp_mode') != 'dev_retriever': raise RuntimeError(f'Pipeline produced no final image. Inspect {result_path}') stop = result.get('critic_stop_reason') - return {'candidate_id': index, 'artifact': artifact, 'result': str(result_path), - 'critic_stop_reason': stop, - 'status': 'partial' if stop in ('invalid_response', 'render_failed') else 'completed'} - trace_path = root / 'trace.json' - tasks = [asyncio.create_task(candidate(i)) for i in range(num_candidates)] + summary = {'candidate_id': index, 'artifact': artifact, 'result': str(result_path), + 'critic_stop_reason': stop, + 'status': 'partial' if stop in ('invalid_response', 'render_failed') else 'completed'} + except asyncio.CancelledError: + await events.emit('candidate.cancelled', candidate_id=index) + raise + except Exception as error: + await events.emit('candidate.failed', candidate_id=index, error_type=type(error).__name__) + raise + await events.emit('candidate.completed', **summary) + return summary + + trace_path = root / 'trace.json' + await events.emit('run.started', run_dir=str(root), num_candidates=num_candidates) + try: + async with Backend(configured) as backend: + tasks = [asyncio.create_task(candidate(i, backend)) for i in range(num_candidates)] + try: + results = await asyncio.gather(*tasks) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + trace_path.write_text(json.dumps(backend.trace, indent=2)) + except asyncio.CancelledError: + await events.emit('run.cancelled', trace=str(trace_path)) + raise + except Exception as error: + await events.emit('run.failed', trace=str(trace_path), error_type=type(error).__name__) + raise + result = {'candidates': results, 'trace': str(trace_path)} + await events.emit('run.completed', result=result, + status='partial' if any(c['status'] == 'partial' for c in results) else 'completed') + return result + + +async def generate_events(args: GenerateInput, ctx: Context) -> AsyncIterator[dict[str, Any]]: + """Subscribe before starting; yield pushed events and propagate run failures. + + Use ``contextlib.aclosing`` when leaving the stream early so its owned run + is cancelled and joined. The final run.completed event contains the same + result as generate. This stream does not detach from the caller's process. + """ + queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + + async def run(): try: - results = await asyncio.gather(*tasks) + return await generate(args, ctx, on_event=queue.put) finally: - for task in tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - trace_path.write_text(json.dumps(backend.trace, indent=2)) - return {'candidates': results, 'trace': str(trace_path)} + queue.put_nowait(None) + + task = asyncio.create_task(run()) + try: + while (event := await queue.get()) is not None: + yield event + await task + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) OPERATIONS = [ diff --git a/src/papervizagent/toolfactory/mcp.py b/src/papervizagent/toolfactory/mcp.py index a77d5df..0402d47 100644 --- a/src/papervizagent/toolfactory/mcp.py +++ b/src/papervizagent/toolfactory/mcp.py @@ -33,7 +33,7 @@ server = MCPServer( name="papervizagent", - version="0.3.0", + version="0.4.0", description="Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", ) diff --git a/tests/test_completion_events.py b/tests/test_completion_events.py new file mode 100644 index 0000000..06abbae --- /dev/null +++ b/tests/test_completion_events.py @@ -0,0 +1,165 @@ +"""Runs push completion once, preserve errors, and stop when their owner closes.""" +import asyncio +import base64 +from contextlib import aclosing +from io import BytesIO +from pathlib import Path + +from PIL import Image +import pytest + +from papervizagent import ops, pipeline +from papervizagent.toolfactory import Context + + +@pytest.fixture +def runtime(monkeypatch, tmp_path): + image = BytesIO() + Image.new('RGB', (2, 2), 'blue').save(image, format='JPEG') + encoded = base64.b64encode(image.getvalue()).decode() + + class Backend: + trace = [] + closed = False + + def __init__(self, _settings): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + Backend.closed = True + + async def generate(self, *_args): + return [encoded] + + async def run(backend, data, **_options): + image, = await backend.generate('visualizer', 'image', 'private prompt', [], {}) + return {'eval_image_field': 'image', 'image': image, + 'critic_stop_reason': data.get('stop', 'round_limit')} + + monkeypatch.setattr(ops, 'Backend', Backend) + monkeypatch.setattr(pipeline, 'run_pipeline', run) + return Context(config={}, data_dir=tmp_path), Backend + + +async def test_stream_completes_once_after_artifacts_and_keeps_candidates_distinct(runtime): + ctx, backend = runtime + args = ops.GenerateInput(data={}, num_candidates=2) + events = [event async for event in ops.generate_events(args, ctx)] + assert events[0]['type'] == 'run.started' + assert events[-1]['type'] == 'run.completed' + assert [e['type'] for e in events].count('run.completed') == 1 + assert [e['sequence'] for e in events] == list(range(1, len(events) + 1)) + assert len({e['run_id'] for e in events}) == 1 + starts = [e for e in events if e['type'] == 'role.started'] + ends = [e for e in events if e['type'] == 'role.completed'] + assert {e['candidate_id'] for e in starts} == {0, 1} + assert {e['call_id'] for e in starts} == {e['call_id'] for e in ends} + assert len({e['call_id'] for e in starts}) == 2 + result = events[-1]['result'] + assert Path(result['trace']).exists() + assert backend.closed + for candidate in result['candidates']: + assert Path(candidate['artifact']).exists() + assert Path(candidate['result']).exists() + assert candidate['critic_stop_reason'] == 'round_limit' + assert 'private prompt' not in str(events) + + +async def test_partial_pipeline_is_not_approval(runtime): + ctx, _ = runtime + events = [e async for e in ops.generate_events( + ops.GenerateInput(data={'stop': 'invalid_response'}), ctx)] + assert events[-1]['type'] == 'run.completed' + assert events[-1]['status'] == 'partial' + assert events[-1]['result']['candidates'][0]['critic_stop_reason'] == 'invalid_response' + + +async def test_queued_candidate_is_not_reported_started(runtime, monkeypatch): + ctx, backend = runtime + entered, release = asyncio.Event(), asyncio.Event() + original = backend.generate + events = [] + + async def wait(self, *args): + entered.set() + await release.wait() + return await original(self, *args) + + async def receive(event): + events.append(event) + + monkeypatch.setattr(backend, 'generate', wait) + task = asyncio.create_task(ops.generate( + ops.GenerateInput(data={}, num_candidates=2, max_concurrent=1), ctx, on_event=receive)) + try: + await entered.wait() + assert [e['candidate_id'] for e in events if e['type'] == 'candidate.started'] == [0] + finally: + release.set() + await task + assert [e['candidate_id'] for e in events if e['type'] == 'candidate.started'] == [0, 1] + + +async def test_failure_is_pushed_then_raised_without_leaking_error_body(runtime, monkeypatch): + ctx, backend = runtime + + async def fail(*_args): + raise RuntimeError('private-provider-response') + + monkeypatch.setattr(backend, 'generate', fail) + events = [] + with pytest.raises(RuntimeError, match='private-provider-response'): + async for event in ops.generate_events(ops.GenerateInput(data={}), ctx): + events.append(event) + assert [e['type'] for e in events][-3:] == ['role.failed', 'candidate.failed', 'run.failed'] + assert 'private-provider-response' not in str(events) + assert backend.closed + assert Path(events[-1]['trace']).is_file() + + +async def test_closing_stream_joins_cancelled_work(runtime, monkeypatch): + ctx, backend = runtime + entered = asyncio.Event() + cancelled = asyncio.Event() + + async def wait(*_args): + entered.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + monkeypatch.setattr(backend, 'generate', wait) + async with aclosing(ops.generate_events(ops.GenerateInput(data={}), ctx)) as events: + async for event in events: + if event['type'] == 'role.started': + await entered.wait() + break + assert cancelled.is_set() + assert backend.closed + + +async def test_callback_is_attached_before_run_and_receives_cancellation(runtime, monkeypatch): + ctx, backend = runtime + entered = asyncio.Event() + events = [] + + async def wait(*_args): + entered.set() + await asyncio.Event().wait() + + async def receive(event): + events.append(event) + + monkeypatch.setattr(backend, 'generate', wait) + task = asyncio.create_task(ops.generate(ops.GenerateInput(data={}), ctx, on_event=receive)) + await entered.wait() + assert events[0]['type'] == 'run.started' + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert [e['type'] for e in events][-3:] == ['role.cancelled', 'candidate.cancelled', 'run.cancelled'] + assert backend.closed diff --git a/tests/test_routing.py b/tests/test_routing.py index 7d011f8..829bcc3 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -225,3 +225,40 @@ async def turn_interrupt(self, *_args): def unregister_turn_notifications(self, turn_id): self.unregistered.append(turn_id) + + +async def test_failed_terminal_event_is_not_interrupted_again(tmp_path): + terminal = types.SimpleNamespace(method='turn/completed', payload=types.SimpleNamespace( + turn=types.SimpleNamespace(status=types.SimpleNamespace(value='failed')))) + backend = Backend() + backend.models = [{'id': 'coordinator', 'model': 'coordinator', 'inputModalities': ['text']}] + backend.client = _TurnClient([terminal]) + backend._directory = types.SimpleNamespace(name=str(tmp_path)) + with unittest.TestCase().assertRaisesRegex(RuntimeError, 'Codex inference failed'): + await backend._codex_generate(Model(provider='codex'), 'planner', 'llm', 'system', [], {}) + assert backend.client.unregistered == ['turn-1'] + + +async def test_cancellation_cleanup_cannot_mask_original_exception(tmp_path): + import asyncio + entered = asyncio.Event() + + class Client(_TurnClient): + async def next_turn_notification(self, _turn_id): + entered.set() + await asyncio.Event().wait() + + async def turn_interrupt(self, *_args): + raise RuntimeError('no active turn to interrupt') + + backend = Backend() + backend.models = [{'id': 'coordinator', 'model': 'coordinator', 'inputModalities': ['text']}] + backend.client = Client([]) + backend._directory = types.SimpleNamespace(name=str(tmp_path)) + task = asyncio.create_task(backend._codex_generate( + Model(provider='codex'), 'planner', 'llm', 'system', [], {})) + await entered.wait() + task.cancel() + with unittest.TestCase().assertRaises(asyncio.CancelledError): + await task + assert backend.client.unregistered == ['turn-1'] diff --git a/uv.lock b/uv.lock index 686c01b..d425b2d 100644 --- a/uv.lock +++ b/uv.lock @@ -1279,7 +1279,7 @@ wheels = [ [[package]] name = "papervizagent" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["vertex"] }, diff --git a/web/package-lock.json b/web/package-lock.json index 83a89ad..30e4a06 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,19 +1,19 @@ { "name": "papervizagent-web", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papervizagent-web", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/geist": "^5.3.0", "@tailwindcss/vite": "^4.3.3", "class-variance-authority": "^0.7.1", "cn": "^0.2.6", - "lucide-react": "^1.42.0", + "lucide-react": "^1.43.0", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "^4.21.0", @@ -3871,9 +3871,9 @@ } }, "node_modules/lucide-react": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz", - "integrity": "sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.43.0.tgz", + "integrity": "sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/web/package.json b/web/package.json index 69131ef..761b4a4 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "papervizagent-web", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", @@ -16,7 +16,7 @@ "@tailwindcss/vite": "^4.3.3", "class-variance-authority": "^0.7.1", "cn": "^0.2.6", - "lucide-react": "^1.42.0", + "lucide-react": "^1.43.0", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "^4.21.0",